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>
633 lines
26 KiB
Python
633 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate new video from a style pack. Stage 3 of taste-forge.
|
|
|
|
The pack supplies the *look*; this stage supplies the *content*. Two separate
|
|
flags carry those two axes, and keeping them separate is the whole point:
|
|
|
|
* ``--style-steer`` - how it should LOOK. A per-run nudge on top of the pack's
|
|
distilled spec: "push the teal harder", "longer lens", "less grain". It is
|
|
appended to the look half of the prompt, alongside spec.json and the palette
|
|
measured by mint.py.
|
|
* ``--brief`` - what should HAPPEN. Subject, action, place: "a courier weaves
|
|
through night traffic". It is the only text describing content.
|
|
|
|
Collapsing them into one prompt string is the standard mistake, and it fails
|
|
in both directions: style words leak into the scene ("teal" becomes a teal
|
|
object in frame), and subject words get read as style. Splitting them also
|
|
makes the pack reusable - the same pack drives a hundred different briefs, and
|
|
the same brief can be rendered through a hundred different packs.
|
|
|
|
Shot lengths come from the reference's own cut rhythm (``Cadence.plan_shots``)
|
|
rather than a fixed clip length, so the rough cut inherits the pacing that
|
|
mint.py measured.
|
|
|
|
python apply.py --genre flashethereal \\
|
|
--style-steer "push the teal, longer lens" \\
|
|
--brief "a courier weaves through night traffic" \\
|
|
--duration 20
|
|
|
|
Every network call is stubbed under ``--dry-run`` / ``TASTE_FORGE_DRY_RUN=1``,
|
|
so the full plan, prompts and manifest can be inspected without spending.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import json
|
|
import logging
|
|
import sys
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from taste import cadence as cad_mod
|
|
from taste import falapi
|
|
from taste import grade as grade_mod
|
|
from taste import pack as pack_mod
|
|
|
|
log = logging.getLogger("taste.apply")
|
|
|
|
# ffmpeg-api/compose track types are exactly 'video', 'audio' or 'image', and
|
|
# it accepts only ONE video track - a second one is rejected outright with
|
|
# "Multiple video tracks are not supported". So an image overlay has to ride
|
|
# an 'image' track; declaring it 'video' fails the whole compose.
|
|
OVERLAY_TRACK_TYPE = "image"
|
|
|
|
# Keyframe timestamps and durations are MILLISECONDS in this API, not seconds.
|
|
# Nothing in the response says so - a run submitted in seconds is accepted and
|
|
# returns a video, it is simply 1000x too short.
|
|
_MS = 1000.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prompt construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _spec_line(spec: dict, key: str, label: str) -> str | None:
|
|
val = spec.get(key)
|
|
if isinstance(val, list):
|
|
val = ", ".join(str(v) for v in val)
|
|
val = (val or "").strip() if isinstance(val, str) else ""
|
|
return f"{label}: {val}" if val else None
|
|
|
|
|
|
def build_prompt(
|
|
spec: dict,
|
|
grade: grade_mod.GradeStats | None,
|
|
style_steer: str,
|
|
brief: str,
|
|
index: int,
|
|
n_shots: int,
|
|
duration: float,
|
|
n_cuts: int = 1,
|
|
) -> str:
|
|
"""Assemble one shot prompt: structure and motion only, then brief, then avoid.
|
|
|
|
Deliberately says NOTHING about colour or contrast. That is not an
|
|
oversight, it is the measured conclusion.
|
|
|
|
Three paid generations were run against this pack with progressively more
|
|
explicit colour direction, and the grade never arrived. The reference has
|
|
a*+24.9 in the lower midtones; asking for it produced +1.9, then +2.8, and
|
|
with colour language removed entirely, +0.3. Contrast was asked for in
|
|
escalating terms across all three and sat at 23.4, 19.3, 19.2 against a
|
|
target of 34.7. The model simply does not take numeric colour or tone
|
|
direction.
|
|
|
|
The pack does, deterministically and for free. Applying the same pack's
|
|
zone transfer to the generated footage lands chroma at a mean absolute
|
|
error of 1.4, and its L* CDF match moves contrast 18.7 -> 34.9 against a
|
|
target of 34.7.
|
|
|
|
So the division of labour is: the model supplies content, motion, lighting
|
|
structure and framing, which it is good at; ``grade_clip`` supplies the
|
|
look. Colour words in this prompt are worse than useless - they cost money
|
|
and push the generation away from the neutral base the LUT wants.
|
|
"""
|
|
look: list[str] = []
|
|
for key, label in (
|
|
("lighting", "Lighting"),
|
|
("focal_length", "Lens"),
|
|
("camera_motion", "Camera"),
|
|
("subject_framing", "Framing"),
|
|
("grain", "Texture"),
|
|
("mood_adjectives", "Mood"),
|
|
):
|
|
line = _spec_line(spec, key, label)
|
|
if line:
|
|
look.append(line)
|
|
|
|
# Exposure structure, stated without hue, and QUANTIFIED from the pack.
|
|
#
|
|
# The model responds to local, checkable rules about regions far better
|
|
# than to global ones ("extreme contrast"). But an unbounded rule
|
|
# over-steers: "backgrounds pure black and unlit" produced generations
|
|
# that were 79% pure black against a reference that is 26% black, and the
|
|
# grade cannot pull that back - anchored tone preserves source blacks by
|
|
# design, so the finished cut landed at 52% and failed its background
|
|
# check. Naming the measured share turns an absolute into a target.
|
|
bg = float(getattr(grade, "bg_share", 0.0) or 0.0) if grade else 0.0
|
|
if bg > 0:
|
|
look.append(
|
|
f"Exposure: roughly {round(100 * bg / 5) * 5:.0f}% of each frame is "
|
|
"unlit background falling to pure black, and the rest is brilliantly "
|
|
"lit subject blowing toward white; no flat mid-grey anywhere. Do not "
|
|
"let the frame go mostly black - the lit subject should fill most of it"
|
|
)
|
|
else:
|
|
look.append(
|
|
"Exposure: unlit background falling to pure black behind a brilliantly "
|
|
"lit subject that fills most of the frame and blows toward white; "
|
|
"no flat mid-grey anywhere"
|
|
)
|
|
look.append(
|
|
"Colour: none. Render neutral. Grading is applied afterwards - do not "
|
|
"attempt any colour styling, tint, or cast"
|
|
)
|
|
|
|
if style_steer.strip():
|
|
look.append(f"Style direction (overrides the above on conflict): {style_steer.strip()}")
|
|
|
|
parts = [
|
|
"LOOK - match the reference image's structure, lighting and motion:",
|
|
"\n".join(f"- {ln}" for ln in look) if look else "- match the reference image",
|
|
"",
|
|
"CONTENT - what happens in this shot:",
|
|
brief.strip() or "continue the scene",
|
|
"",
|
|
_take_line(index, n_shots, duration, n_cuts),
|
|
]
|
|
|
|
avoid = spec.get("avoid")
|
|
if isinstance(avoid, list) and avoid:
|
|
parts += ["", "AVOID: " + "; ".join(str(a) for a in avoid)]
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _take_line(index: int, n_takes: int, duration: float, n_cuts: int) -> str:
|
|
"""The one sentence that tells the model what shape of clip to produce.
|
|
|
|
A take that will be cut into six pieces needs different direction from a
|
|
take that plays whole. If the model is told "a single continuous take" and
|
|
nothing else, it happily renders a slow locked-off push, and cutting that
|
|
into six 0.8s pieces produces six near-identical frames - the cuts land
|
|
but read as a stutter, not as edits. Asking for continuous change across
|
|
the take is what makes each cut point look like a different shot.
|
|
"""
|
|
if n_cuts <= 1:
|
|
return (f"This is shot {index + 1} of {n_takes}, {duration:.1f}s, a single "
|
|
"continuous take with no cuts.")
|
|
return (
|
|
f"This is take {index + 1} of {n_takes}: {duration:.1f}s, filmed as ONE "
|
|
f"continuous take with no hard cuts inside it. It will be cut into "
|
|
f"{n_cuts} pieces of roughly {duration / n_cuts:.1f}s in the edit, so "
|
|
"the framing, subject and light must keep changing throughout - any "
|
|
f"{duration / n_cuts:.1f}s window of it has to stand alone as its own shot."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# shot generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ShotPlan:
|
|
"""One planned shot, plus whatever the run produced for it."""
|
|
|
|
def __init__(
|
|
self,
|
|
index: int,
|
|
start: float,
|
|
duration: float,
|
|
still: Path,
|
|
cuts: list[dict] | None = None,
|
|
gen_duration: float | None = None,
|
|
):
|
|
self.index = index
|
|
self.start = start
|
|
self.duration = duration
|
|
self.still = still
|
|
# In take mode this clip is generated once and cut into several shots
|
|
# locally; ``cuts`` are those sub-shot in/out points, relative to the
|
|
# start of the generated file. In shot mode it is a single cut.
|
|
self.cuts = cuts or [{"start": 0.0, "duration": duration}]
|
|
# What the model is actually asked for, which is >= duration because
|
|
# the endpoint has a 4s floor and quantizes to whole seconds.
|
|
self.gen_duration = float(gen_duration or duration)
|
|
self.local_path: Path | None = None
|
|
# ``start`` is the shot's slot in the PLANNED timeline, and doubles as
|
|
# the timecode read out of --base-video. ``timeline_start`` is where it
|
|
# actually lands in the delivered cut, which differs once a failed shot
|
|
# is dropped and the survivors close ranks.
|
|
self.timeline_start: float | None = None
|
|
self.prompt: str = ""
|
|
self.still_url: str | None = None
|
|
self.image_ref_url: str | None = None
|
|
self.video_url: str | None = None
|
|
self.error: str | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"index": self.index,
|
|
"start": round(self.start, 3),
|
|
"timeline_start": (
|
|
round(self.timeline_start, 3) if self.timeline_start is not None else None
|
|
),
|
|
"duration": round(self.duration, 3),
|
|
"duration_sent": self.gen_duration,
|
|
"cuts": self.cuts,
|
|
"local_path": str(self.local_path) if self.local_path else None,
|
|
"still": self.still.name,
|
|
"still_path": str(self.still),
|
|
"still_url": self.still_url,
|
|
"image_ref_url": self.image_ref_url,
|
|
"prompt": self.prompt,
|
|
"video_url": self.video_url,
|
|
"error": self.error,
|
|
}
|
|
|
|
|
|
def _generate(shot: ShotPlan, base_video_url: str | None, lock: threading.Lock) -> ShotPlan:
|
|
"""Produce one shot. Runs on a worker thread; never raises."""
|
|
try:
|
|
shot.still_url = falapi.upload(shot.still)
|
|
|
|
if base_video_url:
|
|
# Supplementing existing footage: the frame already on the timeline
|
|
# at this timecode is a stronger conditioning image than a pack
|
|
# still, because it carries the actual subject and set continuity.
|
|
shot.image_ref_url = falapi.extract_frame(base_video_url, shot.start)
|
|
else:
|
|
shot.image_ref_url = shot.still_url
|
|
|
|
shot.video_url = falapi.reference_to_video(
|
|
shot.image_ref_url, shot.prompt, shot.gen_duration
|
|
)
|
|
with lock:
|
|
log.info("shot %d ok -> %s", shot.index, shot.video_url)
|
|
except Exception as exc: # noqa: BLE001 - one bad shot must not kill the run
|
|
shot.error = f"{type(exc).__name__}: {exc}"
|
|
with lock:
|
|
log.error("shot %d failed: %s", shot.index, shot.error)
|
|
return shot
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# main pipeline
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def apply(
|
|
genre: str,
|
|
style_steer: str,
|
|
brief: str,
|
|
duration: float,
|
|
root: str = "stylepacks",
|
|
base_video: str | None = None,
|
|
overlays: list[str] | None = None,
|
|
out: str | None = None,
|
|
concurrency: int = 4,
|
|
take_len: float = 5.0,
|
|
assemble: bool = True,
|
|
base_ratio: float = 0.35,
|
|
strength: float = 1.0,
|
|
fps: float | None = None,
|
|
tier: str | None = None,
|
|
) -> dict:
|
|
if fps is not None and (not math.isfinite(fps) or fps <= 0):
|
|
raise ValueError("fps must be finite and positive")
|
|
# Fail before uploads/generation, and keep every run's paid originals.
|
|
from forge import validate_output
|
|
out_path = Path(out) if out else Path("out") / f"{genre}_roughcut.mp4"
|
|
validate_output(out_path)
|
|
takes_dir = out_path.parent / f"{out_path.stem}_takes"
|
|
manifest_path = out_path.with_suffix(".generation.json")
|
|
for destination in (takes_dir, manifest_path):
|
|
if destination.exists() or destination.is_symlink():
|
|
raise FileExistsError(f"output already exists; choose a new --out: {destination}")
|
|
if tier:
|
|
falapi.use_tier("reference_to_video", tier)
|
|
sp = pack_mod.load(genre, root=root)
|
|
stills = sp.stills()
|
|
if not stills:
|
|
raise SystemExit(f"pack '{genre}' has no stills - run mint.py first")
|
|
|
|
spec = sp.read_json(sp.spec_path)
|
|
if not spec:
|
|
print(
|
|
f" !! no spec.json in pack; prompts will rely on --style-steer alone.\n"
|
|
f" run: python distill.py --genre {genre}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
grade = grade_mod.load_stats(sp.grade_path) if sp.grade_path.exists() else None
|
|
cad = cad_mod.load(sp.cadence_path) if sp.cadence_path.exists() else cad_mod.Cadence()
|
|
|
|
# Plan TAKES, not shots.
|
|
#
|
|
# One generation per shot is the obvious reading of "match the reference's
|
|
# cadence" and it is economically absurd here: this pack averages 0.78s per
|
|
# shot while the endpoint refuses anything under 4s, so a 10s piece becomes
|
|
# twelve calls, 48 generated seconds for 10 used (21% efficiency), and
|
|
# twelve mutually unrelated clips stitched into what should read as one
|
|
# continuous piece. Rolling ~5s takes and cutting inside them locally is
|
|
# what an editor does: 3 calls, 13 generated seconds, 77% efficiency, and
|
|
# consecutive shots that actually belong to each other.
|
|
mode = "DRY RUN" if falapi.is_dry_run() else "live"
|
|
if take_len and take_len > 0:
|
|
plan = cad_mod.plan_takes(cad, duration, take_len=take_len)
|
|
else:
|
|
plan = [
|
|
{"index": i, "gen_duration": cad_mod.quantize_gen_duration(d),
|
|
"used": d, "shots": [{"start": 0.0, "duration": d}]}
|
|
for i, d in enumerate(cad.plan_shots(duration))
|
|
]
|
|
n_cuts = sum(len(t["shots"]) for t in plan)
|
|
gen_secs = sum(t["gen_duration"] for t in plan)
|
|
used_secs = sum(t["used"] for t in plan)
|
|
print(f"applying '{genre}' [{mode}]: {len(plan)} take(s) -> {n_cuts} shot(s) "
|
|
f"over {duration:.1f}s")
|
|
print(f" cadence : mean {cad.mean_shot:.2f}s, variance {cad.rhythm_variance:.2f}")
|
|
print(f" efficiency : {used_secs:.1f}s used of {gen_secs:.0f}s generated "
|
|
f"({100 * used_secs / max(1e-6, gen_secs):.0f}%), {len(plan)} call(s)")
|
|
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Rotate through the stills so consecutive shots do not all inherit the
|
|
# same frame's composition - the look should carry, the framing should not.
|
|
shots: list[ShotPlan] = []
|
|
clock = 0.0
|
|
for t in plan:
|
|
i = t["index"]
|
|
s = ShotPlan(
|
|
i, clock, float(t["used"]), stills[i % len(stills)],
|
|
cuts=t["shots"], gen_duration=float(t["gen_duration"]),
|
|
)
|
|
s.prompt = build_prompt(
|
|
spec, grade, style_steer, brief, i, len(plan),
|
|
float(t["gen_duration"]), n_cuts=len(t["shots"]),
|
|
)
|
|
shots.append(s)
|
|
clock += float(t["used"])
|
|
|
|
base_video_url = None
|
|
if base_video:
|
|
bp = Path(base_video)
|
|
if not bp.exists():
|
|
raise SystemExit(f"--base-video not found: {bp}")
|
|
base_video_url = falapi.upload(bp)
|
|
print(f" base video : {bp.name} (frames pulled per shot timecode)")
|
|
|
|
overlay_urls: list[str] = []
|
|
if overlays:
|
|
# Fail before any paid upload: forge() rejects a missing overlay
|
|
# later, which would strand every generated take without a manifest.
|
|
missing = [Path(o) for o in overlays if not Path(o).is_file()]
|
|
if missing:
|
|
raise SystemExit("--overlay not found: " + ", ".join(str(m) for m in missing))
|
|
for o in overlays:
|
|
overlay_urls.append(falapi.upload(Path(o)))
|
|
print(f" overlays : {len(overlay_urls)}")
|
|
|
|
# Uploads are cached by (path, mtime, size), so the workers racing on the
|
|
# same handful of stills still only pay for each upload once.
|
|
lock = threading.Lock()
|
|
workers = max(1, min(int(concurrency), len(shots)))
|
|
print(f" generating : {len(shots)} shot(s), {workers} worker(s) ...")
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
list(pool.map(lambda s: _generate(s, base_video_url, lock), shots))
|
|
|
|
ok = [s for s in shots if s.video_url]
|
|
failed = [s for s in shots if not s.video_url]
|
|
for s in failed:
|
|
print(f" !! shot {s.index} failed: {s.error}", file=sys.stderr)
|
|
if not ok:
|
|
raise SystemExit("every shot failed; nothing to compose")
|
|
|
|
# Close ranks over any failed shot so the cut has no black hole in it.
|
|
timeline_clock = 0.0
|
|
for s in ok:
|
|
s.timeline_start = timeline_clock
|
|
timeline_clock += s.duration
|
|
|
|
# Generate long, trim short. Video models quantize to whole seconds with a
|
|
# floor of a few, but the pack's cadence is often faster than that (a 1.1s
|
|
# shot is normal in a fast reference). So each shot is requested at the
|
|
# model's nearest legal length and then cut back to its cadence-derived
|
|
# duration on the timeline - which is the only way the rough cut actually
|
|
# inherits the reference's rhythm instead of a 3s-per-clip floor.
|
|
tracks = [
|
|
{
|
|
"id": "shots",
|
|
"type": "video",
|
|
"keyframes": [
|
|
{
|
|
"url": s_.video_url,
|
|
"timestamp": round(s_.timeline_start * _MS, 1),
|
|
"duration": round(s_.duration * _MS, 1),
|
|
}
|
|
for s_ in ok
|
|
],
|
|
}
|
|
]
|
|
if overlay_urls:
|
|
total = timeline_clock or duration
|
|
span = total / len(overlay_urls)
|
|
tracks.append(
|
|
{
|
|
"id": "overlays",
|
|
"type": OVERLAY_TRACK_TYPE,
|
|
"keyframes": [
|
|
{
|
|
"url": u,
|
|
"timestamp": round(i * span * _MS, 1),
|
|
"duration": round(span * _MS, 1),
|
|
}
|
|
for i, u in enumerate(overlay_urls)
|
|
],
|
|
}
|
|
)
|
|
|
|
# Pull the takes down before anything else touches them. Everything from
|
|
# here on - grade, cut, overlay, concat - is local ffmpeg, which is exact,
|
|
# free, and re-runnable, whereas the hosted composer can only place whole
|
|
# clips at whole timestamps and cannot cut inside a take at all.
|
|
takes_dir.mkdir(parents=True, exist_ok=False)
|
|
for s_ in ok:
|
|
s_.local_path = takes_dir / f"take_{s_.index:03d}.mp4"
|
|
falapi.download(s_.video_url, s_.local_path)
|
|
print(f" takes : {len(ok)} downloaded -> {takes_dir}")
|
|
|
|
compose_mode = "local"
|
|
final_url = None
|
|
if assemble and falapi.is_dry_run():
|
|
# A dry-run "take" is a text placeholder, not an mp4, so there is
|
|
# nothing for the assembler to grade or cut. Everything up to this
|
|
# point - the plan, the prompts, the track layout, the manifest - is
|
|
# still exercised, which is what the dry run is for.
|
|
print(" assemble : skipped (dry-run takes are placeholders)")
|
|
compose_mode = "skipped"
|
|
elif assemble:
|
|
# forge() is the finishing stage: it grades each take with the pack,
|
|
# cuts it at the planned in/out points, weaves in shots from the video
|
|
# being supplemented, composites overlays, and concatenates.
|
|
from forge import forge as _forge
|
|
|
|
_forge(
|
|
genre=genre,
|
|
takes=[str(s_.local_path) for s_ in ok],
|
|
out=str(out_path),
|
|
root=root,
|
|
base_video=base_video,
|
|
base_ratio=base_ratio if base_video else 0.0,
|
|
overlays=overlays,
|
|
duration=duration,
|
|
strength=strength,
|
|
plan=[{"index": s_.index, "shots": s_.cuts} for s_ in ok],
|
|
fps=fps,
|
|
)
|
|
else:
|
|
compose_mode = "compose"
|
|
try:
|
|
final_url = falapi.compose(tracks)
|
|
except falapi.FalError as exc:
|
|
# A composed timeline is the goal, but a plain concatenation still
|
|
# gives an editor something to cut against, so degrade rather than die.
|
|
log.error("compose failed (%s); falling back to merge_videos", exc)
|
|
compose_mode = "merge_videos"
|
|
final_url = falapi.merge_videos([s_.video_url for s_ in ok])
|
|
falapi.download(final_url, out_path)
|
|
|
|
manifest = {
|
|
"genre": genre,
|
|
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"dry_run": falapi.is_dry_run(),
|
|
"style_steer": style_steer,
|
|
"brief": brief,
|
|
"target_duration": duration,
|
|
"planned_duration": round(sum(s.duration for s in shots), 3),
|
|
"base_video": str(base_video) if base_video else None,
|
|
"base_video_url": base_video_url,
|
|
"overlays": [str(o) for o in (overlays or [])],
|
|
"overlay_urls": overlay_urls,
|
|
"concurrency": workers,
|
|
"take_len": take_len,
|
|
"assembled_locally": compose_mode == "local",
|
|
"fps": fps,
|
|
"tier": tier,
|
|
"takes_dir": str(takes_dir),
|
|
"generated_seconds": gen_secs,
|
|
"used_seconds": round(used_secs, 3),
|
|
"efficiency": round(used_secs / max(1e-6, gen_secs), 4),
|
|
"cadence": {
|
|
"mean_shot": cad.mean_shot,
|
|
"rhythm_variance": cad.rhythm_variance,
|
|
"cuts_per_min": cad.cuts_per_min,
|
|
},
|
|
"endpoints": dict(falapi.ENDPOINTS),
|
|
"compose_mode": compose_mode,
|
|
"output_url": final_url,
|
|
"output": str(out_path),
|
|
"n_shots": len(shots),
|
|
"n_failed": len(failed),
|
|
"tracks": tracks,
|
|
"shots": [s.to_dict() for s in shots],
|
|
}
|
|
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
|
|
print(f"\n === {genre} rough cut ===")
|
|
print(f" takes : {len(ok)} ok / {len(failed)} failed")
|
|
print(f" take lengths : {', '.join(f'{s_.gen_duration:.0f}s' for s_ in shots)}")
|
|
print(f" shots cut : {sum(len(s_.cuts) for s_ in ok)}")
|
|
print(f" video : {out_path}")
|
|
print(f" manifest : {manifest_path}")
|
|
if falapi.is_dry_run():
|
|
print(" (dry run - the video file is a placeholder, not footage)")
|
|
return manifest
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(
|
|
description="Generate a rough cut from a style pack (stage 3).",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="--style-steer and --brief are deliberately separate: one is how it\n"
|
|
"LOOKS, the other is what HAPPENS. Merging them leaks style words\n"
|
|
"into the scene and subject words into the grade.",
|
|
)
|
|
ap.add_argument("--genre", required=True, help="existing pack name, e.g. flashethereal")
|
|
ap.add_argument("--root", default="stylepacks")
|
|
ap.add_argument("--style-steer", default="",
|
|
help="HOW IT LOOKS: per-run nudge on top of the pack's spec, "
|
|
"e.g. 'push the teal, longer lens'")
|
|
ap.add_argument("--brief", default="",
|
|
help="WHAT HAPPENS: subject and action, e.g. 'a courier weaves "
|
|
"through night traffic'")
|
|
ap.add_argument("--duration", type=float, default=20.0,
|
|
help="best-effort target seconds, not exact; shot lengths follow the pack's cadence and actual assembly duration is reported")
|
|
ap.add_argument("--base-video",
|
|
help="optional existing footage; each shot is conditioned on the frame "
|
|
"at its own timecode so generated shots supplement the edit")
|
|
ap.add_argument("--overlays", nargs="*", default=None,
|
|
help="optional image paths composited over the rough cut")
|
|
ap.add_argument("--out", help="output video path (default out/<genre>_roughcut.mp4)")
|
|
ap.add_argument("--fps", type=float, default=None, help="local assembly output frame rate")
|
|
ap.add_argument("--tier", default=None, help="reference-to-video provider tier")
|
|
ap.add_argument("--concurrency", type=int, default=4,
|
|
help="parallel take generations; these are slow network calls")
|
|
ap.add_argument("--take-len", type=float, default=5.0,
|
|
help="seconds per generated take; shots are cut inside it. "
|
|
"0 disables take grouping and generates one clip per shot "
|
|
"(far more expensive)")
|
|
ap.add_argument("--base-ratio", type=float, default=0.35,
|
|
help="with --base-video: share of the finished cut taken from it")
|
|
ap.add_argument("--strength", type=float, default=1.0,
|
|
help="0-1 grade intensity applied to the generated takes")
|
|
ap.add_argument("--no-assemble", action="store_true",
|
|
help="skip the local grade/cut/assemble stage and compose the raw "
|
|
"takes on the hosted API instead")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="stub every network call; no API key needed, no spend")
|
|
ap.add_argument("--verbose", "-v", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if a.verbose else logging.INFO,
|
|
format="%(levelname)s %(name)s: %(message)s",
|
|
)
|
|
if a.dry_run:
|
|
falapi.enable_dry_run()
|
|
|
|
try:
|
|
# Check credentials once, up front. Otherwise a missing key surfaces as
|
|
# N identical failures from N worker threads after the uploads have
|
|
# already run, which buries the one line that says what to do.
|
|
if not falapi.is_dry_run():
|
|
falapi.api_key()
|
|
apply(
|
|
genre=a.genre,
|
|
style_steer=a.style_steer,
|
|
brief=a.brief,
|
|
duration=a.duration,
|
|
root=a.root,
|
|
base_video=a.base_video,
|
|
overlays=a.overlays,
|
|
out=a.out,
|
|
concurrency=a.concurrency,
|
|
take_len=a.take_len,
|
|
assemble=not a.no_assemble,
|
|
base_ratio=a.base_ratio,
|
|
strength=a.strength,
|
|
fps=a.fps,
|
|
tier=a.tier,
|
|
)
|
|
except (FileNotFoundError, falapi.FalError) as exc:
|
|
raise SystemExit(f"apply failed: {exc}") from exc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|