Files
ECC/skills/taste-application/scripts/taste/assemble.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

287 lines
12 KiB
Python

"""Final edit: takes in, finished video out.
This is the last stage of the original design - distil taste, mint assets,
generate against them, then *cut the thing together*. Everything upstream
produces material; this produces the deliverable.
Three inputs the earlier stages did not handle:
* **overlay images** composited over the cut, so minted stills, grain plates
and graphic elements can ride on top;
* **a base video to supplement**, where the point is not to generate a new
piece but to push an existing one toward the distilled look and intercut
new material into it;
* **the cut itself**, at the reference's measured cadence rather than at
whatever length the generator happened to emit.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from . import cadence as cad_mod
from . import frames as frame_mod
def _run(cmd: list[str]) -> None:
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {' '.join(cmd[:6])}...\n{proc.stderr[-400:]}")
def cut_take(
src: str | Path,
shots: list[dict],
dest_dir: str | Path,
prefix: str = "shot",
fps: float | None = None,
) -> list[Path]:
"""Slice one generated take into its planned sub-shots.
Re-encodes rather than stream-copying. Stream copy can only cut on
keyframes, and at a mean shot length of 0.78s that rounds every boundary
to the nearest GOP - which is precisely the rhythm this whole pipeline
exists to preserve.
"""
src, dest_dir = Path(src), Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
info = frame_mod.probe(src)
r = fps or info.fps or 24.0
out: list[Path] = []
for i, sh in enumerate(shots):
start, dur = float(sh["start"]), float(sh["duration"])
if start >= info.duration - 0.02:
break
dur = min(dur, max(0.04, info.duration - start))
dst = dest_dir / f"{prefix}_{i:03d}.mp4"
_run([
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-ss", f"{start:.4f}", "-i", str(src), "-t", f"{dur:.4f}",
"-vf", f"fps={r:.6f},setpts=PTS-STARTPTS",
"-an", "-c:v", "libx264", "-crf", "14", "-preset", "veryfast",
"-pix_fmt", "yuv420p", str(dst),
])
out.append(dst)
return out
def overlay(
clip: str | Path,
image: str | Path,
dst: str | Path,
opacity: float = 0.35,
scale: float = 0.55,
position: str | tuple[float, float] = "center",
blend: str = "screen",
width: int | None = None,
height: int | None = None,
rotate: float = 0.0,
) -> Path:
"""Composite a plate over a clip as a placed ELEMENT, not a full-frame wash.
The earlier version stretched every plate to fill the frame with
``scale2ref``. That is right for a diffuse wash and wrong for everything
else: a tightened flare stretched edge to edge reads as a smear, and an
untightened one - 97% empty by construction - reads as a coloured dot
parked in the middle of the shot. Both showed up in a delivered cut.
So the element is scaled to a fraction of frame width, optionally rotated,
placed at a point, and only then blended. ``position`` is either a named
anchor or an ``(x, y)`` pair in frame fractions of the element's top-left
corner, which lets a caller vary placement per shot instead of stamping
the same mark in the same place every time.
``screen`` is the default because plates are premultiplied against black,
so screen drops their blacks for free and no matte is needed.
"""
clip, image, dst = Path(clip), Path(image), Path(dst)
if width is None or height is None:
from . import frames as _fm
info = _fm.probe(clip)
width, height = info.width, info.height
# Resolve the element's pixel size here rather than in ffmpeg expressions.
# pad() rejects a negative offset and cannot pad to a size smaller than its
# input, so an element that lands oversized or off-frame kills the whole
# filtergraph - which it did on the first attempt.
import cv2 as _cv2
_im = _cv2.imread(str(image), _cv2.IMREAD_UNCHANGED)
if _im is None:
raise ValueError(f"cannot read overlay image: {image}")
ih0, iw0 = _im.shape[:2]
ew = max(2, int(width * max(0.02, min(1.0, scale))))
eh = max(2, int(ew * ih0 / max(1, iw0)))
if eh > height: # fit tall elements to the frame instead of overflowing
eh = height
ew = max(2, int(eh * iw0 / max(1, ih0)))
ew, eh = min(ew, width), min(eh, height)
if isinstance(position, tuple):
px = int(width * position[0])
py = int(height * position[1])
else:
anchors = {
"center": (0.5, 0.5), "top": (0.5, 0.12), "bottom": (0.5, 0.88),
"left": (0.14, 0.5), "right": (0.86, 0.5),
"topleft": (0.16, 0.16), "topright": (0.84, 0.16),
"bottomleft": (0.16, 0.84), "bottomright": (0.84, 0.84),
}
ax, ay = anchors.get(position, (0.5, 0.5))
px, py = int(width * ax), int(height * ay)
# Rotation grows the bounding box, so bake it in before computing offsets.
if rotate:
import math as _math
c, sn = abs(_math.cos(rotate)), abs(_math.sin(rotate))
rw, rh = int(ew * c + eh * sn), int(ew * sn + eh * c)
if rw > width or rh > height:
k = min(width / max(1, rw), height / max(1, rh))
ew, eh = max(2, int(ew * k)), max(2, int(eh * k))
rw, rh = int(ew * c + eh * sn), int(ew * sn + eh * c)
ew_f, eh_f = rw, rh
else:
ew_f, eh_f = ew, eh
ox = max(0, min(width - ew_f, px - ew_f // 2))
oy = max(0, min(height - eh_f, py - eh_f // 2))
a = max(0.0, min(1.0, opacity))
rot = (f"rotate={rotate:.4f}:fillcolor=black@0:"
f"ow=rotw({rotate:.4f}):oh=roth({rotate:.4f}),") if rotate else ""
# Scale, rotate, fade, then pad out to full frame on transparent black so a
# full-frame blend only lights up where the element actually sits.
fc = (
f"[1:v]format=rgba,scale={ew}:{eh},{rot}"
f"colorchannelmixer=aa={a:.3f},"
f"pad={width}:{height}:{ox}:{oy}:black@0,"
# Blend RGB planes explicitly: screening neutral YUV chroma produces
# a magenta cast even where the overlay is transparent. Premultiply
# alpha after applying opacity so transparent RGB stays invisible.
f"format=gbrap,premultiply=inplace=1,format=gbrp[ov];"
f"[0:v]format=gbrp[base];"
f"[base][ov]blend=all_mode={blend or 'screen'}:shortest=1,format=yuv420p"
)
_run([
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
# Keep the still alive until the video ends; shortest=1 otherwise
# terminates every shot after the image's single decoded frame.
"-i", str(clip), "-loop", "1", "-i", str(image), "-filter_complex", fc,
"-c:v", "libx264", "-crf", "14", "-preset", "veryfast",
"-pix_fmt", "yuv420p", "-an", str(dst),
])
return Path(dst)
def concat(clips: list[str | Path], dst: str | Path, fps: float = 24.0) -> Path:
"""Join clips into one file. Assumes they already share codec and size."""
clips = [Path(c) for c in clips]
if not clips:
raise ValueError("nothing to concatenate")
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
listing = dst.parent / f"{dst.stem}_concat.txt"
listing.write_text("".join(f"file '{c.resolve().as_posix()}'\n" for c in clips))
_run([
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-f", "concat", "-safe", "0", "-i", str(listing),
"-vf", f"fps={fps:.6f}",
"-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p", str(dst),
])
listing.unlink(missing_ok=True)
return dst
def normalize(
src: str | Path,
dst: str | Path,
width: int,
height: int,
fps: float,
crop: tuple[float, float, float, float] | None = None,
fit: str = "pad",
) -> Path:
"""Force a clip to one size and rate so it can be concatenated with others.
Generated takes and a supplied base video rarely agree on resolution or
frame rate. Scaling with letterbox padding rather than cropping keeps the
supplied footage intact, since the caller chose it deliberately.
"""
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
pre = ""
if crop:
# Crop BEFORE scaling, in fractions of the source frame.
#
# Screen-recorded references carry the capturing app's interface baked
# into the pixels - a like button, a view counter, a comment bubble.
# Borrowing a shot from that footage without cropping ships someone
# else's UI in the finished piece, which is exactly what happened in an
# earlier cut. Fractions rather than pixels because the crop is measured
# on downscaled analysis frames and applied to full-resolution video.
fy0, fy1, fx0, fx1 = crop
pre = (f"crop=w=iw*{max(0.0, fx1 - fx0):.6f}:h=ih*{max(0.0, fy1 - fy0):.6f}"
f":x=iw*{fx0:.6f}:y=ih*{fy0:.6f},")
if fit == "cover":
# Scale up until the frame is covered, then centre-crop the excess.
#
# Padding is the safe default and the wrong one for portrait source in
# a landscape cut. Screen-recorded reference is 9:16; after the UI crop
# it is narrower still, and padding that into 16:9 left roughly 60% of
# frame as black bars - one delivered shot was very nearly an empty
# rectangle. It also poisoned the background measurement, since bars
# are pure black and count as unlit background.
#
# Covering loses the sides of the source, which is the correct trade:
# the subject is centre-framed in this material, and a full frame of
# real picture beats a letterboxed thumbnail of all of it.
geom = (f"scale={width}:{height}:force_original_aspect_ratio=increase,"
f"crop={width}:{height}")
else:
geom = (f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black")
vf = pre + geom + f",setsar=1,fps={fps:.6f}"
_run([
"ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(src),
"-vf", vf, "-an", "-c:v", "libx264", "-crf", "14", "-preset", "veryfast",
"-pix_fmt", "yuv420p", str(dst),
])
return dst
def weave(generated: list[Path], base: list[Path], ratio: float = 0.5) -> list[Path]:
"""Interleave generated shots with shots cut from a supplied base video.
``ratio`` is the share of the finished cut that should come from the base
footage. Shots alternate on a running quota rather than strictly A/B, so
a 0.25 ratio yields occasional base shots scattered through generated
material instead of a rigid every-fourth pattern.
"""
if not base:
return list(generated)
if not generated:
return list(base)
out: list[Path] = []
gi = bi = 0
debt = 0.0
while gi < len(generated) or bi < len(base):
take_base = debt >= 1.0 and bi < len(base)
if not take_base and gi >= len(generated):
take_base = bi < len(base)
if take_base:
out.append(base[bi]); bi += 1; debt -= 1.0
else:
if gi >= len(generated):
break
out.append(generated[gi]); gi += 1; debt += ratio / max(1e-6, 1.0 - ratio)
return out
def write_manifest(path: str | Path, payload: dict) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return path