mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 23:58:07 +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>
260 lines
10 KiB
Python
260 lines
10 KiB
Python
"""Requested image overlays must fail closed if compositing fails."""
|
|
|
|
import importlib.util
|
|
import io
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
if any(
|
|
importlib.util.find_spec(name) is None for name in ("numpy", "cv2", "scenedetect")
|
|
):
|
|
raise unittest.SkipTest("Install taste-application requirements for overlay tests")
|
|
|
|
SCRIPTS = Path(__file__).resolve().parents[1] / "skills/taste-application/scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
spec = importlib.util.spec_from_file_location("overlay_forge", SCRIPTS / "forge.py")
|
|
forge = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(forge)
|
|
|
|
|
|
class OverlayFailureTests(unittest.TestCase):
|
|
@unittest.skipUnless(
|
|
shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg required"
|
|
)
|
|
def test_still_overlay_preserves_all_video_frames(self):
|
|
import cv2
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
take, plate, out = (
|
|
root / name for name in ("take.mp4", "plate.png", "out.mp4")
|
|
)
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:r=30:d=0.5",
|
|
"-c:v",
|
|
"libx264",
|
|
str(take),
|
|
],
|
|
check=True,
|
|
timeout=20,
|
|
)
|
|
image = forge.np.full((16, 16, 4), 255, dtype=forge.np.uint8)
|
|
self.assertTrue(cv2.imwrite(str(plate), image))
|
|
forge.asm.overlay(take, plate, out, width=64, height=64)
|
|
cap = cv2.VideoCapture(str(out))
|
|
frames = []
|
|
while True:
|
|
ok, frame = cap.read()
|
|
if not ok:
|
|
break
|
|
frames.append(frame)
|
|
cap.release()
|
|
self.assertEqual(len(frames), 15)
|
|
self.assertTrue(all(frame.max() > 30 for frame in frames))
|
|
|
|
@unittest.skipUnless(
|
|
shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg required"
|
|
)
|
|
def test_rgba_overlay_preserves_background_and_respects_alpha_opacity(self):
|
|
import cv2
|
|
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
take, plate, out = (
|
|
root / name for name in ("take.mp4", "plate.png", "out.mp4")
|
|
)
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:r=30:d=0.1",
|
|
"-c:v",
|
|
"libx264",
|
|
str(take),
|
|
],
|
|
check=True,
|
|
timeout=20,
|
|
)
|
|
# Nonzero RGB underneath zero alpha must remain invisible.
|
|
image = forge.np.full((16, 16, 4), 255, dtype=forge.np.uint8)
|
|
image[:, :, 3] = 0
|
|
image[4:12, 4:12, 3] = 128
|
|
self.assertTrue(cv2.imwrite(str(plate), image))
|
|
forge.asm.overlay(
|
|
take, plate, out, width=64, height=64, scale=0.5, opacity=0.5
|
|
)
|
|
cap = cv2.VideoCapture(str(out))
|
|
ok, frame = cap.read()
|
|
cap.release()
|
|
self.assertTrue(ok)
|
|
self.assertLess(int(frame[:8, :8].max()), 8)
|
|
self.assertLess(int(frame[17:20, 17:20].max()), 8)
|
|
# Half-alpha white at half opacity over black is about 64/255.
|
|
self.assertGreater(float(frame[29:35, 29:35].mean()), 50)
|
|
self.assertLess(float(frame[29:35, 29:35].mean()), 80)
|
|
|
|
def test_failed_requested_overlay_prevents_final_video_and_manifest(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
root = Path(directory)
|
|
take, plate, out = (
|
|
root / name for name in ("take.mp4", "plate.png", "out.mp4")
|
|
)
|
|
take.write_bytes(b"original video")
|
|
plate.write_bytes(b"original image")
|
|
with (
|
|
patch.object(
|
|
forge.pack_mod,
|
|
"load",
|
|
return_value=SimpleNamespace(
|
|
grade_path="grade", cadence_path="cadence"
|
|
),
|
|
),
|
|
patch.object(forge.grade_mod, "load_stats"),
|
|
patch.object(
|
|
forge.cad_mod,
|
|
"load",
|
|
return_value=SimpleNamespace(
|
|
mean_shot=1,
|
|
cuts_per_min=60,
|
|
rhythm_variance=0,
|
|
plan_shots=lambda _: [1],
|
|
),
|
|
),
|
|
patch.object(
|
|
forge.frame_mod,
|
|
"probe",
|
|
return_value=SimpleNamespace(
|
|
width=320, height=180, fps=30, duration=1
|
|
),
|
|
),
|
|
patch.object(forge.asm, "normalize", return_value=take),
|
|
patch.object(forge.grade_mod, "grade_clip_direct"),
|
|
patch.object(forge.asm, "cut_take", return_value=[take]),
|
|
patch.object(forge.plate_mod, "tighten", return_value=plate),
|
|
patch.object(forge.plate_mod, "plate_coverage", return_value=0.3),
|
|
patch.object(
|
|
forge.asm, "overlay", side_effect=RuntimeError("compositor failed")
|
|
),
|
|
patch.object(forge.asm, "concat") as concat,
|
|
patch.object(forge.tl_mod, "write_timeline") as timeline,
|
|
patch.object(forge.asm, "write_manifest") as manifest,
|
|
):
|
|
with self.assertRaisesRegex(RuntimeError, "compositor failed"):
|
|
forge.forge(
|
|
"look",
|
|
[str(take)],
|
|
str(out),
|
|
overlays=[str(plate)],
|
|
work=str(root / "work"),
|
|
fps=30,
|
|
)
|
|
concat.assert_not_called()
|
|
timeline.assert_not_called()
|
|
manifest.assert_not_called()
|
|
self.assertFalse(out.exists())
|
|
self.assertEqual(take.read_bytes(), b"original video")
|
|
self.assertEqual(plate.read_bytes(), b"original image")
|
|
|
|
|
|
class DurationContractTests(unittest.TestCase):
|
|
def test_cadence_target_records_actual_duration_and_warns_on_frame_difference(self):
|
|
for requested, shortfall, overrun, warning in (
|
|
(2.0, 0.7, 0.0, True),
|
|
(1.3, 0.0, 0.0, False),
|
|
(1.3 + 1 / 30, 0.033333, 0.0, True),
|
|
(1.31, 0.01, 0.0, False),
|
|
(1.0, 0.0, 0.3, True),
|
|
(None, 0.0, 0.0, False),
|
|
):
|
|
with (
|
|
self.subTest(requested=requested),
|
|
tempfile.TemporaryDirectory() as directory,
|
|
):
|
|
root = Path(directory)
|
|
take, out = root / "take.mp4", root / "out.mp4"
|
|
take.write_bytes(b"original")
|
|
info = SimpleNamespace(width=320, height=180, fps=30, duration=1.3)
|
|
stats = SimpleNamespace(contrast=1, black_point=0, white_point=1)
|
|
stdout = io.StringIO()
|
|
|
|
def timeline(*args, **kwargs):
|
|
path = kwargs["out_path"]
|
|
path.touch()
|
|
return path
|
|
|
|
with (
|
|
patch.object(
|
|
forge.pack_mod,
|
|
"load",
|
|
return_value=SimpleNamespace(
|
|
grade_path="grade", cadence_path="cadence"
|
|
),
|
|
),
|
|
patch.object(forge.grade_mod, "load_stats", return_value=stats),
|
|
patch.object(
|
|
forge.cad_mod,
|
|
"load",
|
|
return_value=SimpleNamespace(
|
|
mean_shot=1, cuts_per_min=60, rhythm_variance=0
|
|
),
|
|
),
|
|
patch.object(forge.frame_mod, "probe", return_value=info),
|
|
patch.object(forge.asm, "normalize", return_value=take),
|
|
patch.object(forge.grade_mod, "grade_clip_direct"),
|
|
patch.object(forge.asm, "cut_take", return_value=[take]),
|
|
patch.object(forge.asm, "concat"),
|
|
patch.object(forge.tl_mod, "write_timeline", side_effect=timeline),
|
|
patch.object(forge.asm, "write_manifest") as manifest,
|
|
redirect_stdout(stdout),
|
|
):
|
|
forge.forge(
|
|
"look",
|
|
[str(take)],
|
|
str(out),
|
|
duration=requested,
|
|
work=str(root / "work"),
|
|
fps=30,
|
|
plan=[{"shots": [{"start": 0, "duration": 1.3}]}],
|
|
)
|
|
receipt = manifest.call_args.args[1]
|
|
self.assertEqual(receipt["duration"], 1.3)
|
|
self.assertEqual(
|
|
receipt["duration_contract"],
|
|
{
|
|
"policy": "cadence_target",
|
|
"requested_seconds": requested,
|
|
"actual_seconds": 1.3,
|
|
"shortfall_seconds": shortfall,
|
|
"overrun_seconds": overrun,
|
|
},
|
|
)
|
|
self.assertEqual(
|
|
"WARNING: cadence target" in stdout.getvalue(), warning
|
|
)
|
|
self.assertNotIn("to hit", stdout.getvalue())
|
|
self.assertEqual(take.read_bytes(), b"original")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|