Files
videobeaux/experimental/maskbuffer.py
2025-09-01 19:35:24 -04:00

357 lines
15 KiB
Python

#!/usr/bin/env python3
import argparse, subprocess, random, shlex, sys, os, re, math
# ----------------------------
# Motion expressions (FFmpeg)
# ----------------------------
def motion_expr(pattern, sx, sy, W="W", H="H", w="w", h="h"):
if pattern == "pingpong":
ex = f"({W}-{w})*abs(mod((t/{max(sx,1e-6)})*2,2)-1)"
ey = f"({H}-{h})*abs(mod((t/{max(sy,1e-6)})*2,2)-1)"
elif pattern == "sine":
ex = f"({W}-{w})*(0.5+0.5*sin(2*PI*t/{max(sx,1e-6)}))"
ey = f"({H}-{h})*(0.5+0.5*cos(2*PI*t/{max(sy,1e-6)}))"
elif pattern == "wrap":
ex = f"mod(t*{sx}, {W}-{w})"
ey = f"mod(t*{sy}, {H}-{h})"
elif pattern == "lissajous":
ex = f"({W}-{w})*(0.5+0.5*sin(2*PI*t/{max(sx,1e-6)}))"
ey = f"({H}-{h})*(0.5+0.5*sin(2*PI*t/{max(sy,1e-6)} + PI/2))"
elif pattern == "gravity":
ex = f"({W}-{w})*(0.5+0.5*sin(2*PI*{sx}*t))"
ey = f"({H}-{h})*(abs(sin(2*PI*{sx}*t))*exp(-{max(sy,0)}*t))"
# Extra patterns
elif pattern == "circle":
ex = f"({W}-{w})*(0.5+0.5*cos(2*PI*t/{max(sx,1e-6)}))"
ey = f"({H}-{h})*(0.5+0.5*sin(2*PI*t/{max(sy,1e-6)}))"
elif pattern == "ellipse":
ex = f"({W}-{w})*(0.5+0.5*cos(2*PI*t/{max(sx,1e-6)}))"
ey = f"({H}-{h})*(0.5+0.5*0.7*sin(2*PI*t/{max(sy,1e-6)}))"
elif pattern == "figure8":
ex = f"({W}-{w})*(0.5+0.5*sin(2*PI*t/{max(sx,1e-6)}))"
ey = f"({H}-{h})*(0.5+0.5*sin(4*PI*t/{max(sy,1e-6)}))"
elif pattern == "rose3":
ang = f"(2*PI*t/{max(sx,1e-6)})"
r = f"(0.4*(1+sin(3*{ang})))"
ex = f"({W}-{w})*(0.5+{r}*cos({ang}))"
ey = f"({H}-{h})*(0.5+{r}*sin({ang}))"
else:
raise ValueError("Unknown pattern")
return ex, ey
def _hex_to_rgb0x(s):
s = s.strip()
if s.startswith("#"): s = s[1:]
if not re.fullmatch(r"[0-9a-fA-F]{6}", s):
raise ValueError(f"Bad hex color: {s}")
return "0x" + s.upper()
def _eq_chain(bright, contrast, saturation, gamma):
# Defaults: 0/1/1/1 ⇒ no-op
parts = []
if bright != 0.0: parts.append(f"brightness={bright}")
if contrast != 1.0: parts.append(f"contrast={contrast}")
if saturation != 1.0: parts.append(f"saturation={saturation}")
if gamma != 1.0: parts.append(f"gamma={gamma}")
return ("," + "eq=" + ":".join(parts)) if parts else ""
def build_filtergraph(args, T=None):
W, H = args.res.split("x")
fps = args.fps
tcap = f"if(lt(t,{T}),t,{T})" if (args.duration_mode == "freeze" and T is not None) else "t"
motion_on = (args.motion == "on")
# Motion expressions if needed
if motion_on:
ex, ey = motion_expr(args.pattern, args.speed_x, args.speed_y, "W","H","w","h")
if tcap != "t":
ex = ex.replace("t", tcap)
ey = ey.replace("t", tcap)
if args.randomize:
patterns = ["pingpong","sine","wrap","lissajous","gravity","circle","ellipse","figure8","rose3"]
chosen = random.choice(patterns)
ex, ey = motion_expr(chosen, args.speed_x, args.speed_y, "W","H","w","h")
if tcap != "t":
ex = ex.replace("t", tcap)
ey = ey.replace("t", tcap)
else:
ex = "(W-w)/2"; ey = "(H-h)/2"
# Trails only when moving
trail_chain = []
if motion_on:
if args.decay is not None:
trail_chain.append(f"lagfun=decay={args.decay}")
if args.delay_frames and args.delay_frames > 0:
trail_chain.append(f"tmix=frames={args.delay_frames}")
if args.trail_blur and args.trail_blur > 0:
trail_chain.append(f"gblur=sigma={args.trail_blur}")
trail_chain_str = ",".join(trail_chain) if trail_chain else "null"
# Spin only when moving
spin_ang_expr = None
if motion_on and args.spin_period > 0:
phase = args.spin_phase_deg * math.pi / 180.0
spin_ang_expr = f"(2*PI*({tcap})/{args.spin_period} + {phase})"
# Foreground EQ chain
eqc = _eq_chain(args.fg_brightness, args.fg_contrast, args.fg_saturation, args.fg_gamma)
g = []
# Background to canvas
g.append(f"[1:v]scale={W}:{H},fps={fps}[bg]")
if args.mode == "mask":
# FG to canvas + optional EQ
g.append(f"[0:v]scale={W}:{H},format=rgba,fps={fps}{eqc}[fgraw]")
if motion_on:
# moving small mask (original behavior)
if args.mask:
g.append(f"[2:v]fps={fps},scale={args.mask_size}:-1:flags=lanczos,format=gray,boxblur=2:1[m_gray]")
if args.mask_key_type != "none":
kcol = _hex_to_rgb0x(args.mask_key_color)
g.append(f"[2:v]fps={fps},scale={args.mask_size}:-1:flags=lanczos,format=rgba[m_rgba]")
if args.mask_key_type == "colorkey":
g.append(f"[m_rgba]colorkey={kcol}:{args.mask_key_similarity}:{args.mask_key_blend},format=rgba[m_keyed]")
else:
g.append(f"[m_rgba]chromakey={kcol}:{args.mask_key_similarity}:{args.mask_key_blend},format=rgba[m_keyed]")
g.append(f"[m_keyed]alphaextract[m_key_a]")
g.append(f"[m_gray][m_key_a]blend=all_mode=normal:all_opacity={args.mask_key_mix}[m0]")
else:
g.append(f"[m_gray]copy[m0]")
else:
g.append(f"color=c=white:s={args.mask_size}x{args.mask_size}:r={fps},format=gray[m0]")
if spin_ang_expr:
g.append(f"[m0]rotate=angle='{spin_ang_expr}':fillcolor=black@0:ow='rotw(iw)':oh='roth(ih)'[m0r]")
else:
g.append(f"[m0]copy[m0r]")
g.append(f"color=c=black:s={W}x{H}:r={fps},format=gray[a0]")
g.append(f"[a0][m0r]overlay=x='{ex}':y='{ey}':eval=frame,format=gray[a1]")
g.append(f"[a1]{trail_chain_str},format=gray[atrail]")
g.append(f"[fgraw][atrail]alphamerge[fgm]")
else:
# NON-MOTION: full-frame matte path (your example)
if args.mask:
blur = f",boxblur={args.mask_blur}:1" if args.mask_blur > 0 else ""
g.append(f"[2:v]fps={fps},scale={W}:{H},format=gray{blur}[a1]")
else:
g.append(f"color=c=white:s={W}x{H}:r={fps},format=gray[a1]")
g.append(f"[fgraw][a1]alphamerge[fgm]")
# Optional alpha multiplier (aa)
if args.alpha_mul != 1.0:
g.append(f"[fgm]format=rgba,colorchannelmixer=aa={args.alpha_mul}[fg]")
else:
g.append(f"[fgm]copy[fg]")
if args.alpha_out:
g.append(f"color=c=black@0.0:s={W}x{H}:r={fps},format=rgba[zc]")
g.append(f"[zc][fg]overlay=0:0:eval=frame[out]")
else:
g.append(f"[bg][fg]overlay=0:0:eval=frame[out]")
elif args.mode == "sprite":
# Sprite path (apply EQ to sprite too)
g.append(f"[0:v]fps={fps},scale={args.mask_size}:-1:flags=lanczos,format=rgba[s0]")
if eqc:
g.append(f"[s0]{eqc[1:]}[s0eq]") # eqc starts with ",", remove it
src = "s0eq"
else:
src = "s0"
if spin_ang_expr:
g.append(f"[{src}]rotate=angle='{spin_ang_expr}':fillcolor=black@0:ow='rotw(iw)':oh='roth(ih)'[s0r]")
g.append(f"[s0r]format=gray[sm]")
else:
g.append(f"[{src}]copy[s0r]")
g.append(f"[{src}]format=gray[sm]")
g.append(f"color=c=black@0.0:s={W}x{H}:r={fps},format=rgba[zc]")
g.append(f"[zc][s0r]overlay=x='{ex}':y='{ey}':eval=frame[splaced]")
g.append(f"color=c=black:s={W}x{H}:r={fps},format=gray[a0]")
g.append(f"[a0][sm]overlay=x='{ex}':y='{ey}':eval=frame,format=gray[a1]")
if motion_on and trail_chain_str != "null":
g.append(f"[a1]{trail_chain_str},format=gray[atrail]")
else:
g.append(f"[a1]copy[atrail]")
g.append(f"[splaced][atrail]alphamerge[spr]")
if args.alpha_mul != 1.0:
g.append(f"[spr]format=rgba,colorchannelmixer=aa={args.alpha_mul}[fg]")
else:
g.append(f"[spr]copy[fg]")
if args.alpha_out:
g.append(f"[fg]format=rgba[out]")
else:
g.append(f"[bg][fg]overlay=0:0:eval=frame[out]")
else:
raise ValueError("mode must be 'mask' or 'sprite'")
return ";".join(g)
def probe_duration(infile):
try:
out = subprocess.check_output(
["ffprobe","-v","error","-select_streams","v:0",
"-show_entries","stream=duration","-of","default=nw=1:nk=1", infile],
text=True).strip()
return float(out) if out else None
except Exception:
return None
def probe_has_audio(path: str) -> bool:
try:
out = subprocess.check_output(
["ffprobe","-v","error","-select_streams","a",
"-show_entries","stream=index","-of","csv=p=0", path],
text=True).strip()
return bool(out)
except Exception:
return False
def main():
p = argparse.ArgumentParser(description="Bounce/sprite compositor with motion on/off, full-frame mask matte, EQ, spin, trails, keying, and flexible audio.")
p.add_argument("-i","--input", required=True, help="Foreground video (FG).")
p.add_argument("-o","--output", required=True, help="Output file.")
p.add_argument("-b","--background", default="bg.mp4", help="Background video/image.")
p.add_argument("-m","--mask", help="Mask image/video (mask mode).")
p.add_argument("--mode", choices=["mask","sprite"], default="mask")
p.add_argument("--motion", choices=["on","off"], default="on", help="XY motion/spin/trails on (default) or static matte/sprite.")
p.add_argument("--res", default="1280x720")
p.add_argument("--fps", type=int, default=30)
# Motion
p.add_argument("--pattern", choices=["pingpong","sine","wrap","lissajous","gravity","circle","ellipse","figure8","rose3"], default="sine")
p.add_argument("--randomize", action="store_true")
p.add_argument("--speed-x", type=float, default=4.0)
p.add_argument("--speed-y", type=float, default=6.0)
# Size & trails
p.add_argument("--mask-size", type=int, default=420)
p.add_argument("--decay", type=float, default=0.88)
p.add_argument("--delay-frames", type=int, default=0)
p.add_argument("--trail-blur", type=float, default=0.0)
# Spin
p.add_argument("--spin-period", type=float, default=0.0)
p.add_argument("--spin-phase-deg", type=float, default=0.0)
# Output / formats
p.add_argument("--alpha-out", action="store_true")
p.add_argument("--codec", default="")
p.add_argument("--pixfmt", default="")
p.add_argument("--crf", type=int, default=18)
p.add_argument("--preset", default="veryfast")
p.add_argument("--duration-mode", choices=["shortest","freeze"], default="shortest")
# Mask keying
p.add_argument("--mask-key-type", choices=["none","colorkey","chromakey"], default="none")
p.add_argument("--mask-key-color", default="#00FF00")
p.add_argument("--mask-key-similarity", type=float, default=0.25)
p.add_argument("--mask-key-blend", type=float, default=0.08)
p.add_argument("--mask-key-mix", type=float, default=1.0)
# NEW: Full-frame mask controls (non-motion)
p.add_argument("--mask-fullframe", action="store_true", help="Use the mask as a full-frame matte (best with --motion off).")
p.add_argument("--mask-blur", type=float, default=0.0, help="Boxblur radius for full-frame mask (pixels).")
# NEW: Foreground EQ (applies pre-alpha)
p.add_argument("--fg-brightness", type=float, default=0.0)
p.add_argument("--fg-contrast", type=float, default=1.0)
p.add_argument("--fg-saturation", type=float, default=1.0)
p.add_argument("--fg-gamma", type=float, default=1.0)
# NEW: Alpha multiplier
p.add_argument("--alpha-mul", type=float, default=1.0, help="Multiply final alpha (e.g., 0.85).")
# Audio
p.add_argument("--audio-mode", choices=["auto","mix","1","2","none"], default="auto")
args = p.parse_args()
inputs = ["-i", args.input, "-i", args.background]
if args.mode == "mask" and args.mask:
inputs += ["-i", args.mask]
T = probe_duration(args.input) if args.duration_mode == "freeze" else None
has_a0 = probe_has_audio(args.input)
has_a1 = probe_has_audio(args.background)
fgraph = build_filtergraph(args, T)
vcodec = args.codec or ("libvpx-vp9" if args.alpha_out else "libx264")
pixfmt = args.pixfmt or ("yuva420p" if args.alpha_out else "yuv420p")
extra = ["-auto-alt-ref","0"] if (args.alpha_out and vcodec == "libvpx-vp9") else []
# ----- AUDIO MODES -----
filter_complex_arg = fgraph
map_args = []
if args.alpha_out or args.audio_mode == "none":
map_args = ["-map","[out]"]
else:
mode = args.audio_mode
if mode == "auto":
if has_a0 and has_a1:
filter_complex_arg += ";[0:a][1:a]amix=inputs=2:dropout_transition=200:normalize=0,aformat=sample_fmts=fltp:channel_layouts=stereo,aresample=async=1[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
elif has_a0:
map_args = ["-map","[out]","-map","0:a:0","-c:a","aac"]
elif has_a1:
map_args = ["-map","[out]","-map","1:a:0","-c:a","aac"]
else:
filter_complex_arg += ";anullsrc=r=48000:cl=stereo[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
elif mode == "mix":
if has_a0 and has_a1:
filter_complex_arg += ";[0:a][1:a]amix=inputs=2:dropout_transition=200:normalize=0,aformat=sample_fmts=fltp:channel_layouts=stereo,aresample=async=1[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
elif has_a0:
map_args = ["-map","[out]","-map","0:a:0","-c:a","aac"]
elif has_a1:
map_args = ["-map","[out]","-map","1:a:0","-c:a","aac"]
else:
filter_complex_arg += ";anullsrc=r=48000:cl=stereo[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
elif mode == "1":
if has_a0:
map_args = ["-map","[out]","-map","0:a:0","-c:a","aac"]
else:
filter_complex_arg += ";anullsrc=r=48000:cl=stereo[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
elif mode == "2":
if has_a1:
map_args = ["-map","[out]","-map","1:a:0","-c:a","aac"]
else:
filter_complex_arg += ";anullsrc=r=48000:cl=stereo[aout]"
map_args = ["-map","[out]","-map","[aout]","-c:a","aac"]
cmd = ["ffmpeg","-y"] + inputs + ["-filter_complex", filter_complex_arg] + map_args + [
"-c:v", vcodec, "-pix_fmt", pixfmt, "-crf", str(args.crf), "-preset", args.preset
]
if args.duration_mode == "shortest":
cmd += ["-shortest"]
cmd += extra + [args.output]
print("\n>>> Running:\n" + " ".join(shlex.quote(c) for c in cmd) + "\n")
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as e:
sys.exit(e.returncode)
if __name__ == "__main__":
main()