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>
98 lines
3.2 KiB
Python
98 lines
3.2 KiB
Python
"""Deterministic taste interview: answers in, structured profile out.
|
|
|
|
The interview is the local, human half of distillation. It asks the same axes
|
|
the recovered implementation asks a vision model (palette, grain, lighting,
|
|
lens, motion, framing, grade, mood, avoid) plus the content brief, and keeps
|
|
look and content strictly separate - collapsing them is the standard failure
|
|
(style words leak into the scene; subject words get read as style).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from . import schema
|
|
|
|
__all__ = ["Question", "QUESTIONS", "conduct"]
|
|
|
|
_LOOK_QUESTIONS = (
|
|
("palette", "Name the dominant colors and how they are distributed."),
|
|
("grain", "Describe texture/noise character (e.g. fine 35mm grain)."),
|
|
("lighting", "Key/fill/practical sources and their quality?"),
|
|
("focal_length", "Apparent focal length and its perspective effect?"),
|
|
("camera_motion", "How does the camera move, or is it locked off?"),
|
|
("subject_framing", "How do subjects sit in frame (headroom, thirds, negative space)?"),
|
|
("grade_description", "The color grade, in colorist language?"),
|
|
("mood_adjectives", "Three adjectives for the mood, comma-separated."),
|
|
("avoid", "Failure modes to avoid, comma-separated."),
|
|
)
|
|
_CONTENT_QUESTIONS = (
|
|
("brief", "What should happen on screen (subject, action, place)?"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Question:
|
|
id: str
|
|
prompt: str
|
|
axis: str # "look" or "content"
|
|
|
|
|
|
QUESTIONS: tuple[Question, ...] = (
|
|
*(Question(qid, prompt, "look") for qid, prompt in _LOOK_QUESTIONS),
|
|
*(Question(qid, prompt, "content") for qid, prompt in _CONTENT_QUESTIONS),
|
|
)
|
|
|
|
|
|
def _split_list(value: str) -> list[str]:
|
|
return [part.strip() for part in value.replace(";", ",").split(",") if part.strip()]
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def conduct(answers: dict[str, str], genre: str = "untitled") -> dict[str, Any]:
|
|
"""Build a TasteProfile from free-text answers. Deterministic, offline.
|
|
|
|
Missing answers are recorded under ``unanswered`` - never invented.
|
|
"""
|
|
look: dict[str, Any] = {}
|
|
unanswered: list[str] = []
|
|
|
|
for qid, _ in _LOOK_QUESTIONS:
|
|
raw = (answers.get(qid) or "").strip()
|
|
if not raw:
|
|
unanswered.append(qid)
|
|
continue
|
|
if qid in ("mood_adjectives", "avoid"):
|
|
look[qid] = _split_list(raw)
|
|
else:
|
|
look[qid] = raw
|
|
|
|
# Schema floor: mood_adjectives and avoid must exist as lists.
|
|
look.setdefault("mood_adjectives", [])
|
|
look.setdefault("avoid", [])
|
|
|
|
brief = (answers.get("brief") or "").strip()
|
|
if not brief:
|
|
unanswered.append("brief")
|
|
|
|
profile = {
|
|
"schema_version": 1,
|
|
"genre": genre,
|
|
"created": _utc_now(),
|
|
"answers": {k: str(v).strip() for k, v in answers.items() if str(v).strip()},
|
|
"unanswered": unanswered,
|
|
"constraints": {
|
|
"look": look,
|
|
"content": {"brief": brief},
|
|
},
|
|
}
|
|
problems = schema.validate(profile, schema.TASTE_PROFILE_SCHEMA)
|
|
if problems:
|
|
raise ValueError(f"interview produced an invalid profile: {problems}")
|
|
return profile
|