mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
* feat: bundle standalone taste distillation and application workflows * docs: fix imported taste skill markdown lint * docs: align Turkish agent catalog with taste skills * refactor: make ECC the canonical reusable video engine * fix: preserve video duration when applying image overlays * fix: preserve background colors in image compositing * fix: report best-effort duration targets and shortfalls * feat: ship verified Fusion presets with compatibility provenance * feat(tasteforge): preserve native edits in application bundles * feat(tasteforge): compile local preservation without hosted input * fix: update js-yaml to patched 4.3.2 * test: report bounded Stop wrapper failure diagnostics * fix(tasteforge): fail closed on unsafe output names, missing overlays and cadence - cli: default report and spec paths are derived from pack name and profile genre; require the manifest's name pattern before using either as a filename part so a traversal string cannot write outside cwd/out. - apply_local: a pack without cadence.json, or with no measured shots and no explicit mean_shot, raises instead of silently planning 1.0s shots and reporting a measured cadence. - legacy apply: a missing overlay aborts before any paid upload; forge() would have rejected it after every take was generated. - requirements-live: pin fal-client>=0.13.0, the first release whose subscribe() accepts client_timeout. Addresses the five P1 findings from the independent review of #3033. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fxHRsydPqEcYngGbqkgt1 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
344 lines
16 KiB
Python
344 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Final stage: material in, finished video out.
|
|
|
|
python forge.py --genre flashethereal --takes gen/a.mp4 gen/b.mp4 \
|
|
--base-video existing.mp4 --overlays stills/x.png --duration 15 \
|
|
--out out/final.mp4
|
|
|
|
Takes generated clips (from the fal apply workflow, or anywhere), grades them
|
|
with the pack, cuts them at the reference's measured cadence, optionally weaves
|
|
in shots from an existing video being supplemented, composites overlay images,
|
|
and concatenates the result.
|
|
|
|
The grade happens here rather than in the prompt because that is what the
|
|
measurements support: three paid generations with escalating colour direction
|
|
moved midtone a* from +1.9 to +2.8 against a +24.9 target and never shifted
|
|
contrast off ~19 against 34.7, while applying the pack reached MAE 1.88 and
|
|
contrast 33.7 deterministically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
from taste import assemble as asm
|
|
from taste import cadence as cad_mod
|
|
from taste import frames as frame_mod
|
|
from taste import grade as grade_mod
|
|
from taste import pack as pack_mod
|
|
from taste import plates as plate_mod
|
|
from taste import timeline as tl_mod
|
|
|
|
|
|
def validate_output(out_path: Path) -> None:
|
|
"""Refuse to replace either a viewing copy or any part of its handoff."""
|
|
outputs = [out_path, *(out_path.with_suffix(s) for s in (".fcpxml", ".edl", ".json"))]
|
|
if len(set(outputs)) != len(outputs):
|
|
raise ValueError("output must have a video suffix distinct from timeline/manifest files")
|
|
for path in outputs:
|
|
if path.exists() or path.is_symlink():
|
|
raise FileExistsError(f"output already exists; choose a new --out: {path}")
|
|
|
|
|
|
def forge(
|
|
genre: str,
|
|
takes: list[str],
|
|
out: str,
|
|
root: str = "stylepacks",
|
|
base_video: str | None = None,
|
|
base_ratio: float = 0.35,
|
|
overlays: list[str] | None = None,
|
|
overlay_every: int = 4,
|
|
overlay_opacity: float = 0.3,
|
|
duration: float | None = None,
|
|
strength: float = 1.0,
|
|
grade_base: bool = True,
|
|
width: int | None = None,
|
|
height: int | None = None,
|
|
work: str = "out/forge_work",
|
|
plan: list[dict] | None = None,
|
|
fps: float | None = None,
|
|
) -> Path:
|
|
out_path = Path(out)
|
|
validate_output(out_path)
|
|
if not takes or any(not Path(t).is_file() for t in takes):
|
|
raise ValueError("all takes must be readable local files")
|
|
if base_video and not Path(base_video).is_file():
|
|
raise ValueError("base video must be a readable local file")
|
|
if any(not Path(o).is_file() for o in (overlays or [])):
|
|
raise ValueError("all overlays must be readable local files")
|
|
if fps is not None and (not math.isfinite(fps) or fps <= 0):
|
|
raise ValueError("fps must be finite and positive")
|
|
sp = pack_mod.load(genre, root=root)
|
|
tgt = grade_mod.load_stats(sp.grade_path)
|
|
cad = cad_mod.load(sp.cadence_path)
|
|
|
|
# Geometry comes from the first take unless overridden; everything else is
|
|
# normalized to it so concat does not silently fail on a size mismatch.
|
|
info0 = frame_mod.probe(takes[0])
|
|
W = width or info0.width
|
|
H = height or info0.height
|
|
FPS = fps if fps is not None else info0.fps
|
|
if not math.isfinite(FPS) or FPS <= 0:
|
|
raise ValueError("source fps must be finite and positive; provide --fps")
|
|
if W <= 0 or H <= 0:
|
|
raise ValueError("output width and height must be positive")
|
|
# Prior timelines reference these shot files. Each run owns a fresh child,
|
|
# including failed runs, so retries cannot erase an existing edit.
|
|
work_root = Path(work)
|
|
work_root.mkdir(parents=True, exist_ok=True)
|
|
work_dir = Path(tempfile.mkdtemp(prefix="run-", dir=work_root)).resolve()
|
|
print(f"forging '{genre}' -> {W}x{H} @ {FPS:g}fps")
|
|
print(f" cadence: mean {cad.mean_shot:.2f}s, {cad.cuts_per_min:.0f} cuts/min, "
|
|
f"variance {cad.rhythm_variance:.2f}")
|
|
|
|
total_target = duration or sum(frame_mod.probe(t).duration for t in takes)
|
|
# When apply.py generated these takes it already decided where the cuts
|
|
# fall, and it told the model so ("cut into 6 pieces of ~0.8s"). Re-planning
|
|
# here would silently cut somewhere else, against footage shot for the
|
|
# original plan - so the caller's plan wins when there is one.
|
|
if plan is None:
|
|
# Plan PER TAKE against each take's own length, not by splitting the
|
|
# target across an arbitrary number of groups.
|
|
#
|
|
# plan_takes() answers "how do I fill N seconds": for a 12s target it
|
|
# returns 4 groups whose shot counts taper (8, 3, 1, ...). Handing
|
|
# those groups to three 5-second takes cuts the first take into 8
|
|
# shots and the third into 1, throwing away most of the footage that
|
|
# was just paid for. Each supplied take is 5 seconds of usable
|
|
# material and should be cut as such.
|
|
plan = []
|
|
for i, t in enumerate(takes):
|
|
tdur = frame_mod.probe(t).duration
|
|
cursor, shots = 0.0, []
|
|
for d in cad.plan_shots(tdur):
|
|
if cursor + d > tdur:
|
|
break
|
|
shots.append({"start": round(cursor, 3), "duration": round(d, 3)})
|
|
cursor += d
|
|
plan.append({"index": i, "shots": shots or
|
|
[{"start": 0.0, "duration": round(tdur, 3)}]})
|
|
print(f" plan: {sum(len(t['shots']) for t in plan)} shots across "
|
|
f"{len(plan)} takes, cut to each take's own length (re-planned)")
|
|
else:
|
|
print(f" plan: {sum(len(t['shots']) for t in plan)} shots across "
|
|
f"{len(plan)} takes (from generation plan)")
|
|
|
|
# ---- grade + cut each take -------------------------------------------
|
|
gen_shots: list[Path] = []
|
|
for i, take_path in enumerate(takes):
|
|
norm = asm.normalize(take_path, work_dir / f"take{i}_norm.mp4", W, H, FPS)
|
|
graded = work_dir / f"take{i}_graded.mp4"
|
|
print(f" [take {i}] grading {Path(take_path).name} ...")
|
|
grade_mod.grade_clip_direct(norm, graded, tgt, strength=strength)
|
|
shots = plan[i % len(plan)]["shots"]
|
|
cuts = asm.cut_take(graded, shots, work_dir / f"take{i}_shots", prefix=f"g{i}", fps=FPS)
|
|
print(f" {len(cuts)} shots cut")
|
|
gen_shots.extend(cuts)
|
|
|
|
# ---- optional: shots from the video being supplemented ---------------
|
|
base_shots: list[Path] = []
|
|
if base_video and Path(base_video).exists():
|
|
print(f" [base] supplementing {Path(base_video).name} ...")
|
|
# Detect and cut off the capture app's interface before anything else
|
|
# touches this footage. Skipping it ships a like button and a view
|
|
# counter into the finished piece.
|
|
bframes = frame_mod.sample_frames(base_video, n=48, max_edge=720)
|
|
bcrop = frame_mod.crop_fractions(bframes)
|
|
kept = (bcrop[1] - bcrop[0]) * (bcrop[3] - bcrop[2])
|
|
print(f" UI crop: keeping {100 * (bcrop[3] - bcrop[2]):.0f}% wide x "
|
|
f"{100 * (bcrop[1] - bcrop[0]):.0f}% tall ({100 * kept:.0f}% of frame)")
|
|
norm = asm.normalize(base_video, work_dir / "base_norm.mp4", W, H, FPS,
|
|
crop=bcrop, fit="cover")
|
|
src = norm
|
|
if grade_base:
|
|
src = work_dir / "base_graded.mp4"
|
|
grade_mod.grade_clip_direct(norm, src, tgt, strength=strength)
|
|
bdur = frame_mod.probe(src).duration
|
|
# Use the base video's OWN shot boundaries, not synthetic ones.
|
|
#
|
|
# Slicing it into contiguous pieces and playing them in order simply
|
|
# reassembles the original: every "cut" falls mid-shot and is
|
|
# invisible. Measured that way, a 20-shot assembly registered only 12
|
|
# detected cuts, because the base segments rejoined seamlessly. Real
|
|
# boundaries make each borrowed piece an actual shot, and taking every
|
|
# Nth one guarantees a visible discontinuity between consecutive picks.
|
|
bcad = cad_mod.detect(base_video)
|
|
real = [s for s in bcad.shots if s.get("duration", 0) > 0.15]
|
|
print(f" base has {len(real)} real shots")
|
|
want = max(1, int(total_target * (base_ratio / max(1e-6, 1 - base_ratio)) / max(0.2, cad.mean_shot)))
|
|
step = max(1, len(real) // max(1, want))
|
|
picked = real[::step][:want]
|
|
flat = []
|
|
for k, s in enumerate(picked):
|
|
dur = min(float(s["duration"]), max(0.25, cad.plan_shots(cad.mean_shot * 1.2)[0]))
|
|
st = float(s["start"])
|
|
if st >= bdur - 0.1:
|
|
continue
|
|
flat.append({"start": round(st, 3), "duration": round(min(dur, bdur - st), 3)})
|
|
base_shots = asm.cut_take(src, flat, work_dir / "base_shots", prefix="b", fps=FPS)
|
|
print(f" {len(base_shots)} base shots taken (every {step}th real shot)")
|
|
|
|
order = asm.weave(gen_shots, base_shots, ratio=base_ratio) if base_shots else gen_shots
|
|
|
|
# ---- overlays ---------------------------------------------------------
|
|
ov = [o for o in (overlays or []) if Path(o).exists()]
|
|
if ov:
|
|
# Tighten every plate to its own content first. A glow plate is ~4%
|
|
# covered by construction, so compositing it at frame size puts a small
|
|
# bright dot in the middle of the shot - it reads as a sticker, not as
|
|
# light. Tightening raises coverage to 15-35% and hands size control to
|
|
# the caller.
|
|
tight = []
|
|
for o in ov:
|
|
try:
|
|
t = plate_mod.tighten(o, work_dir / "plates" / (Path(o).stem + ".png"))
|
|
tight.append((t, plate_mod.plate_coverage(t)))
|
|
except Exception:
|
|
tight.append((Path(o), 0.15))
|
|
|
|
print(f" [overlay] {len(tight)} plate(s) every {overlay_every} shots @ {overlay_opacity:.2f}")
|
|
# Deterministic variation: the same inputs give the same cut, but no two
|
|
# stamped shots share a placement. Stamping one mark in one spot every
|
|
# Nth shot is what made the earlier cut look like a watermark.
|
|
rng = np.random.default_rng(11)
|
|
anchors = ["center", "topright", "bottomleft", "topleft", "bottomright",
|
|
"left", "right", "top", "bottom"]
|
|
stamped: list[Path] = []
|
|
for i, clip in enumerate(order):
|
|
if overlay_every > 0 and i % overlay_every == 0:
|
|
plate, cov = tight[(i // max(1, overlay_every)) % len(tight)]
|
|
# Diffuse plates work as a full-frame wash; concentrated ones
|
|
# are elements and want to be placed and kept smallish.
|
|
wash = cov < 0.10
|
|
sc = float(rng.uniform(0.85, 1.0) if wash else rng.uniform(0.35, 0.7))
|
|
pos = "center" if wash else anchors[int(rng.integers(len(anchors)))]
|
|
rot = 0.0 if wash else float(rng.uniform(-0.6, 0.6))
|
|
opa = overlay_opacity * (0.75 if wash else 1.25)
|
|
dst = work_dir / f"ov_{i:03d}.mp4"
|
|
# Requested overlays are part of the output contract. A failed
|
|
# composite must not produce a successful, unstamped handoff.
|
|
stamped.append(asm.overlay(
|
|
clip, plate, dst, opacity=min(0.95, opa), scale=sc,
|
|
position=pos, rotate=rot, width=W, height=H))
|
|
continue
|
|
stamped.append(clip)
|
|
order = stamped
|
|
|
|
# ---- assemble ---------------------------------------------------------
|
|
if duration:
|
|
kept, acc = [], 0.0
|
|
for c in order:
|
|
d = frame_mod.probe(c).duration
|
|
if acc + d > duration * 1.08 and kept:
|
|
break
|
|
kept.append(c); acc += d
|
|
if kept:
|
|
print(f" trimmed {len(order)} -> {len(kept)} shots toward target {duration:.1f}s")
|
|
order = kept
|
|
|
|
out_path = Path(out)
|
|
asm.concat(order, out_path, fps=FPS)
|
|
final = frame_mod.probe(out_path)
|
|
duration_delta = final.duration - duration if duration is not None else 0.0
|
|
duration_contract = {
|
|
"policy": "cadence_target",
|
|
"requested_seconds": duration,
|
|
"actual_seconds": round(final.duration, 6),
|
|
"shortfall_seconds": round(max(0.0, -duration_delta), 6),
|
|
"overrun_seconds": round(max(0.0, duration_delta), 6),
|
|
}
|
|
if duration is not None and abs(duration_delta) + 1e-9 >= 1.0 / FPS:
|
|
print(f" WARNING: cadence target {duration:.3f}s produced {final.duration:.3f}s "
|
|
f"(shortfall {duration_contract['shortfall_seconds']:.3f}s, "
|
|
f"overrun {duration_contract['overrun_seconds']:.3f}s); "
|
|
"whole cadence shots are preserved without padding or duplication")
|
|
|
|
# Ship an EDITABLE timeline beside the flattened mp4.
|
|
#
|
|
# The mp4 is a viewing copy; it is the one thing a colourist cannot work
|
|
# with, because every cut is baked in and the shots are no longer separable.
|
|
# The FCPXML and EDL carry the same 24 cuts as real edit points referencing
|
|
# the individual graded shot files, so the piece lands in Resolve as a
|
|
# timeline that can be re-cut, re-ordered and re-graded rather than as a
|
|
# single clip somebody has to razor by hand.
|
|
#
|
|
# Shot files are kept: the timeline references them by absolute path, so
|
|
# deleting work_dir breaks the handoff even though the mp4 still plays.
|
|
tl_clips = [
|
|
{"path": str(Path(c).resolve()),
|
|
"duration": frame_mod.probe(c).duration,
|
|
"name": Path(c).stem}
|
|
for c in order
|
|
]
|
|
timelines = {}
|
|
for fmt in ("fcpxml", "edl"):
|
|
tp = tl_mod.write_timeline(
|
|
tl_clips, fps=FPS, out_path=out_path.with_suffix("." + fmt),
|
|
fmt=fmt, title=f"{genre}_cut", width=W, height=H,
|
|
)
|
|
if not Path(tp).is_file():
|
|
raise RuntimeError(f"{fmt} export did not create a timeline: {tp}")
|
|
timelines[fmt] = str(tp)
|
|
print(f" timeline -> {tp}")
|
|
|
|
asm.write_manifest(out_path.with_suffix(".json"), {
|
|
"genre": genre,
|
|
"work_directory": str(work_dir),
|
|
"created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"geometry": {"width": W, "height": H, "fps": FPS},
|
|
"takes": [str(t) for t in takes],
|
|
"base_video": base_video,
|
|
"base_ratio": base_ratio if base_shots else 0.0,
|
|
"overlays": ov,
|
|
"shots": len(order),
|
|
"generated_shots": len(gen_shots),
|
|
"base_shots": len(base_shots),
|
|
"duration": round(final.duration, 3),
|
|
"duration_contract": duration_contract,
|
|
"grade_strength": strength,
|
|
"timelines": timelines,
|
|
"shot_files": [str(Path(c).resolve()) for c in order],
|
|
"pack": {"contrast": tgt.contrast, "black": tgt.black_point, "white": tgt.white_point},
|
|
})
|
|
|
|
print(f"\n {len(order)} shots -> {final.duration:.2f}s @ {final.width}x{final.height}")
|
|
print(f" final -> {out_path}")
|
|
return out_path
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="Assemble a finished video from generated takes.")
|
|
ap.add_argument("--genre", required=True)
|
|
ap.add_argument("--takes", required=True, nargs="+", help="generated clips to cut from")
|
|
ap.add_argument("--out", default="out/final.mp4")
|
|
ap.add_argument("--root", default="stylepacks")
|
|
ap.add_argument("--base-video", default=None, help="existing video to supplement")
|
|
ap.add_argument("--base-ratio", type=float, default=0.35, help="share of cut from base video")
|
|
ap.add_argument("--no-grade-base", action="store_true", help="leave base video ungraded")
|
|
ap.add_argument("--overlays", nargs="*", default=None, help="overlay image paths")
|
|
ap.add_argument("--overlay-every", type=int, default=4)
|
|
ap.add_argument("--overlay-opacity", type=float, default=0.3)
|
|
ap.add_argument("--duration", type=float, default=None,
|
|
help="best-effort cadence target in seconds, not an exact output duration")
|
|
ap.add_argument("--strength", type=float, default=1.0, help="0-1 grade intensity")
|
|
ap.add_argument("--width", type=int, default=None)
|
|
ap.add_argument("--take-len", type=float, default=5.0)
|
|
ap.add_argument("--fps", type=float, default=None, help="output frame rate; defaults to first take")
|
|
ap.add_argument("--work", default="out/forge_work", help="parent of preserved per-run shot directories")
|
|
ap.add_argument("--height", type=int, default=None)
|
|
a = ap.parse_args()
|
|
forge(a.genre, a.takes, a.out, a.root, a.base_video, a.base_ratio, a.overlays,
|
|
a.overlay_every, a.overlay_opacity, a.duration, a.strength,
|
|
not a.no_grade_base, a.width, a.height, work=a.work, fps=a.fps)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|