@@ -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.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 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 :
# Sen sible 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 :
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.
# 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 )
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.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_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
# =============================================================================
# =============================================================================