diff --git a/GlitchTrailer/project.json b/GlitchTrailer/project.json index cb23a82..5ec665f 100644 --- a/GlitchTrailer/project.json +++ b/GlitchTrailer/project.json @@ -73,10 +73,10 @@ "height": "80%" }, "square": { - "x": "46.5%", - "y": "4.5%", + "x": "47.91875%", + "y": "5.55%", "width": "50%", - "height": "90%" + "height": "88.888888%" }, "fullscreen": { "x": "0%", diff --git a/example/project.json b/example/project.json index 927b185..bc26719 100644 --- a/example/project.json +++ b/example/project.json @@ -47,11 +47,13 @@ "target_tp": -1.5 }, { - "type": "color_grade", - "saturation": 1.15, - "contrast": 1.05, - "bm": -0.10, - "rm": 0.04 + "type": "color_grade", + "saturation": 1.02, + "contrast": 1.05, + "brightness": 0.04, + "bm": 0.0, + "gm": 0.02, + "rm": -0.07 }, { "type": "gnommokey", diff --git a/filter_defaults.json b/filter_defaults.json new file mode 100644 index 0000000..783ab8e --- /dev/null +++ b/filter_defaults.json @@ -0,0 +1,59 @@ +{ + "talkinghead": [ + { + "type": "audio_normalize", + "enabled": true, + "highpass": 85, + "eq_bands": [ + { + "type": "peak", + "freq": 200, + "gain": -3.5, + "q": 1.2 + } + ], + "compress": false, + "normalize": true, + "target_lufs": -14, + "target_lra": 11, + "target_tp": -1.5 + }, + { + "type": "gnommokey", + "screen_color": [ + 81, + 137, + 65 + ], + "screen_gain": 175, + "screen_balance": 58, + "despill_bias": [ + 235, + 222, + 210 + ], + "despill_strength": 7.0, + "spill_suppress": 1.3, + "yellow_protect": 0.9, + "edge_erode": 1.0, + "clip_black": 0, + "clip_white": 100 + }, + { + "type": "color_grade", + "saturation": 1.02, + "contrast": 1.05, + "brightness": 0.04, + "bm": 0.0, + "gm": 0.02, + "rm": -0.07 + }, + { + "type": "mask", + "left": 0.05, + "right": 0.1, + "top": 0.1, + "bottom": 0.0 + } + ] +} diff --git a/gnommo/cli.py b/gnommo/cli.py index 5f0776a..eb900bd 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -24,6 +24,28 @@ class NotImplementedException(GnommoError): pass +# Repo-root filter_defaults.json: default filter chains (talkinghead, etc.) used +# when scaffolding a new project and no sibling project.json is available to copy +# from. Lives next to .env in the gnommo root, not inside the package. +FILTER_DEFAULTS_PATH = Path(__file__).parent.parent / "filter_defaults.json" + + +def load_filter_defaults() -> dict: + """Load the default filter chains from filter_defaults.json (repo root). + + Returns a dict mapping filter-set name (e.g. "talkinghead") to its filter + list. Returns {} if the file is missing or malformed. + """ + try: + return json.loads(FILTER_DEFAULTS_PATH.read_text(encoding="utf-8")) + except FileNotFoundError: + print(f" WARNING: {FILTER_DEFAULTS_PATH.name} not found in gnommo root") + return {} + except json.JSONDecodeError as e: + print(f" WARNING: {FILTER_DEFAULTS_PATH.name} is not valid JSON: {e}") + return {} + + def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( @@ -55,9 +77,12 @@ Examples: gnommo -p video0 new Create a new project with standard folder structure gnommo -p video1 all Full pipeline: import → preprocess → trim → render → push → handoff → up gnommo -p video1 render --dry-run Show FFmpeg command without running - gnommo -p video1 grade Sample a few seconds of a raw_mov clip through the talkinghead filters for grading - gnommo -p video1 grade --ss 12 --dur 4 Seek 12s in, produce a 4s preview - gnommo -p video1 grade --file media/narration/raw_mov/clipA.mov Grade a specific raw clip + gnommo -p video1 grade Preview the talkinghead filter on a few seconds of raw_mov + gnommo -p video1 grade --set screen_gain=200 Preview with a gnommokey override + gnommo -p video1 grade --stage key Auto-tune the matte key → candidate + manifest (then --pick key_1) + gnommo -p video1 grade --stage despill Sweep spill_suppress 0.7–1.5 → stills to pick from + gnommo -p video1 grade --stage grade Sweep paleness → color-grade stills to pick from + gnommo -p video1 grade --pick despill_5 Apply a chosen candidate to project.json gnommo -p video1 description Generate YouTube description file gnommo -p video1 archive Copy project to connected external drive gnommo -p video1 load Copy project from external drive to local @@ -248,6 +273,32 @@ Examples: dest="grade_dur", help="For grade: duration in seconds of the preview clip (default: 3)", ) + parser.add_argument( + "--set", + action="append", + default=None, + dest="grade_set", + metavar="KEY=VALUE", + help="For grade: override a gnommokey field (repeatable), e.g. --set screen_gain=200", + ) + parser.add_argument( + "--stage", + type=str, + default=None, + dest="grade_stage", + choices=["key", "despill", "grade"], + help="For grade: generate deterministic candidate stills + manifest for one stage " + "(key=auto matte, despill=spill sweep, grade=paleness sweep)", + ) + parser.add_argument( + "--pick", + type=str, + default=None, + dest="grade_pick", + metavar="ID", + help="For grade: apply a candidate from a stage manifest, e.g. --pick despill_5 " + "(or --stage X --pick best)", + ) parser.add_argument( "--ffmpeg-log", type=str, @@ -334,6 +385,9 @@ Examples: file=args.file, ss=args.grade_ss, dur=args.grade_dur, + overrides=args.grade_set, + stage=args.grade_stage, + pick=args.grade_pick, ) elif action == "align": return cmd_align(project_path, args.verbose) @@ -1398,9 +1452,15 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No print(f" Skipping {segment_id} (already exists)") continue - # If a raw_mov equivalent exists, skip — step 2 will handle it + # If a raw_mov equivalent exists, skip — step 2 will handle it. + # Compare stems case-INSENSITIVELY: on a case-sensitive disk a raw file + # "S1-end.mov" must still match the lowercased segment id "s1-end", or we + # wrongly add a duplicate processed/ entry alongside the raw-based one. raw_mov_has_file = raw_dir.exists() and any( - (raw_dir / f"{segment_id}{ext}").exists() for ext in _raw_video_exts + f.is_file() + and f.suffix.lower() in _raw_video_exts + and f.stem.lower() == segment_id + for f in raw_dir.iterdir() ) if raw_mov_has_file: continue @@ -1933,45 +1993,20 @@ def cmd_new(project_path: Path, verbose: bool) -> int: pass if not talkinghead_filter: - # Sensible placeholder — user should tweak gnommokey values for their camera - talkinghead_filter = [ - { - "type": "audio_normalize", - "compress": False, - "normalize": True, - "target_lufs": -14, - "target_lra": 11, - "target_tp": -1.5, - }, - { - "type": "gnommokey", - "screen_color": [81, 137, 65], - "screen_gain": 175, - "screen_balance": 58, - "despill_bias": [217, 240, 255], - "despill_strength": 5.0, - "edge_erode": 1.0, - "clip_black": 0, - "clip_white": 100, - }, - { - "type": "color_grade", - "saturation": 0.95, - "contrast": 1.06, - "rm": -0.05, - "gm": 0.02, - "bm": -0.04, - "curves_master": "0/0.02 0.5/0.5 1/0.97", - }, - { - "type": "mask", - "left": 0.05, - "right": 0.1, - "top": 0.1, - "bottom": 0.0, - }, - ] - print(" Using default talkinghead filter (adjust gnommokey values for your camera)") + # No sibling project to copy from — fall back to the repo-root defaults. + # User should tweak gnommokey values for their camera. + talkinghead_filter = load_filter_defaults().get("talkinghead") + if talkinghead_filter: + print( + " Using default talkinghead filter from filter_defaults.json " + "(adjust gnommokey values for your camera)" + ) + else: + print( + " WARNING: no 'talkinghead' filter in filter_defaults.json — " + "project.json will have an empty talkinghead filter" + ) + talkinghead_filter = [] # ------------------------------------------------------------------ # # project.json # @@ -2424,6 +2459,28 @@ def cmd_preprocess( gnommo_scratch = project_path / gnommo_scratch print(f" Using intermediate dir: {gnommo_scratch}") + # Clear a segment's stale intermediate/scratch dir before (re)processing it. + # A run that crashed mid-file (e.g. the laptop battery dying with the external + # drive attached) leaves partial chunks, half-written batch files, and — for + # low/tiny res — a truncated raw downscale. create_downscaled_video reuses an + # existing raw_ file as-is, so a truncated one would silently corrupt the + # output. Wiping the scratch dir forces a clean restart of that file. It only + # touches segments this run is about to redo, so complete outputs (files that + # already finished) and any concurrent run's in-flight files are left alone. + import shutil as _shutil + + def _clear_segment_scratch(seg_videos_dir: Path, seg_id: str) -> None: + scratch = ( + gnommo_scratch / seg_id + if gnommo_scratch + else seg_videos_dir / "intermediate" / seg_id + ) + if scratch.exists(): + print( + f" Restarting {seg_id}: clearing incomplete intermediate files from previous run" + ) + _shutil.rmtree(scratch, ignore_errors=True) + # --- Filter pipeline --- talkinghead_filter = (config.default_filters or {}).get("talkinghead", []) if not talkinghead_filter: @@ -2568,6 +2625,7 @@ def cmd_preprocess( def process_segment_task(task): seg_id, seg_source = task + _clear_segment_scratch(cache_narration_dir or narration_dir, seg_id) preprocess_video( cache_narration_dir or narration_dir, seg_id, @@ -2602,6 +2660,7 @@ def cmd_preprocess( print(f" Source: {segment_source.source_file}") print(f" Output: {_out_full}") print(f" Filters: {len(segment_source.filter)} step(s)") + _clear_segment_scratch(cache_narration_dir or narration_dir, segment_id) preprocess_video( cache_narration_dir or narration_dir, segment_id, @@ -2687,6 +2746,7 @@ def cmd_preprocess( ) continue print(f" Processing: {video_id}") + _clear_segment_scratch(videos_dir, video_id) preprocess_video( videos_dir, video_id, @@ -4124,6 +4184,17 @@ def cmd_render( narration_schedule, _ = build_narration_schedule( narration_map, narration_seg_dir, get_video_duration ) + # Preprocess may write the processed segments to the process cache (an + # external disk that mirrors media/narration/) rather than locally. If the + # local outputs aren't present, rebuild the schedule against the cache so + # source paths — and their probed durations — resolve to the real files. + if any(not s.source_path.exists() for s in narration_schedule): + _cache_root = _resolve_process_cache(project_path, config) + if _cache_root: + _cache_narr = _cache_root / "media" / "narration" + narration_schedule, _ = build_narration_schedule( + narration_map, _cache_narr, get_video_duration + ) missing = [s.seg_id for s in narration_schedule if not s.source_path.exists()] if missing: print( @@ -4435,15 +4506,23 @@ def cmd_grade( file: Optional[str] = None, ss: Optional[float] = None, dur: float = 3.0, + overrides: Optional[list] = None, + stage: Optional[str] = None, + pick: Optional[str] = None, ) -> int: - """Sample a few seconds of a raw narration clip through the talkinghead - filter chain so you can iterate on gnommokey / color_grade settings without - running a full preprocess. + """Sample a raw narration clip through the talkinghead filter chain so you + can iterate on gnommokey / color_grade settings without a full preprocess. - Writes two files to the project root: - grade_preview.mov — the exact keyed ProRes 4444 output (alpha over black) - grade_preview.mp4 — the same result flattened over mid-gray, easy to view - in any player (best for judging spill and skin tone) + Default: writes grade_preview.mov (keyed ProRes 4444 with alpha) to the + project root. + + --set KEY=VALUE (repeatable) overrides any gnommokey field for the preview, + e.g. --set screen_gain=200 --set spill_suppress=1.5 --set screen_color=81,137,65 + + --sweep KEY=START:END:STEPS renders STEPS still frames varying one gnommokey + field, reports the transparent / opaque / partial-alpha pixel split for each + (to find the value that keys the background out cleanly without eating the + subject), and saves a magenta-composite PNG per step for eyeballing. """ from .parser import parse_project_config from .preprocessor import _process_chunk_to_prores4444, get_video_duration @@ -4493,22 +4572,67 @@ def cmd_grade( # --- Resolve seek / duration, clamped to the clip length --- clip_len = get_video_duration(source) if ss is None: - # Default: 5s in, or centred if the clip is short. - ss = 5.0 if clip_len > 8 else max(0.0, clip_len / 2 - dur / 2) + if stage or pick: + # A frame well into the clip (subject settled, lit): ~1 min in, or + # the midpoint on a short clip. + ss = min(60.0, clip_len / 2) + else: + # Default: 5s in, or centred if the clip is short. + ss = 5.0 if clip_len > 8 else max(0.0, clip_len / 2 - dur / 2) if ss >= clip_len: ss = max(0.0, clip_len - dur) take = min(dur, max(0.1, clip_len - ss)) + # Deep-copy the filter chain so CLI overrides don't mutate the parsed config, + # and locate the gnommokey step (the keyer we tune). + import copy + filters = copy.deepcopy(talkinghead_filter) + key_cfg = next((f for f in filters if f.get("type") == "gnommokey"), None) + + # Apply --set overrides to the gnommokey config. + if overrides: + if key_cfg is None: + print(" ERROR: no 'gnommokey' step in the talkinghead filter to override.") + return 1 + for kv in overrides: + if "=" not in kv: + print(f" ERROR: --set expects KEY=VALUE, got '{kv}'") + return 1 + k, v = kv.split("=", 1) + key_cfg[k.strip()] = _parse_grade_value(v.strip()) + print(f"Grading preview: {project_path.name}") print(f" Source: {source}") + + # --- Pick mode: apply a previously-generated candidate from a manifest --- + if pick: + return _grade_pick(project_path, stage, pick) + + # --- Stage mode: generate deterministic candidate stills + a manifest --- + if stage: + if key_cfg is None: + print(" ERROR: no 'gnommokey' step in the talkinghead filter.") + return 1 + out_dir = project_path / "grade_sweep" + out_dir.mkdir(exist_ok=True) + ref = out_dir / "_ref.png" + if not _grade_extract_frame(source, ss, ref): + print(f" ERROR: could not extract a frame at {ss:.1f}s from {source.name}") + return 1 + print(f" Stage: {stage} (reference frame {ref} @ {ss:.1f}s)") + stage_fn = {"key": _stage_key, "despill": _stage_despill, "grade": _stage_grade}[stage] + return stage_fn(project_path, filters, ref, out_dir, source.name, ss) + print(f" Sample: {take:.1f}s starting at {ss:.1f}s (clip is {clip_len:.1f}s)") - print(f" Filters: {len(talkinghead_filter)} step(s)") + print(f" Filters: {len(filters)} step(s)") + if overrides: + print(f" Overrides: {', '.join(overrides)}") mov_out = project_path / "grade_preview.mov" _process_chunk_to_prores4444( source, mov_out, - talkinghead_filter, + filters, start_time=ss, chunk_duration=take, verbose=verbose, @@ -4520,6 +4644,338 @@ def cmd_grade( return 0 +def _parse_grade_value(v: str): + """Parse a --set value into list[int] (comma-separated), float, or str.""" + if "," in v: + parts = [p.strip() for p in v.split(",")] + try: + return [int(p) for p in parts] + except ValueError: + return v + try: + f = float(v) + return int(f) if f.is_integer() else f + except ValueError: + return v + + +def _grade_extract_frame(source: Path, ss: float, out_png: Path) -> bool: + """Extract a single RGB reference frame at `ss` seconds. Returns success.""" + out_png.parent.mkdir(parents=True, exist_ok=True) + cmd = [ + "ffmpeg", "-y", "-v", "error", "-ss", f"{ss:.3f}", "-i", str(source), + "-frames:v", "1", "-f", "image2", "-pix_fmt", "rgb24", str(out_png), + ] + subprocess.run(cmd, capture_output=True) + return out_png.exists() + + +def _grade_video_filter(filters: "list[dict]") -> str: + """Build an FFmpeg video-filter string from a list of filter-config dicts + (gnommokey / color_grade / mask), skipping audio steps.""" + from .preprocessor import ( + build_gnommokey_filter, build_color_grade_filter, build_mask_filter, + ) + parts = [] + for f in filters: + t = f.get("type") + if t == "gnommokey": + parts.append(build_gnommokey_filter(f)) + elif t == "color_grade": + parts.append(build_color_grade_filter(f)) + elif t == "mask": + m = build_mask_filter(f) + if m != "copy": + parts.append(m) + return ",".join(parts) if parts else "null" + + +def _grade_step(filters, step_type): + return next((f for f in filters if f.get("type") == step_type), None) + + +def _mask_and(filters, *chains): + """Return [*chains, fixed mask] — the tuned step(s) followed by the mask.""" + out = list(chains) + mask = _grade_step(filters, "mask") + if mask: + out.append(mask) + return out + + +def _eval_alpha(ref: Path, filters: "list[dict]") -> "tuple[float, float, float]": + """Return (transparent, opaque, partial) fractions of the alpha channel. + + A clean key = high transparent (background gone) + steady opaque (subject + intact) + low partial (mid-alpha = green fringe/spill the keyer missed). + """ + from collections import Counter + vf = _grade_video_filter(filters) + cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1", + "-vf", f"{vf},format=yuva444p10le,alphaextract,format=gray", + "-f", "rawvideo", "-"] + raw = subprocess.run(cmd, capture_output=True).stdout + total = len(raw) + if total == 0: + return (0.0, 0.0, 1.0) + h = Counter(raw) + transparent = sum(c for b, c in h.items() if b < 16) + opaque = sum(c for b, c in h.items() if b > 240) + return (transparent / total, opaque / total, + (total - transparent - opaque) / total) + + +def _read_subject_rgba(ref: Path, filters: "list[dict]", step: int = 7): + """Yield (r,g,b) for subsampled subject skin pixels — opaque, fleshy (has + blue, so not the low-blue yellow suit or pure green screen), not deep shadow.""" + vf = _grade_video_filter(filters) + cmd = ["ffmpeg", "-v", "error", "-i", str(ref), "-frames:v", "1", + "-vf", f"{vf},format=rgba", "-f", "rawvideo", "-pix_fmt", "rgba", "-"] + raw = subprocess.run(cmd, capture_output=True).stdout + stride = 4 * step + for i in range(0, len(raw) - 3, stride): + r, g, b, a = raw[i], raw[i + 1], raw[i + 2], raw[i + 3] + if a > 200 and 55 < b < 210 and r > 70 and r >= b: + yield r, g, b + + +def _measure_green_cast(ref: Path, filters: "list[dict]") -> float: + """Mean green tint on subject skin: G − (R+B)/2. >0 residual green, ~0 + neutral, <0 over-despilled toward magenta.""" + tot, n = 0.0, 0 + for r, g, b in _read_subject_rgba(ref, filters): + tot += g - (r + b) / 2.0 + n += 1 + return round(tot / n, 2) if n else 0.0 + + +def _measure_skin(ref: Path, filters: "list[dict]") -> "tuple[int, int, int]": + """Mean (R,G,B) of subject skin pixels.""" + sr = sg = sb = n = 0 + for r, g, b in _read_subject_rgba(ref, filters): + sr += r; sg += g; sb += b; n += 1 + return (sr // n, sg // n, sb // n) if n else (0, 0, 0) + + +def _render_preview(ref: Path, filters: "list[dict]", out_png: Path) -> None: + """Save the filtered still composited over magenta (to judge the matte).""" + vf = _grade_video_filter(filters) + cmd = ["ffmpeg", "-y", "-v", "error", + "-f", "lavfi", "-i", "color=c=magenta:s=1280x720", + "-i", str(ref), "-filter_complex", + f"[1]{vf},format=yuva444p10le[fg];" + f"[0][fg]overlay=shortest=1,format=rgb24", + str(out_png)] + subprocess.run(cmd, capture_output=True) + + +def _grade_write_manifest(out_dir: Path, manifest: dict) -> Path: + p = out_dir / f"{manifest['stage']}_manifest.json" + with open(p, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2) + return p + + +def _stage_key(project_path, filters, ref, out_dir, source_name, ss) -> int: + """Auto matte search (objective) → one recommended candidate + manifest. + + Coordinate descent over the matte fields, minimising 'partial' (unresolved + green fringe) subject to keeping the subject opaque — a candidate that drops + the opaque fraction below baseline (over-keying eats the subject) is rejected. + """ + def clamp(v, lo, hi): + return max(lo, min(hi, v)) + + base = _grade_step(filters, "gnommokey") + cur = dict(base) + bt, bo, bp = _eval_alpha(ref, _mask_and(filters, cur)) + floor = bo * 0.98 + print(f" Baseline: transparent {bt*100:.1f}% opaque {bo*100:.1f}% partial {bp*100:.1f}%") + + g0, b0 = float(cur.get("screen_gain", 100)), float(cur.get("screen_balance", 50)) + grids = { + "screen_gain": sorted({int(clamp(g0 * f, 80, 300)) for f in (0.7, 0.85, 1.0, 1.2, 1.4, 1.7)}), + "screen_balance": sorted({int(clamp(b0 + d, 0, 100)) for d in (-20, -10, 0, 10, 20)}), + "shadow_boost": [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0], + "clip_black": [0, 2, 4, 6, 8, 10, 12], + } + + def better(cand, best): + ct, co, cp = cand + bt_, bo_, bp_ = best + cok, bok = co >= floor, bo_ >= floor + if cok != bok: + return cok + if abs(cp - bp_) > 1e-6: + return cp < bp_ + return ct > bt_ + + best = (bt, bo, bp) + for _ in range(2): + for param, values in grids.items(): + lb_cfg, lb = dict(cur), best + for v in values: + cand = dict(cur) + cand[param] = v + s = _eval_alpha(ref, _mask_and(filters, cand)) + if better(s, lb): + lb, lb_cfg = s, cand + cur, best = lb_cfg, lb + + ft, fo, fp = best + params = {k: cur[k] for k in ("screen_gain", "screen_balance", "shadow_boost", "clip_black") if k in cur} + png = out_dir / "key_1.png" + _render_preview(ref, _mask_and(filters, cur), png) + manifest = { + "stage": "key", "target_step": "gnommokey", + "source": source_name, "ss": round(ss, 2), "swept": list(params.keys()), + "recommended": "key_1", + "candidates": [{ + "id": "key_1", "file": png.name, "params": params, + "hint": {"transparent_pct": round(ft * 100, 1), "partial_pct": round(fp * 100, 1)}, + }], + } + mpath = _grade_write_manifest(out_dir, manifest) + print(f"\n Selected (partial {bp*100:.1f}% → {fp*100:.1f}%, subject held at {fo*100:.1f}%):") + for k, v in params.items(): + print(f" {k}: {base.get(k, '—')} → {v}") + print(f" Preview: {png}") + print(f" Manifest: {mpath}") + print(f" Apply: gnommo -p {project_path.name} grade --pick key_1") + return 0 + + +def _stage_despill(project_path, filters, ref, out_dir, source_name, ss) -> int: + """Deterministic spill_suppress sweep 0.7–1.5 → candidates for a visual pick.""" + base = _grade_step(filters, "gnommokey") + values = [round(0.7 + (1.5 - 0.7) * i / 6, 2) for i in range(7)] # 0.7 .. 1.5 + print(" Sweeping spill_suppress 0.7 → 1.5 (yellow_protect held fixed)") + candidates = [] + for i, v in enumerate(values, 1): + cfg = dict(base) + cfg["spill_suppress"] = v + chain = _mask_and(filters, cfg) + cast = _measure_green_cast(ref, chain) + png = out_dir / f"despill_{i}.png" + _render_preview(ref, chain, png) + candidates.append({ + "id": f"despill_{i}", "file": png.name, + "params": {"spill_suppress": v}, "hint": {"green_cast": cast}, + }) + # Advisory only: the value nearest neutral green cast. Localized bald-head + # spill means the eye is the real judge, hence a full sweep to pick from. + rec = min(candidates, key=lambda c: abs(c["hint"]["green_cast"]))["id"] + manifest = { + "stage": "despill", "target_step": "gnommokey", + "source": source_name, "ss": round(ss, 2), "swept": ["spill_suppress"], + "recommended": rec, "candidates": candidates, + } + mpath = _grade_write_manifest(out_dir, manifest) + print(f" {'id':>12} {'spill':>7} {'green_cast':>10} png") + print(f" {'-'*12} {'-'*7} {'-'*10} {'-'*3}") + for c in candidates: + star = " ◀ suggested" if c["id"] == rec else "" + print(f" {c['id']:>12} {c['params']['spill_suppress']:>7} " + f"{c['hint']['green_cast']:>10} {c['file']}{star}") + print("\n green_cast: >0 residual green · ~0 neutral · <0 over-despilled (magenta)") + print(f" Manifest: {mpath}") + print(" Pick the one with a clean crown and no magenta skin:") + print(f" gnommo -p {project_path.name} grade --pick despill_5") + return 0 + + +def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int: + """Deterministic paleness sweep 0.0–1.0 → color_grade candidates to pick.""" + base_cg = _grade_step(filters, "color_grade") or {} + key = _grade_step(filters, "gnommokey") + contrast = float(base_cg.get("contrast", 1.05)) + values = [round(i / 5, 1) for i in range(6)] # paleness 0.0 .. 1.0 + print(" Sweeping paleness 0.0 → 1.0") + candidates = [] + for i, p in enumerate(values, 1): + cg = {"type": "color_grade", "contrast": contrast, + "rm": round(-0.10 * p, 3), "gm": round(0.02 * p, 3), + "saturation": round(1.0 - 0.15 * p, 3), "brightness": round(0.05 * p, 3)} + chain = _mask_and(filters, key, cg) + skin = _measure_skin(ref, chain) + png = out_dir / f"grade_{i}.png" + _render_preview(ref, chain, png) + params = {k: cg[k] for k in ("rm", "gm", "saturation", "brightness", "contrast")} + params["paleness"] = p + candidates.append({ + "id": f"grade_{i}", "file": png.name, "params": params, + "hint": {"skin_rgb": list(skin), "warmth": skin[0] - skin[2]}, + }) + manifest = { + "stage": "grade", "target_step": "color_grade", + "source": source_name, "ss": round(ss, 2), "swept": ["paleness"], + "recommended": "grade_3", "candidates": candidates, + } + mpath = _grade_write_manifest(out_dir, manifest) + rec = manifest["recommended"] + print(f" {'id':>10} {'paleness':>8} {'skin RGB':>17} {'warmth':>6} png") + print(f" {'-'*10} {'-'*8} {'-'*17} {'-'*6} {'-'*3}") + for c in candidates: + star = " ◀ suggested" if c["id"] == rec else "" + print(f" {c['id']:>10} {c['params']['paleness']:>8} " + f"{str(tuple(c['hint']['skin_rgb'])):>17} {c['hint']['warmth']:>6} {c['file']}{star}") + print(f"\n Manifest: {mpath}") + print(" Pick the paleness you like:") + print(f" gnommo -p {project_path.name} grade --pick grade_3") + return 0 + + +def _grade_pick(project_path, stage_hint, pick) -> int: + """Apply a candidate (by id) from its stage manifest to project.json.""" + out_dir = project_path / "grade_sweep" + stage = stage_hint + if "_" in pick and pick.split("_")[0] in ("key", "despill", "grade"): + stage = pick.split("_")[0] + if stage is None: + print(" ERROR: pass --stage with a numeric/best pick, or a full id like 'despill_5'.") + return 1 + mpath = out_dir / f"{stage}_manifest.json" + if not mpath.exists(): + print(f" ERROR: no manifest for '{stage}' — run 'grade --stage {stage}' first.") + return 1 + manifest = _read_json(mpath) + cand_id = pick + if pick == "best": + cand_id = manifest.get("recommended") + elif pick.isdigit(): + cand_id = f"{stage}_{pick}" + cand = next((c for c in manifest["candidates"] if c["id"] == cand_id), None) + if cand is None: + print(f" ERROR: candidate '{cand_id}' not in {mpath.name}.") + return 1 + # 'paleness' is a UI-only dial, not a real color_grade field. + params = {k: v for k, v in cand["params"].items() if k != "paleness"} + _apply_candidate_to_project(project_path, manifest["target_step"], params) + print(f" Applied {cand_id} → project.json ({manifest['target_step']}): " + + ", ".join(f"{k}={v}" for k, v in params.items())) + return 0 + + +def _apply_candidate_to_project(project_path, step_type, params) -> None: + """Merge params into the talkinghead step in project.json, + creating a color_grade step (before the mask) if it doesn't exist.""" + vpath = project_path / "project.json" + data = _read_json(vpath) + th = (data.get("default_filters") or {}).get("talkinghead") + if not isinstance(th, list): + return + step = next((s for s in th if isinstance(s, dict) and s.get("type") == step_type), None) + if step is None and step_type == "color_grade": + step = {"type": "color_grade"} + idx = next((i for i, s in enumerate(th) if s.get("type") == "mask"), len(th)) + th.insert(idx, step) + if step is None: + return + step.update(params) + with open(vpath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # ============================================================================= # Align Command # ============================================================================= diff --git a/up-all.sh b/up-all.sh index 594d929..3e68286 100755 --- a/up-all.sh +++ b/up-all.sh @@ -1,5 +1,16 @@ #!/bin/sh +./gnommo.sh -p video0 import +./gnommo.sh -p video1 import +./gnommo.sh -p video2 import +./gnommo.sh -p video3 import +./gnommo.sh -p video4 import +./gnommo.sh -p video5 import +./gnommo.sh -p video6 import + + + + ./gnommo.sh -p video0 up ./gnommo.sh -p video1 up ./gnommo.sh -p video2 up