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>
233 lines
8.6 KiB
Python
233 lines
8.6 KiB
Python
"""Operator CLI: python3 -m tasteforge <command> [ ... ].
|
|
|
|
All commands are offline and deterministic. Provider-backed operations fail
|
|
closed with exit code 2 and an actionable message; no command accepts or
|
|
reads credentials, and none can invoke a provider.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import apply as apply_mod
|
|
from . import contract as contract_mod
|
|
from . import distill as distill_mod
|
|
from . import export as export_mod
|
|
from . import interview as interview_mod
|
|
from . import pack as pack_mod
|
|
from . import provenance
|
|
from . import workflow as workflow_mod
|
|
|
|
EXIT_OK = 0
|
|
EXIT_INVALID = 1
|
|
EXIT_FAIL_CLOSED = 2
|
|
|
|
|
|
def _print_json(payload) -> None:
|
|
print(json.dumps(payload, indent=2))
|
|
|
|
|
|
def cmd_provenance(args: argparse.Namespace) -> int:
|
|
_print_json(provenance.lineage_report())
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_inspect(args: argparse.Namespace) -> int:
|
|
sp = pack_mod.load(args.pack)
|
|
report = sp.inspect()
|
|
_print_json(report)
|
|
return EXIT_OK if report["validation"]["status"] == "valid" else EXIT_INVALID
|
|
|
|
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
sp = pack_mod.load(args.pack)
|
|
report = sp.inspect()
|
|
v = report["validation"]
|
|
for err in v["errors"]:
|
|
print(f"ERROR {err}", file=sys.stderr)
|
|
for warn in v["warnings"]:
|
|
print(f"WARN {warn}", file=sys.stderr)
|
|
print(f"{report['name']}: {v['status']}")
|
|
return EXIT_OK if v["status"] == "valid" else EXIT_INVALID
|
|
|
|
|
|
def cmd_interview(args: argparse.Namespace) -> int:
|
|
answers = json.loads(Path(args.answers).read_text(encoding="utf-8"))
|
|
profile = interview_mod.conduct(answers, genre=args.genre)
|
|
out = Path(args.out) if args.out else Path(f"{args.genre}-profile.json")
|
|
out.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
print(out)
|
|
return EXIT_OK
|
|
|
|
|
|
_OUTPUT_COMPONENT = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
|
|
|
|
def _output_component(value: Any, what: str) -> str:
|
|
"""Return ``value`` only when it is safe to use as an output filename part.
|
|
|
|
Pack names and genres come from operator-authored JSON. They are only
|
|
used to derive default output paths, so they must never carry path
|
|
separators or traversal; the same pattern the manifest schema declares.
|
|
"""
|
|
if not isinstance(value, str) or not _OUTPUT_COMPONENT.fullmatch(value):
|
|
raise ValueError(
|
|
f"{what} must match {_OUTPUT_COMPONENT.pattern} to name an output file; pass --out"
|
|
)
|
|
return value
|
|
|
|
|
|
def cmd_distill(args: argparse.Namespace) -> int:
|
|
if args.live:
|
|
print(distill_mod._FAIL_CLOSED, file=sys.stderr)
|
|
return EXIT_FAIL_CLOSED
|
|
profile = json.loads(Path(args.profile).read_text(encoding="utf-8"))
|
|
sp = pack_mod.load(args.pack) if args.pack else None
|
|
spec = distill_mod.distill_local(profile, sp)
|
|
out = (Path(args.out) if args.out
|
|
else Path(f"{_output_component(profile.get('genre', 'spec'), 'profile genre')}-spec.json"))
|
|
out.write_text(json.dumps(spec, indent=2), encoding="utf-8")
|
|
print(out)
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_apply(args: argparse.Namespace) -> int:
|
|
if args.live:
|
|
print(apply_mod._FAIL_CLOSED, file=sys.stderr)
|
|
return EXIT_FAIL_CLOSED
|
|
sp = pack_mod.load(args.pack)
|
|
media = json.loads(Path(args.media).read_text(encoding="utf-8"))["clips"]
|
|
report = apply_mod.apply_local(
|
|
sp, media, duration=args.duration, fps=args.fps, no_repeat=args.no_repeat
|
|
)
|
|
out = (Path(args.out) if args.out
|
|
else Path("out") / f"{_output_component(sp.name, 'pack name')}_apply_report.json")
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
print(out)
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_export(args: argparse.Namespace) -> int:
|
|
clips = json.loads(Path(args.events).read_text(encoding="utf-8"))["clips"]
|
|
out_dir = Path(args.out_dir) if args.out_dir else Path("out")
|
|
edl, fcpxml = export_mod.write_timeline(
|
|
clips, out_dir=out_dir, fps=args.fps, title=args.title
|
|
)
|
|
print(edl)
|
|
print(fcpxml)
|
|
return EXIT_OK
|
|
|
|
|
|
def cmd_multimodal(args: argparse.Namespace) -> int:
|
|
"""Run and validate the file-driven multimodal dry-run contract."""
|
|
config = Path(args.config)
|
|
out_dir = Path(args.out_dir)
|
|
receipt = workflow_mod.run_workflow(config, out_dir)
|
|
contract_mod.validate_bundle(out_dir)
|
|
_print_json(receipt)
|
|
return EXIT_OK
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
ap = argparse.ArgumentParser(
|
|
prog="tasteforge",
|
|
description=(
|
|
"Repeatable taste-driven video workflow (offline, deterministic; "
|
|
"provider operations fail closed)"
|
|
),
|
|
)
|
|
sub = ap.add_subparsers(dest="command", required=True)
|
|
|
|
p = sub.add_parser("provenance", help="print the recovered-source lineage")
|
|
p.add_argument("--json", action="store_true", help="(output is always JSON)")
|
|
p.set_defaults(func=cmd_provenance)
|
|
|
|
p = sub.add_parser("inspect", help="inspect and validate a style pack")
|
|
p.add_argument("pack", help="pack directory containing pack.json")
|
|
p.add_argument("--json", action="store_true", help="(output is always JSON)")
|
|
p.set_defaults(func=cmd_inspect)
|
|
|
|
p = sub.add_parser("validate", help="validate a style pack; exit 1 on errors")
|
|
p.add_argument("pack", help="pack directory containing pack.json")
|
|
p.set_defaults(func=cmd_validate)
|
|
|
|
p = sub.add_parser("interview", help="taste interview answers -> profile")
|
|
p.add_argument("--answers", required=True, help="JSON {question_id: answer}")
|
|
p.add_argument("--genre", default="untitled")
|
|
p.add_argument("--out", help="output profile path (default <genre>-profile.json)")
|
|
p.set_defaults(func=cmd_interview)
|
|
|
|
p = sub.add_parser("distill", help="profile (+ pack) -> style spec (offline)")
|
|
p.add_argument("--profile", required=True, help="profile JSON from `interview`")
|
|
p.add_argument("--pack", help="optional pack dir for measured grounding")
|
|
p.add_argument("--out", help="output spec path")
|
|
p.add_argument("--live", action="store_true",
|
|
help="refused: provider distillation fails closed")
|
|
p.set_defaults(func=cmd_distill)
|
|
|
|
p = sub.add_parser("apply", help="apply a pack's cadence to local media")
|
|
p.add_argument("--pack", required=True, help="pack directory")
|
|
p.add_argument("--media", required=True,
|
|
help='JSON {"clips": [{"path", "duration", "name"?}]}')
|
|
p.add_argument("--duration", type=float, default=None,
|
|
help="target seconds (default: sum of media durations)")
|
|
p.add_argument("--fps", type=float, default=None,
|
|
help="sequence fps (overrides pack fps)")
|
|
p.add_argument("--no-repeat", action="store_true",
|
|
help="use each source once; reject insufficient or short clips")
|
|
p.add_argument("--out", help="output report path")
|
|
p.add_argument("--live", action="store_true",
|
|
help="refused: provider generation fails closed")
|
|
p.set_defaults(func=cmd_apply)
|
|
|
|
p = sub.add_parser("export", help="timeline events -> EDL + FCPXML")
|
|
p.add_argument("--events", required=True,
|
|
help='JSON {"clips": [{"path", "duration", "name"?}]}')
|
|
p.add_argument("--out-dir", default=None)
|
|
p.add_argument("--fps", type=float, default=24.0)
|
|
p.add_argument("--title", default="taste-forge")
|
|
p.set_defaults(func=cmd_export)
|
|
|
|
p = sub.add_parser(
|
|
"multimodal",
|
|
help="file contract -> image/video/3D dry-run manifests and evidence receipt",
|
|
)
|
|
p.add_argument("--config", required=True, help="workflow JSON contract")
|
|
p.add_argument("--out-dir", required=True, help="new evidence bundle directory")
|
|
p.set_defaults(func=cmd_multimodal)
|
|
|
|
return ap
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = build_parser()
|
|
args = ap.parse_args(argv)
|
|
try:
|
|
return args.func(args)
|
|
except (distill_mod.ProviderDisabledError, apply_mod.ProviderDisabledError) as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return EXIT_FAIL_CLOSED
|
|
except FileNotFoundError as exc:
|
|
print(f"ERROR {exc}", file=sys.stderr)
|
|
return EXIT_INVALID
|
|
except subprocess.CalledProcessError:
|
|
print("ERROR local media processing failed", file=sys.stderr)
|
|
return EXIT_INVALID
|
|
except workflow_mod.MediaToolUnavailable:
|
|
print("ERROR local media processing unavailable", file=sys.stderr)
|
|
return EXIT_INVALID
|
|
except ValueError as exc:
|
|
print(f"ERROR {exc}", file=sys.stderr)
|
|
return EXIT_INVALID
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|