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>
247 lines
9.7 KiB
Python
247 lines
9.7 KiB
Python
"""Mint overlay plates: composable graphic assets, not just conditioning stills.
|
|
|
|
Stills exported by ``mint.py`` serve one purpose - they condition the video
|
|
model. They are whole frames, so compositing one over a shot just puts a
|
|
second picture on top of the first.
|
|
|
|
An overlay *plate* is different: it is the reference's graphic vocabulary -
|
|
light streaks, flare, glow, glitch fragments - lifted off its background onto
|
|
black, so it can be screen-blended over anything without a matte. That is the
|
|
asset a colourist or editor actually drops on a timeline, and it is what the
|
|
original design meant by minting usable assets rather than reference images.
|
|
|
|
Three plate types, each isolating a different layer of the look:
|
|
|
|
``glow``
|
|
Bright, high-chroma elements only. Screen-blends as light.
|
|
``streak``
|
|
Directional smear of those elements, which is what reads as motion energy.
|
|
``grain``
|
|
The reference's measured noise, rendered as a tileable plate, so footage
|
|
that was denoised by a generative model can be given the reference's
|
|
texture back.
|
|
|
|
All three are written with alpha, so they also work as straight overlays in
|
|
Resolve or After Effects, and all three are premultiplied against black so
|
|
``blend=screen`` in ffmpeg needs no keying step.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from . import grade as grade_mod
|
|
|
|
|
|
def _lab(rgb: np.ndarray) -> np.ndarray:
|
|
return cv2.cvtColor(np.ascontiguousarray(rgb, np.float32), cv2.COLOR_RGB2LAB)
|
|
|
|
|
|
def _write_rgba(path: Path, rgb: np.ndarray, alpha: np.ndarray) -> Path:
|
|
"""Write straight (non-premultiplied) RGBA as PNG.
|
|
|
|
ffmpeg's screen blend ignores alpha and reads the RGB, so the RGB is
|
|
already black where alpha is zero; the alpha channel is carried purely
|
|
for compositors that do respect it.
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
bgr = cv2.cvtColor((np.clip(rgb, 0, 1) * 255).astype(np.uint8), cv2.COLOR_RGB2BGR)
|
|
a = (np.clip(alpha, 0, 1) * 255).astype(np.uint8)
|
|
cv2.imwrite(str(path), np.dstack([bgr, a]))
|
|
return path
|
|
|
|
|
|
def _energy(frame: np.ndarray) -> np.ndarray:
|
|
"""Per-pixel "is this a graphic element" score: bright AND saturated.
|
|
|
|
Both factors are required. Brightness alone selects blown highlights that
|
|
carry no colour identity; chroma alone selects dark saturated fill.
|
|
"""
|
|
lab = _lab(frame)
|
|
L = lab[..., 0]
|
|
chroma = np.sqrt(lab[..., 1].astype(np.float64) ** 2 + lab[..., 2].astype(np.float64) ** 2)
|
|
return ((L / 100.0).clip(0, 1) * (chroma / 60.0).clip(0, 1)).astype(np.float32)
|
|
|
|
|
|
# A plate is an ELEMENT lifted off a frame. Past roughly this share of frame
|
|
# it stops being an element and becomes the frame - which is not a reusable
|
|
# asset, and on this material produced plates dominated by a recognisable
|
|
# face from the reference. Absolute thresholds cannot enforce this because
|
|
# they behave completely differently on a dark reel and a bright one, so the
|
|
# selection is a percentile and the coverage is checked afterwards.
|
|
_MAX_COVERAGE = 0.22
|
|
_SELECT_PCT = 96.5
|
|
|
|
|
|
def _selection(frame: np.ndarray, feather: int, pct: float = _SELECT_PCT) -> np.ndarray:
|
|
e = _energy(frame)
|
|
thr = float(np.percentile(e, pct))
|
|
if thr <= 1e-6:
|
|
return np.zeros_like(e)
|
|
alpha = ((e - thr) / max(1e-6, e.max() - thr)).clip(0, 1).astype(np.float32)
|
|
k = max(3, feather) | 1
|
|
alpha = cv2.GaussianBlur(alpha, (k, k), 0)
|
|
m = alpha.max()
|
|
return alpha / m if m > 1e-6 else alpha
|
|
|
|
|
|
def glow_plate(frame: np.ndarray, dest: str | Path, feather: int = 21) -> Path:
|
|
"""Lift the frame's brightest, most saturated elements onto black."""
|
|
alpha = _selection(frame, feather)
|
|
return _write_rgba(Path(dest), frame * alpha[..., None], alpha)
|
|
|
|
|
|
def streak_plate(
|
|
frame: np.ndarray,
|
|
dest: str | Path,
|
|
angle: float = 0.0,
|
|
length: int = 121,
|
|
gain: float = 1.6,
|
|
) -> Path:
|
|
"""Directional smear of the glow elements - anamorphic-style light streaks."""
|
|
sel = _selection(frame, 5, pct=98.5)
|
|
|
|
n = length | 1
|
|
kern = np.zeros((n, n), np.float32)
|
|
kern[n // 2, :] = 1.0
|
|
M = cv2.getRotationMatrix2D((n / 2 - 0.5, n / 2 - 0.5), angle, 1.0)
|
|
kern = cv2.warpAffine(kern, M, (n, n))
|
|
kern /= max(1e-6, kern.sum())
|
|
|
|
smear = np.clip(cv2.filter2D(sel, -1, kern) * gain * n / 8.0, 0, 1)
|
|
src = frame * sel[..., None]
|
|
rgb = np.dstack([cv2.filter2D(src[..., i], -1, kern) for i in range(3)])
|
|
if rgb.max() > 1e-6:
|
|
rgb = np.clip(rgb / rgb.max(), 0, 1)
|
|
return _write_rgba(Path(dest), rgb, smear)
|
|
|
|
|
|
def grain_plate(
|
|
dest: str | Path,
|
|
sigma: float,
|
|
width: int = 1080,
|
|
height: int = 1920,
|
|
seed: int = 7,
|
|
) -> Path:
|
|
"""A plate of the reference's measured grain, centred on mid-grey.
|
|
|
|
Generative video is conspicuously clean, and a clean image graded toward a
|
|
grainy reference still does not look like the reference. Overlaying this
|
|
at ``blend=overlay`` puts the measured texture back at the amplitude
|
|
``mint.py`` actually recorded, instead of at whatever a plugin defaults to.
|
|
"""
|
|
rng = np.random.default_rng(seed)
|
|
noise = rng.normal(0.5, max(1e-4, sigma), size=(height, width)).astype(np.float32)
|
|
noise = np.clip(noise, 0, 1)
|
|
rgb = np.dstack([noise] * 3)
|
|
return _write_rgba(Path(dest), rgb, np.ones_like(noise))
|
|
|
|
|
|
def mint_plates(
|
|
frames: list[np.ndarray],
|
|
dest: str | Path,
|
|
noise_sigma: float = 0.0,
|
|
max_plates: int = 4,
|
|
mask: np.ndarray | None = None,
|
|
) -> list[Path]:
|
|
"""Pick the most graphic frames in the set and render plates from them.
|
|
|
|
"Most graphic" is scored as the share of pixels that are both bright and
|
|
saturated - the frames that actually have something to lift. A dark,
|
|
low-chroma frame yields an empty plate, so ranking beats taking the first
|
|
N frames.
|
|
"""
|
|
dest = Path(dest)
|
|
|
|
# Mask before scoring, not after. Reference reels carry burnt-in
|
|
# typography - titles, captions, watermarks - and it is bright, saturated
|
|
# and high-contrast, so it is exactly what a glow plate selects. The first
|
|
# unmasked run produced two plates whose dominant element was the word
|
|
# "HYPER MOTION" lifted cleanly off its background: a perfect plate of
|
|
# someone else's title card, which is worse than useless as a reusable
|
|
# asset. Temporal-variance masking removes it because the text is static
|
|
# while the footage under it is not.
|
|
if mask is not None:
|
|
frames = [f * mask[..., None].astype(np.float32) for f in frames]
|
|
|
|
# Rank by how GRAPHIC a frame is, not by how much of it is bright.
|
|
# "Share of bright saturated pixels" sounds like the same thing and is
|
|
# the opposite: it ranks a washed-out near-white frame top, because
|
|
# almost all of it qualifies, and ranks a black frame with one intense
|
|
# cyan flare - the actual signature of this look - near the bottom. The
|
|
# ratio of peak energy to median energy measures separation instead, and
|
|
# separation is what makes a liftable element.
|
|
scored = []
|
|
for i, f in enumerate(frames):
|
|
e = _energy(f)
|
|
peak = float(np.percentile(e, 99.5))
|
|
floor = float(np.median(e)) + 1e-3
|
|
scored.append((peak / floor, i))
|
|
scored.sort(reverse=True)
|
|
|
|
out: list[Path] = []
|
|
rank = 0
|
|
for sep, i in scored:
|
|
if rank >= max_plates or sep < 3.0:
|
|
break
|
|
alpha = _selection(frames[i], 21)
|
|
coverage = float((alpha > 0.08).mean())
|
|
if coverage > _MAX_COVERAGE or coverage < 0.001:
|
|
# Not an element: either the whole frame, or nothing.
|
|
continue
|
|
out.append(glow_plate(frames[i], dest / f"glow_{rank:02d}.png"))
|
|
out.append(streak_plate(frames[i], dest / f"streak_{rank:02d}.png",
|
|
angle=0.0 if rank % 2 == 0 else 90.0))
|
|
rank += 1
|
|
|
|
if noise_sigma > 0:
|
|
h, w = frames[0].shape[:2]
|
|
out.append(grain_plate(dest / "grain.png", noise_sigma,
|
|
width=max(640, w), height=max(640, h)))
|
|
return out
|
|
|
|
|
|
def tighten(path: str | Path, dest: str | Path | None = None, pad: float = 0.06) -> Path:
|
|
"""Crop a plate to its own content, so the element fills the file.
|
|
|
|
A glow plate is mostly empty by construction - the selection keeps the top
|
|
few percent of pixels by energy, so a typical plate is 2-7% covered and
|
|
97% transparent black. Compositing that at full frame produces a small
|
|
bright dot floating in the middle of the shot, which reads as a sticker
|
|
rather than as light. Measured on the first cut: a plate covering 1.7% of
|
|
its own frame, screen-blended full-frame, was visible only as a coloured
|
|
blob near centre.
|
|
|
|
Cropping to the alpha bounding box means the caller controls the element's
|
|
size on screen by scaling, instead of inheriting whatever fraction of the
|
|
source frame the element happened to occupy.
|
|
"""
|
|
path = Path(path)
|
|
im = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
|
if im is None:
|
|
raise ValueError(f"cannot read plate: {path}")
|
|
alpha = im[..., 3] if im.shape[2] == 4 else im[..., :3].max(axis=2)
|
|
ys, xs = np.where(alpha > 12)
|
|
if len(ys) == 0:
|
|
return path
|
|
h, w = alpha.shape
|
|
py, px = int(h * pad), int(w * pad)
|
|
y0 = max(0, int(ys.min()) - py); y1 = min(h, int(ys.max()) + py + 1)
|
|
x0 = max(0, int(xs.min()) - px); x1 = min(w, int(xs.max()) + px + 1)
|
|
out = Path(dest) if dest else path.with_name(path.stem + "_tight.png")
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
cv2.imwrite(str(out), im[y0:y1, x0:x1])
|
|
return out
|
|
|
|
|
|
def plate_coverage(path: str | Path) -> float:
|
|
"""Share of the plate that is actually lit. Drives element-vs-wash choice."""
|
|
im = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
|
if im is None:
|
|
return 0.0
|
|
alpha = im[..., 3] if im.shape[2] == 4 else im[..., :3].max(axis=2)
|
|
return float((alpha > 12).mean())
|