Adding autokeying

This commit is contained in:
2026-07-18 21:44:22 +02:00
parent c65b246401
commit 4a05c1c78d
5 changed files with 591 additions and 63 deletions
+3 -3
View File
@@ -73,10 +73,10 @@
"height": "80%" "height": "80%"
}, },
"square": { "square": {
"x": "46.5%", "x": "47.91875%",
"y": "4.5%", "y": "5.55%",
"width": "50%", "width": "50%",
"height": "90%" "height": "88.888888%"
}, },
"fullscreen": { "fullscreen": {
"x": "0%", "x": "0%",
+6 -4
View File
@@ -47,11 +47,13 @@
"target_tp": -1.5 "target_tp": -1.5
}, },
{ {
"type": "color_grade", "type": "color_grade",
"saturation": 1.15, "saturation": 1.02,
"contrast": 1.05, "contrast": 1.05,
"bm": -0.10, "brightness": 0.04,
"rm": 0.04 "bm": 0.0,
"gm": 0.02,
"rm": -0.07
}, },
{ {
"type": "gnommokey", "type": "gnommokey",
+59
View File
@@ -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
}
]
}
+511 -55
View File
@@ -24,6 +24,28 @@ class NotImplementedException(GnommoError):
pass 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: def main() -> int:
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -55,9 +77,12 @@ Examples:
gnommo -p video0 new Create a new project with standard folder structure 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 all Full pipeline: import → preprocess → trim → render → push → handoff → up
gnommo -p video1 render --dry-run Show FFmpeg command without running 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 Preview the talkinghead filter on a few seconds of raw_mov
gnommo -p video1 grade --ss 12 --dur 4 Seek 12s in, produce a 4s preview gnommo -p video1 grade --set screen_gain=200 Preview with a gnommokey override
gnommo -p video1 grade --file media/narration/raw_mov/clipA.mov Grade a specific raw clip 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.71.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 description Generate YouTube description file
gnommo -p video1 archive Copy project to connected external drive gnommo -p video1 archive Copy project to connected external drive
gnommo -p video1 load Copy project from external drive to local gnommo -p video1 load Copy project from external drive to local
@@ -248,6 +273,32 @@ Examples:
dest="grade_dur", dest="grade_dur",
help="For grade: duration in seconds of the preview clip (default: 3)", 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( parser.add_argument(
"--ffmpeg-log", "--ffmpeg-log",
type=str, type=str,
@@ -334,6 +385,9 @@ Examples:
file=args.file, file=args.file,
ss=args.grade_ss, ss=args.grade_ss,
dur=args.grade_dur, dur=args.grade_dur,
overrides=args.grade_set,
stage=args.grade_stage,
pick=args.grade_pick,
) )
elif action == "align": elif action == "align":
return cmd_align(project_path, args.verbose) 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)") print(f" Skipping {segment_id} (already exists)")
continue 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_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: if raw_mov_has_file:
continue continue
@@ -1933,45 +1993,20 @@ def cmd_new(project_path: Path, verbose: bool) -> int:
pass pass
if not talkinghead_filter: if not talkinghead_filter:
# Sensible placeholder — user should tweak gnommokey values for their camera # No sibling project to copy from — fall back to the repo-root defaults.
talkinghead_filter = [ # User should tweak gnommokey values for their camera.
{ talkinghead_filter = load_filter_defaults().get("talkinghead")
"type": "audio_normalize", if talkinghead_filter:
"compress": False, print(
"normalize": True, " Using default talkinghead filter from filter_defaults.json "
"target_lufs": -14, "(adjust gnommokey values for your camera)"
"target_lra": 11, )
"target_tp": -1.5, else:
}, print(
{ " WARNING: no 'talkinghead' filter in filter_defaults.json — "
"type": "gnommokey", "project.json will have an empty talkinghead filter"
"screen_color": [81, 137, 65], )
"screen_gain": 175, talkinghead_filter = []
"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)")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# project.json # # project.json #
@@ -2424,6 +2459,28 @@ def cmd_preprocess(
gnommo_scratch = project_path / gnommo_scratch gnommo_scratch = project_path / gnommo_scratch
print(f" Using intermediate dir: {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_<res> 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 --- # --- Filter pipeline ---
talkinghead_filter = (config.default_filters or {}).get("talkinghead", []) talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
if not talkinghead_filter: if not talkinghead_filter:
@@ -2568,6 +2625,7 @@ def cmd_preprocess(
def process_segment_task(task): def process_segment_task(task):
seg_id, seg_source = task seg_id, seg_source = task
_clear_segment_scratch(cache_narration_dir or narration_dir, seg_id)
preprocess_video( preprocess_video(
cache_narration_dir or narration_dir, cache_narration_dir or narration_dir,
seg_id, seg_id,
@@ -2602,6 +2660,7 @@ def cmd_preprocess(
print(f" Source: {segment_source.source_file}") print(f" Source: {segment_source.source_file}")
print(f" Output: {_out_full}") print(f" Output: {_out_full}")
print(f" Filters: {len(segment_source.filter)} step(s)") print(f" Filters: {len(segment_source.filter)} step(s)")
_clear_segment_scratch(cache_narration_dir or narration_dir, segment_id)
preprocess_video( preprocess_video(
cache_narration_dir or narration_dir, cache_narration_dir or narration_dir,
segment_id, segment_id,
@@ -2687,6 +2746,7 @@ def cmd_preprocess(
) )
continue continue
print(f" Processing: {video_id}") print(f" Processing: {video_id}")
_clear_segment_scratch(videos_dir, video_id)
preprocess_video( preprocess_video(
videos_dir, videos_dir,
video_id, video_id,
@@ -4124,6 +4184,17 @@ def cmd_render(
narration_schedule, _ = build_narration_schedule( narration_schedule, _ = build_narration_schedule(
narration_map, narration_seg_dir, get_video_duration 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()] missing = [s.seg_id for s in narration_schedule if not s.source_path.exists()]
if missing: if missing:
print( print(
@@ -4435,15 +4506,23 @@ def cmd_grade(
file: Optional[str] = None, file: Optional[str] = None,
ss: Optional[float] = None, ss: Optional[float] = None,
dur: float = 3.0, dur: float = 3.0,
overrides: Optional[list] = None,
stage: Optional[str] = None,
pick: Optional[str] = None,
) -> int: ) -> int:
"""Sample a few seconds of a raw narration clip through the talkinghead """Sample a raw narration clip through the talkinghead filter chain so you
filter chain so you can iterate on gnommokey / color_grade settings without can iterate on gnommokey / color_grade settings without a full preprocess.
running a full preprocess.
Writes two files to the project root: Default: writes grade_preview.mov (keyed ProRes 4444 with alpha) to the
grade_preview.mov — the exact keyed ProRes 4444 output (alpha over black) project root.
grade_preview.mp4 — the same result flattened over mid-gray, easy to view
in any player (best for judging spill and skin tone) --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 .parser import parse_project_config
from .preprocessor import _process_chunk_to_prores4444, get_video_duration 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 --- # --- Resolve seek / duration, clamped to the clip length ---
clip_len = get_video_duration(source) clip_len = get_video_duration(source)
if ss is None: if ss is None:
# Default: 5s in, or centred if the clip is short. if stage or pick:
ss = 5.0 if clip_len > 8 else max(0.0, clip_len / 2 - dur / 2) # 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: if ss >= clip_len:
ss = max(0.0, clip_len - dur) ss = max(0.0, clip_len - dur)
take = min(dur, max(0.1, clip_len - ss)) 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"Grading preview: {project_path.name}")
print(f" Source: {source}") 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" 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" mov_out = project_path / "grade_preview.mov"
_process_chunk_to_prores4444( _process_chunk_to_prores4444(
source, source,
mov_out, mov_out,
talkinghead_filter, filters,
start_time=ss, start_time=ss,
chunk_duration=take, chunk_duration=take,
verbose=verbose, verbose=verbose,
@@ -4520,6 +4644,338 @@ def cmd_grade(
return 0 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.71.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.01.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_type> 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 # Align Command
# ============================================================================= # =============================================================================
+11
View File
@@ -1,5 +1,16 @@
#!/bin/sh #!/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 video0 up
./gnommo.sh -p video1 up ./gnommo.sh -p video1 up
./gnommo.sh -p video2 up ./gnommo.sh -p video2 up