Files
ECC/skills/taste-distillation/scripts/taste/pack.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

165 lines
5.3 KiB
Python

"""Style pack: the durable artifact that makes taste reusable.
A pack is a directory, not a database row, so it can be copied, versioned in
git, zipped, and handed to someone else. Genres partition the library:
``stylepacks/flashethereal/``, ``stylepacks/<next-genre>/``, and so on.
Layout::
stylepacks/flashethereal/
pack.json manifest: refs, artifact inventory, version
grade.json GradeStats - color statistics incl. per-zone chroma
cadence.json Cadence - shot-length distribution
spec.json VLM style spec (written by distill.py)
look.cube 33^3 LUT baked against canonical neutral
stills/ full-res keyframes - the primary style carrier
props/ GLB meshes minted from hero frames
plates/ grain / overlay plates
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_ROOT = Path("stylepacks")
PACK_VERSION = 1
def _utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@dataclass
class StylePack:
name: str
root: Path = DEFAULT_ROOT
manifest: dict = field(default_factory=dict)
# ---- paths -----------------------------------------------------------
@property
def dir(self) -> Path:
return Path(self.root) / self.name
@property
def manifest_path(self) -> Path:
return self.dir / "pack.json"
@property
def grade_path(self) -> Path:
return self.dir / "grade.json"
@property
def cadence_path(self) -> Path:
return self.dir / "cadence.json"
@property
def spec_path(self) -> Path:
return self.dir / "spec.json"
@property
def lut_path(self) -> Path:
return self.dir / "look.cube"
@property
def stills_dir(self) -> Path:
return self.dir / "stills"
@property
def props_dir(self) -> Path:
return self.dir / "props"
@property
def plates_dir(self) -> Path:
return self.dir / "plates"
# ---- lifecycle -------------------------------------------------------
def ensure(self) -> "StylePack":
for d in (self.dir, self.stills_dir, self.props_dir, self.plates_dir):
d.mkdir(parents=True, exist_ok=True)
if not self.manifest:
self.manifest = {
"name": self.name,
"version": PACK_VERSION,
"created": _utc_now(),
"updated": _utc_now(),
"refs": [],
"artifacts": {},
}
return self
def add_ref(self, ref_id: str, src: str, duration: float, n_shots: int) -> None:
self.manifest.setdefault("refs", []).append(
{
"id": ref_id,
"src": str(src),
"duration": round(float(duration), 3),
"n_shots": int(n_shots),
}
)
def stills(self) -> list[Path]:
return sorted(self.stills_dir.glob("*.png")) if self.stills_dir.exists() else []
def props(self) -> list[Path]:
return sorted(self.props_dir.glob("*.glb")) if self.props_dir.exists() else []
def refresh_inventory(self) -> None:
self.manifest["artifacts"] = {
"lut": self.lut_path.name if self.lut_path.exists() else None,
"grade": self.grade_path.exists(),
"cadence": self.cadence_path.exists(),
"spec": self.spec_path.exists(),
"stills": len(self.stills()),
"props": len(self.props()),
"plates": len(list(self.plates_dir.glob("*"))) if self.plates_dir.exists() else 0,
}
self.manifest["updated"] = _utc_now()
def save(self) -> Path:
self.ensure()
self.refresh_inventory()
self.manifest_path.write_text(json.dumps(self.manifest, indent=2), encoding="utf-8")
return self.manifest_path
def write_json(self, path: Path, payload: dict) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return path
def read_json(self, path: Path) -> dict:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def archive(self, dest_dir: str | Path = "out") -> Path:
"""Zip the pack so a whole taste can be handed off as one file."""
dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
base = dest_dir / f"{self.name}-stylepack"
return Path(shutil.make_archive(str(base), "zip", root_dir=self.dir))
def load(name: str, root: str | Path = DEFAULT_ROOT) -> StylePack:
p = StylePack(name=name, root=Path(root))
if not p.manifest_path.exists():
raise FileNotFoundError(
f"no style pack '{name}' under {root} - run mint.py first"
)
p.manifest = json.loads(p.manifest_path.read_text(encoding="utf-8"))
return p
def create(name: str, root: str | Path = DEFAULT_ROOT) -> StylePack:
return StylePack(name=name, root=Path(root)).ensure()
def list_packs(root: str | Path = DEFAULT_ROOT) -> list[str]:
root = Path(root)
if not root.exists():
return []
return sorted(d.name for d in root.iterdir() if (d / "pack.json").exists())