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

340 lines
12 KiB
Python

"""Validated overlay placement using injected Resolve objects, without connecting.
The caller selects a versioned target timeline, saves a pre-edit project backup,
then supplies its timeline and media pool. Failures raise without a completion
receipt; partial edits may remain and must be discarded/restored by the caller.
Composite values are explicit API integers, never guessed blend-mode names.
"""
from __future__ import annotations
import copy
import json
import math
import subprocess
from fractions import Fraction
from pathlib import Path
from .timeline import fps_fraction
def _integer(value, label, minimum=0):
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ValueError(f"{label} must be an integer >= {minimum}")
return value
def _fps(value):
if isinstance(value, bool):
raise ValueError("fps must be finite and positive")
try:
number = float(Fraction(str(value)))
if not math.isfinite(number) or number <= 0:
raise ValueError("fps must be finite and positive")
return fps_fraction(number)
except (TypeError, ZeroDivisionError, OverflowError) as exc:
raise ValueError("invalid fps") from exc
def probe_asset(path):
"""Count decoded video frames; never infer source length from duration."""
result = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-count_frames",
"-show_entries",
"stream=avg_frame_rate,r_frame_rate,nb_read_frames,pix_fmt",
"-of",
"json",
str(path),
],
capture_output=True,
text=True,
check=True,
timeout=120,
)
streams = json.loads(result.stdout).get("streams", [])
if len(streams) != 1:
raise ValueError(f"asset must contain a video stream: {path}")
stream = streams[0]
average = _fps(stream["avg_frame_rate"])
if average != _fps(stream["r_frame_rate"]):
raise ValueError(f"variable or ambiguous frame rate: {path}")
pixel_format = stream.get("pix_fmt", "")
alpha = pixel_format.startswith(("yuva", "gbrap")) or pixel_format in {
"rgba",
"bgra",
"argb",
"abgr",
"rgba64be",
"rgba64le",
"bgra64be",
"bgra64le",
"ya8",
"ya16be",
"ya16le",
}
return {
"fps": str(average),
"frames": int(stream["nb_read_frames"]),
"has_alpha": alpha,
}
def allocate_placements(placements, *, fps, base_track_count, probe=probe_asset):
"""Validate assets and interval-color overlays above preserved video tracks.
record_frame is an absolute timeline frame; intervals are [start, end).
Optional requires_alpha=True enforces a decoded alpha-capable pixel format.
"""
rate = _fps(fps)
_integer(base_track_count, "base_track_count")
checked, seen, metadata = [], set(), {}
for event in placements:
identifier = event.get("id")
if not isinstance(identifier, str) or not identifier or identifier in seen:
raise ValueError("placement id must be unique and nonempty")
seen.add(identifier)
start = _integer(event.get("record_frame"), "record_frame")
frames = _integer(event.get("frames"), "frames", 1)
opacity = event.get("opacity")
if (
isinstance(opacity, bool)
or not isinstance(opacity, (int, float))
or not math.isfinite(opacity)
or not 0 <= opacity <= 100
):
raise ValueError("explicit opacity must be finite within 0..100")
composite = _integer(event.get("composite"), "composite")
raw_asset = event.get("asset")
if not isinstance(raw_asset, (str, Path)) or not str(raw_asset):
raise ValueError("asset must be a local regular file")
asset = Path(raw_asset).expanduser().resolve()
if not asset.is_file():
raise ValueError(f"asset must be a local regular file: {asset}")
alpha = event.get("requires_alpha", False)
if not isinstance(alpha, bool):
raise ValueError("requires_alpha must be boolean")
key = str(asset)
if key not in metadata:
metadata[key] = probe(asset)
info = metadata[key]
if _fps(info.get("fps")) != rate:
raise ValueError(f"asset fps differs from timeline: {asset}")
if _integer(info.get("frames"), "asset frames", 1) < frames:
raise ValueError(f"asset contains too few frames: {asset}")
if alpha and info.get("has_alpha") is not True:
raise ValueError(f"asset requires verified alpha: {asset}")
checked.append(
{
"id": identifier,
"asset": key,
"record_frame": start,
"frames": frames,
"opacity": opacity,
"composite": composite,
}
)
if not checked:
raise ValueError("at least one placement is required")
ends, allocated = [], {}
for event in sorted(checked, key=lambda entry: entry["record_frame"]):
index = next(
(i for i, end in enumerate(ends) if end <= event["record_frame"]), len(ends)
)
if index == len(ends):
ends.append(0)
ends[index] = event["record_frame"] + event["frames"]
allocated[event["id"]] = {**event, "track": base_track_count + index + 1}
return [allocated[event["id"]] for event in checked]
def _path(item):
media = item.GetMediaPoolItem()
raw = media.GetClipProperty("File Path") if media else None
return str(Path(raw).expanduser().resolve()) if raw else None
def _items(timeline, kind, track):
items = timeline.GetItemListInTrack(kind, track)
if items is None:
raise RuntimeError(f"could not read {kind} track {track}")
return items
def _base_snapshot(timeline, base_track_count):
snapshot = {}
for kind, count in [
("video", base_track_count),
("audio", timeline.GetTrackCount("audio")),
]:
for track in range(1, count + 1):
snapshot[f"{kind}:{track}"] = [
{
"start": item.GetStart(),
"end": item.GetEnd(),
"duration": item.GetDuration(),
"enabled": item.GetClipEnabled(),
"path": _path(item),
"properties": copy.deepcopy(item.GetProperty()),
}
for item in _items(timeline, kind, track)
]
return snapshot
def _readback(timeline, item, event):
track = event["track"]
members = _items(timeline, "video", track)
# API wrappers can be recreated on each call, so verify track membership by
# stable unique id when available; test doubles may use object identity.
def identity(candidate):
method = getattr(candidate, "GetUniqueId", None)
return method() if callable(method) else id(candidate)
item_id = identity(item)
if item_id is None:
raise RuntimeError("Resolve returned an item without an identity")
matches = [member for member in members if identity(member) == item_id]
if len(matches) != 1:
raise RuntimeError(f"placement {event['id']} missing from requested track")
item = matches[0]
actual = {
"start": item.GetStart(),
"end": item.GetEnd(),
"duration": item.GetDuration(),
"enabled": item.GetClipEnabled(),
"track": track,
"opacity": item.GetProperty("Opacity"),
"composite": item.GetProperty("CompositeMode"),
"path": _path(item),
}
expected = {
"start": event["record_frame"],
"end": event["record_frame"] + event["frames"],
"duration": event["frames"],
"enabled": True,
"track": track,
"opacity": event["opacity"],
"composite": event["composite"],
"path": event["asset"],
}
if actual != expected:
raise RuntimeError(f"placement {event['id']} readback mismatch: {actual!r}")
return actual
def apply_placements(
timeline,
media_pool,
placements,
*,
source_timeline,
source_end_mode,
fps,
base_track_count,
probe=probe_asset,
):
"""Append and verify overlays; returns an in-memory placement receipt only.
This does not save/export/render a project. Supply the selected target's
media pool. Existing overlay tracks must be empty; base tracks are preserved.
source_end_mode is required: use the endpoint convention verified on this
Resolve host. No automatic retry occurs if that convention is incorrect.
"""
if source_end_mode not in ("inclusive", "exclusive"):
raise ValueError("source_end_mode must be inclusive or exclusive")
if not isinstance(source_timeline, str) or not source_timeline:
raise ValueError("source_timeline must be explicit")
name = timeline.GetName()
if not name or name == source_timeline:
raise ValueError("target must be a distinct versioned timeline")
plan = allocate_placements(
placements, fps=fps, base_track_count=base_track_count, probe=probe
)
if _fps(timeline.GetSetting("timelineFrameRate")) != _fps(fps):
raise ValueError("target timeline fps mismatch")
count = timeline.GetTrackCount("video")
if count < base_track_count:
raise ValueError("base_track_count exceeds target video tracks")
for track in range(base_track_count + 1, count + 1):
if _items(timeline, "video", track):
raise ValueError("target overlay tracks must be empty")
before = _base_snapshot(timeline, base_track_count)
for _ in range(count, max(event["track"] for event in plan)):
old_count = timeline.GetTrackCount("video")
if (
not timeline.AddTrack("video")
or timeline.GetTrackCount("video") != old_count + 1
):
raise RuntimeError("could not create overlay track")
appended = []
for event in plan:
imported = media_pool.ImportMedia([event["asset"]])
if not imported or len(imported) != 1:
raise RuntimeError(f"could not import {event['asset']}")
items = media_pool.AppendToTimeline(
[
{
"mediaPoolItem": imported[0],
"startFrame": 0,
"endFrame": event["frames"] - (source_end_mode == "inclusive"),
"mediaType": 1,
"trackIndex": event["track"],
"recordFrame": event["record_frame"],
}
]
)
if not items or len(items) != 1:
raise RuntimeError(f"could not append {event['id']}")
item = items[0]
for key, value in [
("Opacity", event["opacity"]),
("CompositeMode", event["composite"]),
]:
if not item.SetProperty(key, value):
raise RuntimeError(f"could not set {key} for {event['id']}")
_readback(timeline, item, event)
appended.append((event, item))
receipts = []
for event, item in appended:
actual = _readback(timeline, item, event)
receipts.append(
{
"id": event["id"],
"asset": event["asset"],
"requested": {
key: event[key]
for key in (
"record_frame",
"frames",
"track",
"opacity",
"composite",
)
},
"actual": actual,
}
)
for track in range(base_track_count + 1, timeline.GetTrackCount("video") + 1):
if len(_items(timeline, "video", track)) != sum(
e["track"] == track for e in plan
):
raise RuntimeError("unexpected overlay items after append")
if before != _base_snapshot(timeline, base_track_count):
raise RuntimeError("base tracks changed during overlay placement")
return {
"timeline": name,
"source_timeline": source_timeline,
"source_end_mode": source_end_mode,
"base_track_count": base_track_count,
"fps": float(_fps(fps)),
"placements": receipts,
"preservation": {"base_tracks_match": True},
}