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>
340 lines
12 KiB
Python
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},
|
|
}
|