Files
ECC/skills/taste-application/scripts/forge.py
T
928c1dea72 feat(tasteforge): package reusable workflows and preserve native edits (#3033)
* 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>
2026-09-10 15:31:36 +01:00

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()