Files
videobeaux/experimental/dynakey.py
2025-09-04 21:43:09 -04:00

169 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# videobeaux/programs/dynakey.py
# Keyer that respects Videobeaux GLOBALS (-i/-o/-F/-h come from the CLI).
# MP4-only output (libx264, yuv420p), optional background video or solid fill.
# Dynamic mode smoothly morphs similarity/blend over time.
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
def register_arguments(parser):
parser.description = (
"Dynamic Color/Chroma Keyer (FFmpeg) with despill, alpha-edge soften, solid/bg compositing, and art modes."
)
print("✅ dynakey ready. Use global -i/-o/-F. This mode adds only feature flags.")
# Key engine + params
parser.add_argument("--engine", choices=["colorkey", "chromakey"], default="colorkey",
help="colorkey=RGB distance; chromakey=YUV chroma distance (more broadcast-like).")
parser.add_argument("--key", default="0x00FF00",
help="Key color. colorkey: 0xRRGGBB (e.g., 0x00FF00). chromakey: g/b/r or hex.")
parser.add_argument("--similarity", type=float, default=0.12,
help="Color distance threshold to remove (~0.050.4 typical).")
parser.add_argument("--blend", type=float, default=0.02,
help="Edge feather (01).")
# Soften keyed edges by blurring alpha only
parser.add_argument("--soften", type=float, default=0.0,
help="Extra edge soften in pixels (alpha-only blur).")
# Spill + pre
parser.add_argument("--despill", action="store_true", help="Reduce green/blue spill halos on edges.")
parser.add_argument("--pre-denoise", action="store_true", help="Mild denoise before key (hqdn3d).")
parser.add_argument("--pre-smooth-chroma", action="store_true",
help="Light chroma smoothing before key (yuv444p + blur).")
# Background composition (video or solid color)
parser.add_argument("--bg", help="Optional background VIDEO path (composite keyed FG over it).")
parser.add_argument("--bg-solid", default="black",
help="Solid background if no --bg. Accepts names (black, white) or hex (#RRGGBB/0xRRGGBB).")
# Art modes (post-key stylization)
parser.add_argument("--art", choices=["none", "trails", "lighten-feedback"], default="none",
help="Stylize after key: 'trails' (lagfun) or 'lighten-feedback' (tblend).")
parser.add_argument("--art-intensity", type=float, default=0.96,
help="Trails decay (0.850.995).")
# Dynamic morphing (no-ZMQ): sweep similarity/blend over N segments
parser.add_argument("--dynamic", action="store_true", help="Morph key params over time, then xfade between variants.")
parser.add_argument("--segments", type=int, default=4, help="Number of morph segments (>=2).")
parser.add_argument("--cycle-sim-min", type=float, default=0.08, help="Min similarity in morph.")
parser.add_argument("--cycle-sim-max", type=float, default=0.25, help="Max similarity in morph.")
parser.add_argument("--cycle-blend-min", type=float, default=0.0, help="Min blend in morph.")
parser.add_argument("--cycle-blend-max", type=float, default=0.08, help="Max blend in morph.")
# Output cadence
parser.add_argument("--fps", type=float, default=30, help="Output FPS (default 30).")
def _norm_hex(s: str) -> str:
"""Normalize color strings to FFmpeg-friendly forms."""
s = s.strip()
if s.startswith("#"):
return "0x" + s[1:]
return s
def _key_filter(engine, key, similarity, blend):
"""Return filter that keys to RGBA (alpha created)."""
if engine == "chromakey":
return f"chromakey={key}:{similarity}:{blend},format=rgba"
return f"colorkey={key}:{similarity}:{blend},format=rgba"
def _post_art(mode, intensity):
if mode == "trails":
return f",lagfun=decay={float(intensity)}"
if mode == "lighten-feedback":
return ",tblend=all_mode=lighten"
return ""
def run(args):
# ---------- Build pre-chain ----------
pre = []
if args.pre_denoise:
pre.append("hqdn3d=l=2.0:c=1.5")
if args.pre_smooth_chroma:
pre.append("format=yuv444p,boxblur=0:1")
pre_chain = (",".join(pre) + ",") if pre else ""
# ---------- Foreground key(s) ----------
graph = []
key_color = _norm_hex(args.key)
def keyed_stream(src_label, sim, blnd, out_label):
chain = _key_filter(args.engine, key_color, sim, blnd)
# alpha-only soften using gblur planes=a (avoids label collisions)
if args.soften and args.soften > 0:
chain += f",gblur=sigma={float(args.soften)}:steps=1:planes=a"
if args.despill:
chain += ",despill"
chain += _post_art(args.art, args.art_intensity)
graph.append(f"[{src_label}]{chain}[{out_label}]")
if not args.dynamic:
graph.append(f"[0:v]{pre_chain}setpts=PTS-STARTPTS[pre]")
keyed_stream("pre", args.similarity, args.blend, "FG")
fg_out = "FG"
else:
N = max(2, int(args.segments))
graph.append(f"[0:v]{pre_chain}setpts=PTS-STARTPTS,format=yuv420p[pre]")
graph.append(f"[pre]split={N}" + "".join(f"[s{i}]" for i in range(N)))
def lerp(a, b, i, n): return a if n <= 1 else (a + (b - a) * (i / (n - 1)))
segs = []
for i in range(N):
sim_i = round(lerp(args.cycle_sim_min, args.cycle_sim_max, i, N), 4)
bln_i = round(lerp(args.cycle_blend_min, args.cycle_blend_max, i, N), 4)
outl = f"K{i}"
keyed_stream(f"s{i}", sim_i, bln_i, outl)
segs.append(outl)
# xfade through segments for a smooth morph (1s fades starting at 1s)
cur = segs[0]
for idx, nxt in enumerate(segs[1:], start=1):
outl = f"XF{idx}"
graph.append(f"[{cur}][{nxt}]xfade=transition=fade:duration=1.0:offset=1.0[{outl}]")
cur = outl
fg_out = cur
# ---------- Background (video or solid) & composite ----------
if args.bg:
# Use provided background video; scale to FG dimensions using scale2ref, then overlay FG (RGBA) over BG.
graph.append(f"[1:v]setpts=PTS-STARTPTS[BG0]")
graph.append(f"[BG0][{fg_out}]scale2ref[BG][FGref]") # BG resized to FG size
graph.append(f"[BG][{fg_out}]overlay=shortest=1:format=auto[out_v]")
need_bg_input = True
else:
# Create solid background sized to FG with scale2ref, then overlay.
solid = _norm_hex(args.bg_solid)
graph.append(f"color=c={solid}:s=16x16:d=1[BGsrc]")
graph.append(f"[BGsrc][{fg_out}]scale2ref[BG][FGref]")
graph.append(f"[BG][{fg_out}]overlay=shortest=1:format=auto[out_v]")
need_bg_input = False
filter_complex = ";".join(graph)
# ---------- FFmpeg command (MP4 only; globals provide -i/-o/-F) ----------
command = ["ffmpeg", "-i", args.input]
if need_bg_input:
command += ["-i", args.bg]
command += [
"-filter_complex", filter_complex,
"-map", "[out_v]",
"-map", "0:a",
"-c:v", "libx264",
"-profile:v", "high",
"-level:v", "4.2",
"-pix_fmt", "yuv420p",
"-r", str(args.fps),
"-movflags", "+faststart",
"-shortest",
args.output, # NOTE: your CLI states 'no extension'; we honor whatever it passes here.
]
run_ffmpeg_with_progress(
(command[:1] + ["-y"] + command[1:]) if getattr(args, "force", False) else command,
args.input,
args.output
)