From 2cd36fa8e045b158edeeda846eeb6c1b263428fd Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 3 Apr 2026 09:35:09 -0700 Subject: [PATCH] Implementation spec: governance, decision intelligence, theme system, and E2E bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 2026-04-02 transformation spec (Phases 1-8) and fixes all critical bugs found during 5-pipeline E2E testing. Governance & Decision Intelligence: - Pipeline-specific stage order in checkpoint (replaces global STAGES list) - Provider scoring engine (lib/scoring.py) with 7-dimension weighted ranking - Decision log artifact enforced at proposal/idea stage across all 10 pipelines - Delivery promise classifier prevents silent motion-to-still downgrades - Structured shot language in scene_plan schema (camera, lens, lighting, DOF) - Variation checker and slideshow risk scorer block samey output before render - Creative intake, capability extension, and creative-intake meta skills - Final self-review artifact with 5 mandatory checks before presenting output - Source media review contract for user-supplied footage Render & Theme System: - Remotion AnimatedBackground now derives colors from playbook (no more hardcoded dark blue fintech gradient on every video) - video_compose builds custom ThemeConfig from playbook YAML colors/fonts — custom playbooks flow through to Remotion automatically - Explainer component wires theme to all child components (charts, cards, etc.) - resolveAsset() handles absolute paths on Windows/Unix via file:// URIs - RENDERER_FAMILY_MAP synced with actual Remotion compositions Critical Bug Fixes: - Windows npx subprocess: run_command() resolves .cmd wrappers via shutil.which() - Silent renderer downgrade: Remotion failure now returns explicit error with options instead of silently falling back to FFmpeg - .env inline comment parsing strips trailing # comments from API keys - concat_path UnboundLocalError in video_compose finally block - audio_mixer and showcase_card capture=True kwarg bug - Selector estimate_cost() calls fixed (_select_tool -> _select_best_tool) - asset_manifest schema expanded with provider, license, subtype fields - screen-demo subtitle_gen moved from required to optional tools - Duration drift detection in post-render final review (>25% warns) --- README.md | 51 +- diagram.png | Bin 5921 -> 5915 bytes lib/checkpoint.py | 162 +++- lib/delivery_promise.py | 247 ++++++ lib/pipeline_loader.py | 50 ++ lib/playbook_generator.py | 225 +++++ lib/scoring.py | 369 ++++++++ lib/shot_prompt_builder.py | 166 ++++ lib/slideshow_risk.py | 245 ++++++ lib/source_media_review.py | 395 +++++++++ lib/variation_checker.py | 169 ++++ pipeline_defs/animated-explainer.yaml | 9 + pipeline_defs/animation.yaml | 9 + pipeline_defs/avatar-spokesperson.yaml | 9 + pipeline_defs/cinematic.yaml | 64 +- pipeline_defs/clip-factory.yaml | 11 + pipeline_defs/hybrid.yaml | 11 + pipeline_defs/localization-dub.yaml | 11 + pipeline_defs/podcast-repurpose.yaml | 11 + pipeline_defs/screen-demo.yaml | 15 +- pipeline_defs/talking-head.yaml | 11 + remotion-composer/src/Explainer.tsx | 157 ++-- remotion-composer/src/Root.tsx | 103 +++ schemas/artifacts/__init__.py | 3 + schemas/artifacts/asset_manifest.schema.json | 7 +- schemas/artifacts/decision_log.schema.json | 101 +++ schemas/artifacts/edit_decisions.schema.json | 13 + schemas/artifacts/final_review.schema.json | 136 +++ schemas/artifacts/proposal_packet.schema.json | 120 +++ schemas/artifacts/render_report.schema.json | 21 + schemas/artifacts/scene_plan.schema.json | 45 + schemas/artifacts/script.schema.json | 4 + .../artifacts/source_media_review.schema.json | 82 ++ schemas/checkpoints/checkpoint.schema.json | 4 +- .../pipelines/pipeline_manifest.schema.json | 27 +- schemas/styles/playbook.schema.json | 49 +- skills/creative/image-gen-usage.md | 60 +- skills/meta/capability-extension.md | 115 +++ skills/meta/creative-intake.md | 64 ++ skills/meta/onboarding.md | 14 +- skills/meta/reviewer.md | 139 ++- skills/pipelines/animation/asset-director.md | 32 +- .../pipelines/animation/proposal-director.md | 53 +- .../pipelines/animation/publish-director.md | 2 +- skills/pipelines/animation/scene-director.md | 2 +- skills/pipelines/animation/script-director.md | 11 + .../avatar-spokesperson/asset-director.md | 30 + .../avatar-spokesperson/script-director.md | 11 + skills/pipelines/cinematic/asset-director.md | 34 +- .../pipelines/cinematic/executive-producer.md | 39 +- .../pipelines/cinematic/proposal-director.md | 241 ++++++ .../pipelines/cinematic/publish-director.md | 2 +- .../pipelines/cinematic/research-director.md | 172 ++++ skills/pipelines/cinematic/scene-director.md | 2 +- skills/pipelines/cinematic/script-director.md | 13 +- .../pipelines/clip-factory/asset-director.md | 40 + .../pipelines/clip-factory/script-director.md | 11 + skills/pipelines/explainer/asset-director.md | 32 +- .../pipelines/explainer/proposal-director.md | 107 ++- .../pipelines/explainer/publish-director.md | 8 +- skills/pipelines/explainer/scene-director.md | 2 +- skills/pipelines/explainer/script-director.md | 11 + skills/pipelines/hybrid/asset-director.md | 30 + skills/pipelines/hybrid/script-director.md | 11 + .../localization-dub/asset-director.md | 40 + .../localization-dub/script-director.md | 11 + .../podcast-repurpose/asset-director.md | 40 + .../podcast-repurpose/script-director.md | 11 + .../pipelines/screen-demo/asset-director.md | 40 + .../pipelines/screen-demo/script-director.md | 11 + .../pipelines/talking-head/asset-director.md | 39 + .../pipelines/talking-head/script-director.md | 11 + tests/eval/bench_runner.py | 488 +++++++++++ tests/qa/test_08_end_to_end.py | 172 +++- tools/audio/audio_mixer.py | 2 +- tools/audio/music_gen.py | 30 +- tools/audio/tts_selector.py | 74 +- tools/base_tool.py | 20 +- tools/graphics/google_imagen.py | 8 + tools/graphics/image_selector.py | 85 +- tools/video/showcase_card.py | 2 +- tools/video/video_compose.py | 792 +++++++++++++++++- tools/video/video_selector.py | 82 +- 83 files changed, 6076 insertions(+), 282 deletions(-) create mode 100644 lib/delivery_promise.py create mode 100644 lib/playbook_generator.py create mode 100644 lib/scoring.py create mode 100644 lib/shot_prompt_builder.py create mode 100644 lib/slideshow_risk.py create mode 100644 lib/source_media_review.py create mode 100644 lib/variation_checker.py create mode 100644 schemas/artifacts/decision_log.schema.json create mode 100644 schemas/artifacts/final_review.schema.json create mode 100644 schemas/artifacts/source_media_review.schema.json create mode 100644 skills/meta/capability-extension.md create mode 100644 skills/meta/creative-intake.md create mode 100644 skills/pipelines/cinematic/proposal-director.md create mode 100644 skills/pipelines/cinematic/research-director.md create mode 100644 tests/eval/bench_runner.py diff --git a/README.md b/README.md index ddbe5be..61e3ad1 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Open the project in your AI coding assistant and tell it what you want: "Make a 60-second animated explainer about how neural networks learn" ``` -That's it. The agent researches your topic with live web search, generates AI images, writes and narrates the script with voice direction, finds royalty-free background music automatically, burns in word-level subtitles, validates everything before rendering — then reviews its own output by extracting frames and transcribing audio to catch issues before you even see them. Every creative decision gets your approval. +That's it. The agent researches your topic with live web search, generates AI images, writes and narrates the script with voice direction, finds royalty-free background music automatically, burns in word-level subtitles, and renders the final video. Before you see anything, the system runs a multi-point self-review — ffprobe validation, frame sampling, audio level analysis, delivery promise verification, and subtitle checks. Every provider selection is scored across 7 dimensions with an auditable decision log. Every creative decision gets your approval. > **No `make`?** Run manually: `pip install -r requirements.txt && cd remotion-composer && npm install && cd .. && pip install piper-tts && cp .env.example .env` > @@ -214,7 +214,8 @@ Edit your own talking-head footage. Generate a fully animated explainer from scr - **400+ agent skills** — production skills, pipeline directors, creative techniques, quality checklists, and deep technology knowledge packs that teach the agent how to use every tool like an expert - **Live web research built in** — before writing a single word of script, the agent runs 15-25+ web searches across YouTube, Reddit, news sites, and academic sources to ground your video in real, current data - **Both free/local AND cloud providers** — every capability supports open-source local alternatives alongside premium APIs. Use what you have. -- **No vendor lock-in** — swap providers freely. The selector pattern auto-routes to whatever's available on your machine. +- **No vendor lock-in** — swap providers freely. The scored selector ranks every provider across 7 dimensions (task fit, output quality, control, reliability, cost efficiency, latency, continuity) and picks the best match automatically. +- **Production-grade quality gates** — delivery promise enforcement blocks slideshow-looking renders, pre-compose validation catches broken plans before wasting GPU time, and mandatory post-render self-review (ffprobe + frame extraction + audio analysis) ensures the agent never presents garbage. Every provider choice, style decision, and fallback gets logged in an auditable decision trail. - **Budget governance built in** — cost estimation before execution, spend caps, per-action approval thresholds. No surprise bills. --- @@ -227,28 +228,37 @@ OpenMontage uses an **agent-first architecture**. There is no code orchestrator. You: "Make an explainer video about how black holes form" | v -Agent reads pipeline manifest (YAML) -- what stages to run, what tools to use +Agent reads pipeline manifest (YAML) -- stages, tools, review criteria, success gates | v Agent reads stage director skill (Markdown) -- HOW to execute each stage | v -Agent calls Python tools -- TTS, image gen, video gen, FFmpeg, etc. +Agent calls Python tools -- scored provider selection ranks every tool across 7 dimensions | v -Agent self-reviews using reviewer skill -- quality checks +Agent self-reviews using reviewer skill -- schema validation, playbook compliance, quality checks | v -Agent checkpoints state (JSON) -- resumable if interrupted +Agent checkpoints state (JSON) -- resumable, with decision log and cost snapshot | v -Agent presents for your approval -- you stay in control +Agent presents for your approval -- you stay in control at every creative decision | v -Final video output +Pre-compose validation gate -- delivery promise, slideshow risk, renderer governance + | + v +Render (Remotion or FFmpeg) -- composition engine matched to visual grammar + | + v +Post-render self-review -- ffprobe, frame extraction, audio analysis, promise verification + | + v +Final video output -- only if self-review passes ``` -**Python provides tools and persistence.** All creative decisions, orchestration logic, review criteria, and quality standards live in readable instruction files (YAML manifests + Markdown skills) that you can inspect and customize. +**Python provides tools and persistence.** All creative decisions, orchestration logic, review criteria, and quality standards live in readable instruction files (YAML manifests + Markdown skills) that you can inspect and customize. Every decision is logged with alternatives considered, confidence scores, and the reasoning behind each choice. --- @@ -432,9 +442,26 @@ Built-in render profiles for every major platform: --- -## Budget Governance +## Production Governance -Every paid API call goes through the cost tracker: +OpenMontage treats video production like real engineering — with quality gates, audit trails, and enforcement at every stage. + +### Quality Gates + +- **Pre-compose validation** — blocks render if the delivery promise is violated (e.g. "motion-led" video with 80% still images), slideshow risk score is critical, or renderer family is missing. Catches broken plans before wasting GPU time. +- **Post-render self-review** — after every render, the runtime runs ffprobe validation, extracts frames at 4 positions to check for black frames and broken overlays, analyzes audio levels for silence and clipping, verifies the delivery promise was honored, and checks subtitle presence. If the review fails, the video is not presented. +- **Slideshow risk scoring** — 6-dimension analysis (repetition, decorative visuals, weak motion, shot intent, typography overreliance, unsupported cinematic claims) prevents "animated PowerPoint" outputs. +- **Source media inspection** — when users supply their own footage, the system probes every file (resolution, codec, audio channels, duration) and builds planning implications before a single creative decision is made. No hallucinating content from filenames. + +### Scored Provider Selection + +Every tool selection (video generation, image generation, TTS, music) runs through a 7-dimension scoring engine: task fit (30%), output quality (20%), control features (15%), reliability (15%), cost efficiency (10%), latency (5%), continuity (5%). The winning provider and its score are logged in the decision trail with all alternatives considered. + +### Decision Audit Trail + +Every major creative and technical choice — provider selection, style/playbook choice, music track, voice selection, renderer family, any fallback or downgrade — is logged with alternatives considered, confidence scores, and reasoning. The cumulative decision log persists across all stages so you can trace exactly why the output looks the way it does. + +### Budget Controls - **Estimate** before execution — see what it will cost - **Reserve** budget — lock funds before the call @@ -514,6 +541,6 @@ make test --- -**OpenMontage** — Production-grade video, orchestrated by your AI assistant. +**OpenMontage** — Production-grade video with real quality enforcement, orchestrated by your AI assistant. If this project looks useful to you, a star would really mean a lot — it helps others discover it too. diff --git a/diagram.png b/diagram.png index 01ec2dbb09d40ed375eeda0cf4e40291414c4c26..2f38f343b453f8ac19c94ac747715acc2409d929 100644 GIT binary patch delta 1599 zcmV-F2Eh5DE}JfpBmv@)B_(GjlLcKgCdQy9n_P9^Vxv|-24U!AEDI!p z3S!F`yTlcHqaaXjrIgb9g-c6iy_kAT*A%j>UFVzkd_SMRp7%NDJm)#ToPWOW^Lu`0 zZ2I(B0{{R3dM1tc2><{9Fg~-v5J(2IfB^;qe|p<^p8x>=A3kxzVgt?Ly8fCu1`S}e zjZL3EYrM|^fFB?!$oEMW0*o+02#~13h4Zf-?{i_|nfuAxh8vm?U{nc0fDx}jDacXD zLQM!Tk}N6%Fq+gLN6YYk3g#XIj3hw_000mr2mwZr%Yz(UE)oKaDnSSU01zbz0RRA^ zlZ*jcf8I9UCjbD4?IXYZYv*71{=f&2gE%eUWp>~Y8c`qjVl@!$P}ojZ2i@eemYwqn@@7hn3`i@tLs;u&Yo zxctLceeo;DndV~#oI-1E+V;rZuYd3pQ( z8xH@s_r?zTW%?D%7w_4#=lyd(bmHVG=8FO_ahWm-z(jT7C3D|=58+@icyPgehs^){ z9{twGKegcB?%B0#*G2D}e|!87zx@lh-Z427{k{@5N6(@#yg92Y2q+@$|~a z&-=NHwrt+CX7#G?`=bEB_z;8uOjH+KeA!#>|JsB9egr}I!t>8Qwqn`OUw-AW#~rtN z)w4Iv|BDybtR6n}$kK;rojvD+S6nq?_Bl8F)pf(2%a$xU2E)K z$j;$BQu9Rtn9$rq2mnXo-1nTnX7#F#8}`p21%L_46+!?w43j2J`h_b$I(6D9?>ckl zKYsr2j2!?Inji##D#NgM@7~kTc=wbkQy1R<&yTHGYU}`*&;%g>9D2jycQ^04YYiCy zqrp9s5dlSOFoLeXX3k(RnET1w#`{DV5yS7&@OuToC=rAJ002>f5MVU9NXXIR@*uzn z8=F3T)_9)-06!?7xMA^dW8wU(jWUPpz^r4FMFB>YAOxs1AsiVs2r$9~AwYsGP9^B2g?UyTlcHqaaXjrL?8=UM?-jdNK8wt|??0rR>dnzMs!u&w0){&+~hd^UwEve$Ve6 zn?8Nk0001ho=M|900000#)q>J0qzHrZ~3cn<-9 zx7XP8>9gMEQvfiYq#$pRECd)~f)F54gNx>0G2UZg;+gxYTZS8&5MWdZLVyvkK`F=) z$wEyCFp?}P12CG@AVf5C8y^ zTLN2u>K)@f004N?KKd)ae(w1ne8ab%eEhMSuD|BUp6q-SpaP|8r!I z3;^Rx5CSk!ee3J@zVgbhLw0W4y5)$U=YMj3+P-b; zc#i@ACNMz=z-apPAN~2dXP>ll+UYYc z{mA9ty8qsHSbuO3&ia{iUVZhIRgXS2PFD#4htHHz04AzG{T`L zU;9^2Ke=kSy>083&6}PdZg1bVf3cazSN-?!<-UFUUf8ky_{mcad~x%p=O2IU|KHuc z`#Vb?_|JKKVy}bn~X?Crz3(bJkf8Ke%l7?)_6w0bpVg zgaC}xDx<^j8``z=eR2mt#{op#c}FQ=V&@?bF7x@Gf!8-94jvc>Ou z_bDe#op#2I*(;a-*YNfu08BW75CHc3u~VkM>)odew~spNsI$*G_t|Hje({AJ`yV*` zzup@==s(l1T(M-&o;@F&`wPcUo?^Zz027xfqX0})=U+7U_0JFv27~(+-hIgY&+pT3 zf8x^%|NX94Uw!q056nG(_J`m3rJJuG?tl5^mj;8u&%Xb{#~yuX`0odI?cDjqsz=WG zxeK;#*}Qhm>bHC;05CoTApjHAc^6*thWo#M-+vrN5I+0NQxC6P{_~e!cJwjFtXcik z4fFqU-P$$7w;o*fz^pUpe0bjFGiIN4-Ctil+_`+|;?vHU`Jmf>g8)oqf)Id-=gvEB zyYr6QzV~PT=5wDp_~nz2Kl&$s@bQE9+;sglhkWH5Ut9Q%uPuE2p?2-sdBfj*?vS0s zd8Fox0x+R@h7bS_$Jy^YckPOyBK?(p9lskj~@Fq-}H0c*F``FZJC%xzNng95O zzaQ`60Ki)!2m#K?nc<5G4o!Mw6R_94T%O z0*tV+>C list[str]: + """Return the ordered stage list for a specific pipeline. + + Falls back to STAGES (deterministic canonical order) when pipeline_type + is not provided or the manifest cannot be loaded. + + Previous versions used a set intersection here, which produced + nondeterministic ordering. The fallback now uses a stable list. + """ + if pipeline_type is None: + # Deterministic canonical fallback — sorted to ensure stable ordering + import logging + logging.getLogger(__name__).warning( + "get_pipeline_stages called without pipeline_type — " + "using canonical fallback order. Pass pipeline_type for correctness." + ) + return list(STAGES) + + try: + from lib.pipeline_loader import load_pipeline, get_stage_order + manifest = load_pipeline(pipeline_type) + return get_stage_order(manifest) + except (FileNotFoundError, Exception): + # Graceful fallback: return all known stages in canonical order + return list(STAGES) + CHECKPOINT_SCHEMA_PATH = ( Path(__file__).resolve().parent.parent / "schemas" @@ -76,13 +119,26 @@ def _validate_artifacts_for_stage( def validate_checkpoint(checkpoint: dict[str, Any]) -> None: - """Validate checkpoint structure and canonical artifact payloads.""" + """Validate checkpoint structure and canonical artifact payloads. + + Uses pipeline_type (if present) to resolve the valid stage list. + Falls back to ALL_KNOWN_STAGES when pipeline_type is absent. + """ stage = checkpoint.get("stage") status = checkpoint.get("status") artifacts = checkpoint.get("artifacts") + pipeline_type = checkpoint.get("pipeline_type") - if not isinstance(stage, str) or stage not in STAGES: - raise CheckpointValidationError(f"Invalid stage: {stage!r}") + valid_stages = ( + set(get_pipeline_stages(pipeline_type)) if pipeline_type + else ALL_KNOWN_STAGES + ) + + if not isinstance(stage, str) or stage not in valid_stages: + raise CheckpointValidationError( + f"Invalid stage: {stage!r} for pipeline {pipeline_type!r}. " + f"Valid stages: {sorted(valid_stages)}" + ) if not isinstance(status, str): raise CheckpointValidationError(f"Invalid status: {status!r}") if not isinstance(artifacts, dict): @@ -100,6 +156,40 @@ def _checkpoint_path(pipeline_dir: Path, project_id: str, stage: str) -> Path: return pipeline_dir / project_id / f"checkpoint_{stage}.json" +def _decision_log_path(pipeline_dir: Path, project_id: str) -> Path: + return pipeline_dir / project_id / "decision_log.json" + + +def _merge_decision_log( + pipeline_dir: Path, project_id: str, new_log: dict[str, Any] +) -> None: + """Append new decisions to the project-level decision log. + + Each stage may produce decisions. This function merges them into a + single cumulative file so reviewers and the bench can inspect the + full audit trail. + """ + path = _decision_log_path(pipeline_dir, project_id) + if path.exists(): + with open(path) as f: + existing = json.load(f) + else: + existing = { + "version": "1.0", + "project_id": project_id, + "decisions": [], + } + + existing_ids = {d["decision_id"] for d in existing.get("decisions", [])} + for decision in new_log.get("decisions", []): + if decision.get("decision_id") not in existing_ids: + existing["decisions"].append(decision) + + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(existing, f, indent=2) + + def write_checkpoint( pipeline_dir: Path, project_id: str, @@ -118,12 +208,20 @@ def write_checkpoint( metadata: Optional[dict] = None, ) -> Path: """Write a checkpoint file for a pipeline stage.""" - if stage not in STAGES: - raise ValueError(f"Invalid stage: {stage!r}. Must be one of {STAGES}") + valid_stages = ( + set(get_pipeline_stages(pipeline_type)) if pipeline_type + else ALL_KNOWN_STAGES + ) + if stage not in valid_stages: + raise ValueError( + f"Invalid stage: {stage!r} for pipeline {pipeline_type!r}. " + f"Valid stages: {sorted(valid_stages)}" + ) checkpoint = { "version": "1.0", "project_id": project_id, + "pipeline_type": pipeline_type or "unknown", "stage": stage, "status": status, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -132,9 +230,6 @@ def write_checkpoint( "human_approved": human_approved, "artifacts": artifacts, } - - if pipeline_type is not None: - checkpoint["pipeline_type"] = pipeline_type if style_playbook is not None: checkpoint["style_playbook"] = style_playbook if review is not None: @@ -146,6 +241,26 @@ def write_checkpoint( if metadata is not None: checkpoint["metadata"] = metadata + # Merge decision_log: if this checkpoint carries new decisions, + # append them to the project-level decision log file, then write the + # reference back into relevant artifacts so downstream consumers can find it. + if "decision_log" in artifacts and isinstance(artifacts["decision_log"], dict): + _merge_decision_log(pipeline_dir, project_id, artifacts["decision_log"]) + log_ref = str(_decision_log_path(pipeline_dir, project_id)) + + # Write decision_log_ref into proposal_packet and render_report + # artifacts if they are present in this checkpoint. + for artifact_key in ("proposal_packet", "render_report"): + if artifact_key in artifacts and isinstance(artifacts[artifact_key], dict): + plan_or_top = artifacts[artifact_key] + # proposal_packet stores it under production_plan + if artifact_key == "proposal_packet": + plan = plan_or_top.get("production_plan") + if isinstance(plan, dict): + plan["decision_log_ref"] = log_ref + else: + plan_or_top["decision_log_ref"] = log_ref + validate_checkpoint(checkpoint) path = _checkpoint_path(pipeline_dir, project_id, stage) @@ -191,20 +306,35 @@ def get_latest_checkpoint( return checkpoint -def get_completed_stages(pipeline_dir: Path, project_id: str) -> list[str]: - """Return list of stages that have a completed checkpoint.""" +def get_completed_stages( + pipeline_dir: Path, project_id: str, pipeline_type: str | None = None +) -> list[str]: + """Return list of stages that have a completed checkpoint. + + When pipeline_type is provided, only checks stages defined in that + pipeline's manifest — preventing false positives from leftover + checkpoints of a different pipeline type. + """ + stages_to_check = get_pipeline_stages(pipeline_type) completed = [] - for stage in STAGES: + for stage in stages_to_check: cp = read_checkpoint(pipeline_dir, project_id, stage) if cp and cp.get("status") == "completed": completed.append(stage) return completed -def get_next_stage(pipeline_dir: Path, project_id: str) -> Optional[str]: - """Determine the next stage to run based on completed checkpoints.""" - completed = set(get_completed_stages(pipeline_dir, project_id)) - for stage in STAGES: +def get_next_stage( + pipeline_dir: Path, project_id: str, pipeline_type: str | None = None +) -> Optional[str]: + """Determine the next stage to run based on completed checkpoints. + + Uses pipeline-specific stage order so that pipelines with different + stage sequences (e.g. cinematic vs explainer) progress correctly. + """ + stages = get_pipeline_stages(pipeline_type) if pipeline_type else STAGES + completed = set(get_completed_stages(pipeline_dir, project_id, pipeline_type)) + for stage in stages: if stage not in completed: return stage return None diff --git a/lib/delivery_promise.py b/lib/delivery_promise.py new file mode 100644 index 0000000..9d99b96 --- /dev/null +++ b/lib/delivery_promise.py @@ -0,0 +1,247 @@ +"""Delivery promise classifier. + +Before provider selection, classify what the production is actually promising +to deliver. This prevents the most damaging failure mode: silently downgrading +from motion-led to still-led without the user knowing. + +The delivery promise is set at the proposal stage and locked. If the compose +stage can't honor it, the system must stop and ask — not silently substitute. +""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict +from enum import Enum +from typing import Any + + +class PromiseType(Enum): + MOTION_LED = "motion_led" + SOURCE_LED = "source_led" + DATA_EXPLAINER = "data_explainer" + TEACHER_EXPLAINER = "teacher_explainer" + SCREEN_DEMO = "screen_demo" + AVATAR_PRESENTER = "avatar_presenter" + HYBRID = "hybrid" + LOCALIZATION = "localization" + + +# Rules per promise type — what is and isn't acceptable +PROMISE_RULES: dict[str, dict[str, Any]] = { + "motion_led": { + "still_fallback_allowed": False, + "requires_video_generation": True, + "min_motion_ratio": 0.7, # At least 70% of cuts must be real motion (video/animation, not Remotion slides) + "description": "Video's quality depends on real motion — generated video clips, footage, or animation.", + }, + "source_led": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.3, + "description": "User-provided footage is the primary medium. Generated assets fill gaps only.", + }, + "data_explainer": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.0, + "description": "Data visualization and explanation. Motion graphics preferred but images acceptable.", + }, + "teacher_explainer": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.0, + "description": "Educational content. Clarity and comprehension over spectacle.", + }, + "screen_demo": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.0, + "description": "Screen recording or product demo. Legibility over cinematic dressing.", + }, + "avatar_presenter": { + "still_fallback_allowed": False, + "requires_video_generation": True, + "min_motion_ratio": 0.3, + "description": "AI avatar or talking head presentation. Requires video generation for presenter.", + }, + "hybrid": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.2, + "description": "Mix of source footage, generated content, and graphics.", + }, + "localization": { + "still_fallback_allowed": True, + "requires_video_generation": False, + "min_motion_ratio": 0.0, + "description": "Translation/dubbing of existing video. Preserving source timing and clarity.", + }, +} + + +@dataclass +class DeliveryPromise: + """Classifies what the production promises to deliver.""" + + promise_type: PromiseType + motion_required: bool + source_required: bool + tone_mode: str # "cinematic", "educational", "corporate", "playful", "raw" + quality_floor: str # "draft", "presentable", "broadcast" + approved_fallback: str | None = None # "animatic", "still_led", or None + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["promise_type"] = self.promise_type.value + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "DeliveryPromise": + return cls( + promise_type=PromiseType(data["promise_type"]), + motion_required=data.get("motion_required", False), + source_required=data.get("source_required", False), + tone_mode=data.get("tone_mode", "corporate"), + quality_floor=data.get("quality_floor", "presentable"), + approved_fallback=data.get("approved_fallback"), + ) + + def get_rules(self) -> dict[str, Any]: + """Get the enforcement rules for this promise type.""" + return PROMISE_RULES.get(self.promise_type.value, {}) + + def validate_cuts(self, cuts: list[dict]) -> dict[str, Any]: + """Validate a list of edit cuts against this delivery promise. + + Returns a dict with 'valid', 'violations', and 'motion_ratio'. + """ + rules = self.get_rules() + violations = [] + + if not cuts: + return {"valid": False, "violations": ["No cuts provided"], "motion_ratio": 0.0} + + # Count motion vs slide-grammar vs still cuts. + # Only real video/animation/avatar footage counts as motion. + # Remotion component scenes (text_card, chart, kpi_grid, etc.) are + # "animated slides" — they have transitions but are NOT real motion. + _SLIDE_GRAMMAR_TYPES = frozenset({ + "text_card", "stat_card", "chart", "bar_chart", + "line_chart", "pie_chart", "kpi_grid", "comparison", + "progress", "callout", + }) + _REAL_MOTION_TYPES = frozenset({"video", "animation", "avatar"}) + + motion_cuts = 0 + slide_cuts = 0 + still_cuts = 0 + for cut in cuts: + source = cut.get("source", "") + cut_type = cut.get("type", "") + + # Determine category for this cut + is_motion = False + is_slide = False + + if source: + ext = source.rsplit(".", 1)[-1].lower() if "." in source else "" + if ext in ("mp4", "mov", "webm", "avi", "mkv"): + is_motion = True + if cut_type in _REAL_MOTION_TYPES: + is_motion = True + elif cut_type in _SLIDE_GRAMMAR_TYPES: + is_slide = True + + if is_motion: + motion_cuts += 1 + elif is_slide: + slide_cuts += 1 + else: + still_cuts += 1 + + total = motion_cuts + slide_cuts + still_cuts + # Motion ratio is real motion vs everything — slide grammar does NOT count + motion_ratio = motion_cuts / total if total > 0 else 0.0 + + # Check motion requirement + min_ratio = rules.get("min_motion_ratio", 0.0) + if self.motion_required and motion_ratio < min_ratio: + violations.append( + f"Motion ratio {motion_ratio:.0%} is below minimum {min_ratio:.0%} " + f"for {self.promise_type.value}. " + f"{motion_cuts}/{total} cuts have real motion " + f"({slide_cuts} are animated slides which do not count as motion)." + ) + + # Check still fallback (slides + stills both count as non-motion) + non_motion = slide_cuts + still_cuts + if not rules.get("still_fallback_allowed", True) and non_motion > total * 0.5: + if self.approved_fallback != "still_led": + violations.append( + f"{self.promise_type.value} does not allow still-led fallback, " + f"but {non_motion}/{total} cuts are non-motion (stills + animated slides). " + f"User must approve 'still_led' fallback or provide motion content." + ) + + return { + "valid": len(violations) == 0, + "violations": violations, + "motion_ratio": motion_ratio, + "motion_cuts": motion_cuts, + "slide_cuts": slide_cuts, + "still_cuts": still_cuts, + } + + +def classify_from_brief( + pipeline_type: str, + user_intent: dict[str, Any], +) -> DeliveryPromise: + """Classify delivery promise from pipeline type and user intent. + + This provides a sensible default. The proposal-director should refine + it based on research and capability checks. + + Args: + pipeline_type: Pipeline manifest name. + user_intent: Dict with keys like 'motion_required', 'has_footage', + 'tone', 'quality', 'platform'. + """ + # Pipeline → default promise type mapping + pipeline_defaults: dict[str, PromiseType] = { + "cinematic": PromiseType.MOTION_LED, + "animated-explainer": PromiseType.DATA_EXPLAINER, + "animation": PromiseType.MOTION_LED, + "talking-head": PromiseType.AVATAR_PRESENTER, + "avatar-spokesperson": PromiseType.AVATAR_PRESENTER, + "screen-demo": PromiseType.SCREEN_DEMO, + "hybrid": PromiseType.HYBRID, + "localization-dub": PromiseType.LOCALIZATION, + "podcast-repurpose": PromiseType.SOURCE_LED, + "clip-factory": PromiseType.SOURCE_LED, + } + + promise_type = pipeline_defaults.get(pipeline_type, PromiseType.HYBRID) + + # Override with explicit user intent + if user_intent.get("motion_required") is False and promise_type == PromiseType.MOTION_LED: + promise_type = PromiseType.HYBRID + + motion_required = user_intent.get("motion_required", promise_type in ( + PromiseType.MOTION_LED, PromiseType.AVATAR_PRESENTER, + )) + + source_required = user_intent.get("has_footage", False) + if source_required and promise_type not in (PromiseType.SOURCE_LED, PromiseType.LOCALIZATION): + promise_type = PromiseType.SOURCE_LED + + tone_mode = user_intent.get("tone", "corporate") + quality_floor = user_intent.get("quality", "presentable") + + return DeliveryPromise( + promise_type=promise_type, + motion_required=motion_required, + source_required=source_required, + tone_mode=tone_mode, + quality_floor=quality_floor, + ) diff --git a/lib/pipeline_loader.py b/lib/pipeline_loader.py index d555ad3..63b8fed 100644 --- a/lib/pipeline_loader.py +++ b/lib/pipeline_loader.py @@ -85,3 +85,53 @@ def get_stage_review_focus(manifest: dict, stage_name: str) -> list[str]: if stage["name"] == stage_name: return stage.get("review_focus", []) return [] + + +# --------------------------------------------------------------------------- +# Capability-Extension Enforcement +# --------------------------------------------------------------------------- + +class ExtensionNotPermitted(PermissionError): + """Raised when a capability extension is used but not permitted by the pipeline.""" + + +def check_extension_permitted( + manifest: dict, + extension_type: str, +) -> None: + """Enforce that a capability extension is permitted by the pipeline manifest. + + Args: + manifest: Loaded pipeline manifest dict. + extension_type: One of 'custom_scripts', 'custom_playbooks', + 'custom_skills', 'custom_tools'. + + Raises: + ExtensionNotPermitted: If the extension is not allowed. + """ + valid_extensions = {"custom_scripts", "custom_playbooks", "custom_skills", "custom_tools"} + if extension_type not in valid_extensions: + raise ValueError( + f"Unknown extension type {extension_type!r}. " + f"Valid types: {sorted(valid_extensions)}" + ) + + extensions = manifest.get("extensions", {}) + if not extensions.get(extension_type, False): + raise ExtensionNotPermitted( + f"Pipeline {manifest.get('name', 'unknown')!r} does not permit " + f"{extension_type}. Set extensions.{extension_type}: true in the " + f"pipeline manifest to allow this." + ) + + +def get_permitted_extensions(manifest: dict) -> dict[str, bool]: + """Return the extension permission flags for a pipeline.""" + defaults = { + "custom_scripts": False, + "custom_playbooks": False, + "custom_skills": False, + "custom_tools": False, + } + extensions = manifest.get("extensions", {}) + return {k: extensions.get(k, v) for k, v in defaults.items()} diff --git a/lib/playbook_generator.py b/lib/playbook_generator.py new file mode 100644 index 0000000..a2df1ef --- /dev/null +++ b/lib/playbook_generator.py @@ -0,0 +1,225 @@ +"""Custom playbook generator. + +When none of the existing 4 playbooks match the production brief, the agent +can generate a custom playbook. This replaces the old behavior of forcing +everything through the closest preset. + +The generator produces a schema-valid playbook YAML from a production context. +""" + +from __future__ import annotations + +import json +import yaml +from pathlib import Path +from typing import Any + +import jsonschema + +PLAYBOOK_SCHEMA_PATH = ( + Path(__file__).resolve().parent.parent + / "schemas" / "styles" / "playbook.schema.json" +) +STYLES_DIR = Path(__file__).resolve().parent.parent / "styles" +CUSTOM_STYLES_DIR = STYLES_DIR / "custom" + + +def _load_playbook_schema() -> dict: + with open(PLAYBOOK_SCHEMA_PATH) as f: + return json.load(f) + + +def load_existing_playbook(name: str) -> dict[str, Any]: + """Load an existing playbook YAML by name.""" + path = STYLES_DIR / f"{name}.yaml" + if not path.exists(): + # Check custom directory + path = CUSTOM_STYLES_DIR / f"{name}.yaml" + if not path.exists(): + raise FileNotFoundError(f"Playbook not found: {name}") + with open(path) as f: + return yaml.safe_load(f) + + +def list_playbooks() -> list[str]: + """List all available playbook names (preset + custom).""" + names = [p.stem for p in STYLES_DIR.glob("*.yaml")] + if CUSTOM_STYLES_DIR.exists(): + names.extend(p.stem for p in CUSTOM_STYLES_DIR.glob("*.yaml")) + return sorted(set(names)) + + +def generate_playbook( + name: str, + context: dict[str, Any], + base_playbook: str | None = None, +) -> dict[str, Any]: + """Generate a custom playbook from production context. + + Args: + name: Name for the new playbook. + context: Dict with keys like: + - mood: str (e.g., "warm", "dark", "energetic") + - tone: str (e.g., "cinematic", "educational", "corporate") + - colors: dict with primary, accent, background, text (optional) + - fonts: dict with headings, body (optional) + - pace: str (optional) + - audience: str (optional) + base_playbook: Name of existing playbook to use as a starting point. + + Returns: + Schema-valid playbook dict. + """ + # Start from base or create fresh + if base_playbook: + playbook = load_existing_playbook(base_playbook) + else: + playbook = _create_minimal_playbook(name, context) + + # Override identity + playbook["identity"]["name"] = name + if context.get("mood"): + playbook["identity"]["mood"] = context["mood"] + if context.get("pace"): + playbook["identity"]["pace"] = context["pace"] + if context.get("tone"): + # Map tone to category + tone_to_category = { + "cinematic": "cinematic", + "educational": "minimalist", + "corporate": "motion-graphics", + "playful": "motion-graphics", + "raw": "cinematic", + } + playbook["identity"]["category"] = tone_to_category.get( + context["tone"], "custom" + ) + + # Override colors if provided + if context.get("colors"): + colors = context["colors"] + cp = playbook["visual_language"]["color_palette"] + if colors.get("primary"): + cp["primary"] = [colors["primary"]] if isinstance(colors["primary"], str) else colors["primary"] + if colors.get("accent"): + cp["accent"] = [colors["accent"]] if isinstance(colors["accent"], str) else colors["accent"] + if colors.get("background"): + cp["background"] = colors["background"] + if colors.get("text"): + cp["text"] = colors["text"] + + # Override fonts if provided + if context.get("fonts"): + fonts = context["fonts"] + if fonts.get("headings"): + playbook["typography"]["headings"]["font"] = fonts["headings"] + if fonts.get("body"): + playbook["typography"]["body"]["font"] = fonts["body"] + + return playbook + + +def _create_minimal_playbook(name: str, context: dict[str, Any]) -> dict[str, Any]: + """Create a minimal but complete playbook from scratch.""" + mood = context.get("mood", "professional") + tone = context.get("tone", "corporate") + + # Sensible defaults based on mood + if mood in ("dark", "cinematic", "dramatic"): + bg = "#0F172A" + text = "#F8FAFC" + primary = ["#3B82F6"] + accent = ["#F59E0B"] + elif mood in ("warm", "intimate", "organic"): + bg = "#FFFBEB" + text = "#1C1917" + primary = ["#D97706"] + accent = ["#059669"] + elif mood in ("playful", "energetic", "bold"): + bg = "#FFFFFF" + text = "#1F2937" + primary = ["#7C3AED"] + accent = ["#EC4899"] + else: # professional, clean, neutral + bg = "#FFFFFF" + text = "#1F2937" + primary = ["#2563EB"] + accent = ["#F59E0B"] + + return { + "identity": { + "name": name, + "category": "custom", + "mood": mood, + "pace": context.get("pace", "moderate"), + "best_for": f"Custom playbook for {tone} {mood} content", + }, + "visual_language": { + "color_palette": { + "primary": primary, + "accent": accent, + "background": bg, + "text": text, + }, + "composition": "balanced grid with breathing room", + "texture": "clean digital", + }, + "typography": { + "headings": {"font": "Inter", "weight": 700}, + "body": {"font": "Inter", "weight": 400}, + }, + "motion": { + "transitions": ["crossfade", "cut"], + "animation_style": "spring-based with moderate damping", + "pacing_rules": { + "min_scene_hold_seconds": 2.0, + "max_scene_hold_seconds": 6.0, + "text_card_hold_seconds": 3.5, + "stat_card_hold_seconds": 4.0, + "transition_duration_seconds": 0.4, + }, + }, + "audio": { + "voice_style": "clear, conversational, authoritative", + "music_mood": mood, + "music_volume": 0.15, + }, + "asset_generation": { + "image_prompt_prefix": f"{mood} {tone} style", + "consistency_anchors": [f"{mood} color palette", f"{tone} visual language"], + }, + "quality_rules": [ + "Maintain color consistency across all scenes", + "Text must be legible on all backgrounds", + "Transitions should be purposeful, not decorative", + ], + "chart_palette": primary + accent + ["#10B981", "#EF4444", "#8B5CF6"], + } + + +def save_playbook( + playbook: dict[str, Any], + project_name: str | None = None, +) -> Path: + """Validate and save a playbook to the custom styles directory. + + Args: + playbook: Schema-valid playbook dict. + project_name: Optional project name for the filename. + + Returns: + Path to the saved YAML file. + """ + schema = _load_playbook_schema() + jsonschema.validate(instance=playbook, schema=schema) + + name = project_name or playbook["identity"]["name"] + filename = name.lower().replace(" ", "-").replace("_", "-") + + CUSTOM_STYLES_DIR.mkdir(parents=True, exist_ok=True) + path = CUSTOM_STYLES_DIR / f"{filename}.yaml" + + with open(path, "w") as f: + yaml.dump(playbook, f, default_flow_style=False, allow_unicode=True) + + return path diff --git a/lib/scoring.py b/lib/scoring.py new file mode 100644 index 0000000..7bf2458 --- /dev/null +++ b/lib/scoring.py @@ -0,0 +1,369 @@ +"""Provider and production path scoring engine. + +Replaces naive "first available provider" selection with weighted +multi-dimensional scoring. Every provider choice should be explainable — +not just "it was available." + +Scores are normalized 0-1. Higher is better. +""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict, field +from typing import Any + + +# --------------------------------------------------------------------------- +# Provider Score +# --------------------------------------------------------------------------- + +@dataclass +class ProviderScore: + """Scored evaluation of a provider against a specific task context.""" + + tool_name: str + provider: str + task_fit: float = 0.0 # 0-1: best fit for this exact asset class + output_quality: float = 0.0 # 0-1: expected fidelity for the brief + control: float = 0.0 # 0-1: reference/style directability + reliability: float = 0.0 # 0-1: runtime confidence + cost_efficiency: float = 0.0 # 0-1: quality per dollar + latency: float = 0.0 # 0-1: acceptable turnaround + continuity: float = 0.0 # 0-1: fits already locked decisions + + @property + def weighted_score(self) -> float: + return ( + self.task_fit * 0.30 + + self.output_quality * 0.20 + + self.control * 0.15 + + self.reliability * 0.15 + + self.cost_efficiency * 0.10 + + self.latency * 0.05 + + self.continuity * 0.05 + ) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["weighted_score"] = self.weighted_score + return d + + def explain(self) -> str: + """Human-readable explanation of this score.""" + parts = [f"{self.tool_name} ({self.provider}): {self.weighted_score:.2f}"] + top = sorted( + [ + ("task_fit", self.task_fit, 0.30), + ("output_quality", self.output_quality, 0.20), + ("control", self.control, 0.15), + ("reliability", self.reliability, 0.15), + ("cost_efficiency", self.cost_efficiency, 0.10), + ("latency", self.latency, 0.05), + ("continuity", self.continuity, 0.05), + ], + key=lambda x: x[1] * x[2], + reverse=True, + ) + for name, val, weight in top[:3]: + parts.append(f" {name}={val:.2f} (w={weight})") + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Production Path Score +# --------------------------------------------------------------------------- + +@dataclass +class ProductionPathScore: + """Scored evaluation of an entire production path.""" + + path_label: str + delivery_fit: float = 0.0 + quality_fit: float = 0.0 + capability_confidence: float = 0.0 + fallback_integrity: float = 0.0 + budget_fit: float = 0.0 + speed_fit: float = 0.0 + controllability: float = 0.0 + consistency_fit: float = 0.0 + + @property + def weighted_score(self) -> float: + return ( + self.delivery_fit * 0.25 + + self.quality_fit * 0.20 + + self.capability_confidence * 0.15 + + self.fallback_integrity * 0.10 + + self.budget_fit * 0.10 + + self.speed_fit * 0.08 + + self.controllability * 0.07 + + self.consistency_fit * 0.05 + ) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + d["weighted_score"] = self.weighted_score + return d + + +# --------------------------------------------------------------------------- +# Scoring Functions +# --------------------------------------------------------------------------- + +def _keyword_overlap(set_a: set[str], set_b: set[str]) -> float: + """Jaccard-like overlap score between two keyword sets.""" + if not set_a or not set_b: + return 0.0 + a = {s.lower().strip() for s in set_a} + b = {s.lower().strip() for s in set_b} + intersection = len(a & b) + union = len(a | b) + return intersection / union if union > 0 else 0.0 + + +# Semantic synonym clusters: when intent says "cinematic" and tool says +# "film" or "movie", that's a match even without literal keyword overlap. +_SYNONYM_CLUSTERS: list[set[str]] = [ + {"cinematic", "film", "movie", "trailer", "dramatic", "epic"}, + {"explainer", "educational", "tutorial", "teaching", "lesson"}, + {"corporate", "business", "professional", "enterprise"}, + {"social", "tiktok", "instagram", "reels", "shorts", "viral"}, + {"animation", "animated", "motion-graphics", "motion", "kinetic"}, + {"realistic", "photorealistic", "lifelike", "natural"}, + {"stock", "footage", "b-roll", "library"}, + {"avatar", "presenter", "talking-head", "spokesperson"}, + {"voiceover", "narration", "speech", "voice"}, + {"music", "soundtrack", "background-music", "score", "ambient"}, +] + +def _expand_synonyms(words: set[str]) -> set[str]: + """Expand a word set with synonyms from known clusters.""" + expanded = set(words) + for cluster in _SYNONYM_CLUSTERS: + if expanded & cluster: + expanded |= cluster + return expanded + + +def _compute_task_fit( + best_for: set[str], + intent: str, + style_keywords: set[str], +) -> float: + """Score how well a tool's best_for matches the task intent and style. + + Uses synonym expansion so that semantic near-misses (e.g. "cinematic" + vs "film") still score well, not just literal keyword overlap. + """ + if not best_for: + return 0.3 # Unknown capability — modest default + + intent_words = _expand_synonyms(set(intent.lower().split())) + best_for_words = set() + for desc in best_for: + best_for_words.update(desc.lower().split()) + best_for_words = _expand_synonyms(best_for_words) + + intent_score = _keyword_overlap(intent_words, best_for_words) + + style_expanded = _expand_synonyms({kw.lower() for kw in style_keywords}) + style_score = _keyword_overlap(style_expanded, best_for_words) + + return min(1.0, intent_score * 0.7 + style_score * 0.3 + 0.1) + + +def _compute_control(supports: dict[str, Any]) -> float: + """Score controllability from the supports dict. + + Features are weighted by creative impact — controlnet and reference_image + are worth more than seed or aspect_ratio. + """ + # (feature_name, weight) — higher weight = more creative control + control_features = [ + ("controlnet", 2.0), + ("reference_image", 1.8), + ("style_transfer", 1.5), + ("inpainting", 1.5), + ("img2img", 1.3), + ("negative_prompt", 1.0), + ("custom_size", 0.8), + ("aspect_ratio", 0.7), + ("seed", 0.5), + ] + if not supports: + return 0.3 + total_weight = sum(w for _, w in control_features) + earned = sum(w for f, w in control_features if supports.get(f)) + return min(1.0, earned / (total_weight * 0.5)) + + +def _compute_cost_efficiency( + estimated_cost: float, + budget_remaining: float | None, +) -> float: + """Score cost efficiency. Free is 1.0, over-budget is 0.0.""" + if estimated_cost <= 0: + return 1.0 + if budget_remaining is not None and budget_remaining <= 0: + return 0.0 + if budget_remaining is not None: + ratio = estimated_cost / budget_remaining + if ratio > 0.5: + return 0.1 + if ratio > 0.2: + return 0.5 + return 0.8 + # No budget info — use absolute cost heuristic + if estimated_cost < 0.05: + return 0.9 + if estimated_cost < 0.20: + return 0.7 + if estimated_cost < 1.00: + return 0.5 + return 0.3 + + +def _compute_continuity( + provider: str, + locked_providers: set[str], +) -> float: + """Score how well this provider fits already-locked decisions.""" + if not locked_providers: + return 0.5 # No prior context + if provider in locked_providers: + return 0.9 # Same provider = likely consistent style + return 0.4 # Different provider = possible style break + + +def score_provider(tool, task_context: dict[str, Any]) -> ProviderScore: + """Score a provider against a task context. + + Args: + tool: A BaseTool instance. + task_context: Dict with keys: + - intent (str): What the asset is for + - style_keywords (list[str]): Visual/audio style descriptors + - budget_remaining_usd (float|None): Remaining budget + - locked_providers (set[str]): Providers already chosen + - motion_required (bool): Whether motion is a hard requirement + - asset_type (str): "image", "video", "audio", "music", "voice" + """ + info = tool.get_info() + status = str(tool.get_status()) + + best_for = set(info.get("best_for", [])) + intent = task_context.get("intent", "") + style_keywords = set(task_context.get("style_keywords", [])) + + task_fit = _compute_task_fit(best_for, intent, style_keywords) + + # Reliability: uses historical success rate if available, else availability status. + hist_success = info.get("historical_success_rate") # 0.0-1.0 if tracked + if hist_success is not None: + reliability = float(hist_success) + elif status == "available": + # Stable tools get higher baseline than experimental ones + reliability = 0.95 if info.get("stability") == "production" else 0.8 + elif status == "degraded": + reliability = 0.4 + else: + reliability = 0.0 + + # Control: from supports dict + control = _compute_control(info.get("supports", {})) + + # Cost efficiency + try: + estimated_cost = tool.estimate_cost(task_context) + except Exception: + estimated_cost = 0.0 + cost_efficiency = _compute_cost_efficiency( + estimated_cost, task_context.get("budget_remaining_usd") + ) + + # Latency: uses measured p50 latency if available, else runtime class heuristic. + measured_p50 = info.get("latency_p50_seconds") # historical median + if measured_p50 is not None: + # Map measured latency to a 0-1 score (sub-second is best, >60s is worst) + if measured_p50 <= 1.0: + latency = 1.0 + elif measured_p50 <= 10.0: + latency = 0.8 + elif measured_p50 <= 30.0: + latency = 0.6 + elif measured_p50 <= 60.0: + latency = 0.4 + else: + latency = 0.2 + else: + runtime = info.get("runtime", "api") + if runtime in ("local", "local_gpu"): + latency = 0.9 + elif runtime == "hybrid": + latency = 0.6 + else: + latency = 0.4 + + # Continuity + continuity = _compute_continuity( + info.get("provider", ""), + set(task_context.get("locked_providers", [])), + ) + + # Output quality: uses measured quality score if available (e.g. from + # user ratings or automated eval), else falls back to stability + tier. + measured_quality = info.get("quality_score") # 0.0-1.0 if tracked + if measured_quality is not None: + output_quality = float(measured_quality) + else: + stability = info.get("stability", "experimental") + tier = info.get("tier", "") + quality_map = {"production": 0.9, "beta": 0.7, "experimental": 0.4} + output_quality = quality_map.get(stability, 0.5) + # Tier bonus: generate-tier tools that are production-stable get a nudge + if tier == "generate" and stability == "production": + output_quality = min(1.0, output_quality + 0.05) + + # Motion-required penalty: if task needs motion but tool is image-only + if task_context.get("motion_required") and task_context.get("asset_type") == "video": + cap = info.get("capability", "") + if "video" not in cap: + task_fit *= 0.2 # Heavy penalty + + return ProviderScore( + tool_name=info.get("name", "unknown"), + provider=info.get("provider", "unknown"), + task_fit=min(1.0, task_fit), + output_quality=output_quality, + control=control, + reliability=reliability, + cost_efficiency=cost_efficiency, + latency=latency, + continuity=continuity, + ) + + +def rank_providers( + tools: list, + task_context: dict[str, Any], +) -> list[ProviderScore]: + """Rank a list of tools by weighted score for a given task context. + + Returns scores sorted best-first. + """ + scores = [score_provider(t, task_context) for t in tools] + return sorted(scores, key=lambda s: s.weighted_score, reverse=True) + + +def format_ranking(rankings: list[ProviderScore], top_n: int = 5) -> str: + """Format a ranking list for user presentation.""" + lines = [] + for i, r in enumerate(rankings[:top_n], 1): + lines.append( + f" {i}. {r.tool_name} ({r.provider}) — " + f"score: {r.weighted_score:.2f} " + f"[fit={r.task_fit:.1f} quality={r.output_quality:.1f} " + f"control={r.control:.1f} reliable={r.reliability:.1f} " + f"cost={r.cost_efficiency:.1f}]" + ) + return "\n".join(lines) diff --git a/lib/shot_prompt_builder.py b/lib/shot_prompt_builder.py new file mode 100644 index 0000000..ca154f1 --- /dev/null +++ b/lib/shot_prompt_builder.py @@ -0,0 +1,166 @@ +"""Shot prompt builder — converts structured shot language into provider-optimized prompts. + +Uses a 5-layer framework based on professional cinematography prompting research: + Layer 1: Camera (lens, depth of field) + Layer 2: Movement (shot size, camera movement) + Layer 3: Subject (description + texture keywords) + Layer 4: Lighting (lighting key, color temperature) + Layer 5: Style (adapted from playbook, not verbatim) + +This replaces the old approach of prepending a fixed playbook image_prompt_prefix +to every scene description, which made all scenes look the same. +""" + +from __future__ import annotations + +from typing import Any + + +# Mapping from shot_language enums to natural language for prompting +_SHOT_SIZE_PHRASES = { + "extreme_wide": "extreme wide shot showing vast environment", + "wide": "wide shot capturing full scene", + "medium_wide": "medium-wide shot framing subject with surroundings", + "medium": "medium shot from waist up", + "medium_close": "medium close-up from chest up", + "close_up": "close-up focusing on face or detail", + "extreme_close_up": "extreme close-up on fine detail", + "over_shoulder": "over-the-shoulder perspective", + "insert": "insert shot of specific detail", + "establishing": "establishing shot setting the location", +} + +_MOVEMENT_PHRASES = { + "static": "locked-off static camera", + "pan_left": "smooth pan to the left", + "pan_right": "smooth pan to the right", + "tilt_up": "gentle tilt upward", + "tilt_down": "gentle tilt downward", + "dolly_in": "slow dolly in toward subject", + "dolly_out": "slow dolly out from subject", + "tracking_left": "tracking shot moving left alongside subject", + "tracking_right": "tracking shot moving right alongside subject", + "crane_up": "crane shot rising upward", + "crane_down": "crane shot descending", + "handheld": "handheld camera with natural movement", + "steadicam": "smooth steadicam following movement", + "whip_pan": "fast whip pan", + "orbital": "orbital camera circling subject", + "zoom_in": "slow zoom in", + "zoom_out": "slow zoom out", + "rack_focus": "rack focus shift between foreground and background", +} + +_LIGHTING_PHRASES = { + "high_key": "bright high-key lighting, minimal shadows", + "low_key": "dramatic low-key lighting with deep shadows", + "natural": "natural ambient lighting", + "golden_hour": "warm golden hour sunlight", + "blue_hour": "cool blue hour twilight", + "tungsten_warm": "warm tungsten interior lighting", + "neon": "neon-lit with vibrant color spill", + "silhouette": "backlit silhouette", + "rim_lit": "rim lighting highlighting edges", + "volumetric": "volumetric light with visible rays", + "overcast_soft": "soft overcast diffused light", +} + +_DOF_PHRASES = { + "shallow": "shallow depth of field with bokeh", + "medium": "medium depth of field", + "deep": "deep focus with everything sharp", +} + +_COLOR_TEMP_PHRASES = { + "cool": "cool blue-toned color palette", + "neutral": "neutral balanced colors", + "warm": "warm amber-toned color palette", + "mixed": "mixed color temperatures for contrast", +} + + +def build_shot_prompt( + scene: dict[str, Any], + style_context: dict[str, Any] | None = None, +) -> str: + """Convert a scene with structured shot language into a generation prompt. + + Args: + scene: Scene dict from scene_plan (with shot_language, description, + texture_keywords, etc.) + style_context: Optional playbook-derived style info with keys like + 'generation_prefix', 'visual_language', 'mood'. + + Returns: + A natural-language prompt optimized for image/video generation. + """ + sl = scene.get("shot_language", {}) + layers: list[str] = [] + + # Layer 1: Camera — lens and depth of field + camera_parts = [] + if sl.get("lens_mm"): + camera_parts.append(f"{sl['lens_mm']}mm lens") + if sl.get("depth_of_field"): + camera_parts.append(_DOF_PHRASES.get(sl["depth_of_field"], "")) + if camera_parts: + layers.append(", ".join(filter(None, camera_parts))) + + # Layer 2: Movement — shot size and camera movement + movement_parts = [] + if sl.get("shot_size"): + movement_parts.append(_SHOT_SIZE_PHRASES.get(sl["shot_size"], sl["shot_size"])) + if sl.get("camera_movement") and sl["camera_movement"] != "static": + movement_parts.append(_MOVEMENT_PHRASES.get(sl["camera_movement"], sl["camera_movement"])) + if movement_parts: + layers.append(", ".join(movement_parts)) + + # Layer 3: Subject — the scene description + texture keywords + description = scene.get("description", "") + texture = scene.get("texture_keywords", []) + subject_parts = [description] + if texture: + subject_parts.append(", ".join(texture)) + layers.append(". ".join(filter(None, subject_parts))) + + # Layer 4: Lighting — lighting key and color temperature + lighting_parts = [] + if sl.get("lighting_key"): + lighting_parts.append(_LIGHTING_PHRASES.get(sl["lighting_key"], sl["lighting_key"])) + if sl.get("color_temperature"): + lighting_parts.append(_COLOR_TEMP_PHRASES.get(sl["color_temperature"], "")) + if lighting_parts: + layers.append(", ".join(filter(None, lighting_parts))) + + # Layer 5: Style — adapted from playbook (NOT verbatim prefix) + if style_context: + mood = style_context.get("mood", "") + visual_lang = style_context.get("visual_language", {}) + style_hint = visual_lang.get("aesthetic", "") or mood + if style_hint: + layers.append(f"Style: {style_hint}") + + return ". ".join(filter(None, layers)) + + +def build_batch_prompts( + scenes: list[dict[str, Any]], + style_context: dict[str, Any] | None = None, +) -> list[dict[str, str]]: + """Build prompts for all visual scenes in a scene plan. + + Returns list of {scene_id, prompt} dicts. + """ + results = [] + for scene in scenes: + # Skip non-visual scene types + scene_type = scene.get("type", "") + if scene_type in ("transition",): + continue + prompt = build_shot_prompt(scene, style_context) + results.append({ + "scene_id": scene.get("id", "unknown"), + "prompt": prompt, + "hero_moment": scene.get("hero_moment", False), + }) + return results diff --git a/lib/slideshow_risk.py b/lib/slideshow_risk.py new file mode 100644 index 0000000..72c5596 --- /dev/null +++ b/lib/slideshow_risk.py @@ -0,0 +1,245 @@ +"""Slideshow risk scorer. + +Scores a video plan across 6 dimensions that reliably predict whether +the output will feel like a slideshow rather than directed video. + +Each dimension is scored 0-5 (lower is better): + - repetition: same layouts/backgrounds/scene grammar recurring + - decorative_visuals: scenes decorate instead of communicate + - weak_motion: motion exists but has no narrative purpose + - weak_shot_intent: no explicit reason for framing or reveal rhythm + - typography_overreliance: too much of the video is text-first + - unsupported_cinematic_claims: cinematic label without structure + +Verdict: + < 2.0: strong + < 3.0: acceptable + < 4.0: revise + >= 4.0: fail — should not proceed to compose +""" + +from __future__ import annotations + +from typing import Any + + +def score_slideshow_risk( + scenes: list[dict[str, Any]], + edit_decisions: dict[str, Any] | None = None, + renderer_family: str | None = None, +) -> dict[str, Any]: + """Score slideshow risk across 6 dimensions. + + Args: + scenes: Scene list from scene_plan artifact. + edit_decisions: Optional edit_decisions artifact for transition analysis. + renderer_family: Optional renderer family for cinematic claim verification. + + Returns: + { + "average": float, + "verdict": str, + "dimensions": {dimension_name: {"score": float, "reason": str}}, + } + """ + if not scenes: + return { + "average": 5.0, + "verdict": "fail", + "dimensions": {}, + } + + dimensions = { + "repetition": _score_repetition(scenes), + "decorative_visuals": _score_decorative(scenes), + "weak_motion": _score_weak_motion(scenes), + "weak_shot_intent": _score_weak_intent(scenes), + "typography_overreliance": _score_typography(scenes), + "unsupported_cinematic_claims": _score_cinematic_claims(scenes, renderer_family), + } + + scores = [d["score"] for d in dimensions.values()] + average = sum(scores) / len(scores) + + if average < 2.0: + verdict = "strong" + elif average < 3.0: + verdict = "acceptable" + elif average < 4.0: + verdict = "revise" + else: + verdict = "fail" + + return { + "average": round(average, 2), + "verdict": verdict, + "dimensions": dimensions, + } + + +def _score_repetition(scenes: list[dict]) -> dict[str, Any]: + """Score visual repetition across scenes.""" + if len(scenes) < 3: + return {"score": 0.0, "reason": "Too few scenes to assess repetition"} + + # Check for repeated scene types + from collections import Counter + types = Counter(s.get("type", "unknown") for s in scenes) + most_common_type, most_common_count = types.most_common(1)[0] + type_ratio = most_common_count / len(scenes) + + # Check for repeated descriptions (crude similarity) + descriptions = [s.get("description", "").lower()[:50] for s in scenes] + unique_desc_ratio = len(set(descriptions)) / len(descriptions) + + # Check shot size repetition + sizes = [s.get("shot_language", {}).get("shot_size", "none") for s in scenes] + size_ratio = Counter(sizes).most_common(1)[0][1] / len(scenes) + + score = 0.0 + reasons = [] + + if type_ratio > 0.7: + score += 2.0 + reasons.append(f"Scene type '{most_common_type}' dominates at {type_ratio:.0%}") + if unique_desc_ratio < 0.6: + score += 1.5 + reasons.append(f"Only {unique_desc_ratio:.0%} unique descriptions") + if size_ratio > 0.6: + score += 1.5 + reasons.append(f"Same shot size in {size_ratio:.0%} of scenes") + + return {"score": min(5.0, score), "reason": "; ".join(reasons) or "Good variety"} + + +def _score_decorative(scenes: list[dict]) -> dict[str, Any]: + """Score whether scenes are decorative vs communicative.""" + decorative_count = 0 + for scene in scenes: + has_info_role = bool(scene.get("information_role")) + has_narrative_role = bool(scene.get("narrative_role")) + has_intent = bool(scene.get("shot_intent")) + + # A scene with no role or intent is likely decorative + if not has_info_role and not has_narrative_role and not has_intent: + decorative_count += 1 + + ratio = decorative_count / len(scenes) + score = min(5.0, ratio * 5.0) + + if ratio > 0.5: + reason = f"{decorative_count}/{len(scenes)} scenes have no stated purpose (no information_role, narrative_role, or shot_intent)" + elif ratio > 0.2: + reason = f"{decorative_count}/{len(scenes)} scenes lack stated purpose" + else: + reason = "Most scenes have clear communicative purpose" + + return {"score": round(score, 1), "reason": reason} + + +def _score_weak_motion(scenes: list[dict]) -> dict[str, Any]: + """Score whether camera movement is purposeful.""" + total_moving = 0 + purposeless_moving = 0 + + for scene in scenes: + sl = scene.get("shot_language", {}) + movement = sl.get("camera_movement", "static") + if movement not in ("static", "unspecified", None): + total_moving += 1 + # Movement without shot_intent suggests arbitrary motion + if not scene.get("shot_intent"): + purposeless_moving += 1 + + if total_moving == 0: + # No movement at all is fine for some styles, but scores moderate + return {"score": 1.5, "reason": "No camera movement defined (may be intentional for static style)"} + + ratio = purposeless_moving / total_moving + score = min(5.0, ratio * 4.0) + + if ratio > 0.5: + reason = f"{purposeless_moving}/{total_moving} moving shots lack shot_intent" + else: + reason = "Camera movement appears purposeful" + + return {"score": round(score, 1), "reason": reason} + + +def _score_weak_intent(scenes: list[dict]) -> dict[str, Any]: + """Score shot intent completeness.""" + with_intent = sum(1 for s in scenes if s.get("shot_intent")) + ratio = with_intent / len(scenes) + + # Invert: more intent = lower score + score = min(5.0, (1.0 - ratio) * 5.0) + + if ratio < 0.3: + reason = f"Only {with_intent}/{len(scenes)} scenes have shot_intent — most shots lack purpose" + elif ratio < 0.6: + reason = f"{with_intent}/{len(scenes)} scenes have shot_intent" + else: + reason = "Strong shot intent coverage" + + return {"score": round(score, 1), "reason": reason} + + +def _score_typography(scenes: list[dict]) -> dict[str, Any]: + """Score text-first overreliance.""" + text_scenes = sum( + 1 for s in scenes + if s.get("type") in ("text_card", "stat_card", "kpi_grid") + ) + ratio = text_scenes / len(scenes) + + if ratio > 0.6: + score = 4.0 + reason = f"{text_scenes}/{len(scenes)} scenes are text/stat cards — video feels like animated slides" + elif ratio > 0.4: + score = 2.5 + reason = f"{text_scenes}/{len(scenes)} scenes are text-based — consider balancing with visual scenes" + elif ratio > 0.2: + score = 1.0 + reason = "Balanced text and visual content" + else: + score = 0.0 + reason = "Visual-first approach" + + return {"score": score, "reason": reason} + + +def _score_cinematic_claims( + scenes: list[dict], + renderer_family: str | None, +) -> dict[str, Any]: + """Score whether cinematic claims are backed by cinematic structure.""" + is_cinematic = renderer_family and "cinematic" in renderer_family.lower() + + if not is_cinematic: + return {"score": 0.0, "reason": "Not claiming cinematic treatment"} + + issues = [] + + # Cinematic should have: varied shot sizes, intentional movement, hero moments + hero_count = sum(1 for s in scenes if s.get("hero_moment")) + if hero_count == 0: + issues.append("Claims cinematic but has no hero_moment defined") + + has_movement = sum( + 1 for s in scenes + if s.get("shot_language", {}).get("camera_movement", "static") != "static" + ) + if has_movement < len(scenes) * 0.3: + issues.append(f"Claims cinematic but only {has_movement}/{len(scenes)} scenes have camera movement") + + has_lighting = sum( + 1 for s in scenes + if s.get("shot_language", {}).get("lighting_key") + ) + if has_lighting < len(scenes) * 0.3: + issues.append(f"Claims cinematic but only {has_lighting}/{len(scenes)} scenes define lighting") + + score = min(5.0, len(issues) * 1.8) + reason = "; ".join(issues) if issues else "Cinematic claims supported by structure" + + return {"score": round(score, 1), "reason": reason} diff --git a/lib/source_media_review.py b/lib/source_media_review.py new file mode 100644 index 0000000..e6b577b --- /dev/null +++ b/lib/source_media_review.py @@ -0,0 +1,395 @@ +"""Source media review helper. + +Standardizes inspection of user-supplied media files so pipelines stop +reinventing partial checks. Uses existing analysis tools (audio_probe, +frame_sampler, scene_detect, transcriber) to produce a normalized +source_media_review artifact. + +The contract: if user-supplied media exists, source_media_review is +REQUIRED before the first planning stage that depends on creative +assumptions. Never claim a file was reviewed unless a real probe ran. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# Media type detection by extension +_VIDEO_EXTENSIONS = frozenset({".mp4", ".mov", ".webm", ".avi", ".mkv", ".m4v"}) +_AUDIO_EXTENSIONS = frozenset({".mp3", ".wav", ".aac", ".flac", ".ogg", ".m4a", ".opus"}) +_IMAGE_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".svg"}) + + +def detect_media_type(path: Path) -> Optional[str]: + """Classify a file as video, audio, or image by extension.""" + ext = path.suffix.lower() + if ext in _VIDEO_EXTENSIONS: + return "video" + if ext in _AUDIO_EXTENSIONS: + return "audio" + if ext in _IMAGE_EXTENSIONS: + return "image" + return None + + +def _probe_video(path: Path, tool_registry: Any) -> dict[str, Any]: + """Probe a video file using audio_probe (ffprobe wrapper) and frame_sampler.""" + result: dict[str, Any] = {"technical_probe": {}, "representative_frames": [], "quality_risks": []} + + # Technical probe via audio_probe or ffprobe + try: + audio_probe = tool_registry.get_tool("audio_probe") + if audio_probe: + probe_result = audio_probe.execute({"input_path": str(path)}) + if probe_result.success: + result["technical_probe"] = probe_result.data + except Exception as e: + logger.warning("audio_probe failed for %s: %s", path, e) + + # If audio_probe didn't work, try ffprobe directly + if not result["technical_probe"]: + try: + import subprocess + cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", str(path), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if proc.returncode == 0: + probe_data = json.loads(proc.stdout) + fmt = probe_data.get("format", {}) + streams = probe_data.get("streams", []) + video_stream = next((s for s in streams if s.get("codec_type") == "video"), {}) + audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {}) + result["technical_probe"] = { + "duration_seconds": float(fmt.get("duration", 0)), + "resolution": f"{video_stream.get('width', '?')}x{video_stream.get('height', '?')}", + "fps": _parse_fps(video_stream.get("r_frame_rate", "0/1")), + "codec": video_stream.get("codec_name", "unknown"), + "audio_codec": audio_stream.get("codec_name", ""), + "sample_rate": int(audio_stream.get("sample_rate", 0)) if audio_stream else 0, + "channels": int(audio_stream.get("channels", 0)) if audio_stream else 0, + "file_size_bytes": int(fmt.get("size", 0)), + "bitrate_kbps": round(int(fmt.get("bit_rate", 0)) / 1000, 1), + } + except Exception as e: + logger.warning("ffprobe failed for %s: %s", path, e) + result["quality_risks"].append(f"Could not probe file: {e}") + + # Sample frames + try: + frame_sampler = tool_registry.get_tool("frame_sampler") + if frame_sampler: + duration = result["technical_probe"].get("duration_seconds", 0) + timestamps = _sample_timestamps(duration, count=4) + sample_result = frame_sampler.execute({ + "input_path": str(path), + "timestamps": timestamps, + "output_dir": str(path.parent / ".source_review_frames"), + }) + if sample_result.success: + result["representative_frames"] = sample_result.data.get("frame_paths", []) + except Exception as e: + logger.warning("frame_sampler failed for %s: %s", path, e) + + # Quality risk assessment + probe = result["technical_probe"] + if probe: + res = probe.get("resolution", "") + if res and "x" in res: + try: + w, h = res.split("x") + if int(w) < 720 or int(h) < 480: + result["quality_risks"].append(f"Low resolution ({res}) — may appear pixelated in final output") + except ValueError: + pass + if probe.get("channels", 0) == 1: + result["quality_risks"].append("Mono audio — consider if stereo output is expected") + if probe.get("duration_seconds", 0) < 3: + result["quality_risks"].append("Very short clip (<3s) — limited usability") + + return result + + +def _probe_audio(path: Path, tool_registry: Any) -> dict[str, Any]: + """Probe an audio file using audio_probe.""" + result: dict[str, Any] = {"technical_probe": {}, "quality_risks": []} + + try: + audio_probe = tool_registry.get_tool("audio_probe") + if audio_probe: + probe_result = audio_probe.execute({"input_path": str(path)}) + if probe_result.success: + result["technical_probe"] = probe_result.data + except Exception as e: + logger.warning("audio_probe failed for %s: %s", path, e) + + # Fallback ffprobe + if not result["technical_probe"]: + try: + import subprocess + cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", str(path), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if proc.returncode == 0: + probe_data = json.loads(proc.stdout) + fmt = probe_data.get("format", {}) + stream = next( + (s for s in probe_data.get("streams", []) if s.get("codec_type") == "audio"), + {}, + ) + result["technical_probe"] = { + "duration_seconds": float(fmt.get("duration", 0)), + "audio_codec": stream.get("codec_name", "unknown"), + "sample_rate": int(stream.get("sample_rate", 0)), + "channels": int(stream.get("channels", 0)), + "file_size_bytes": int(fmt.get("size", 0)), + "bitrate_kbps": round(int(fmt.get("bit_rate", 0)) / 1000, 1), + } + except Exception as e: + logger.warning("ffprobe failed for audio %s: %s", path, e) + result["quality_risks"].append(f"Could not probe audio: {e}") + + return result + + +def _probe_image(path: Path) -> dict[str, Any]: + """Probe an image file for basic metadata.""" + result: dict[str, Any] = {"technical_probe": {}, "quality_risks": []} + + try: + from PIL import Image + img = Image.open(path) + w, h = img.size + result["technical_probe"] = { + "resolution": f"{w}x{h}", + "file_size_bytes": path.stat().st_size, + "codec": img.format or "unknown", + } + if w < 640 or h < 480: + result["quality_risks"].append(f"Low resolution ({w}x{h}) — may need upscaling") + except ImportError: + # PIL not available — use file size as minimal probe + result["technical_probe"] = { + "file_size_bytes": path.stat().st_size, + } + except Exception as e: + result["quality_risks"].append(f"Could not probe image: {e}") + + return result + + +def _transcribe_if_available( + path: Path, media_type: str, tool_registry: Any +) -> Optional[str]: + """Attempt transcription for video/audio files.""" + if media_type not in ("video", "audio"): + return None + + try: + transcriber = tool_registry.get_tool("transcriber") + if transcriber and transcriber.get_status().value == "available": + result = transcriber.execute({"input_path": str(path)}) + if result.success: + text = result.data.get("text", "") + if text: + # Return summary, not full transcript + words = text.split() + if len(words) > 100: + return f"{' '.join(words[:100])}... ({len(words)} words total)" + return text + except Exception as e: + logger.warning("Transcription failed for %s: %s", path, e) + + return None + + +def review_source_media( + files: list[Path], + context: dict[str, Any], + tool_registry: Any = None, +) -> dict[str, Any]: + """Review user-supplied media files and produce a source_media_review artifact. + + Args: + files: Paths to user-supplied media files. + context: Dict with optional keys like 'pipeline_type', 'project_dir'. + tool_registry: The tool registry instance (for accessing analysis tools). + + Returns: + Schema-valid source_media_review artifact dict. + + Must never claim a file was reviewed unless a real probe/sampling/transcription ran. + """ + if tool_registry is None: + try: + from tools.tool_registry import registry + registry.ensure_discovered() + tool_registry = registry + except Exception: + pass + + reviewed_files: list[dict[str, Any]] = [] + all_implications: list[str] = [] + summaries: list[str] = [] + + for file_path in files: + media_type = detect_media_type(file_path) + if media_type is None: + logger.warning("Skipping unrecognized file type: %s", file_path) + continue + + if not file_path.exists(): + logger.warning("File does not exist: %s", file_path) + continue + + entry: dict[str, Any] = { + "path": str(file_path), + "media_type": media_type, + "reviewed": True, + } + + # Probe based on media type + if media_type == "video": + probe_data = _probe_video(file_path, tool_registry) + elif media_type == "audio": + probe_data = _probe_audio(file_path, tool_registry) + else: + probe_data = _probe_image(file_path) + + entry["technical_probe"] = probe_data.get("technical_probe", {}) + entry["quality_risks"] = probe_data.get("quality_risks", []) + entry["representative_frames"] = probe_data.get("representative_frames", []) + + # Attempt transcription for audio/video + transcript = _transcribe_if_available(file_path, media_type, tool_registry) + if transcript: + entry["transcript_summary"] = transcript + + # Build content summary + probe = entry["technical_probe"] + if media_type == "video": + dur = probe.get("duration_seconds", 0) + res = probe.get("resolution", "unknown") + has_audio = bool(probe.get("audio_codec")) + entry["content_summary"] = ( + f"Video file: {dur:.1f}s at {res}, " + f"{'with' if has_audio else 'without'} audio" + ) + entry["usable_for"] = _infer_video_usability(probe, transcript) + elif media_type == "audio": + dur = probe.get("duration_seconds", 0) + entry["content_summary"] = f"Audio file: {dur:.1f}s, {probe.get('audio_codec', 'unknown')}" + entry["usable_for"] = _infer_audio_usability(probe, transcript) + else: + res = probe.get("resolution", "unknown") + entry["content_summary"] = f"Image file: {res}" + entry["usable_for"] = ["visual asset", "reference image"] + + summaries.append(f"{file_path.name}: {entry['content_summary']}") + reviewed_files.append(entry) + + # Derive planning implications from quality risks + for risk in entry.get("quality_risks", []): + all_implications.append(f"Quality risk in {file_path.name}: {risk}") + + # Build overall summary + if not reviewed_files: + summary = "No user-supplied media files could be reviewed." + all_implications.append("No source media available — production is fully generated.") + else: + summary = "; ".join(summaries) + + # Add media-type implications + has_video = any(f["media_type"] == "video" for f in reviewed_files) + has_audio = any(f["media_type"] == "audio" for f in reviewed_files) + has_images = any(f["media_type"] == "image" for f in reviewed_files) + + if has_video: + all_implications.append("Source video available — consider source-led or hybrid production approach") + if has_audio and not has_video: + all_implications.append("Audio-only source — production needs visual assets to accompany audio") + if has_images and not has_video: + all_implications.append("Image-only source — motion must come from animation or video generation") + + if not all_implications: + all_implications.append("No specific constraints identified from source media.") + + return { + "version": "1.0", + "files": reviewed_files, + "summary": summary, + "planning_implications": all_implications, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_fps(fps_str: str) -> float: + """Parse ffprobe fps string like '30/1' or '24000/1001'.""" + try: + if "/" in fps_str: + num, den = fps_str.split("/") + return round(int(num) / max(int(den), 1), 2) + return float(fps_str) + except (ValueError, ZeroDivisionError): + return 0.0 + + +def _sample_timestamps(duration: float, count: int = 4) -> list[float]: + """Generate evenly-spaced sample timestamps for a given duration.""" + if duration <= 0: + return [0.0] + if count <= 1: + return [duration / 2] + step = duration / (count + 1) + return [round(step * (i + 1), 2) for i in range(count)] + + +def _infer_video_usability(probe: dict, transcript: Optional[str]) -> list[str]: + """Infer what a video file can be used for.""" + uses = [] + dur = probe.get("duration_seconds", 0) + if dur > 10: + uses.append("hero footage") + if dur > 3: + uses.append("b-roll") + if transcript: + uses.append("source dialogue") + if probe.get("audio_codec"): + uses.append("source audio") + return uses or ["short clip"] + + +def _infer_audio_usability(probe: dict, transcript: Optional[str]) -> list[str]: + """Infer what an audio file can be used for.""" + uses = [] + dur = probe.get("duration_seconds", 0) + if transcript: + uses.append("narration source") + if dur > 30: + uses.append("background music candidate") + if dur > 5: + uses.append("sound effect or ambient") + return uses or ["audio clip"] + + +def has_user_media(project_dir: Path) -> bool: + """Check if a project directory contains user-supplied media files.""" + if not project_dir.exists(): + return False + for ext_set in (_VIDEO_EXTENSIONS, _AUDIO_EXTENSIONS, _IMAGE_EXTENSIONS): + for ext in ext_set: + if list(project_dir.glob(f"*{ext}")): + return True + return False diff --git a/lib/variation_checker.py b/lib/variation_checker.py new file mode 100644 index 0000000..2604a94 --- /dev/null +++ b/lib/variation_checker.py @@ -0,0 +1,169 @@ +"""Scene plan variation checker. + +Analyzes a scene plan for repetitive patterns that make videos feel +like slideshows. Catches problems before asset generation begins. + +This is a structural check, not a creative judgment — it flags concrete +patterns that reliably produce generic-feeling output. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any + + +# Generic language patterns that signal lazy scene descriptions +GENERIC_PHRASES = { + "a person", "a beautiful", "modern", "futuristic", "cutting-edge", + "in today's world", "sleek design", "innovative", "state-of-the-art", + "next-generation", "revolutionary", "a professional", "dynamic", + "vibrant", "stunning", "breathtaking", "amazing", "incredible", + "powerful", "seamless", "elegant solution", +} + + +def check_scene_variation(scenes: list[dict[str, Any]]) -> dict[str, Any]: + """Analyze a scene plan for repetitive patterns. + + Returns: + { + "score": float (0-5, lower is better), + "verdict": "strong" | "acceptable" | "revise" | "fail", + "violations": list of specific issues, + "suggestions": list of improvement suggestions, + } + """ + if not scenes: + return {"score": 5.0, "verdict": "fail", "violations": ["No scenes to check"], "suggestions": []} + + violations: list[str] = [] + suggestions: list[str] = [] + + # --- Check 1: Shot size variety --- + shot_sizes = [ + s.get("shot_language", {}).get("shot_size", "unspecified") + for s in scenes + ] + size_counts = Counter(shot_sizes) + if len(scenes) >= 4: + most_common_size, most_common_count = size_counts.most_common(1)[0] + if most_common_count / len(scenes) > 0.5: + violations.append( + f"Shot size '{most_common_size}' used in {most_common_count}/{len(scenes)} scenes " + f"({most_common_count/len(scenes):.0%}). Vary shot sizes for visual interest." + ) + suggestions.append("Mix wide establishing shots with close-ups for visual rhythm.") + + # --- Check 2: Consecutive same-size shots --- + consecutive_same = 0 + for i in range(1, len(shot_sizes)): + if shot_sizes[i] == shot_sizes[i-1] and shot_sizes[i] != "unspecified": + consecutive_same += 1 + if consecutive_same >= 3: + violations.append( + f"{consecutive_same} consecutive same-size shots. " + f"Vary shot sizes between scenes for editorial rhythm." + ) + + # --- Check 3: Static shot overuse --- + movements = [ + s.get("shot_language", {}).get("camera_movement", "unspecified") + for s in scenes + ] + static_count = sum(1 for m in movements if m in ("static", "unspecified")) + if len(scenes) >= 4 and static_count / len(scenes) > 0.6: + violations.append( + f"{static_count}/{len(scenes)} scenes are static or unspecified movement. " + f"Add intentional camera movement to at least 40% of scenes." + ) + suggestions.append("Consider dolly_in for emphasis, tracking for energy, or crane for scale.") + + # --- Check 4: Lighting variety --- + lightings = { + s.get("shot_language", {}).get("lighting_key") + for s in scenes + if s.get("shot_language", {}).get("lighting_key") + } + if len(scenes) >= 4 and len(lightings) <= 1: + violations.append( + f"Only {len(lightings)} unique lighting setup(s) across {len(scenes)} scenes. " + f"Vary lighting to create mood shifts." + ) + + # --- Check 5: Hero moment exists and is visually distinct --- + hero_scenes = [s for s in scenes if s.get("hero_moment")] + if len(scenes) >= 4 and not hero_scenes: + violations.append( + "No hero_moment flagged. Every video should have at least one visual peak." + ) + suggestions.append("Mark the most impactful scene as hero_moment=true.") + + if hero_scenes: + for hero in hero_scenes: + hero_idx = scenes.index(hero) + hero_size = hero.get("shot_language", {}).get("shot_size") + # Check neighbors + for offset in (-1, 1): + neighbor_idx = hero_idx + offset + if 0 <= neighbor_idx < len(scenes): + neighbor_size = scenes[neighbor_idx].get("shot_language", {}).get("shot_size") + if hero_size and neighbor_size and hero_size == neighbor_size: + violations.append( + f"Hero scene '{hero.get('id')}' has same shot size as neighbor. " + f"Hero moments should be visually distinct from surrounding scenes." + ) + + # --- Check 6: Description specificity --- + generic_count = 0 + for scene in scenes: + desc = scene.get("description", "").lower() + for phrase in GENERIC_PHRASES: + if phrase in desc: + generic_count += 1 + break + if generic_count >= len(scenes) * 0.3: + violations.append( + f"{generic_count}/{len(scenes)} scenes use generic language. " + f"Replace vague descriptions with specific visual details." + ) + suggestions.append( + "Instead of 'a beautiful cityscape', try 'rain-slicked Tokyo intersection " + "at night, neon reflections in puddles, pedestrians with translucent umbrellas'." + ) + + # --- Check 7: Texture keywords presence --- + textured = sum(1 for s in scenes if s.get("texture_keywords")) + if len(scenes) >= 4 and textured < len(scenes) * 0.3: + violations.append( + f"Only {textured}/{len(scenes)} scenes have texture_keywords. " + f"Add texture descriptors to visual scenes for richer generation prompts." + ) + + # --- Check 8: Shot intent completeness --- + intented = sum(1 for s in scenes if s.get("shot_intent")) + if len(scenes) >= 4 and intented < len(scenes) * 0.5: + violations.append( + f"Only {intented}/{len(scenes)} scenes have shot_intent. " + f"Every scene should explain WHY it exists in the video." + ) + + # --- Score --- + # Each violation category adds ~0.6 to score + score = min(5.0, len(violations) * 0.6) + + if score < 2.0: + verdict = "strong" + elif score < 3.0: + verdict = "acceptable" + elif score < 4.0: + verdict = "revise" + else: + verdict = "fail" + + return { + "score": round(score, 1), + "verdict": verdict, + "violations": violations, + "suggestions": suggestions, + } diff --git a/pipeline_defs/animated-explainer.yaml b/pipeline_defs/animated-explainer.yaml index 0a30424..0971ab6 100644 --- a/pipeline_defs/animated-explainer.yaml +++ b/pipeline_defs/animated-explainer.yaml @@ -9,6 +9,12 @@ stability: production default_checkpoint_policy: guided +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/explainer/executive-producer - pipelines/explainer/research-director @@ -63,6 +69,7 @@ stages: - research_brief produces: - proposal_packet + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -189,6 +196,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -212,6 +220,7 @@ stages: skill: pipelines/explainer/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - proposal_packet produces: diff --git a/pipeline_defs/animation.yaml b/pipeline_defs/animation.yaml index c123196..3c49829 100644 --- a/pipeline_defs/animation.yaml +++ b/pipeline_defs/animation.yaml @@ -10,6 +10,12 @@ category: animation stability: production default_checkpoint_policy: guided +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/animation/executive-producer - pipelines/animation/research-director @@ -64,6 +70,7 @@ stages: - research_brief produces: - proposal_packet + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -194,6 +201,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -218,6 +226,7 @@ stages: skill: pipelines/animation/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - proposal_packet - script diff --git a/pipeline_defs/avatar-spokesperson.yaml b/pipeline_defs/avatar-spokesperson.yaml index eecd3ad..8487b2d 100644 --- a/pipeline_defs/avatar-spokesperson.yaml +++ b/pipeline_defs/avatar-spokesperson.yaml @@ -17,6 +17,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 12 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/avatar-spokesperson/executive-producer - pipelines/avatar-spokesperson/idea-director @@ -38,6 +44,7 @@ stages: skill: pipelines/avatar-spokesperson/idea-director produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -152,6 +159,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -177,6 +185,7 @@ stages: skill: pipelines/avatar-spokesperson/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief - script diff --git a/pipeline_defs/cinematic.yaml b/pipeline_defs/cinematic.yaml index 343e432..b7dbc42 100644 --- a/pipeline_defs/cinematic.yaml +++ b/pipeline_defs/cinematic.yaml @@ -17,9 +17,16 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 12 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/cinematic/executive-producer - - pipelines/cinematic/idea-director + - pipelines/cinematic/research-director + - pipelines/cinematic/proposal-director - pipelines/cinematic/script-director - pipelines/cinematic/scene-director - pipelines/cinematic/asset-director @@ -30,15 +37,41 @@ required_skills: - meta/checkpoint-protocol compatible_playbooks: - - clean-professional - - flat-motion-graphics - - minimalist-diagram + recommended: + - clean-professional + - flat-motion-graphics + also_works: + - minimalist-diagram + custom_allowed: true stages: - - name: idea - skill: pipelines/cinematic/idea-director + - name: research + skill: pipelines/cinematic/research-director produces: - - brief + - research_brief + tools_available: + - web_search + checkpoint_required: true + human_approval_default: false + review_focus: + - Visual references are specific and relevant to the mood + - Sound and music direction is substantive, not generic + - Motion commitment is honest about available capabilities + - At least 3 genuinely different cinematic directions identified + success_criteria: + - Schema-valid research_brief artifact + - At least 8 web searches executed + - Visual references include specific URLs and descriptions + + - name: proposal + skill: pipelines/cinematic/proposal-director + required_artifacts_in: + - research_brief + optional_artifacts_in: + - source_media_review + produces: + - proposal_packet + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -47,14 +80,19 @@ stages: - Motion-required delivery is explicitly identified when applicable - Delivery shape is realistic for available footage and assets - Cinematic treatment is justified rather than decorative + - Delivery promise is explicit and honest + - Renderer family is selected and locked + - Music plan is resolved success_criteria: - - Schema-valid brief artifact - - Brief records source reality and target output shape + - Schema-valid proposal_packet artifact + - At least 3 concept options with different emotional arcs + - Delivery promise present with motion_required flag + - Cost estimate includes per-item breakdown - name: script skill: pipelines/cinematic/script-director required_artifacts_in: - - brief + - proposal_packet produces: - script optional_tools: @@ -78,7 +116,7 @@ stages: required_artifacts_in: - script optional_artifacts_in: - - brief + - proposal_packet produces: - scene_plan optional_tools: @@ -158,6 +196,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -188,8 +227,9 @@ stages: skill: pipelines/cinematic/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - - brief + - proposal_packet - script produces: - publish_log diff --git a/pipeline_defs/clip-factory.yaml b/pipeline_defs/clip-factory.yaml index 53b7038..de901ff 100644 --- a/pipeline_defs/clip-factory.yaml +++ b/pipeline_defs/clip-factory.yaml @@ -17,6 +17,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 12 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/clip-factory/executive-producer - pipelines/clip-factory/idea-director @@ -36,8 +42,11 @@ compatible_playbooks: stages: - name: idea skill: pipelines/clip-factory/idea-director + optional_artifacts_in: + - source_media_review produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -149,6 +158,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - video_trimmer @@ -176,6 +186,7 @@ stages: skill: pipelines/clip-factory/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief produces: diff --git a/pipeline_defs/hybrid.yaml b/pipeline_defs/hybrid.yaml index 582ac95..e3bef2c 100644 --- a/pipeline_defs/hybrid.yaml +++ b/pipeline_defs/hybrid.yaml @@ -17,6 +17,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 12 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/hybrid/executive-producer - pipelines/hybrid/idea-director @@ -37,8 +43,11 @@ compatible_playbooks: stages: - name: idea skill: pipelines/hybrid/idea-director + optional_artifacts_in: + - source_media_review produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -164,6 +173,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -193,6 +203,7 @@ stages: skill: pipelines/hybrid/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief - script diff --git a/pipeline_defs/localization-dub.yaml b/pipeline_defs/localization-dub.yaml index bb5813c..2bbc234 100644 --- a/pipeline_defs/localization-dub.yaml +++ b/pipeline_defs/localization-dub.yaml @@ -16,6 +16,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 15 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/localization-dub/executive-producer - pipelines/localization-dub/idea-director @@ -37,6 +43,7 @@ stages: skill: pipelines/localization-dub/idea-director produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -52,6 +59,8 @@ stages: skill: pipelines/localization-dub/script-director required_artifacts_in: - brief + optional_artifacts_in: + - source_media_review produces: - script required_tools: @@ -151,6 +160,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -176,6 +186,7 @@ stages: skill: pipelines/localization-dub/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief - script diff --git a/pipeline_defs/podcast-repurpose.yaml b/pipeline_defs/podcast-repurpose.yaml index 0ec4300..a9339ac 100644 --- a/pipeline_defs/podcast-repurpose.yaml +++ b/pipeline_defs/podcast-repurpose.yaml @@ -18,6 +18,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 12 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/podcast-repurpose/executive-producer - pipelines/podcast-repurpose/idea-director @@ -37,8 +43,11 @@ compatible_playbooks: stages: - name: idea skill: pipelines/podcast-repurpose/idea-director + optional_artifacts_in: + - source_media_review produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -157,6 +166,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -182,6 +192,7 @@ stages: skill: pipelines/podcast-repurpose/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief produces: diff --git a/pipeline_defs/screen-demo.yaml b/pipeline_defs/screen-demo.yaml index 21b8cfc..7dcd023 100644 --- a/pipeline_defs/screen-demo.yaml +++ b/pipeline_defs/screen-demo.yaml @@ -17,6 +17,12 @@ orchestration: max_send_backs: 3 max_wall_time_minutes: 10 +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/screen-demo/executive-producer - pipelines/screen-demo/idea-director @@ -38,6 +44,7 @@ stages: skill: pipelines/screen-demo/idea-director produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -53,6 +60,8 @@ stages: skill: pipelines/screen-demo/script-director required_artifacts_in: - brief + optional_artifacts_in: + - source_media_review produces: - script required_tools: @@ -104,9 +113,9 @@ stages: - script produces: - asset_manifest - required_tools: - - subtitle_gen + required_tools: [] optional_tools: + - subtitle_gen - tts_selector - image_selector - diagram_gen @@ -156,6 +165,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose - audio_mixer @@ -181,6 +191,7 @@ stages: skill: pipelines/screen-demo/publish-director required_artifacts_in: - render_report + - final_review optional_artifacts_in: - brief produces: diff --git a/pipeline_defs/talking-head.yaml b/pipeline_defs/talking-head.yaml index 7445340..0f0586d 100644 --- a/pipeline_defs/talking-head.yaml +++ b/pipeline_defs/talking-head.yaml @@ -8,6 +8,12 @@ category: talking_head stability: beta default_checkpoint_policy: guided +extensions: + custom_scripts: true + custom_playbooks: true + custom_skills: true + custom_tools: false + required_skills: - pipelines/talking-head/executive-producer - pipelines/talking-head/idea-director @@ -35,6 +41,7 @@ stages: skill: pipelines/talking-head/idea-director produces: - brief + - decision_log tools_available: [] checkpoint_required: true human_approval_default: true @@ -50,6 +57,8 @@ stages: skill: pipelines/talking-head/script-director required_artifacts_in: - brief + optional_artifacts_in: + - source_media_review produces: - script required_tools: @@ -156,6 +165,7 @@ stages: - scene_plan produces: - render_report + - final_review required_tools: - video_compose # rendering - audio_mixer # audio layering + segmented music @@ -201,6 +211,7 @@ stages: skill: pipelines/talking-head/publish-director required_artifacts_in: - render_report + - final_review produces: - publish_log tools_available: [] diff --git a/remotion-composer/src/Explainer.tsx b/remotion-composer/src/Explainer.tsx index a0d8761..1da4be7 100644 --- a/remotion-composer/src/Explainer.tsx +++ b/remotion-composer/src/Explainer.tsx @@ -12,13 +12,18 @@ import { } from "remotion"; import { loadFont } from "@remotion/google-fonts/SpaceGrotesk"; -// Resolve asset path — use staticFile() for local paths, passthrough URLs +// Resolve asset path — handle URLs, absolute paths (Windows/Unix), and public/ relative paths function resolveAsset(src: string): string { if (src.startsWith("http://") || src.startsWith("https://") || src.startsWith("data:")) { return src; } // Strip any file:// prefix const clean = src.replace(/^file:\/\/\/?/, ""); + // Absolute paths (Unix: /foo, Windows: C:\foo or C:/foo) — convert to file:// URI + // staticFile() only accepts relative paths within public/, so absolute paths must bypass it + if (clean.startsWith("/") || /^[A-Za-z]:[\\/]/.test(clean)) { + return `file:///${clean.replace(/\\/g, "/")}`; + } return staticFile(clean); } import { TextCard } from "./components/TextCard"; @@ -37,6 +42,7 @@ import { HeroTitle } from "./components/HeroTitle"; import { AnimeScene } from "./components/AnimeScene"; import type { CameraMotion } from "./components/AnimeScene"; import type { ParticleType } from "./components/ParticleOverlay"; +import { resolveTheme, type ThemeConfig, DEFAULT_THEME } from "./Root"; // Load Space Grotesk font for cinematic typography const { fontFamily } = loadFont("normal", { @@ -48,45 +54,84 @@ const { fontFamily } = loadFont("normal", { // Animated Background — Gradient Mesh + Floating Orbs // --------------------------------------------------------------------------- -const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({ - style: bgStyle = "default", -}) => { +// Parse hex color to RGB components +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const clean = hex.replace("#", ""); + const bigint = parseInt(clean.length === 3 + ? clean.split("").map(c => c + c).join("") + : clean, 16); + return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 }; +} + +// Detect if a color is "light" (for choosing grid/overlay treatment) +function isLightColor(hex: string): boolean { + const { r, g, b } = hexToRgb(hex); + return (r * 299 + g * 587 + b * 114) / 1000 > 128; +} + +// Darken/lighten a color by mixing toward black or white +function shiftColor(hex: string, amount: number): string { + const { r, g, b } = hexToRgb(hex); + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + if (amount < 0) { + // Darken + const f = 1 + amount; + return `rgb(${clamp(r * f)}, ${clamp(g * f)}, ${clamp(b * f)})`; + } + // Lighten + return `rgb(${clamp(r + (255 - r) * amount)}, ${clamp(g + (255 - g) * amount)}, ${clamp(b + (255 - b) * amount)})`; +} + +const AnimatedBackground: React.FC<{ theme: ThemeConfig }> = ({ theme }) => { const frame = useCurrentFrame(); - const { fps, durationInFrames, width, height } = useVideoConfig(); - const progress = frame / durationInFrames; + const { fps, durationInFrames } = useVideoConfig(); + + const bg = theme.backgroundColor; + const primary = theme.primaryColor; + const accent = theme.accentColor; + const surface = theme.surfaceColor; + const light = isLightColor(bg); // Slow-moving gradient angles const angle1 = 135 + Math.sin(frame / (fps * 8)) * 30; - const angle2 = 225 + Math.cos(frame / (fps * 6)) * 25; - // Color stops shift over time - const shift = Math.sin(frame / (fps * 12)) * 0.15; + // Build gradient from theme colors instead of hardcoded dark blue + const { r: bgR, g: bgG, b: bgB } = hexToRgb(bg); + const { r: priR, g: priG, b: priB } = hexToRgb(primary); + const { r: accR, g: accG, b: accB } = hexToRgb(accent); const gradient = ` radial-gradient(ellipse at ${30 + Math.sin(frame / (fps * 10)) * 20}% ${40 + Math.cos(frame / (fps * 8)) * 20}%, - rgba(15, 23, 60, 1) 0%, transparent 60%), + rgba(${priR}, ${priG}, ${priB}, 0.15) 0%, transparent 60%), radial-gradient(ellipse at ${70 + Math.cos(frame / (fps * 7)) * 20}% ${60 + Math.sin(frame / (fps * 9)) * 25}%, - rgba(30, 10, 60, 0.8) 0%, transparent 55%), - radial-gradient(ellipse at ${50 + Math.sin(frame / (fps * 14)) * 30}% ${20 + Math.cos(frame / (fps * 11)) * 15}%, - rgba(0, 40, 60, 0.6) 0%, transparent 50%), - linear-gradient(${angle1}deg, #060918 0%, #0B1026 40%, #0F0A2E 70%, #080D1F 100%) + rgba(${accR}, ${accG}, ${accB}, 0.1) 0%, transparent 55%), + linear-gradient(${angle1}deg, ${bg} 0%, ${shiftColor(bg, light ? -0.05 : 0.05)} 40%, ${surface} 70%, ${bg} 100%) `; - // Floating orbs + // Floating orbs — derived from theme chart colors with low opacity + const orbColors = theme.chartColors.slice(0, 5); + const orbOpacity = light ? 0.06 : 0.08; const orbs = [ - { x: 20, y: 30, size: 300, color: "rgba(34, 211, 238, 0.08)", speedX: 7, speedY: 11 }, - { x: 70, y: 60, size: 250, color: "rgba(139, 92, 246, 0.1)", speedX: 9, speedY: 8 }, - { x: 40, y: 80, size: 200, color: "rgba(16, 185, 129, 0.07)", speedX: 13, speedY: 6 }, - { x: 80, y: 20, size: 350, color: "rgba(245, 158, 11, 0.06)", speedX: 11, speedY: 14 }, - { x: 10, y: 70, size: 180, color: "rgba(236, 72, 153, 0.05)", speedX: 8, speedY: 10 }, + { x: 20, y: 30, size: 300, color: orbColors[0] || primary, speedX: 7, speedY: 11 }, + { x: 70, y: 60, size: 250, color: orbColors[1] || accent, speedX: 9, speedY: 8 }, + { x: 40, y: 80, size: 200, color: orbColors[2] || primary, speedX: 13, speedY: 6 }, + { x: 80, y: 20, size: 350, color: orbColors[3] || accent, speedX: 11, speedY: 14 }, + { x: 10, y: 70, size: 180, color: orbColors[4] || primary, speedX: 8, speedY: 10 }, ]; + // Grid and overlay colors adapt to light vs dark backgrounds + const gridColor = light ? "rgba(0,0,0,0.03)" : "rgba(255,255,255,0.02)"; + const fadeColor = light + ? `rgba(${bgR},${bgG},${bgB},0.2)` + : `rgba(${bgR},${bgG},${bgB},0.4)`; + return ( {/* Floating glow orbs */} {orbs.map((orb, i) => { const ox = orb.x + Math.sin(frame / (fps * orb.speedX)) * 15; const oy = orb.y + Math.cos(frame / (fps * orb.speedY)) * 12; + const { r, g, b } = hexToRgb(orb.color); return (
= ({ width: orb.size, height: orb.size, borderRadius: "50%", - background: orb.color, + background: `rgba(${r}, ${g}, ${b}, ${orbOpacity})`, filter: `blur(${orb.size * 0.4}px)`, transform: "translate(-50%, -50%)", willChange: "transform", @@ -112,8 +157,8 @@ const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({ position: "absolute", inset: 0, backgroundImage: ` - linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px) + linear-gradient(${gridColor} 1px, transparent 1px), + linear-gradient(90deg, ${gridColor} 1px, transparent 1px) `, backgroundSize: "60px 60px", opacity: 0.5 + Math.sin(frame / (fps * 20)) * 0.2, @@ -128,7 +173,7 @@ const AnimatedBackground: React.FC<{ style?: "fintech" | "default" }> = ({ left: 0, right: 0, height: "30%", - background: "linear-gradient(to bottom, rgba(6,9,24,0.4), transparent)", + background: `linear-gradient(to bottom, ${fadeColor}, transparent)`, }} /> @@ -427,7 +472,7 @@ const BackgroundImageLayer: React.FC<{ ); }; -const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { +const SceneRenderer: React.FC<{ cut: Cut; theme: ThemeConfig }> = ({ cut, theme }) => { // Wrap component with background image if specified const maybeWrapWithBgImage = (element: React.ReactElement) => { if (cut.backgroundImage) { @@ -445,26 +490,29 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { // Resolve the scene element based on cut type, then wrap with backgroundImage if set // Use transparent bg so the animated gradient background shows through - const rawBg = cut.backgroundImage ? "transparent" : cut.backgroundColor; - const bgColor = (rawBg === "#0F172A" || rawBg === "#0f172a") ? "transparent" : rawBg; + // When no explicit backgroundColor on the cut, inherit from theme + const rawBg = cut.backgroundImage ? "transparent" : (cut.backgroundColor || theme.surfaceColor); + const bgColor = (rawBg === theme.backgroundColor || rawBg === "#0F172A" || rawBg === "#0f172a") ? "transparent" : rawBg; + const textColor = cut.color || theme.textColor; + const accent = cut.accentColor || theme.accentColor; - // Explicit component types + // Explicit component types — use theme-derived defaults for colors if (cut.type === "text_card" && cut.text) { return maybeWrapWithBgImage( - + ); } if (cut.type === "stat_card" && cut.stat) { return maybeWrapWithBgImage( - + ); } if (cut.type === "callout" && cut.text) { return maybeWrapWithBgImage( ); } @@ -473,7 +521,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { ); } @@ -483,11 +531,11 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { ); } - // --- Chart types --- + // --- Chart types — use theme.chartColors as default palette --- if (cut.type === "bar_chart" && cut.chartData) { return maybeWrapWithBgImage( @@ -496,7 +544,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { if (cut.type === "line_chart" && cut.chartSeries) { return maybeWrapWithBgImage( = ({ cut }) => { if (cut.type === "pie_chart" && cut.chartData) { return maybeWrapWithBgImage( = ({ cut }) => { return maybeWrapWithBgImage( ); @@ -526,7 +574,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { return maybeWrapWithBgImage( = ({ cut }) => { {cut.title && (
{cut.title}
)}
); @@ -585,7 +633,7 @@ const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => { } // No source, no type — render as text card with cut id as fallback - return ; + return ; }; // --------------------------------------------------------------------------- @@ -623,18 +671,17 @@ const OverlayRenderer: React.FC<{ overlay: Overlay }> = ({ overlay }) => { // Main composition // --------------------------------------------------------------------------- -export const Explainer: React.FC = ({ - cuts, - overlays, - captions, - audio, -}) => { +export const Explainer: React.FC = (props) => { + const { cuts, overlays, captions, audio } = props; const { fps, durationInFrames } = useVideoConfig(); + // Resolve theme from props — playbook name, theme name, or custom themeConfig + const theme = resolveTheme(props as Record); + return ( - - {/* Layer 0: Animated gradient background */} - + + {/* Layer 0: Animated gradient background — driven by theme */} + {/* Layer 1: Visual scenes */} {cuts.map((cut) => { @@ -643,7 +690,7 @@ export const Explainer: React.FC = ({ return ( - + ); })} @@ -668,8 +715,8 @@ export const Explainer: React.FC = ({ words={captions} wordsPerPage={6} fontSize={42} - highlightColor="#22D3EE" - backgroundColor="rgba(15, 23, 42, 0.7)" + highlightColor={theme.captionHighlightColor} + backgroundColor={theme.captionBackgroundColor} /> )} diff --git a/remotion-composer/src/Root.tsx b/remotion-composer/src/Root.tsx index 2ff270e..cc78026 100644 --- a/remotion-composer/src/Root.tsx +++ b/remotion-composer/src/Root.tsx @@ -7,6 +7,109 @@ import { import { signalFromTomorrowWithMusicFixture } from "./cinematic/fixtures"; import { TalkingHead, TalkingHeadProps } from "./TalkingHead"; +// --------------------------------------------------------------------------- +// Theme System — prevents every video from looking like dark fintech +// --------------------------------------------------------------------------- + +export interface ThemeConfig { + primaryColor: string; + accentColor: string; + backgroundColor: string; + surfaceColor: string; + textColor: string; + mutedTextColor: string; + headingFont: string; + bodyFont: string; + monoFont: string; + chartColors: string[]; + springConfig: { damping: number; stiffness: number; mass: number }; + transitionDuration: number; + captionHighlightColor: string; + captionBackgroundColor: string; +} + +export const THEMES: Record = { + "clean-professional": { + primaryColor: "#2563EB", + accentColor: "#F59E0B", + backgroundColor: "#FFFFFF", + surfaceColor: "#F9FAFB", + textColor: "#1F2937", + mutedTextColor: "#6B7280", + headingFont: "Inter", + bodyFont: "Inter", + monoFont: "JetBrains Mono", + chartColors: ["#2563EB", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#06B6D4"], + springConfig: { damping: 20, stiffness: 120, mass: 1 }, + transitionDuration: 0.4, + captionHighlightColor: "#2563EB", + captionBackgroundColor: "rgba(255, 255, 255, 0.85)", + }, + "flat-motion-graphics": { + primaryColor: "#7C3AED", + accentColor: "#EC4899", + backgroundColor: "#0F172A", + surfaceColor: "#1E293B", + textColor: "#F8FAFC", + mutedTextColor: "#94A3B8", + headingFont: "Space Grotesk", + bodyFont: "Space Grotesk", + monoFont: "Fira Code", + chartColors: ["#7C3AED", "#EC4899", "#06B6D4", "#F59E0B", "#10B981", "#EF4444"], + springConfig: { damping: 12, stiffness: 80, mass: 1 }, + transitionDuration: 0.3, + captionHighlightColor: "#22D3EE", + captionBackgroundColor: "rgba(15, 23, 42, 0.75)", + }, + "minimalist-diagram": { + primaryColor: "#1A1A2E", + accentColor: "#E94560", + backgroundColor: "#FAFAFA", + surfaceColor: "#FFFFFF", + textColor: "#1A1A2E", + mutedTextColor: "#6B7280", + headingFont: "IBM Plex Sans", + bodyFont: "IBM Plex Sans", + monoFont: "IBM Plex Mono", + chartColors: ["#E94560", "#1A1A2E", "#0F3460", "#9CA3AF"], + springConfig: { damping: 25, stiffness: 150, mass: 1 }, + transitionDuration: 0.5, + captionHighlightColor: "#E94560", + captionBackgroundColor: "rgba(250, 250, 250, 0.9)", + }, + "anime-ghibli": { + primaryColor: "#2D5016", + accentColor: "#FFB347", + backgroundColor: "#0A0A1A", + surfaceColor: "#1A2332", + textColor: "#F0E6D3", + mutedTextColor: "#A8957E", + headingFont: "Noto Serif JP", + bodyFont: "Noto Sans", + monoFont: "Fira Code", + chartColors: ["#FFB347", "#2D5016", "#FF6B9D", "#A8E6CF", "#6B4C8A", "#E8927C"], + springConfig: { damping: 18, stiffness: 60, mass: 1 }, + transitionDuration: 1.0, + captionHighlightColor: "#FFB347", + captionBackgroundColor: "rgba(10, 10, 26, 0.8)", + }, +}; + +// Default theme when none is specified — uses the existing dark style for backwards compatibility +export const DEFAULT_THEME = THEMES["flat-motion-graphics"]; + +export function resolveTheme(props: Record): ThemeConfig { + const themeName = (props.theme as string) || (props.playbook as string); + if (themeName && THEMES[themeName]) { + return THEMES[themeName]; + } + // Allow custom theme passed as full object + if (props.themeConfig && typeof props.themeConfig === "object") { + return { ...DEFAULT_THEME, ...(props.themeConfig as Partial) }; + } + return DEFAULT_THEME; +} + const calculateMetadata: CalculateMetadataFunction = async ({ props, }) => { diff --git a/schemas/artifacts/__init__.py b/schemas/artifacts/__init__.py index 72408f2..41246a4 100644 --- a/schemas/artifacts/__init__.py +++ b/schemas/artifacts/__init__.py @@ -22,6 +22,9 @@ ARTIFACT_NAMES = [ "publish_log", "review", "cost_log", + "decision_log", + "source_media_review", + "final_review", ] diff --git a/schemas/artifacts/asset_manifest.schema.json b/schemas/artifacts/asset_manifest.schema.json index fa828e7..596b85d 100644 --- a/schemas/artifacts/asset_manifest.schema.json +++ b/schemas/artifacts/asset_manifest.schema.json @@ -28,7 +28,12 @@ "duration_seconds": { "type": "number", "minimum": 0 }, "resolution": { "type": "string" }, "format": { "type": "string" }, - "quality_score": { "type": "number", "minimum": 0, "maximum": 1 } + "quality_score": { "type": "number", "minimum": 0, "maximum": 1 }, + "subtype": { "type": "string", "description": "Sub-classification (e.g. 'background', 'stock', 'generated')" }, + "generation_summary": { "type": "string", "description": "Brief summary of how the asset was generated or sourced" }, + "provider": { "type": "string", "description": "Provider name (e.g. pixabay, google_imagen)" }, + "license": { "type": "string", "description": "License type (e.g. Pixabay License, CC0)" }, + "original_url": { "type": "string", "description": "Source URL if downloaded from a stock service" } }, "additionalProperties": false } diff --git a/schemas/artifacts/decision_log.schema.json b/schemas/artifacts/decision_log.schema.json new file mode 100644 index 0000000..f72b374 --- /dev/null +++ b/schemas/artifacts/decision_log.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/artifacts/decision_log", + "title": "Decision Log", + "description": "Audit trail of every meaningful choice made during production. Prevents hidden system behavior, enables reviewer inspection, and guards against context drift.", + "type": "object", + "required": ["version", "project_id", "decisions"], + "properties": { + "version": { "type": "string", "const": "1.0" }, + "project_id": { "type": "string" }, + "decisions": { + "type": "array", + "items": { + "type": "object", + "required": ["decision_id", "stage", "category", "subject", "options_considered", "selected", "reason"], + "properties": { + "decision_id": { + "type": "string", + "description": "Unique ID, e.g., d-001, d-002" + }, + "stage": { + "type": "string", + "description": "Pipeline stage where this decision was made" + }, + "category": { + "type": "string", + "enum": [ + "pipeline_selection", + "provider_selection", + "renderer_family_selection", + "playbook_selection", + "fallback_decision", + "budget_tradeoff", + "downgrade_approval", + "music_source", + "motion_commitment", + "concept_selection", + "voice_selection", + "capability_extension", + "playbook_override", + "visual_accuracy_check" + ], + "description": "What class of decision this is" + }, + "subject": { + "type": "string", + "description": "What is being decided, e.g., 'TTS provider for narration'" + }, + "options_considered": { + "type": "array", + "items": { + "type": "object", + "required": ["option_id", "label", "score", "reason"], + "properties": { + "option_id": { "type": "string" }, + "label": { "type": "string", "description": "Human-readable name" }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Normalized score 0-1 (1 is best)" + }, + "reason": { "type": "string", "description": "Why this option scored this way" }, + "rejected_because": { "type": "string", "description": "If rejected, why" } + }, + "additionalProperties": false + }, + "minItems": 1, + "description": "All options evaluated (including rejected ones)" + }, + "selected": { + "type": "string", + "description": "option_id of the selected option" + }, + "reason": { + "type": "string", + "description": "Why this option was selected over alternatives" + }, + "user_visible": { + "type": "boolean", + "default": true, + "description": "Whether this decision was surfaced to the user" + }, + "user_approved": { + "type": "boolean", + "default": false, + "description": "Whether the user explicitly approved this decision" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Agent's confidence in this decision (0 = uncertain, 1 = certain)" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/artifacts/edit_decisions.schema.json b/schemas/artifacts/edit_decisions.schema.json index d20fe0f..d287632 100644 --- a/schemas/artifacts/edit_decisions.schema.json +++ b/schemas/artifacts/edit_decisions.schema.json @@ -189,6 +189,19 @@ "required": ["type", "at_seconds", "duration_seconds"] } }, + "renderer_family": { + "type": "string", + "enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"], + "description": "Locked at proposal stage — determines which Remotion composition renders this video" + }, + "slideshow_risk_score": { + "type": "object", + "description": "Slideshow risk assessment from lib/slideshow_risk.py", + "properties": { + "average": { "type": "number" }, + "verdict": { "type": "string", "enum": ["strong", "acceptable", "revise", "fail"] } + } + }, "metadata": { "type": "object" } }, "additionalProperties": false diff --git a/schemas/artifacts/final_review.schema.json b/schemas/artifacts/final_review.schema.json new file mode 100644 index 0000000..3153d30 --- /dev/null +++ b/schemas/artifacts/final_review.schema.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/artifacts/final_review", + "title": "Final Review", + "description": "Structured self-review of the rendered output. The compose stage must produce this artifact to prove the agent inspected the actual rendered video before presenting it to the user. If status is 'revise' or 'fail', the agent must not present the video as complete.", + "type": "object", + "required": ["version", "output_path", "status", "checks"], + "properties": { + "version": { "type": "string", "const": "1.0" }, + "output_path": { + "type": "string", + "description": "Path to the rendered output file that was reviewed" + }, + "status": { + "type": "string", + "enum": ["pass", "revise", "fail"], + "description": "pass=ready to present, revise=fixable issues found, fail=critical problems" + }, + "checks": { + "type": "object", + "required": [ + "technical_probe", + "visual_spotcheck", + "audio_spotcheck", + "promise_preservation", + "subtitle_check" + ], + "properties": { + "technical_probe": { + "type": "object", + "description": "ffprobe validation of the output file", + "properties": { + "valid_container": { "type": "boolean" }, + "duration_seconds": { "type": "number" }, + "resolution": { "type": "string" }, + "fps": { "type": "number" }, + "has_audio": { "type": "boolean" }, + "codec": { "type": "string" }, + "file_size_bytes": { "type": "integer" }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "visual_spotcheck": { + "type": "object", + "description": "Sample frames inspected from opening, middle, climax, ending", + "properties": { + "frames_sampled": { "type": "integer", "minimum": 4 }, + "frame_paths": { + "type": "array", + "items": { "type": "string" } + }, + "black_frames_detected": { "type": "boolean" }, + "broken_overlays": { "type": "boolean" }, + "missing_assets": { "type": "boolean" }, + "unreadable_text": { "type": "boolean" }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "audio_spotcheck": { + "type": "object", + "description": "Audio quality verification", + "properties": { + "narration_present": { "type": "boolean" }, + "music_present": { "type": "boolean" }, + "unexpected_silence": { "type": "boolean" }, + "clipping_detected": { "type": "boolean" }, + "mix_intelligible": { "type": "boolean" }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "promise_preservation": { + "type": "object", + "description": "Delivery promise and renderer governance checks", + "properties": { + "delivery_promise_honored": { "type": "boolean" }, + "renderer_family_used": { "type": "string" }, + "motion_ratio_actual": { "type": "number" }, + "silent_downgrade_detected": { "type": "boolean" }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "subtitle_check": { + "type": "object", + "description": "Subtitle/caption verification against source script", + "properties": { + "subtitles_expected": { "type": "boolean" }, + "subtitles_present": { "type": "boolean" }, + "coverage_ratio": { "type": "number", "minimum": 0, "maximum": 1 }, + "timing_drift_detected": { "type": "boolean" }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "transcript_comparison": { + "type": "object", + "description": "Optional: transcribe the output and compare to source script", + "properties": { + "transcript_matches_script": { "type": "boolean" }, + "word_accuracy": { "type": "number", "minimum": 0, "maximum": 1 }, + "issues": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "additionalProperties": false + }, + "issues_found": { + "type": "array", + "items": { "type": "string" }, + "description": "All issues found across all checks, summarized for the user" + }, + "recommended_action": { + "type": "string", + "enum": ["present_to_user", "re_render", "revise_edit", "revise_assets", "block"], + "description": "What the agent should do next based on the review findings" + }, + "metadata": { "type": "object" } + }, + "additionalProperties": false +} diff --git a/schemas/artifacts/proposal_packet.schema.json b/schemas/artifacts/proposal_packet.schema.json index d43bcab..8985084 100644 --- a/schemas/artifacts/proposal_packet.schema.json +++ b/schemas/artifacts/proposal_packet.schema.json @@ -127,6 +127,126 @@ "additionalProperties": false }, "description": "Alternative production paths at different price/quality points" + }, + "delivery_promise": { + "type": "object", + "description": "Explicit classification of what this production promises to deliver", + "required": ["promise_type", "motion_required", "tone_mode", "quality_floor"], + "properties": { + "promise_type": { + "type": "string", + "enum": ["motion_led", "source_led", "data_explainer", "teacher_explainer", "screen_demo", "avatar_presenter", "hybrid", "localization"] + }, + "motion_required": { "type": "boolean", "description": "True when the video's quality depends on actual motion, not still-image animation" }, + "source_required": { "type": "boolean", "description": "True when user-provided footage is the primary medium" }, + "tone_mode": { "type": "string", "description": "cinematic, educational, corporate, playful, raw, intimate, epic" }, + "quality_floor": { "type": "string", "enum": ["draft", "presentable", "broadcast"] }, + "approved_fallback": { "type": ["string", "null"], "description": "animatic, still_led, or null (no fallback approved)" } + }, + "additionalProperties": false + }, + "renderer_family": { + "type": "string", + "enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"], + "description": "Locked at proposal stage — compose cannot change without logging a decision" + }, + "music_source": { + "type": "object", + "description": "Resolved music plan from the proposal stage", + "properties": { + "source_type": { "type": "string", "enum": ["user_library", "ai_generated", "bring_your_own", "none"] }, + "track_path": { "type": "string", "description": "Path to selected track, if known" }, + "provider": { "type": "string", "description": "Music generation provider, if AI-generated" }, + "mood_direction": { "type": "string", "description": "Music mood description from research" }, + "estimated_cost_usd": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + }, + "voice_selection": { + "type": "object", + "description": "Selected voice/TTS provider and rationale", + "properties": { + "provider": { "type": "string" }, + "voice_id": { "type": "string" }, + "rationale": { "type": "string" }, + "estimated_cost_usd": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + }, + "decision_log_ref": { + "type": "string", + "description": "Path to the decision_log artifact for this project" + }, + "provider_rankings": { + "type": "object", + "description": "Scored provider rankings for each asset category used in this production", + "properties": { + "video": { + "type": "array", + "items": { + "type": "object", + "required": ["tool_name", "provider", "weighted_score"], + "properties": { + "tool_name": { "type": "string" }, + "provider": { "type": "string" }, + "weighted_score": { "type": "number" }, + "task_fit": { "type": "number" }, + "output_quality": { "type": "number" }, + "explanation": { "type": "string" } + }, + "additionalProperties": false + } + }, + "image": { + "type": "array", + "items": { + "type": "object", + "required": ["tool_name", "provider", "weighted_score"], + "properties": { + "tool_name": { "type": "string" }, + "provider": { "type": "string" }, + "weighted_score": { "type": "number" }, + "task_fit": { "type": "number" }, + "output_quality": { "type": "number" }, + "explanation": { "type": "string" } + }, + "additionalProperties": false + } + }, + "tts": { + "type": "array", + "items": { + "type": "object", + "required": ["tool_name", "provider", "weighted_score"], + "properties": { + "tool_name": { "type": "string" }, + "provider": { "type": "string" }, + "weighted_score": { "type": "number" }, + "task_fit": { "type": "number" }, + "output_quality": { "type": "number" }, + "explanation": { "type": "string" } + }, + "additionalProperties": false + } + }, + "music": { + "type": "array", + "items": { + "type": "object", + "required": ["tool_name", "provider", "weighted_score"], + "properties": { + "tool_name": { "type": "string" }, + "provider": { "type": "string" }, + "weighted_score": { "type": "number" }, + "task_fit": { "type": "number" }, + "output_quality": { "type": "number" }, + "explanation": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/schemas/artifacts/render_report.schema.json b/schemas/artifacts/render_report.schema.json index cc7181f..33d12dd 100644 --- a/schemas/artifacts/render_report.schema.json +++ b/schemas/artifacts/render_report.schema.json @@ -36,6 +36,27 @@ "type": "array", "items": { "type": "string" } }, + "render_grammar": { + "type": "string", + "description": "Which renderer family was used for this render", + "enum": ["explainer-data", "explainer-teacher", "cinematic-trailer", "product-reveal", "screen-demo", "presenter", "animation-first"] + }, + "slideshow_risk_score": { + "type": "object", + "description": "Final slideshow risk assessment", + "properties": { + "average": { "type": "number" }, + "verdict": { "type": "string" } + } + }, + "decision_log_ref": { + "type": "string", + "description": "Path to the project's cumulative decision log" + }, + "final_review_ref": { + "type": "string", + "description": "Path or inline reference to the final_review artifact produced by post-render self-review. Provides durable audit linkage between the render output and its quality inspection." + }, "metadata": { "type": "object" } }, "additionalProperties": false diff --git a/schemas/artifacts/scene_plan.schema.json b/schemas/artifacts/scene_plan.schema.json index 72e64a8..2086e56 100644 --- a/schemas/artifacts/scene_plan.schema.json +++ b/schemas/artifacts/scene_plan.schema.json @@ -28,6 +28,51 @@ "transition_in": { "type": "string" }, "transition_out": { "type": "string" }, "overlay_notes": { "type": "string" }, + "shot_language": { + "type": "object", + "description": "Structured cinematography vocabulary for this scene", + "properties": { + "shot_size": { + "type": "string", + "enum": ["extreme_wide", "wide", "medium_wide", "medium", "medium_close", "close_up", "extreme_close_up", "over_shoulder", "insert", "establishing"] + }, + "camera_movement": { + "type": "string", + "enum": ["static", "pan_left", "pan_right", "tilt_up", "tilt_down", "dolly_in", "dolly_out", "tracking_left", "tracking_right", "crane_up", "crane_down", "handheld", "steadicam", "whip_pan", "orbital", "zoom_in", "zoom_out", "rack_focus"] + }, + "lens_mm": { "type": "integer", "enum": [14, 24, 35, 50, 85, 135, 200] }, + "lighting_key": { + "type": "string", + "enum": ["high_key", "low_key", "natural", "golden_hour", "blue_hour", "tungsten_warm", "neon", "silhouette", "rim_lit", "volumetric", "overcast_soft"] + }, + "depth_of_field": { "type": "string", "enum": ["shallow", "medium", "deep"] }, + "color_temperature": { "type": "string", "enum": ["cool", "neutral", "warm", "mixed"] } + }, + "additionalProperties": false + }, + "shot_intent": { + "type": "string", + "description": "WHY this shot exists — what purpose it serves in the video" + }, + "narrative_role": { + "type": "string", + "enum": ["establish_context", "introduce_subject", "build_tension", "deliver_payload", "transition", "emotional_beat", "evidence", "comparison", "resolution", "call_to_action"], + "description": "What role this scene plays in the narrative structure" + }, + "information_role": { + "type": "string", + "description": "What the viewer learns or feels from this scene" + }, + "hero_moment": { + "type": "boolean", + "default": false, + "description": "True if this is the visual peak of the video — deserves extra attention" + }, + "texture_keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Visual texture descriptors: grain, clean, anamorphic, gritty, ethereal, etc." + }, "required_assets": { "type": "array", "items": { diff --git a/schemas/artifacts/script.schema.json b/schemas/artifacts/script.schema.json index bfa2eba..2a0ba40 100644 --- a/schemas/artifacts/script.schema.json +++ b/schemas/artifacts/script.schema.json @@ -43,6 +43,10 @@ }, "required": ["word", "phonetic"] } + }, + "source_ref": { + "type": "string", + "description": "Reference to the research_brief data point or URL that supports this claim. Every factual claim should be traceable." } }, "additionalProperties": false diff --git a/schemas/artifacts/source_media_review.schema.json b/schemas/artifacts/source_media_review.schema.json new file mode 100644 index 0000000..7e60350 --- /dev/null +++ b/schemas/artifacts/source_media_review.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "openmontage/artifacts/source_media_review", + "title": "Source Media Review", + "description": "Structured review of user-supplied media files. Created before the first planning stage when user media exists. Ensures the agent inspects actual media content instead of planning around assumptions.", + "type": "object", + "required": ["version", "files", "summary", "planning_implications"], + "properties": { + "version": { "type": "string", "const": "1.0" }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": ["path", "media_type", "reviewed"], + "properties": { + "path": { "type": "string" }, + "media_type": { + "type": "string", + "enum": ["video", "audio", "image"] + }, + "reviewed": { + "type": "boolean", + "const": true, + "description": "Must be true — the file was actually inspected, not assumed" + }, + "technical_probe": { + "type": "object", + "description": "Raw ffprobe or metadata output for this file", + "properties": { + "duration_seconds": { "type": "number" }, + "resolution": { "type": "string" }, + "fps": { "type": "number" }, + "codec": { "type": "string" }, + "audio_codec": { "type": "string" }, + "sample_rate": { "type": "integer" }, + "channels": { "type": "integer" }, + "file_size_bytes": { "type": "integer" }, + "bitrate_kbps": { "type": "number" } + } + }, + "content_summary": { + "type": "string", + "description": "What the file actually contains — described from observation, not assumption" + }, + "transcript_summary": { + "type": "string", + "description": "Summary of spoken content, if audio/video with speech" + }, + "representative_frames": { + "type": "array", + "items": { "type": "string" }, + "description": "Paths to sampled frames (video/image only)" + }, + "quality_risks": { + "type": "array", + "items": { "type": "string" }, + "description": "Quality issues that affect planning (low resolution, poor audio, shaky footage, etc.)" + }, + "usable_for": { + "type": "array", + "items": { "type": "string" }, + "description": "What this file can be used for in the production (hero footage, b-roll, background audio, etc.)" + } + }, + "additionalProperties": false + }, + "minItems": 1 + }, + "summary": { + "type": "string", + "description": "What the supplied media actually contains and what it does not. Be specific — 'contains 45s of interview footage, no b-roll, mono audio at 44.1kHz' not 'user provided footage'" + }, + "planning_implications": { + "type": "array", + "items": { "type": "string" }, + "description": "Concrete constraints or opportunities that must affect proposal/script/scene planning. Each item should be actionable.", + "minItems": 1 + }, + "metadata": { "type": "object" } + }, + "additionalProperties": false +} diff --git a/schemas/checkpoints/checkpoint.schema.json b/schemas/checkpoints/checkpoint.schema.json index b23cbe0..1249c8f 100644 --- a/schemas/checkpoints/checkpoint.schema.json +++ b/schemas/checkpoints/checkpoint.schema.json @@ -4,13 +4,13 @@ "title": "Pipeline Checkpoint", "description": "Full resumable snapshot after each pipeline stage.", "type": "object", - "required": ["version", "project_id", "stage", "status", "timestamp", "artifacts"], + "required": ["version", "project_id", "pipeline_type", "stage", "status", "timestamp", "artifacts"], "properties": { "version": { "type": "string", "const": "1.0" }, "project_id": { "type": "string" }, "stage": { "type": "string", - "enum": ["research", "proposal", "idea", "script", "scene_plan", "assets", "edit", "compose", "publish"] + "description": "Stage name — validated at runtime against the pipeline manifest's stage list" }, "status": { "type": "string", diff --git a/schemas/pipelines/pipeline_manifest.schema.json b/schemas/pipelines/pipeline_manifest.schema.json index f2aff6c..e26abd5 100644 --- a/schemas/pipelines/pipeline_manifest.schema.json +++ b/schemas/pipelines/pipeline_manifest.schema.json @@ -19,8 +19,20 @@ "description": "Whether this pipeline has been audited and is considered stable" }, "compatible_playbooks": { - "type": "array", - "items": { "type": "string" } + "oneOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { + "type": "object", + "properties": { + "recommended": { "type": "array", "items": { "type": "string" } }, + "also_works": { "type": "array", "items": { "type": "string" } }, + "custom_allowed": { "type": "boolean", "default": false } + } + } + ] }, "required_skills": { "type": "array", @@ -107,6 +119,17 @@ "max_wall_time_minutes": { "type": "integer" } }, "additionalProperties": false + }, + "extensions": { + "type": "object", + "description": "Capability extension permissions for this pipeline", + "properties": { + "custom_scripts": { "type": "boolean", "default": true }, + "custom_playbooks": { "type": "boolean", "default": true }, + "custom_skills": { "type": "boolean", "default": true }, + "custom_tools": { "type": "boolean", "default": false } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/schemas/styles/playbook.schema.json b/schemas/styles/playbook.schema.json index 477b66b..a8c22c2 100644 --- a/schemas/styles/playbook.schema.json +++ b/schemas/styles/playbook.schema.json @@ -114,9 +114,21 @@ "music_mood": { "type": "string" }, "music_volume": { "type": "number", "minimum": 0, "maximum": 1 }, "sfx_style": { "type": "string" }, - "ducking_threshold_db": { "type": "number" } - }, - "additionalProperties": false + "ducking_threshold_db": { "type": "number" }, + "voice_variation_allowed": { + "type": "boolean", + "default": false, + "description": "Whether voice characteristics can shift between sections for emphasis" + }, + "hero_moment_voice_shift": { + "type": "string", + "description": "How the voice should change for hero moments, e.g., 'slower, more deliberate, drop pitch'" + }, + "transition_voice_shift": { + "type": "string", + "description": "How the voice should change at transitions, e.g., 'brief pause, then resume at slightly higher energy'" + } + } }, "asset_generation": { "type": "object", @@ -172,11 +184,36 @@ "description": "Enable color-blind safety checking for palettes.", "type": "boolean" } - }, - "additionalProperties": false + } + }, + "overrides": { + "description": "Per-scene palette or rule exceptions. Up to 20% of scenes may intentionally deviate from the playbook for creative impact.", + "type": "object", + "properties": { + "max_deviation_ratio": { + "type": "number", + "minimum": 0, + "maximum": 0.5, + "default": 0.2, + "description": "Maximum fraction of scenes that may deviate from playbook defaults" + }, + "scene_overrides": { + "type": "array", + "items": { + "type": "object", + "required": ["scene_id", "reason"], + "properties": { + "scene_id": { "type": "string" }, + "reason": { "type": "string", "description": "Why this scene deviates from the playbook" }, + "color_override": { "type": "object" }, + "typography_override": { "type": "object" }, + "motion_override": { "type": "object" } + } + } + } + } } }, - "additionalProperties": false, "$defs": { "font_spec": { "type": "object", diff --git a/skills/creative/image-gen-usage.md b/skills/creative/image-gen-usage.md index 234e1e2..4b9ba6d 100644 --- a/skills/creative/image-gen-usage.md +++ b/skills/creative/image-gen-usage.md @@ -55,17 +55,71 @@ FLUX.2 supports up to 4 references (klein) or 8 references (pro/max/flex). Refer Use the same `seed` parameter across generations with similar prompts. Produces similar compositions but is fragile to prompt changes — use as supplement, not primary strategy. -## Prompt Template +## Prompt Construction — 3-Part Contextual Approach + +**Do NOT copy the playbook's `image_prompt_prefix` verbatim into every prompt.** That's what makes all scenes look the same. Instead, build each prompt from 3 contextual layers: + +### Part 1: Scene-Specific Style Direction (from shot_language + texture_keywords) + +Use the scene's `shot_language` fields to set camera and lighting: +``` +[SHOT SIZE from shot_language.shot_size, e.g., "medium close-up"]. +[LIGHTING from shot_language.lighting_key, e.g., "golden hour warm light"]. +[DEPTH from shot_language.depth_of_field, e.g., "shallow depth of field with bokeh"]. +[TEXTURE from scene.texture_keywords, e.g., "film grain, warm tones"]. +``` + +If the scene has no shot_language, fall back to the template below. + +### Part 2: Playbook Consistency Anchor (adapted, not verbatim) + +Extract the ESSENCE of the playbook's visual language — don't copy the prefix. For example: +- Playbook says "Clean, minimal illustration with soft shadows, muted color palette" → Adapt to: "muted color palette, soft shadows" +- Playbook says "Bold flat motion graphics, vibrant gradients" → Adapt to: "vibrant flat style" + +The anchor keeps scenes visually coherent without making them identical. + +### Part 3: Scene Description + +The actual content of the scene. Be specific — replace generic words with concrete details. + +**BAD:** "A person using a computer in a modern office" +**GOOD:** "Software developer in a dimly lit home office, blue monitor glow reflecting off glasses, desk cluttered with energy drinks and sticky notes" + +### Full Prompt Example (with shot_language) ``` -[STYLE PREFIX from playbook]. -[SCENE DESCRIPTION: subject, action, environment]. +Medium close-up, golden hour warm lighting, shallow depth of field. +Muted earth tones, soft shadows. +Beekeeper in white protective gear lifting a frame dripping with honey, +late afternoon sun catching golden droplets, lavender field blurred +in the background. Film grain, warm amber tones. +16:9 aspect ratio. +``` + +### Fallback Template (when no shot_language is available) + +``` +[ADAPTED STYLE ANCHOR from playbook — 5-10 words, not the full prefix]. +[SCENE DESCRIPTION: specific subject, action, environment]. [LIGHTING: golden hour / overcast / studio softbox / dramatic side-light]. [COMPOSITION: wide shot / medium shot / close-up / overhead / isometric]. [CAMERA: Shot on [camera] with [lens] at [aperture]] (for photorealistic only). 16:9 aspect ratio. ``` +### Using lib/shot_prompt_builder.py + +For programmatic prompt construction, use the shot prompt builder which automates the 3-part approach: + +```python +from lib.shot_prompt_builder import build_shot_prompt +prompt = build_shot_prompt(scene, style_context=playbook_data) +``` + +This converts the structured shot_language fields into natural-language prompts +optimized for image/video generation providers. + ### Style-Specific Prompt Patterns | Style | Prompt Pattern | diff --git a/skills/meta/capability-extension.md b/skills/meta/capability-extension.md new file mode 100644 index 0000000..afc4e3c --- /dev/null +++ b/skills/meta/capability-extension.md @@ -0,0 +1,115 @@ +# Capability Extension Protocol + +## When to Use + +When you encounter a production need that no existing tool covers. The agent can extend the system — but with guardrails. This replaces the blanket "do NOT write ad-hoc Python scripts" rule with a structured protocol. + +## Assessment First + +Before writing anything, classify the gap: + +| Gap Type | Example | Action | +|----------|---------|--------| +| **One-off transform** | Custom image crop, color adjustment, format conversion | Write a project-scoped Python script | +| **Recurring visual need** | New illustration style, custom chart type | Generate a custom playbook or Remotion component | +| **Missing provider** | User wants a specific API not in the registry | Create a minimal tool wrapper | +| **Missing knowledge** | Agent doesn't know how to prompt a specific model | Use web search to learn, then document as a Layer 3 skill | + +## Rules for Ad-Hoc Scripts + +Scripts are allowed ONLY when: +1. No existing tool covers the need (verified against registry via preflight) +2. The script is idempotent (safe to re-run) +3. The script produces a file artifact in the project workspace +4. The script is logged in the decision log: `category: "capability_extension"` +5. The user is informed: "I wrote a custom script for X because no existing tool handles Y" +6. The script does NOT call external APIs without user approval + +Scripts go in: `projects//scripts/` + +### Script Template + +```python +""" + +Created by capability extension protocol because: +Decision log entry: +""" +import sys +from pathlib import Path + +def main(input_path: str, output_path: str) -> None: + # Idempotent: check if output already exists + out = Path(output_path) + if out.exists(): + print(f"Output already exists: {out}") + return + + # ... transformation logic ... + + print(f"Created: {out}") + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) +``` + +## Rules for Custom Playbooks + +When the existing playbooks don't match the brief: +1. Use `lib/playbook_generator.py` to create a new playbook +2. Base it on the closest existing playbook if possible +3. Validate against `schemas/styles/playbook.schema.json` +4. Save to `styles/custom/.yaml` +5. Log as decision: `category: "playbook_selection"`, `subject: "custom playbook created"` + +## Rules for New Skills (Technique Learning) + +When the agent discovers technique knowledge during web research: +1. Document it as a project-scoped skill: `projects//skills/.md` +2. Follow the Layer 3 skill format: + - Provider name and version + - Provider-specific prompting patterns + - Optimal parameters for this use case + - Quality tips and known failure modes + - Source URLs for the information +3. Reference it in the decision log +4. Suggest promoting to `.agents/skills/` if it's generally useful + +## Rules for Tool Wrappers + +When a user needs a specific provider that isn't in the registry: +1. The agent can create a minimal `BaseTool` subclass +2. Save to `projects//tools/.py` +3. It MUST inherit from `BaseTool` and implement the full contract (input_schema, execute, capabilities, etc.) +4. It MUST be registered before use +5. Log as decision: `category: "capability_extension"` +6. Requires user approval before first paid API call + +## What Is Still Forbidden + +- Bypassing the pipeline (all production still goes through stages) +- Calling external APIs without user knowledge +- Modifying existing tools in `tools/` (create wrappers, don't modify originals) +- Skipping the decision log +- Writing scripts that have side effects beyond their output file (no sending emails, no pushing to remote, no deleting files outside project workspace) + +## Decision Log Entry Format + +Every extension must be logged: + +```json +{ + "decision_id": "ext-001", + "stage": "", + "category": "capability_extension", + "subject": "Created custom for ", + "options_considered": [ + {"option_id": "existing-tool", "label": "", "rejected_because": ""}, + {"option_id": "extension", "label": "", "reason": ""} + ], + "selected": "extension", + "reason": "", + "user_visible": true, + "confidence": 0.8 +} +``` diff --git a/skills/meta/creative-intake.md b/skills/meta/creative-intake.md new file mode 100644 index 0000000..738ec71 --- /dev/null +++ b/skills/meta/creative-intake.md @@ -0,0 +1,64 @@ +# Creative Intake + +Before the research stage, gather user intent through targeted questions. +Do NOT start production on a vague brief. + +## Required Questions (ask conversationally, not as a survey) + +1. **Purpose**: What is this video FOR? (educate, sell, inspire, document, entertain) +2. **Audience**: Who will watch it? (age, expertise, context — "my team" vs "YouTube public") +3. **Platform**: Where will it live? (YouTube, internal Slack, social media, presentation, website) +4. **Tone**: What should it FEEL like? (serious, playful, cinematic, raw, warm, provocative) +5. **References**: Any videos you admire or want this to feel like? +6. **Outcome**: What should the viewer DO or FEEL after watching? +7. **Constraints**: Budget ceiling? Timeline? Must-include content? + +## How to Ask + +Don't dump all 7 questions at once. Start with purpose and audience, +then let the conversation flow. Fill in gaps naturally. + +If the user gives a detailed brief, skip questions they've already answered. + +Identify what the user has already told you. If they said "I want a +cinematic brand film for Instagram," you already have purpose (inspire/sell), +platform (Instagram), and tone (cinematic). Ask what's missing. + +## Handling Vague Briefs + +When the user says something like "make me a video about X": + +1. Acknowledge the topic — show you understood. +2. Ask the single most important missing question first (usually purpose or audience). +3. Based on their answer, ask the next most important gap. +4. Stop asking when you have enough to start research. You don't need perfect answers — research will fill in details. + +## Handling Detailed Briefs + +When the user provides a multi-paragraph brief or a document: + +1. Summarize what you understood (1-2 sentences). +2. Call out any gaps: "I have a clear picture of the audience and tone, but I'd love to know — is there a specific outcome you're hoping for?" +3. Confirm the platform and constraints if not stated. + +## Output + +Produce an `intake_brief` (informal, not schema-validated) that the +research stage uses as its starting context. Include: + +- Direct quotes from the user where their language reveals intent +- Explicit answers to each of the 7 questions (mark any that were inferred vs stated) +- Any reference videos/images the user mentioned +- Constraints that must be honored (budget, timeline, must-include) + +The intake_brief is passed as context to the research-director, not as a +formal artifact. It exists to prevent the research stage from inventing +intent that the user never expressed. + +## What NOT To Do + +- Do not present a numbered survey. This is a conversation, not a form. +- Do not ask questions the user already answered in their initial message. +- Do not delay production unnecessarily — if the brief is clear, move on. +- Do not invent answers for questions the user didn't address. Mark them as "not specified" and let the research stage handle ambiguity. +- Do not assume the user wants an explainer. Many users want cinematic, animation, or source-led work. Listen for signals. diff --git a/skills/meta/onboarding.md b/skills/meta/onboarding.md index 1259c3b..16f9f3b 100644 --- a/skills/meta/onboarding.md +++ b/skills/meta/onboarding.md @@ -81,9 +81,9 @@ Based on the user's tier, present **3 ready-to-use prompts** they can copy right > > This will research the topic, write a script, find stock visuals, generate narration with Piper, and compose an animated video with transitions and captions — all free. -> **Also try:** "Create a 60-second data-driven video about coffee consumption around the world" +> **Also try:** "I have a screen recording of a dashboard workflow — make it a polished product demo with captions and a voiceover" *(Screen Demo pipeline)* -> **Or:** "Make a short explainer about how the internet works, with narration and animated captions" +> **Or:** "Turn this interview recording into 3 short clips for TikTok and YouTube Shorts" *(Clip Factory pipeline)* **Starter-tier prompts (image gen available):** @@ -91,19 +91,19 @@ Based on the user's tier, present **3 ready-to-use prompts** they can copy right > > I'll use FLUX to generate custom images for each scene — much more visually striking than stock. -> **Also try:** "Make a product launch teaser for a fictional smart water bottle called AquaPulse" +> **Also try:** "Make a short documentary-style video about urban beekeeping — keep it grounded and textural, not flashy" *(Hybrid pipeline — source + generated support)* -> **Or:** "Build a 90-second explainer about the psychology of color in marketing" +> **Or:** "Create a classroom-ready video teaching photosynthesis to 8th graders — simple, clear, and engaging" *(Explainer pipeline — teacher mode)* **Full-tier prompts (video gen available):** > **Try this:** "Create a cinematic 30-second trailer for a sci-fi concept: humanity receives a warning from 1000 years in the future" > -> I'll generate actual motion video clips, compose a soundtrack, and deliver a finished cinematic trailer. +> I'll generate actual motion video clips, compose a soundtrack, and deliver a finished cinematic trailer. *(Cinematic pipeline)* -> **Also try:** "Make a 60-second avatar spokesperson video announcing a company rebrand" +> **Also try:** "Make a 60-second avatar spokesperson video announcing a company rebrand" *(Avatar Spokesperson pipeline)* -> **Or:** "Create a 90-second animated explainer about quantum computing for middle school students, with a fun narrator voice and custom soundtrack" +> **Or:** "I recorded a founder update on my webcam — make it feel polished, confident, and premium without looking fake" *(Talking Head pipeline)* **Rules for prompt suggestions:** - Present exactly 3 prompts. diff --git a/skills/meta/reviewer.md b/skills/meta/reviewer.md index ce08717..cd7fee0 100644 --- a/skills/meta/reviewer.md +++ b/skills/meta/reviewer.md @@ -107,10 +107,143 @@ Structure your review as: | Stage | What matters most | |-------|-----------------| +| research | Source diversity, claim verifiability, visual reference quality | +| proposal | Delivery promise clarity, renderer family selection, music/voice plan, decision log started | | idea | Hook uniqueness, research depth, angle diversity | | script | Timing accuracy, narrative arc, enhancement cue density | -| scene_plan | Full coverage, visual variety, asset feasibility | +| scene_plan | Full coverage, visual variety, asset feasibility, slideshow risk score | | assets | File existence, style consistency, budget adherence | -| edit | Timeline coverage, audio sync, subtitle presence | -| compose | Playability, duration accuracy, audio quality | +| edit | Timeline coverage, audio sync, subtitle presence, delivery promise compliance | +| compose | Playability, duration accuracy, audio quality, pre-compose validation pass | | publish | SEO quality, metadata completeness, export packaging | + +## Slideshow Risk Review + +Run at **scene_plan** and **edit** stages. Use `lib/slideshow_risk.py` to compute the score. + +### At scene_plan stage: +1. Compute `score_slideshow_risk(scenes, renderer_family=renderer_family)` +2. If verdict is **"fail"** (average ≥ 4.0): **CRITICAL** — scene plan must be revised before proceeding +3. If verdict is **"revise"** (average ≥ 3.0): **SUGGESTION** — flag specific dimensions scoring ≥ 3.5 +4. If verdict is **"strong"** or **"acceptable"**: note in review summary, no finding needed + +### At edit stage: +1. Recompute with full edit_decisions: `score_slideshow_risk(scenes, edit_decisions, renderer_family)` +2. Same thresholds apply — if the edit stage made things worse (higher score than scene_plan), flag it + +### What to flag per dimension: +| Dimension | What to say when score ≥ 3.0 | +|-----------|------------------------------| +| repetition | "X scenes use the same layout/shot size — vary the visual grammar" | +| decorative_visuals | "X scenes have no stated purpose (no information_role or shot_intent)" | +| weak_motion | "Camera movement exists but lacks narrative justification" | +| weak_shot_intent | "X scenes are missing shot_intent — why does this frame exist?" | +| typography_overreliance | "X% of scenes are text/stat cards — video feels like animated slides" | +| unsupported_cinematic_claims | "Claiming cinematic but missing hero moments / lighting / movement" | + +## Decision Log Review + +Run at **every stage** after proposal. The decision log (`schemas/artifacts/decision_log.schema.json`) is a cumulative audit trail. + +### Checks: +1. **Existence**: Does the checkpoint reference a `decision_log_ref`? If not after proposal stage, flag as **SUGGESTION**. +2. **Coverage**: Does every major choice have an entry? Key decisions that MUST be logged: + - Provider selection (which image/video/audio tool and why) + - Style/playbook selection + - Music track selection + - Voice selection + - Renderer family selection + - Any fallback or downgrade (e.g., motion → still) +3. **Quality**: Each decision should have: + - At least 2 `options_considered` (not just the one picked) + - A `reason` that isn't boilerplate ("best option" is not a reason) + - Correct `confidence` (0.0–1.0) — flag if everything is 1.0 (unrealistic) +4. **User visibility**: Decisions marked `user_visible: true` should be ones the user would actually care about (not internal routing) + +### Severity: +- Missing decision log after proposal: **SUGGESTION** (first time), **CRITICAL** (if still missing at edit stage) +- Decision with only 1 option considered: **SUGGESTION** — "Log rejected alternatives for auditability" +- All decisions at confidence 1.0: **SUGGESTION** — "Unrealistic confidence — at least provider selection involves tradeoffs" + +## Creative Differentiation Review + +Run at **scene_plan** and **edit** stages. Prevents the "every video looks the same" failure mode. + +### Checks: +1. **Variation check** (scene_plan only): Use `lib/variation_checker.py` → `check_scene_variation(scenes)`. + - If verdict is "poor" (score ≤ 2): **CRITICAL** — "Scene plan lacks variety: [list violations]" + - If verdict is "fair" (score ≤ 3): **SUGGESTION** — note specific suggestions from the checker + +2. **Playbook alignment**: Is the active playbook appropriate for this content? + - Cinematic trailer using "clean-professional" theme → flag mismatch + - Educational explainer using "anime-ghibli" theme without user request → flag + +3. **Shot language completeness** (scene_plan): + - Every scene should have at least `shot_size` and `shot_intent` + - Hero moments should have full shot_language (all 6 fields) + - Flag scenes with empty shot_language as **SUGGESTION** + +4. **Renderer family match** (edit stage): + - Does `renderer_family` in edit_decisions match what was set at proposal? + - If changed without documented reason in decision log → **CRITICAL** + +## Delivery Promise Review + +Run at **edit** and **compose** stages. Uses `lib/delivery_promise.py`. + +### At edit stage: +1. Extract delivery promise from proposal packet or edit_decisions metadata +2. Run `promise.validate_cuts(cuts)` against the resolved cut list +3. If `valid` is False: **CRITICAL** — "Delivery promise violation: [violations]" +4. Check `motion_ratio`: if a motion-led promise has < 50% motion cuts, flag even if technically valid + +### At compose stage: +1. The `_pre_compose_validation()` in video_compose.py enforces this automatically +2. Review should verify the validation was not bypassed (check render report for warnings) +3. If render succeeded despite low motion ratio on a motion-led promise, flag as **SUGGESTION** + +## Source Understanding Review + +Run at **research** and **proposal** stages when user-supplied media files exist. + +### Checks: +1. **Existence**: If user-supplied files were provided to the project, does a `source_media_review` artifact exist? + - If user media exists but no `source_media_review`: **CRITICAL** — "User supplied media but the agent did not inspect it before planning. Run `lib/source_media_review.review_source_media()` before proceeding." +2. **Actual inspection**: Does every file entry have `reviewed: true` and a non-empty `technical_probe`? + - If `reviewed` is missing or `technical_probe` is empty: **CRITICAL** — "The source_media_review claims review but contains no probe data. The file was not actually inspected." +3. **Planning reflection**: Do the `planning_implications` appear in the proposal's production plan? + - If quality risks were identified (e.g. low resolution, mono audio) but the proposal doesn't mention them: **SUGGESTION** — "Source media has quality risks that the proposal does not address." +4. **Content accuracy**: Does the plan rely on content that the source media does not actually contain? + - E.g. plan assumes interview dialogue but transcript_summary shows no speech: **CRITICAL** — "Plan assumes dialogue but source media contains no speech." +5. **No hallucinated content**: The agent must not infer unsupported content from filenames alone. If `content_summary` says "interview footage" but the probe only shows 3s of silent video, flag as **CRITICAL**. + +### Severity: +- Missing `source_media_review` when user files exist: **CRITICAL** at proposal stage +- Unreviewed files (no probe): **CRITICAL** +- Plan doesn't reflect quality risks: **SUGGESTION** +- Plan assumes content not in source: **CRITICAL** + +## Final Self-Review Review + +Run at **compose** and **publish** stages. Ensures the agent reviewed the actual rendered output. + +### At compose stage: +1. **Existence**: Does a `final_review` artifact exist alongside the `render_report`? + - If missing: **CRITICAL** — "Compose produced a render_report but no final_review. The agent must inspect the rendered output before presenting it." +2. **Status check**: What is `final_review.status`? + - `pass` → OK, proceed + - `revise` → The agent should have fixed issues before presenting. If the pipeline continued anyway: **CRITICAL** — "Self-review found revise-worthy issues but the agent presented anyway." + - `fail` → The pipeline MUST NOT proceed. If it did: **CRITICAL** +3. **Check completeness**: All 5 required checks must have data: + - `technical_probe` must show a valid container with plausible duration/resolution + - `visual_spotcheck` must have `frames_sampled >= 4` + - `audio_spotcheck` must report narration/music presence + - `promise_preservation` must confirm `delivery_promise_honored` + - `subtitle_check` must report presence/absence + - Any check with missing data: **SUGGESTION** — "Self-review check [X] has incomplete data" +4. **Promise preservation**: If `promise_preservation.silent_downgrade_detected` is true: **CRITICAL** — "Self-review detected silent downgrade from motion-led to still-led." + +### At publish stage: +1. Verify that `final_review` was passed through as a required artifact +2. If `final_review.status` is not `pass`: **CRITICAL** — "Cannot publish with a non-passing self-review" +3. If `final_review.issues_found` is non-empty and `recommended_action` is not `present_to_user`: **SUGGESTION** — "Self-review found issues; verify they were resolved before publishing" diff --git a/skills/pipelines/animation/asset-director.md b/skills/pipelines/animation/asset-director.md index f3b947f..2191fc3 100644 --- a/skills/pipelines/animation/asset-director.md +++ b/skills/pipelines/animation/asset-director.md @@ -9,7 +9,7 @@ This stage prepares the actual animated ingredients: narration, diagrams, math r | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/asset_manifest.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | Tool path and beat map | +| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Tool path and beat map | | Tools | `tts_selector`, `image_selector`, `video_selector`, `math_animate`, `diagram_gen`, `code_snippet`, `music_gen` — selectors auto-discover all available providers from the registry | Asset production options | | Playbook | Active style playbook | Visual consistency | @@ -95,8 +95,38 @@ Recommended metadata keys: - missing capabilities are surfaced honestly, - every referenced file exists. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Using high-variance generation when a deterministic asset would work better. - Rebuilding the same title or label system repeatedly. - Hiding failed asset paths instead of reporting them. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/animation/proposal-director.md b/skills/pipelines/animation/proposal-director.md index e52b898..0263cb3 100644 --- a/skills/pipelines/animation/proposal-director.md +++ b/skills/pipelines/animation/proposal-director.md @@ -164,7 +164,32 @@ Note: This approach is not yet proven in the OpenMontage pipeline. - **Always offer at least one free/local option** alongside paid approaches - **Never silently downgrade** — if the best approach needs a key the user doesn't have, say so explicitly -### Step 4: Design Concept Options +### Step 3d: Mood Board (Before Concepts) + +Before developing full concepts, present a quick mood board to catch direction mismatches early: + +- **3-5 reference images** (animation style examples from web search — show what each approach LOOKS like) +- **Color palette direction** (2-3 options, e.g. clean data-viz vs vibrant motion graphics vs sketchy hand-drawn) +- **Tone references** ("Think: 3Blue1Brown meets Kurzgesagt" or "Think: Pixar short meets infographic") +- **1-2 animation style samples** (if Manim: mathematical elegance; if Remotion: smooth data transitions; if AI video: cinematic motion) + +Ask: **"Does this FEEL like what you're imagining? Any of these off-track?"** + +This catches style misalignment before concept design. If the user expected hand-drawn and you're heading toward data-viz, better to know now. + +### Step 4: Progressive Reveal and Concept Design + +Don't dump the full proposal at once. Build understanding step by step: + +1. **Research summary** (2-3 sentences): "Here's what I found..." + → User reacts, course-corrects if needed. +2. **Mood board** (from Step 3d — already presented) + → User confirms animation style direction. +3. **Concept options** (3+ approaches): + → Present below. +4. **Invite mixing** (see Step 4c below). +5. **Production plan for selected concept** (tools, cost, timeline): + → User approves budget and approach. Build **at least 3 genuinely different concepts.** Start from the `angles_discovered` in the research brief and the animation mode analysis. @@ -233,6 +258,13 @@ Present all concepts clearly to the user. For each concept, show: 4. **Duration** — how long 5. **Reuse strategy** — "5 scenes built from 2 templates" vs "8 unique scenes" +#### Step 5b: Invite Mixing + +After presenting concepts, always say something like: +> "You can also mix elements — for example, Concept A's hook with Concept C's animation approach, or Concept B's narrative with Concept A's visual style. What speaks to you?" + +If the user mixes, create a new hybrid concept entry in the proposal_packet with clear attribution: "Hook from Concept A, animation approach from Concept C, narrative structure from Concept B." + Let the user select, combine, modify, or redirect. Record the selection in `selected_concept` with rationale and any modifications. @@ -351,3 +383,22 @@ Validate the `proposal_packet` artifact against `schemas/artifacts/proposal_pack - **Ignoring mathematical accuracy**: If the research brief flagged technical accuracy constraints, the concept MUST respect them. A beautiful but wrong animation is a failure. - **Not distinguishing image_animation from clip_video**: These are fundamentally different. Image-based animation (Approach A) generates still images and uses Remotion for motion/crossfade. Clip-based video (Approach B) generates actual video clips with an AI video model. The user should understand this distinction clearly. - **Silent downgrades**: If the user picked image_animation but image generation fails, STOP and tell them. Never silently fall back to text cards or diagram stills. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/animation/publish-director.md b/skills/pipelines/animation/publish-director.md index 368dda8..1048201 100644 --- a/skills/pipelines/animation/publish-director.md +++ b/skills/pipelines/animation/publish-director.md @@ -9,7 +9,7 @@ Package the animation so the metadata, thumbnail concept, and platform framing r | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/publish_log.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["idea"]["brief"]`, `state.artifacts["script"]["script"]` | Final outputs and topic framing | +| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["proposal"]["proposal_packet"]`, `state.artifacts["research"]["research_brief"]`, `state.artifacts["script"]["script"]` | Final outputs and topic framing | | Playbook | Active style playbook | Visual naming consistency | ## Process diff --git a/skills/pipelines/animation/scene-director.md b/skills/pipelines/animation/scene-director.md index 03e6747..2a44297 100644 --- a/skills/pipelines/animation/scene-director.md +++ b/skills/pipelines/animation/scene-director.md @@ -9,7 +9,7 @@ You are converting the script into a feasible animation plan. This is the stage | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/scene_plan.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | Beat map and tool path | +| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Beat map and tool path | | Playbook | Active style playbook | Palette, typography, motion consistency | ## Process diff --git a/skills/pipelines/animation/script-director.md b/skills/pipelines/animation/script-director.md index 1d80098..0fa9671 100644 --- a/skills/pipelines/animation/script-director.md +++ b/skills/pipelines/animation/script-director.md @@ -107,6 +107,17 @@ Before submitting the script, verify: - [ ] Mathematical accuracy is maintained (if applicable) - [ ] Later stages can map scenes cleanly from this script +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - **Writing too many ideas into one section.** One beat = one visual idea. diff --git a/skills/pipelines/avatar-spokesperson/asset-director.md b/skills/pipelines/avatar-spokesperson/asset-director.md index 0ffa41d..9b2ca32 100644 --- a/skills/pipelines/avatar-spokesperson/asset-director.md +++ b/skills/pipelines/avatar-spokesperson/asset-director.md @@ -93,9 +93,39 @@ When the EP has triggered a narration-over-graphics pivot (neither `talking_head - `pivot_reason`: why the no-avatar path was chosen - All other metadata keys remain the same. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Building decorative assets before the narration path is solved. - Mixing multiple avatar-generation strategies in one simple spokesperson video. - Marking the stage complete when the core presenter asset is still hypothetical. - (No-avatar path) Generating filler visuals with no connection to the narration — every image must reinforce the spoken point. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/avatar-spokesperson/script-director.md b/skills/pipelines/avatar-spokesperson/script-director.md index c5ed436..aab9310 100644 --- a/skills/pipelines/avatar-spokesperson/script-director.md +++ b/skills/pipelines/avatar-spokesperson/script-director.md @@ -58,6 +58,17 @@ Recommended metadata keys: - CTA placement is clear, - text overlays are restrained. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Overstuffing one scene because the script reads well on paper. diff --git a/skills/pipelines/cinematic/asset-director.md b/skills/pipelines/cinematic/asset-director.md index a4b00e6..9dcc890 100644 --- a/skills/pipelines/cinematic/asset-director.md +++ b/skills/pipelines/cinematic/asset-director.md @@ -9,7 +9,7 @@ This stage prepares the usable media for the final cinematic edit: source select | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/asset_manifest.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | Scene intent and beat plan | +| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Scene intent and beat plan | | Tools | `subtitle_gen`, `audio_enhance`, `image_selector`, `video_selector`, `music_gen` — selectors auto-discover all available providers from the registry | Optional support asset creation | | Playbook | Active style playbook | Brand and typography consistency | @@ -26,7 +26,7 @@ Start with: These are the primary materials. Everything else is support. -If `brief.metadata.motion_required = true`, actual moving footage or generated video clips are mandatory. In that case: +If `proposal_packet.metadata.motion_required = true`, actual moving footage or generated video clips are mandatory. In that case: - stills may be used only as reference material or backing elements inside a larger motion composition, - stills may not replace the planned motion shots, @@ -92,6 +92,17 @@ Recommended metadata keys: - every referenced file exists. - if motion is required, the asset set contains actual video clips for the motion-led beats. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Generating extra shots before proving the source edit works. @@ -99,3 +110,22 @@ Recommended metadata keys: - Forgetting rights or provenance notes for supplied assets. - Quietly downgrading from video clips to still images because one provider or renderer failed. - Quietly switching providers or models after the user approved a generation path. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/cinematic/executive-producer.md b/skills/pipelines/cinematic/executive-producer.md index 4962747..a0e9895 100644 --- a/skills/pipelines/cinematic/executive-producer.md +++ b/skills/pipelines/cinematic/executive-producer.md @@ -4,14 +4,14 @@ You are the **Executive Producer (EP)** for a cinematic video (trailers, brand films, montages, short dramatic edits). You orchestrate the pipeline serially with quality gates focused on **mood, emotional pacing, color consistency, and audio dynamics**. -**No pre-production stages.** Source footage or direction exists. The EP adds cross-stage gates that enforce emotional arc integrity and cinematic polish. +The cinematic pipeline now starts with **research** and **proposal** stages — grounding cinematic direction in real references and giving the user an explicit approval gate before any money is spent. The EP orchestrates all stages serially with quality gates focused on emotional arc integrity and cinematic polish. ## Prerequisites | Layer | Resource | Purpose | |-------|----------|---------| | Pipeline | `pipeline_defs/cinematic.yaml` | Stage definitions | -| Skills | All 7 director skills + `meta/reviewer` | Stage execution | +| Skills | All 9 director skills + `meta/reviewer` | Stage execution | | Schemas | All artifact schemas | Validation | | Playbook | Active style playbook | Quality constraints | @@ -21,18 +21,21 @@ You are the **Executive Producer (EP)** for a cinematic video (trailers, brand f EP_STATE: pipeline: cinematic playbook: - target_duration_seconds: + target_duration_seconds: budget_total_usd: budget_spent_usd: 0.0 # Cinematic-specific - emotional_arc: null # from brief: build → reveal → landing + emotional_arc: null # from proposal_packet: build → reveal → landing + delivery_promise: null # from proposal_packet: motion_required, tone_mode, quality_floor + renderer_family: null # from proposal_packet: locked at proposal stage color_grade_target: null # mood-driven color palette hero_moments: [] # key reveal/climax frames music_beat_map: null # audio-driven pacing reference artifacts: - idea: null + research: null + proposal: null script: null scene_plan: null assets: null @@ -46,7 +49,7 @@ EP_STATE: ## Execution Protocol -Same as standard EP: Initialize → Execute stages serially (idea → script → scene_plan → assets → edit → compose → publish) → Final QA. +Same as standard EP: Initialize → Execute stages serially (research → proposal → script → scene_plan → assets → edit → compose → publish) → Final QA. Each stage: PREPARE → SPAWN DIRECTOR → REVIEW → GATE DECISION (pass / revise / send-back). @@ -74,13 +77,26 @@ The EP may not switch providers, models, or mediums without user approval once t ## EP-Specific Cross-Stage Checks -### After IDEA stage: +### After RESEARCH stage: ``` -CHECK: Emotional arc definition +CHECK: Research grounding + - Are visual references specific and relevant (not generic "cinematic" searches)? + - Is sound/music direction substantive? + - Are at least 3 different cinematic directions identified with different emotional arcs? + - Is the motion commitment honest about available capabilities? +``` + +### After PROPOSAL stage: +``` +CHECK: Delivery promise - Is the emotional arc explicit (build → reveal → landing)? - Is source mode clear (supplied footage vs generated inserts)? - - Does the brief explicitly say whether motion is required? - - Is the target mood defined and achievable? + - Does the proposal explicitly say whether motion is required? + - Is the delivery_promise present with all required fields? + - Is the renderer_family selected and locked? + - Is the music plan resolved (source chosen or explicitly deferred)? + - Is the cost estimate honest and per-item? + - Has the user approved the proposal? ``` ### After SCRIPT stage: @@ -145,7 +161,8 @@ CHECK: Output validation | Gate | After Stage | What's Checked | Fail Action | |------|-------------|---------------|-------------| -| G1 | idea | Emotional arc, source mode | Revise | +| G0 | research | Visual references, mood grounding | Revise | +| G1 | proposal | Delivery promise, renderer family, music plan, user approval | Revise | | G2 | script | Beat escalation, duration | Revise | | G3 | scene_plan | Hero moments, visual consistency | Revise | | G4 | assets | Music alignment, source quality, budget | Revise | diff --git a/skills/pipelines/cinematic/proposal-director.md b/skills/pipelines/cinematic/proposal-director.md new file mode 100644 index 0000000..66d922c --- /dev/null +++ b/skills/pipelines/cinematic/proposal-director.md @@ -0,0 +1,241 @@ +# Proposal Director — Cinematic Pipeline + +## When to Use + +You are the **Proposal Director** for a cinematic video (trailers, brand films, montages, dramatic edits). You sit between the Research Director and the Script Director. You receive a `research_brief` full of visual references, mood research, and cinematic direction options, and transform it into a concrete, reviewable proposal that the user approves before any money is spent. + +**This is the approval gate.** Nothing downstream runs until the user says "go." + +## Prerequisites + +| Layer | Resource | Purpose | +|-------|----------|---------| +| Schema | `schemas/artifacts/proposal_packet.schema.json` | Artifact validation | +| Prior artifact | `research_brief` from Research Director | Visual references, mood research, cinematic directions | +| Pipeline manifest | `pipeline_defs/cinematic.yaml` | Stage and tool definitions | +| Tool registry | `support_envelope()` output | What's actually available right now | +| Cost tracker | `tools/cost_tracker.py` | Cost estimation data | +| Style playbooks | `styles/*.yaml` | Available visual styles | +| User input | Subject, footage, preferences | Creative direction | + +## Process + +### Step 1: Absorb the Research + +Read the `research_brief` thoroughly. Extract: + +- **`research_summary`** — the researcher's strongest creative direction. +- **`angles_discovered`** — these are your raw cinematic direction candidates. +- **Visual references** — the real-world precedents that inform each direction. +- **Audio direction** — music mood and sound design notes. +- **Source reality** — what footage/stills the user actually has. +- **Motion commitment** — whether motion is required. + +### Step 2: Run Preflight + +Before designing concepts, know what tools are available: + +```bash +python -c "from tools.tool_registry import registry; import json; registry.discover(); print(json.dumps(registry.support_envelope(), indent=2))" +``` + +Record: +- Video generation providers — **critical for cinematic**. If motion is required, these must be available. +- Image generation providers — for support visuals and mood inserts +- TTS providers — for narration (if applicable; many cinematic pieces are narration-free) +- Music generation — check availability honestly +- Enhancement tools — color_grade, audio_enhance are high-value for cinematic +- **Remotion render engine** — check `video_compose.get_info()["render_engines"]["remotion"]` + +**Motion-required enforcement:** If the research brief indicates `motion_required: true`, verify that video generation or source footage can actually deliver motion. If neither is available, **do not silently downgrade to still-led**. Instead, present the constraint honestly and let the user decide. + +### Step 2c: Mood Board (Before Concepts) + +Before developing full concepts, present a quick mood board to catch direction mismatches early. Cinematic work is especially susceptible to tone misalignment, so this step is critical: + +- **3-5 reference images** (from web search — real film stills, not generic stock) +- **Color palette direction** (2-3 palettes: e.g. desaturated cold vs warm golden vs high-contrast noir) +- **Tone references** ("Think: Terrence Malick meets National Geographic" or "Think: David Fincher trailer pacing") +- **1-2 music mood references** (genre + energy + emotional arc, e.g. "ambient synth building to orchestral crescendo") + +Ask: **"Does this FEEL like what you're imagining? Any of these off-track?"** + +This is cheaper than building 3 full cinematic directions and catches tone mismatches before they're embedded in concept design. If the user says "less moody, more energetic," you've saved a concept round. + +### Step 3: Design Concept Directions + +Build **at least 3 genuinely different cinematic directions.** Start from the `angles_discovered` in the research brief. + +For each concept, specify all fields in `proposal_packet.concept_options`: + +#### 3a: Title and Emotional Hook + +Cinematic hooks are different from explainer hooks — they evoke **feeling**, not information gaps. + +| Pattern | When to Use | +|---------|-------------| +| **Sensory** | "You hear it before you see it. A frequency that shouldn't exist." | When the mood is mystery/tension | +| **Scale shift** | "In the time it takes to read this sentence, 4.7 million packets crossed the Atlantic." | When the concept involves scale | +| **Intimate** | "She waters them at 6am. Before the city wakes. Before anyone watches." | When the mood is intimate/human | +| **Provocation** | "They told us the message was noise. It wasn't." | When the concept involves a reveal | +| **Contrast** | "Three blocks from Wall Street, on a rooftop covered in clover, 40,000 bees are building a city." | When the subject is surprising in context | + +#### 3b: Emotional Arc + +Every cinematic concept needs an explicit arc: + +| Arc | Structure | Best For | +|-----|-----------|----------| +| `tension → reveal` | Build unease, then pay it off | Teasers, sci-fi, thriller | +| `wonder → scale` | Start small, expand to massive | Nature, tech, cosmos | +| `intimacy → payoff` | Close, personal, then earned moment | Documentary, human interest | +| `urgency → resolution` | Fast pace to satisfying close | Product, action, launch | +| `mystery → CTA` | Intrigue that leads to action | Brand films, campaigns | +| `stillness → eruption` | Calm before powerful climax | Music videos, art films | + +#### 3c: Delivery Promise + +For cinematic, explicitly classify: + +```yaml +delivery_promise: + promise_type: motion_led # or source_led, hybrid + motion_required: true # false only if user approves still-led + source_required: false # true if user has footage + tone_mode: cinematic # cinematic, raw, intimate, epic + quality_floor: presentable # draft, presentable, broadcast + approved_fallback: null # animatic, still_led, or null (no fallback) +``` + +**Rule:** `motion_led` forbids still-led fallback unless the user explicitly approves `animatic` as the fallback. + +#### 3d: Visual Treatment + +For each concept, define: +- **Color palette** — specific hex references, not just "dark" +- **Lighting approach** — high_key, low_key, natural, golden_hour, etc. +- **Camera language** — dominant shot sizes, movements +- **Texture** — film grain, clean digital, anamorphic, handheld +- **Typography** — if title cards are used, their style and restraint level + +#### 3e: Renderer Family Selection + +Choose the renderer family and lock it in the proposal: + +| Family | When | Composition | +|--------|------|-------------| +| `cinematic-trailer` | Trailers, teasers, brand films with generated/source video | CinematicRenderer | +| `presenter` | Talking head with cinematic enhancement | TalkingHead | +| `explainer-data` | Only if this is really an explainer that wants cinematic dressing | Explainer | + +**Rule:** The renderer family is selected here and locked before scene planning. The compose stage cannot change it without logging a decision and surfacing the change to the user. + +### Step 4: Progressive Reveal, Diversity Check, and Concept Selection + +#### 4a: Progressive Reveal + +Don't dump the full proposal at once. Build understanding step by step: + +1. **Research summary** (2-3 sentences): "Here's what I found about the subject and its visual potential..." + → User reacts, course-corrects if needed. +2. **Mood board** (from Step 2c — already presented) + → User confirms feel. +3. **Concept directions** (3+ emotional/visual approaches): + → Present each concept's emotional hook, arc, and visual treatment. +4. **Invite mixing** (see 4c below). +5. **Production plan for selected direction** (tools, cost, renderer family): + → User approves budget and approach. + +Each step is a chance for the user to course-correct before the next step builds on it. + +#### 4b: Diversity Check + +Before presenting concepts: +- [ ] No two concepts share the same emotional arc +- [ ] No two concepts use the same visual treatment +- [ ] At least one concept takes a creative risk +- [ ] Each concept's visual references are from different sources +- [ ] Each concept is achievable with current capabilities (or states what's missing) + +#### 4c: Invite Mixing + +After presenting concepts, always say something like: +> "You can also mix elements — for example, Concept A's emotional arc with Concept C's visual treatment and Concept B's music direction. What speaks to you?" + +If the user mixes, create a new hybrid concept entry in the proposal_packet with clear attribution: "Emotional arc from Concept A, visual treatment from Concept C, music direction from Concept B." + +Let the user select, combine, modify, or redirect entirely. + +### Step 5: Music Plan (Mandatory for Cinematic) + +Cinematic videos live and die by their audio. Surface the music situation before the user approves. + +Check availability in this order: +1. **User music library (`music_library/`)** — list available tracks +2. **Music generation APIs** — report status, cost, and quality honestly +3. **Bring-your-own path** — user can drop a track in `music_library/` + +Present explicit options: +``` +MUSIC PLAN +├── Your music library: [N tracks / empty] +├── AI generation: [provider] — [AVAILABLE/UNAVAILABLE] [cost] +└── Bring your own: Drop a track in music_library/ before asset stage + +Recommendation: [specific recommendation based on mood research] +``` + +### Step 6: Build Production Plan + +For the selected concept, design the stage-by-stage plan with specific providers, costs, and honest tradeoffs. + +**Cinematic-specific tool priorities:** +- **Color grade** — high priority. Cinematic output without grade looks flat. +- **Audio enhance** — high priority. Audio dynamics matter more in mood-driven work. +- **Video generation** — if motion-required, this is non-negotiable. +- **Music** — must be resolved. No cinematic piece should have silence as a surprise. + +### Step 7: Cost Estimate + +Itemize all costs honestly. Cinematic tends to be more expensive than explainer (more generated clips, music, grade passes). + +### Step 8: Present and Approve + +Present concepts clearly. Invite the user to: +- Select one as-is +- Mix elements from multiple concepts +- Request modifications +- Redirect entirely + +Set `approval.status: "pending"`. Pipeline does NOT proceed without approval. + +### Step 9: Submit + +Validate `proposal_packet` against schema and submit. + +## Common Pitfalls + +- **Calling it cinematic because of black bars**: Letterboxing is not cinematography. The treatment must include shot language, lighting, movement, and emotional arc. +- **Hiding motion downgrade**: If motion-required content will actually be still images with Ken Burns, say so explicitly. +- **Music as afterthought**: In cinematic work, music is 50% of the mood. Surface it early. +- **Three versions of "dark and moody"**: Three concepts with the same emotional register but different titles are one concept. Diversity means different arcs, different moods, different risks. +- **Ignoring source reality**: If the user has no footage and limited generation tools, the proposal must reflect that — not pretend the constraints don't exist. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/cinematic/publish-director.md b/skills/pipelines/cinematic/publish-director.md index 7edd792..bf70f0a 100644 --- a/skills/pipelines/cinematic/publish-director.md +++ b/skills/pipelines/cinematic/publish-director.md @@ -9,7 +9,7 @@ Package the cinematic piece and any cutdowns so the hero version stays clear and | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/publish_log.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["idea"]["brief"]`, `state.artifacts["script"]["script"]` | Final outputs and beat map | +| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["proposal"]["proposal_packet"]`, `state.artifacts["research"]["research_brief"]`, `state.artifacts["script"]["script"]` | Final outputs and beat map | | Playbook | Active style playbook | Tone and naming consistency | ## Process diff --git a/skills/pipelines/cinematic/research-director.md b/skills/pipelines/cinematic/research-director.md new file mode 100644 index 0000000..c653c9a --- /dev/null +++ b/skills/pipelines/cinematic/research-director.md @@ -0,0 +1,172 @@ +# Research Director — Cinematic Pipeline + +## When to Use + +You are the **Research Director** for a cinematic video (trailers, brand films, dramatic montages, mood-led edits). Your job is to deeply research the subject to ground the cinematic direction in real references, real moods, and real audience expectations — before any creative decisions or money is spent. + +Unlike explainer research (which focuses on facts, data, and content gaps), cinematic research focuses on **visual references, emotional language, sound design direction, and motion precedents.** The goal is to arm the Proposal Director with enough material to present mood boards and concept directions that feel intentional, not generic. + +**You do NOT make creative decisions.** You gather raw material. The Proposal Director will use your findings to craft concept directions. + +## Prerequisites + +| Layer | Resource | Purpose | +|-------|----------|---------| +| Schema | `schemas/artifacts/research_brief.schema.json` | Artifact validation | +| User input | Subject, mood hints, footage situation, references | Research scope | +| Tools | Web search, web fetch | Research execution | + +## Process + +### Step 1: Classify the Brief + +Before searching, extract from the user's request: + +- **Subject**: What is this video about? +- **Source reality**: Does the user have footage, stills, audio, or nothing? +- **Motion requirement**: Is motion a hard requirement (trailer, teaser, hype reel) or can it be still-led? +- **Mood hints**: Any emotional direction given? ("dark", "epic", "intimate", "raw", "hopeful") +- **Platform**: Where will this live? +- **Duration hint**: Short (15-30s), medium (30-90s), long (90s+)? + +### Step 2: Visual Reference Mining + +**Goal:** Find real cinematic precedents that match the mood and subject. + +``` +SEARCH BATCH 1 — Visual References (run all in parallel) + +Q1: "[subject] cinematic [mood hint]" site:youtube.com + → Find: Existing trailers, brand films, or mood pieces for this subject. + +Q2: "[subject] [delivery shape] visual style" (breakdown OR making-of OR tutorial) + → Find: How professionals approach this type of visual storytelling. + +Q3: "[mood hint] color palette cinematography" OR "[mood hint] color grading reference" + → Find: Color and grade references that match the intended mood. + +Q4: "[subject] [mood hint]" (short film OR brand film OR trailer) award OR festival + → Find: Award-quality references — the ceiling of what this could look like. +``` + +**For each reference, record:** +- Title and URL +- What works visually (framing, color, movement, texture) +- What works emotionally (pacing, reveal structure, tension arc) +- Relevance to the user's brief + +### Step 3: Sound and Music Landscape + +**Goal:** Understand the audio palette for this mood. + +``` +SEARCH BATCH 2 — Audio References (run in parallel) + +Q5: "[mood hint] [subject] soundtrack" OR "[mood hint] film score reference" + → Find: Music mood references. + +Q6: "[mood hint] sound design" (cinematic OR film OR trailer) + → Find: Sound design approaches — ambient, textural, percussive, silent. +``` + +**Record:** +- Music mood direction (not specific tracks — the energy and texture) +- Sound design notes (atmospheric, minimal, industrial, organic) +- Whether dialogue or narration is expected or if the piece is music-driven + +### Step 4: Subject-Specific Research + +**Goal:** Gather factual or contextual depth that grounds the visual choices. + +``` +SEARCH BATCH 3 — Subject Depth (run in parallel) + +Q7: "[subject]" (story OR history OR origin OR significance) + → Find: Narrative depth that can inform visual decisions. + +Q8: "[subject]" (visual OR texture OR detail OR close-up OR macro) + → Find: Texture and material references for the subject. + +Q9: "[subject]" "[current year]" (trend OR development OR news) + → Find: Current relevance — is there a timeliness angle? +``` + +### Step 5: Motion and Camera Language Research + +**Goal:** Find specific cinematic techniques that suit this mood. + +``` +SEARCH BATCH 4 — Technique Research (run in parallel) + +Q10: "[mood hint] camera movement" (technique OR cinematography) + → Find: Which camera movements suit this mood (handheld for raw, steadicam for contemplative, whip pans for energy). + +Q11: "[mood hint] editing rhythm" OR "[mood hint] pacing" (film OR trailer) + → Find: Editing tempo references. + +Q12: "[delivery shape] structure" (beat sheet OR pacing OR breakdown) + → Find: Structural templates for this delivery type. +``` + +### Step 6: Audience and Distribution Context + +``` +SEARCH BATCH 5 — Audience (run in parallel) + +Q13: "[subject] [platform]" (best OR viral OR most watched) + → Find: What performs well on the target platform for this subject. + +Q14: "[subject]" site:reddit.com (mood OR aesthetic OR vibe) + → Find: How the community talks about and feels about this subject. +``` + +### Step 7: Angle Synthesis + +Using everything from Steps 2-6, identify at least 3 genuinely different cinematic directions: + +For each direction, specify: + +| Field | What | Quality Bar | +|-------|------|-------------| +| `name` | Short direction title (5-8 words) | Specific mood, not just the subject | +| `hook` | One-sentence emotional pitch | Must evoke a feeling, not explain | +| `type` | `mood_piece`, `tension_arc`, `reveal`, `intimate`, `epic`, `raw` | Categorize honestly | +| `visual_references` | Which found references inform this direction | Specific URLs and descriptions | +| `audio_direction` | Music mood, sound design approach | Informed by Step 3 findings | +| `motion_commitment` | What motion is required and how it'll be achieved | Honest about capabilities | +| `grounded_in` | Which research findings support this direction | Cross-reference your findings | + +**Direction diversity checklist:** +- [ ] At least one direction uses a different emotional arc than the others +- [ ] At least one direction emphasizes texture/intimacy over spectacle +- [ ] No two directions use the same primary camera approach +- [ ] Each direction is grounded in different visual references + +### Step 8: Source Bibliography + +Compile all URLs used. Minimum 5 sources. + +### Step 9: Assemble and Submit + +Build the `research_brief` artifact per the schema. Include: + +1. `research_summary` — one paragraph capturing the strongest creative direction found +2. All sections from Steps 2-8 + +Validate against `schemas/artifacts/research_brief.schema.json` before submitting. + +## Execution Constraints + +| Constraint | Value | Why | +|------------|-------|-----| +| Max time on research | 3-5 minutes | Research is valuable but has diminishing returns | +| Max searches | 20 | Prevent infinite rabbit holes | +| Min searches | 8 | Ensure adequate coverage | +| No paid tools | — | Research uses web search only — zero cost | + +## Common Pitfalls + +- **Searching only for "cinematic"**: The word is overused. Search for the specific mood, texture, and subject instead. +- **Ignoring the source reality**: If the user has no footage and no video generation, the research should account for still-led approaches — not ignore the constraint. +- **Generic mood words**: "Dark and moody" is not a direction. "Low-key tungsten lighting with shallow depth of field, inspired by Fincher's title sequences" is a direction. +- **Skipping audio research**: Cinematic videos live and die by their audio. The mood board is incomplete without sound direction. diff --git a/skills/pipelines/cinematic/scene-director.md b/skills/pipelines/cinematic/scene-director.md index da2d787..348baf2 100644 --- a/skills/pipelines/cinematic/scene-director.md +++ b/skills/pipelines/cinematic/scene-director.md @@ -9,7 +9,7 @@ You are deciding how each cinematic beat will look and transition. This is where | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/scene_plan.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | Beat map and source truth | +| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Beat map and source truth | | Tools | `frame_sampler`, `scene_detect` | Source inspection and reframing checks | | Playbook | Active style playbook | Color and typography consistency | diff --git a/skills/pipelines/cinematic/script-director.md b/skills/pipelines/cinematic/script-director.md index 5ca55c6..4f065e4 100644 --- a/skills/pipelines/cinematic/script-director.md +++ b/skills/pipelines/cinematic/script-director.md @@ -9,7 +9,7 @@ This stage builds the beat map, selected lines, title-card copy, and reveal stru | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/script.schema.json` | Artifact validation | -| Prior artifact | `state.artifacts["idea"]["brief"]` | Emotional arc and source truth | +| Prior artifact | `state.artifacts["proposal"]["proposal_packet"]` | Emotional arc and source truth | | Tools | `transcriber`, `scene_detect` | Optional dialogue mining and source review | ## Process @@ -62,6 +62,17 @@ Recommended metadata keys: - the reveal lands distinctly, - the landing gives the viewer a final feeling or action. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Writing full explanatory paragraphs instead of beats. diff --git a/skills/pipelines/clip-factory/asset-director.md b/skills/pipelines/clip-factory/asset-director.md index 01b8ed0..027cc60 100644 --- a/skills/pipelines/clip-factory/asset-director.md +++ b/skills/pipelines/clip-factory/asset-director.md @@ -25,6 +25,16 @@ Prefer reusable assets over per-clip reinvention: - one watermark / brand frame, - one CTA / end-tag treatment if needed. +### 1b. Hero Scene Sample (Mandatory) + +Before batch asset generation: +1. Identify the hero scene (the visual peak of the batch) +2. Generate ONE sample visual asset for that scene +3. Present it: "This is the visual direction for the most important clip. Does this match what you're imagining? I'll generate the rest in this style." +4. Wait for approval before proceeding to batch generation + +This prevents the most expensive mistake: generating 10+ assets in a direction the user doesn't like. + ### 2. Generate Per-Clip Subtitles Each approved clip needs its own subtitle asset, timed from clip start rather than source start. This timestamp rebasing is critical. @@ -60,9 +70,39 @@ Recommended metadata keys: - shared assets are referenced consistently, - the asset count stays practical for the batch size. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Forgetting to rebase subtitle timing per clip. - Overdesigning hook assets so the batch becomes inconsistent. - Normalizing some clips and not others. - Treating a 10-clip batch like 10 unrelated projects. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/clip-factory/script-director.md b/skills/pipelines/clip-factory/script-director.md index 46a1733..fc6a16a 100644 --- a/skills/pipelines/clip-factory/script-director.md +++ b/skills/pipelines/clip-factory/script-director.md @@ -82,6 +82,17 @@ Each candidate should record: - the set covers the source deliberately instead of clustering in one section, - low-quality candidates are rejected honestly. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Trusting first-pass candidate timestamps without transcript-level review. diff --git a/skills/pipelines/explainer/asset-director.md b/skills/pipelines/explainer/asset-director.md index aa007d4..6540f5d 100644 --- a/skills/pipelines/explainer/asset-director.md +++ b/skills/pipelines/explainer/asset-director.md @@ -11,7 +11,7 @@ This is where plans become real files. A missing or low-quality asset will torpe | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/asset_manifest.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | What to produce | +| Prior artifacts | `state.artifacts["scene_plan"]["scene_plan"]`, `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | What to produce | | Playbook | Active style playbook | Image prompts, diagram style, audio preferences | | Tools | `tts_selector`, `image_selector`, `video_selector`, `diagram_gen`, `code_snippet`, `music_gen` — selectors auto-discover all available providers from the registry | Generation capabilities | | Cost tracker | `tools/cost_tracker.py` | Budget governance | @@ -188,6 +188,17 @@ If any dimension scores below 3, fix before proceeding. Validate the asset_manifest against the schema and persist via checkpoint. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - **Generating before checking budget**: Always estimate total cost first. A 60-second video with 15 images can burn $3+ quickly. @@ -195,3 +206,22 @@ Validate the asset_manifest against the schema and persist via checkpoint. - **Ignoring narration timing**: If TTS produces 12s of audio for a 10s section, the edit phase will struggle. Check durations. - **Missing pronunciation guide**: "PostgreSQL" or "Kubernetes" will be mispronounced without explicit guidance. - **One retry then give up**: If an image doesn't match, refine the prompt specifically — don't just retry the same prompt. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/explainer/proposal-director.md b/skills/pipelines/explainer/proposal-director.md index af73fd6..4e210af 100644 --- a/skills/pipelines/explainer/proposal-director.md +++ b/skills/pipelines/explainer/proposal-director.md @@ -58,6 +58,21 @@ This directly affects what you can promise in the production plan. **Do not prop **Setup offers:** If critical tools are UNAVAILABLE but fixable with a simple configuration, read each tool's `install_instructions` from the registry and offer the user setup help before designing around the limitation. See AGENT_GUIDE.md "Provider Menu" protocol for the approach. Group related tools that share the same env var dependency. +### Step 2c: Mood Board (Before Concepts) + +Before developing full concepts, present a quick mood board to catch direction mismatches early: + +- **3-5 reference images** (from web search, stock, or quick generations) +- **Color palette direction** (2-3 options derived from playbook candidates) +- **Tone references** ("Think: Kurzgesagt meets Vice" or "Think: Apple product video meets TED-Ed") +- **1-2 music mood references** (genre + energy level, not specific tracks) + +Ask: **"Does this FEEL like what you're imagining? Any of these off-track?"** + +This is cheaper than generating 3 full concepts and catches direction mismatches before they become expensive. If the user says "too corporate" or "more playful," you've saved an entire concept round. + +If the user confirms the direction, proceed. If they redirect, adjust your concept design to match. + ### Step 3: Design Concept Options Build **at least 3 genuinely different concepts.** Start from the `angles_discovered` in the research brief, but elevate them into full production concepts. @@ -137,32 +152,89 @@ Set realistic duration based on platform and content depth: | YouTube | 60-180s | 150-450 words | | LinkedIn | 60-120s | 150-300 words | -#### 3e: Concept Diversity Check +#### 3e: When to Break the Patterns -Before finalizing, verify diversity: +The hook patterns and narrative structures above are starting points, not templates. Here are signs you should invent something new: +**Signs your concepts are cosmetically diverse but conceptually identical:** +- All three hooks create the same type of curiosity gap +- Swapping the hooks between concepts would barely change anything +- All three would produce roughly the same script if you wrote them blind +- The visual approaches are "dark vs light vs colorful" but the content structure is identical + +**Anti-formula rule:** Write the hook in your own words first. Then check if a pattern helps sharpen it. If you start FROM the pattern, you'll produce pattern-shaped content instead of research-shaped content. + +**When to deviate from the 6 hook templates:** +- The research reveals a unique framing that doesn't fit any template +- The audience is sophisticated enough that template hooks feel condescending +- The topic's best angle is emotional rather than informational +- You found a specific quote, anecdote, or event that IS the hook + +#### 3f: Concept Diversity Gate + +This is two checks, not one: + +**Structural diversity (necessary but not sufficient):** - [ ] No two concepts use the same narrative structure - [ ] No two concepts use the same hook pattern -- [ ] At least one concept targets a different audience segment -- [ ] At least one concept leverages the most surprising data point -- [ ] At least one concept addresses the biggest content gap found - [ ] Each concept's `grounded_in` references different research findings -### Step 4: Present Concepts and Get Selection +**Conceptual diversity (the actual test):** +- [ ] Each concept offers a genuinely different INSIGHT, not just a different title for the same insight +- [ ] At least one concept takes a creative risk (unusual structure, unexpected angle, provocative framing) +- [ ] If you removed the titles and hooks, the concepts would still be distinguishable by their content structure +- [ ] The concepts are NOT interchangeable — each serves a different audience need or curiosity -Present all concepts clearly to the user. For each concept, show: +If your concepts fail the conceptual diversity test, go back to the research brief. The problem is usually that you're working from one angle and varying the surface, instead of working from different angles entirely. +#### 3g: Playbook Violation Budget + +Up to 20% of scenes in the final video may intentionally deviate from the playbook for creative impact. When presenting concepts, note which moments might benefit from visual surprise (a color shift, a different typography treatment, an unexpected transition). These deviations must be logged as `playbook_override` decisions in the decision log. + +#### 3h: Voice Selection + +Surface the voice/TTS decision at proposal time: +- What voice provider and voice ID will be used +- Why this voice fits the concept's tone +- Cost implications +- Whether voice variation is appropriate for hero moments + +### Step 4: Progressive Reveal and Concept Selection + +Don't dump the full proposal at once. Build understanding step by step: + +**4a. Research summary** (2-3 sentences): "Here's what I found..." +→ User reacts, course-corrects if needed. + +**4b. Mood board** (from Step 2c — already presented) +→ User confirms feel. + +**4c. Concept options** (3+ directions): + +For each concept, show: 1. **Title** and **hook** — the creative pitch 2. **Why this works** — the research backing, in one sentence 3. **What it'll look like** — visual approach in plain language 4. **Duration** — how long the video will be +**4d. Invite Mixing:** + +After presenting concepts, always say something like: +> "You can also mix elements — for example, Concept A's hook with Concept C's visual approach. What speaks to you?" + +If the user mixes, create a new hybrid concept entry in the proposal_packet with clear attribution: "Hook from Concept A, visual approach from Concept C, narrative structure from Concept B." + Let the user: - Select one as-is -- Combine elements from multiple concepts +- Combine elements from multiple concepts (hybrid) - Request modifications - Describe a completely different direction (in which case, use the research to strengthen it) +**4e. Production plan for selected concept** (tools, cost, timeline): +→ User approves budget and approach. + +Each step is a chance for the user to course-correct before the next step builds on it. This prevents the "I approved a proposal and then the video wasn't what I expected" failure mode. + Record the selection in `selected_concept` with rationale and any modifications. ### Step 5: Build the Production Plan @@ -378,3 +450,22 @@ TOTAL: $0.64 of $2.00 budget - Premium (Remotion): Best available TTS + 4 AI images + 4 Remotion animated scenes = $0.48 - Standard: Mid-tier TTS + images = $0.40 - Free: Local TTS + Remotion component scenes only = $0.00 (no images, pure motion graphics) + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/explainer/publish-director.md b/skills/pipelines/explainer/publish-director.md index 342ac9a..aaa2dcd 100644 --- a/skills/pipelines/explainer/publish-director.md +++ b/skills/pipelines/explainer/publish-director.md @@ -11,7 +11,7 @@ This is where a great video reaches its audience. Without proper metadata and pa | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/publish_log.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["idea"]["brief"]` | Video file and original brief | +| Prior artifacts | `state.artifacts["compose"]["render_report"]`, `state.artifacts["proposal"]["proposal_packet"]`, `state.artifacts["research"]["research_brief"]` | Video file and original proposal | | Playbook | Active style playbook | Visual style for thumbnail | ## Process @@ -19,14 +19,14 @@ This is where a great video reaches its audience. Without proper metadata and pa ### Step 1: Gather Context Collect everything needed for metadata: -- **Brief**: title, hook, key points, target platform, tone +- **Proposal packet**: title, hook, key points, target platform, tone - **Render report**: output path, duration, resolution - **Script**: section summaries for description/chapters ### Step 2: Generate SEO Metadata **Title** (max 60 characters for YouTube): -- Include the primary keyword from the brief +- Include the primary keyword from the proposal packet - Lead with a hook or number - Avoid clickbait but be compelling - Examples: "Vector Databases Explained in 60 Seconds" > "About Vector Databases" @@ -39,7 +39,7 @@ Collect everything needed for metadata: - Links: relevant resources mentioned in the video **Tags/Keywords** (platform-dependent): -- 5-10 specific tags derived from brief's key_points +- 5-10 specific tags derived from proposal packet's key_points - Mix broad and specific: "machine learning" + "vector database tutorial" - Include the topic, format ("explainer"), and related terms diff --git a/skills/pipelines/explainer/scene-director.md b/skills/pipelines/explainer/scene-director.md index 39f46fe..1c7d558 100644 --- a/skills/pipelines/explainer/scene-director.md +++ b/skills/pipelines/explainer/scene-director.md @@ -11,7 +11,7 @@ This is where words become visuals. A great script with a bad scene plan produce | Layer | Resource | Purpose | |-------|----------|---------| | Schema | `schemas/artifacts/scene_plan.schema.json` | Artifact validation | -| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["idea"]["brief"]` | Script sections and creative brief | +| Prior artifacts | `state.artifacts["script"]["script"]`, `state.artifacts["proposal"]["proposal_packet"]` | Script sections and proposal packet | | Playbook | Active style playbook | Visual language, transitions, motion rules | | Layer 3 | `.agents/skills/flux-best-practices/`, `.agents/skills/beautiful-mermaid/`, `.agents/skills/manim-composer/` | Image gen, diagram, animation knowledge | diff --git a/skills/pipelines/explainer/script-director.md b/skills/pipelines/explainer/script-director.md index 36b8607..80c6160 100644 --- a/skills/pipelines/explainer/script-director.md +++ b/skills/pipelines/explainer/script-director.md @@ -185,6 +185,17 @@ If any dimension scores below 3, revise before submitting. Call `handle_explainer_script(state, {"script": script_json})` to validate and persist. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - **Writing too many words**: The #1 failure. TTS pacing is fixed. If you write 250 words for a 60-second video, either the audio will be rushed or the video will be 100 seconds. Count your words. diff --git a/skills/pipelines/hybrid/asset-director.md b/skills/pipelines/hybrid/asset-director.md index ac49b72..89b7e0e 100644 --- a/skills/pipelines/hybrid/asset-director.md +++ b/skills/pipelines/hybrid/asset-director.md @@ -63,8 +63,38 @@ Recommended metadata keys: - source and generated assets are clearly separated, - every referenced file exists. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Overbuilding support assets before the anchor cut is proven. - Losing track of which assets are generated versus supplied. - Creating inconsistent overlay systems across one project. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/hybrid/script-director.md b/skills/pipelines/hybrid/script-director.md index ff5a319..2d6d9de 100644 --- a/skills/pipelines/hybrid/script-director.md +++ b/skills/pipelines/hybrid/script-director.md @@ -52,6 +52,17 @@ Recommended metadata keys: - the script does not depend on fake or unavailable assets without saying so, - the structure can produce the intended deliverables. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Rewriting strong source dialogue into weaker narration. diff --git a/skills/pipelines/localization-dub/asset-director.md b/skills/pipelines/localization-dub/asset-director.md index d2f42e5..637f3d4 100644 --- a/skills/pipelines/localization-dub/asset-director.md +++ b/skills/pipelines/localization-dub/asset-director.md @@ -19,6 +19,16 @@ This stage produces the localized asset kit: translated subtitle files, dubbed a Create the subtitle or caption package for each language. This gives a reviewable fallback even if dubbed-audio generation or lip sync is blocked. +### 1b. Hero Scene Sample (Mandatory) + +Before batch asset generation: +1. Identify the hero scene (the visual peak of the video) +2. Generate ONE sample dubbed audio clip for that scene in the target language +3. Present it: "This is the voice direction for the most important scene. Does this match what you're imagining? I'll generate the rest in this style." +4. Wait for approval before proceeding to batch generation + +This prevents the most expensive mistake: generating 10+ dubbed assets in a direction the user doesn't like. + ### 2. Generate Dubbed Audio Per Language Use the approved translated script package, not raw machine output. Record which voice or synthesis path was used for each language. @@ -45,8 +55,38 @@ Recommended metadata keys: - lip-sync remains explicitly optional, - every referenced file exists. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Generating dubbed audio before finalizing translation review. - Treating lip sync as mandatory for every language. - Failing to record which language asset maps to which voice and subtitle set. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/localization-dub/script-director.md b/skills/pipelines/localization-dub/script-director.md index 308c88b..2a0f95f 100644 --- a/skills/pipelines/localization-dub/script-director.md +++ b/skills/pipelines/localization-dub/script-director.md @@ -47,6 +47,17 @@ Recommended metadata keys: - glossary terms are preserved, - the script package can be reviewed before audio generation. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Generating audio from an unreviewed transcript. diff --git a/skills/pipelines/podcast-repurpose/asset-director.md b/skills/pipelines/podcast-repurpose/asset-director.md index 53b6969..2902ad2 100644 --- a/skills/pipelines/podcast-repurpose/asset-director.md +++ b/skills/pipelines/podcast-repurpose/asset-director.md @@ -24,6 +24,16 @@ Highest priority: - speaker attribution assets if multiple speakers appear, - quote-card templates for quote-led outputs. +### 1b. Hero Scene Sample (Mandatory) + +Before batch asset generation: +1. Identify the hero clip (the most important or impactful clip in the batch) +2. Generate ONE sample asset for that clip (subtitle style, speaker card, or quote card) +3. Present it: "This is the visual direction for the most important clip. Does this match what you're imagining? I'll generate the rest in this style." +4. Wait for approval before proceeding to batch generation + +This prevents the most expensive mistake: generating 10+ assets in a direction the user doesn't like. + ### 2. Treat Topic Graphics As Optional Generated graphics should support the batch, not dominate it. Use them only when: @@ -58,8 +68,38 @@ Recommended metadata keys: - quote-card text remains mobile-readable, - optional generated art stays within budget and style constraints. +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Spending budget on optional art before subtitles and attribution assets are complete. - Creating inconsistent speaker cards across the same episode. - Overproducing topic graphics for long-form companion videos. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/podcast-repurpose/script-director.md b/skills/pipelines/podcast-repurpose/script-director.md index c013ed3..1167362 100644 --- a/skills/pipelines/podcast-repurpose/script-director.md +++ b/skills/pipelines/podcast-repurpose/script-director.md @@ -63,6 +63,17 @@ Use `sections[]` for the structured production-facing segments and put the riche - weak clips are rejected instead of padded, - chapter markers cover the long-form conversation cleanly. +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Treating diarization errors as minor when they change who said the quote. diff --git a/skills/pipelines/screen-demo/asset-director.md b/skills/pipelines/screen-demo/asset-director.md index 14f158f..28c4dc0 100644 --- a/skills/pipelines/screen-demo/asset-director.md +++ b/skills/pipelines/screen-demo/asset-director.md @@ -25,6 +25,16 @@ Screen demos do not need a large asset pile. They need the right few assets: - optional: one intro card, one outro card, sparse diagram overlays - optional only if preflight allows it: generated narration for silent recordings +### 1b. Hero Scene Sample (Mandatory) + +Before batch asset generation: +1. Identify the hero scene (the most important step or interaction in the demo) +2. Generate ONE sample asset for that scene (subtitle style, highlight overlay, or intro card) +3. Present it: "This is the visual direction for the most important step. Does this match what you're imagining? I'll generate the rest in this style." +4. Wait for approval before proceeding to batch generation + +This prevents the most expensive mistake: generating 10+ assets in a direction the user doesn't like. + ### 2. Generate Subtitles First Rules: @@ -101,9 +111,39 @@ Use `asset_manifest.metadata` for details like: - [ ] Callout colors have sufficient contrast - [ ] Blur masks fully cover the sensitive content +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + ## Common Pitfalls - Generating too many one-off overlay files instead of a reusable kit. - Using subtitles that sit directly on top of terminal output or bottom navigation. - Assuming silent recordings will magically gain narration without checking TTS. - Spending image generation budget on visuals the raw screen already provides. + + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/screen-demo/script-director.md b/skills/pipelines/screen-demo/script-director.md index 48bc16e..fb1d633 100644 --- a/skills/pipelines/screen-demo/script-director.md +++ b/skills/pipelines/screen-demo/script-director.md @@ -108,6 +108,17 @@ Recommended `script.metadata` fields: | **Technical accuracy** | Are all software names, commands, and UI elements named correctly? | | **Word economy** | Is narration concise and procedural? | +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. + ## Common Pitfalls - Narrating the cursor instead of the outcome. diff --git a/skills/pipelines/talking-head/asset-director.md b/skills/pipelines/talking-head/asset-director.md index 2c9fd69..0ae8323 100644 --- a/skills/pipelines/talking-head/asset-director.md +++ b/skills/pipelines/talking-head/asset-director.md @@ -16,6 +16,16 @@ You have a scene plan and script. Your job is to generate the supporting assets ## Process +### Step 0: Hero Scene Sample (Mandatory) + +Before batch asset generation: +1. Identify the hero scene (the visual peak of the video) +2. Generate ONE sample asset for that scene (subtitle style, overlay, or background) +3. Present it: "This is the visual direction for the most important scene. Does this match what you're imagining? I'll generate the rest in this style." +4. Wait for approval before proceeding to batch generation + +This prevents the most expensive mistake: generating 10+ assets in a direction the user doesn't like. + ### Step 1: Generate Subtitles Use the transcription data from the script stage to create: @@ -167,3 +177,32 @@ Document all generated assets with paths, types, and tool references: ### Step 7: Submit Validate the asset_manifest against the schema and persist via checkpoint. + +### Mid-Production Fact Verification + +If you encounter uncertainty during asset generation: +- Use `web_search` to verify visual accuracy of subjects (e.g. what does this building actually look like?) +- Use `web_search` to find reference images before generating illustrations +- Log verification in the decision log: `category="visual_accuracy_check"` + +Visual accuracy matters. If the script mentions a specific place, person, or object, +verify what it actually looks like before generating images. Don't rely on +the AI model's training data — it may be wrong or outdated. + +## When You Do Not Know How + +If you encounter a generation technique, provider behavior, or prompting pattern you are unsure about: + +1. **Search the web** for current best practices — models and APIs change frequently, and the agent's training data may be stale +2. **Check `.agents/skills/`** for existing Layer 3 knowledge (provider-specific prompting guides, API patterns) +3. **If neither helps**, write a project-scoped skill at `projects//skills/.md` documenting what you learned +4. **Reference source URLs** in the skill so the knowledge is traceable +5. **Log it** in the decision log: `category: "capability_extension"`, `subject: "learned technique: "` + +This is especially important for: +- **Video generation prompting** — models respond to specific vocabularies that change with each version +- **Image model parameters** — optimal settings for FLUX, DALL-E, Imagen differ and evolve +- **Audio provider quirks** — voice cloning, music generation, and TTS each have model-specific best practices +- **Remotion component patterns** — new composition techniques emerge as the framework evolves + +Do not rely on stale knowledge. When in doubt, search first. diff --git a/skills/pipelines/talking-head/script-director.md b/skills/pipelines/talking-head/script-director.md index 73a6bae..c98b4f5 100644 --- a/skills/pipelines/talking-head/script-director.md +++ b/skills/pipelines/talking-head/script-director.md @@ -54,3 +54,14 @@ Assemble the structured script with: ### Step 6: Submit Validate the script against the schema and persist via checkpoint. + +### Mid-Production Fact Verification + +If you encounter uncertainty during script writing: +- Use `web_search` to verify factual claims before committing them to the script +- Use `web_search` to find reference images for visual accuracy +- Log verification in the decision log: `category="visual_accuracy_check"` + +Every factual claim in the script should be traceable to the `research_brief`. +If you make a claim that isn't in the research, do additional research and +add the source. Do not invent statistics, dates, or attributions. diff --git a/tests/eval/bench_runner.py b/tests/eval/bench_runner.py new file mode 100644 index 0000000..542e28e --- /dev/null +++ b/tests/eval/bench_runner.py @@ -0,0 +1,488 @@ +"""Video production bench runner. + +Runs a matrix of synthetic scenarios through the lib-level quality validators +(slideshow_risk, variation_checker, delivery_promise) to verify that the +enforcement layer catches known-bad plans and passes known-good ones. + +Usage: + python -m tests.eval.bench_runner # run all + python -m tests.eval.bench_runner --tag cinematic # filter by tag + python -m tests.eval.bench_runner --verbose # show per-scenario details +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass, field +from typing import Any + + +# --------------------------------------------------------------------------- +# Scenario definitions — synthetic plans that exercise validators +# --------------------------------------------------------------------------- + +@dataclass +class BenchScenario: + name: str + tags: list[str] + scenes: list[dict[str, Any]] + edit_decisions: dict[str, Any] | None = None + renderer_family: str | None = None + delivery_promise: dict[str, Any] | None = None + cuts: list[dict[str, Any]] = field(default_factory=list) + + # Expected outcomes + expected_slideshow_verdict: str | None = None # strong/acceptable/revise/fail + expected_variation_verdict: str | None = None # strong/acceptable/revise/fail + expected_promise_valid: bool | None = None + + +@dataclass +class BenchResult: + scenario: str + passed: bool + checks: dict[str, dict[str, Any]] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Scenario factory helpers +# --------------------------------------------------------------------------- + +def _scene( + desc: str, + scene_type: str = "visual", + shot_size: str | None = None, + movement: str = "static", + lighting: str | None = None, + intent: str | None = None, + info_role: str | None = None, + narrative_role: str | None = None, + hero: bool = False, + texture: list[str] | None = None, +) -> dict[str, Any]: + s: dict[str, Any] = {"description": desc, "type": scene_type} + sl: dict[str, Any] = {} + if shot_size: + sl["shot_size"] = shot_size + sl["camera_movement"] = movement + if lighting: + sl["lighting_key"] = lighting + if sl: + s["shot_language"] = sl + if intent: + s["shot_intent"] = intent + if info_role: + s["information_role"] = info_role + if narrative_role: + s["narrative_role"] = narrative_role + if hero: + s["hero_moment"] = True + if texture: + s["texture_keywords"] = texture + return s + + +def _cut(source: str, cut_type: str = "", in_s: float = 0, out_s: float = 5) -> dict: + return {"source": source, "type": cut_type, "in_seconds": in_s, "out_seconds": out_s} + + +# --------------------------------------------------------------------------- +# Scenario bank +# --------------------------------------------------------------------------- + +def build_scenarios() -> list[BenchScenario]: + scenarios: list[BenchScenario] = [] + + # ---- GOOD: Cinematic trailer with full shot language ---- + scenarios.append(BenchScenario( + name="cinematic_full_shot_language", + tags=["cinematic", "good", "slideshow_risk"], + renderer_family="cinematic-trailer", + scenes=[ + _scene("Aerial drone sweep over misty mountain valley at dawn", + shot_size="extreme-wide", movement="drone-forward", lighting="golden-hour", + intent="Establish scale and wonder", narrative_role="hook", hero=True, + texture=["film grain", "warm amber"]), + _scene("Subject emerges from fog, silhouetted against sunrise", + shot_size="medium", movement="dolly-in", lighting="backlit", + intent="Introduce protagonist with mystery", narrative_role="setup"), + _scene("Close-up of weathered hands gripping climbing rope", + shot_size="extreme-close-up", movement="static", lighting="natural", + intent="Texture detail sells physicality", narrative_role="development"), + _scene("Wide shot of summit attempt, tiny figure against rock face", + shot_size="wide", movement="pan-right", lighting="overcast", + intent="Show scale of challenge", narrative_role="rising-action"), + _scene("Slow-motion summit reach, clouds breaking to reveal vista", + shot_size="medium-close-up", movement="crane-up", lighting="golden-hour", + intent="Emotional payoff of the journey", narrative_role="climax", hero=True, + texture=["lens flare", "warm tones"]), + _scene("Wide pull-back to reveal full mountain range at sunset", + shot_size="extreme-wide", movement="zoom-out", lighting="magic-hour", + intent="Resolution — contextualize the achievement", narrative_role="resolution"), + ], + expected_slideshow_verdict="strong", + expected_variation_verdict="strong", + )) + + # ---- GOOD: Data explainer with clear purpose per scene ---- + scenarios.append(BenchScenario( + name="data_explainer_purposeful", + tags=["explainer", "good", "slideshow_risk"], + renderer_family="explainer-data", + scenes=[ + _scene("Animated bar chart showing revenue growth 2020-2025", + scene_type="bar_chart", shot_size="medium", + intent="Anchor the narrative in hard numbers", info_role="key statistic"), + _scene("Split screen: before/after product redesign", + scene_type="comparison", shot_size="medium", + intent="Visual proof of transformation", info_role="comparison"), + _scene("Infographic showing 3-step process flow", + scene_type="callout", shot_size="wide", + intent="Simplify complex process into digestible steps", info_role="process explanation"), + _scene("Customer testimonial overlay on product usage footage", + shot_size="medium-close-up", movement="static", lighting="studio", + intent="Social proof to support data claims", narrative_role="evidence"), + _scene("KPI dashboard showing live metrics", + scene_type="kpi_grid", shot_size="wide", + intent="Real-time impact visualization", info_role="key statistic"), + ], + expected_slideshow_verdict="strong", + expected_variation_verdict="acceptable", + )) + + # ---- BAD: Slideshow — all same shot size, no intent ---- + scenarios.append(BenchScenario( + name="slideshow_all_medium_no_intent", + tags=["bad", "slideshow_risk"], + renderer_family="explainer-data", + scenes=[ + _scene("Stock photo of office building"), + _scene("Stock photo of people in meeting room"), + _scene("Stock photo of laptop on desk"), + _scene("Stock photo of handshake"), + _scene("Stock photo of skyline at night"), + _scene("Stock photo of coffee cup"), + _scene("Stock photo of whiteboard with notes"), + _scene("Stock photo of team celebrating"), + ], + expected_slideshow_verdict="acceptable", + expected_variation_verdict="revise", + )) + + # ---- BAD: Typography overreliance ---- + scenarios.append(BenchScenario( + name="text_card_overload", + tags=["bad", "slideshow_risk", "typography"], + renderer_family="explainer-teacher", + scenes=[ + _scene("Title card: Welcome to Our Company", scene_type="text_card"), + _scene("Bullet points: Our 5 core values", scene_type="text_card"), + _scene("Statistics: 500 employees, 30 countries", scene_type="stat_card"), + _scene("Quote: CEO inspirational message", scene_type="text_card"), + _scene("KPI grid: Q4 results", scene_type="kpi_grid"), + _scene("More bullet points: Our differentiators", scene_type="text_card"), + _scene("Final stat card: Year over year growth", scene_type="stat_card"), + ], + expected_slideshow_verdict="revise", + )) + + # ---- BAD: Cinematic claims without structure ---- + scenarios.append(BenchScenario( + name="fake_cinematic_no_structure", + tags=["bad", "cinematic", "slideshow_risk"], + renderer_family="cinematic-trailer", + scenes=[ + _scene("Beautiful landscape"), + _scene("Person walking"), + _scene("Close-up of face"), + _scene("Wide shot of city"), + _scene("Sunset scene"), + ], + expected_slideshow_verdict="fail", + )) + + # ---- DELIVERY PROMISE: Motion-led with all stills ---- + scenarios.append(BenchScenario( + name="motion_promise_all_stills", + tags=["bad", "delivery_promise"], + delivery_promise={ + "promise_type": "motion_led", + "motion_required": True, + "source_required": False, + "tone_mode": "cinematic", + "quality_floor": "broadcast", + }, + scenes=[], + cuts=[ + _cut("hero.png"), _cut("scene2.jpg"), _cut("scene3.png"), + _cut("scene4.jpg"), _cut("scene5.png"), + ], + expected_promise_valid=False, + )) + + # ---- DELIVERY PROMISE: Motion-led with enough video ---- + scenarios.append(BenchScenario( + name="motion_promise_sufficient_video", + tags=["good", "delivery_promise"], + delivery_promise={ + "promise_type": "motion_led", + "motion_required": True, + "source_required": False, + "tone_mode": "cinematic", + "quality_floor": "broadcast", + }, + scenes=[], + cuts=[ + _cut("intro.mp4", "video"), _cut("scene2.mp4", "video"), + _cut("scene3.mp4", "video"), _cut("overlay.png"), + _cut("scene5.mp4", "video"), + ], + expected_promise_valid=True, + )) + + # ---- DELIVERY PROMISE: Data explainer allows stills ---- + scenarios.append(BenchScenario( + name="data_explainer_all_stills_ok", + tags=["good", "delivery_promise"], + delivery_promise={ + "promise_type": "data_explainer", + "motion_required": False, + "source_required": False, + "tone_mode": "educational", + "quality_floor": "presentable", + }, + scenes=[], + cuts=[ + _cut("chart1.png"), _cut("chart2.png"), _cut("diagram.png"), + ], + expected_promise_valid=True, + )) + + # ---- VARIATION: Good diversity ---- + scenarios.append(BenchScenario( + name="variation_diverse_scenes", + tags=["good", "variation"], + scenes=[ + _scene("Wide establishing shot of laboratory", + shot_size="wide", movement="dolly-in", lighting="fluorescent", + intent="Set the environment", texture=["clinical", "cool tones"]), + _scene("Close-up of researcher examining sample", + shot_size="close-up", movement="static", lighting="practical", + intent="Show detail and expertise"), + _scene("Medium shot of team discussion around data display", + shot_size="medium", movement="pan-left", lighting="natural", + intent="Show collaboration"), + _scene("Extreme close-up of microscope slide revealing cell structure", + shot_size="extreme-close-up", movement="rack-focus", lighting="backlit", + intent="The discovery moment", hero=True), + _scene("Wide overhead shot of full lab in action", + shot_size="extreme-wide", movement="crane-down", lighting="mixed", + intent="Scale — this is a major operation"), + ], + expected_variation_verdict="strong", + )) + + # ---- VARIATION: Poor — all same size, generic descriptions ---- + scenarios.append(BenchScenario( + name="variation_monotonous", + tags=["bad", "variation"], + scenes=[ + _scene("A person using a computer", shot_size="medium"), + _scene("A person typing on keyboard", shot_size="medium"), + _scene("A person looking at screen", shot_size="medium"), + _scene("A person in modern office", shot_size="medium"), + _scene("A person working at desk", shot_size="medium"), + ], + expected_variation_verdict="fail", + )) + + # ---- EDGE: Empty scenes ---- + scenarios.append(BenchScenario( + name="edge_empty_scenes", + tags=["edge"], + scenes=[], + expected_slideshow_verdict="fail", + )) + + # ---- EDGE: Single scene ---- + scenarios.append(BenchScenario( + name="edge_single_scene", + tags=["edge"], + scenes=[ + _scene("Solo hero shot", shot_size="wide", movement="drone-forward", + intent="The only shot", hero=True), + ], + expected_slideshow_verdict="strong", + )) + + # ---- MIXED: Acceptable but not great ---- + scenarios.append(BenchScenario( + name="mixed_acceptable", + tags=["mixed", "slideshow_risk"], + renderer_family="explainer-teacher", + scenes=[ + _scene("Animated title with topic introduction", + scene_type="text_card", intent="Hook the viewer"), + _scene("Diagram showing system architecture", + shot_size="wide", intent="Explain the big picture", info_role="process explanation"), + _scene("Screen recording of feature demo", + shot_size="medium", movement="static", + intent="Show the product in action"), + _scene("Before/after comparison of results", + scene_type="comparison", intent="Prove the value"), + _scene("Summary stats and call to action", + scene_type="stat_card", intent="Drive conversion"), + ], + expected_slideshow_verdict="acceptable", + )) + + return scenarios + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +_VERDICT_ORDER = {"strong": 0, "acceptable": 1, "revise": 2, "fail": 3} + + +def _verdict_matches(expected: str, actual: str) -> bool: + """Check if actual verdict is within +-1 tier of expected.""" + e = _VERDICT_ORDER.get(expected, -1) + a = _VERDICT_ORDER.get(actual, -1) + return abs(e - a) <= 1 + + +def run_bench(scenarios: list[BenchScenario], verbose: bool = False) -> list[BenchResult]: + results: list[BenchResult] = [] + + for sc in scenarios: + result = BenchResult(scenario=sc.name, passed=True) + + # --- Slideshow risk --- + if sc.expected_slideshow_verdict is not None: + try: + from lib.slideshow_risk import score_slideshow_risk + risk = score_slideshow_risk(sc.scenes, sc.edit_decisions, sc.renderer_family) + actual = risk["verdict"] + ok = _verdict_matches(sc.expected_slideshow_verdict, actual) + result.checks["slideshow_risk"] = { + "expected": sc.expected_slideshow_verdict, + "actual": actual, + "score": risk["average"], + "passed": ok, + } + if not ok: + result.passed = False + result.errors.append( + f"slideshow_risk: expected ~{sc.expected_slideshow_verdict}, got {actual} ({risk['average']:.1f})" + ) + except Exception as e: + result.passed = False + result.errors.append(f"slideshow_risk error: {e}") + + # --- Variation checker --- + if sc.expected_variation_verdict is not None and sc.scenes: + try: + from lib.variation_checker import check_scene_variation + var = check_scene_variation(sc.scenes) + actual = var["verdict"] + ok = _verdict_matches(sc.expected_variation_verdict, actual) + result.checks["variation"] = { + "expected": sc.expected_variation_verdict, + "actual": actual, + "score": var["score"], + "passed": ok, + } + if not ok: + result.passed = False + result.errors.append( + f"variation: expected ~{sc.expected_variation_verdict}, got {actual} ({var['score']:.1f})" + ) + except Exception as e: + result.passed = False + result.errors.append(f"variation error: {e}") + + # --- Delivery promise --- + if sc.expected_promise_valid is not None and sc.delivery_promise: + try: + from lib.delivery_promise import DeliveryPromise + promise = DeliveryPromise.from_dict(sc.delivery_promise) + validation = promise.validate_cuts(sc.cuts) + ok = validation["valid"] == sc.expected_promise_valid + result.checks["delivery_promise"] = { + "expected_valid": sc.expected_promise_valid, + "actual_valid": validation["valid"], + "motion_ratio": validation["motion_ratio"], + "passed": ok, + } + if not ok: + result.passed = False + result.errors.append( + f"delivery_promise: expected valid={sc.expected_promise_valid}, " + f"got valid={validation['valid']} (motion_ratio={validation['motion_ratio']:.0%})" + ) + except Exception as e: + result.passed = False + result.errors.append(f"delivery_promise error: {e}") + + results.append(result) + + return results + + +def print_results(results: list[BenchResult], verbose: bool = False) -> None: + passed = sum(1 for r in results if r.passed) + total = len(results) + + print(f"\n{'='*60}") + print(f" Video Production Bench: {passed}/{total} scenarios passed") + print(f"{'='*60}\n") + + for r in results: + status = "PASS" if r.passed else "FAIL" + print(f" [{status}] {r.scenario}") + if verbose or not r.passed: + for check_name, check in r.checks.items(): + check_status = "ok" if check.get("passed") else "FAIL" + print(f" {check_name}: {check_status}") + if verbose: + for k, v in check.items(): + if k != "passed": + print(f" {k}: {v}") + for err in r.errors: + print(f" ERROR: {err}") + + print(f"\n Total: {total} | Passed: {passed} | Failed: {total - passed}") + if passed == total: + print(" All scenarios passed.\n") + else: + print(f" {total - passed} scenario(s) need attention.\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Video production bench runner") + parser.add_argument("--tag", help="Filter scenarios by tag") + parser.add_argument("--verbose", "-v", action="store_true", help="Show per-scenario details") + args = parser.parse_args() + + scenarios = build_scenarios() + if args.tag: + scenarios = [s for s in scenarios if args.tag in s.tags] + + if not scenarios: + print(f"No scenarios found{' for tag ' + args.tag if args.tag else ''}") + sys.exit(1) + + results = run_bench(scenarios, verbose=args.verbose) + print_results(results, verbose=args.verbose) + + # Exit with failure code if any scenario failed + if not all(r.passed for r in results): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/qa/test_08_end_to_end.py b/tests/qa/test_08_end_to_end.py index 9ba712d..b2492bf 100644 --- a/tests/qa/test_08_end_to_end.py +++ b/tests/qa/test_08_end_to_end.py @@ -96,47 +96,150 @@ a11y = validate_accessibility(playbook) print(f" Playbook a11y: pass={a11y['pass']}, errors={a11y['error_count']}, warnings={a11y['warning_count']}") # =================================================================== -# Stage 1: idea -> brief +# Stage 0: research -> research_brief (minimal, to satisfy pipeline order) # =================================================================== -print("\n--- Stage 1: idea ---") -brief = { +print("\n--- Stage 0: research ---") +research_brief = { "version": "1.0", - "title": "AI Video Production in 60 Seconds", - "hook": "What if you could create a professional video in 60 seconds with just a text prompt?", - "key_points": [ - "Traditional video production takes days or weeks", - "AI can automate scripting, visuals, narration, and editing", - "OpenMontage orchestrates the full pipeline", + "topic": "AI Video Production", + "research_date": "2026-04-02", + "landscape": { + "existing_content": [ + {"title": "AI Video Tools Explained", "source": "youtube", "angle": "tutorial", "what_it_covers": "Tool comparison", "what_it_misses": "End-to-end pipeline"}, + {"title": "Making Videos with AI", "source": "blog", "angle": "overview", "what_it_covers": "Market landscape", "what_it_misses": "Hands-on workflow"}, + {"title": "AI Content Creation Guide", "source": "youtube", "angle": "how-to", "what_it_covers": "Individual tools", "what_it_misses": "Orchestration concept"}, + ], + "saturated_angles": ["basic tool demos", "AI will replace editors"], + "underserved_gaps": ["End-to-end orchestration pipeline", "Cost vs quality tradeoffs"], + }, + "data_points": [ + {"claim": "AI video market growing 25% YoY", "source_url": "https://example.com/report", "credibility": "primary_source"}, + {"claim": "70% of creators want automated editing", "source_url": "https://example.com/survey", "credibility": "primary_source"}, + {"claim": "Average production time drops 80% with AI", "source_url": "https://example.com/study", "credibility": "secondary_source"}, ], - "tone": "confident, energetic", - "style": "clean-professional", - "target_platform": "youtube", - "target_duration_seconds": 60, - "target_audience": "content creators and developers", - "cta": "Try OpenMontage today", - "angle_options": [ - {"name": "democratization", "description": "AI makes video creation accessible to everyone"}, - {"name": "workflow", "description": "AI automates the tedious parts of video production"}, - {"name": "quality", "description": "AI-generated content is reaching professional quality"}, + "audience_insights": { + "common_questions": [ + "Can AI really make professional-looking videos?", + "How much does AI video production cost?", + "What tools do I need to get started?", + ], + "misconceptions": [ + {"myth": "AI videos always look robotic", "reality": "Modern AI produces broadcast-quality output"}, + ], + "knowledge_level": "Familiar with basic editing but not AI-specific tools", + }, + "angles_discovered": [ + {"name": "60-Second Studio", "hook": "Your entire production team in a single prompt", "type": "trending", "why_now": "AI orchestration tools just reached production quality"}, + {"name": "Quality Democratization", "hook": "Pro-grade video without pro skills", "type": "evergreen", "why_now": "Creator economy growing, skill gap remains"}, + {"name": "The Hidden Cost Myth", "hook": "AI video costs pennies, not thousands", "type": "contrarian", "why_now": "Most creators still think AI video is expensive"}, + ], + "sources": [ + {"url": "https://example.com/ai-video", "title": "AI Video Production Overview", "used_for": "landscape"}, + {"url": "https://example.com/ai-tools", "title": "Top AI Video Tools 2026", "used_for": "data_points"}, + {"url": "https://example.com/workflow", "title": "Automated Video Workflows", "used_for": "angles"}, + {"url": "https://example.com/creators", "title": "Creator Economy Report", "used_for": "audience_insights"}, + {"url": "https://example.com/market", "title": "AI Video Market Analysis", "used_for": "landscape"}, ], - "selected_angle": "workflow", } - try: - validate_artifact("brief", brief) - check("Brief validates against schema", True) + validate_artifact("research_brief", research_brief) + check("Research brief validates against schema", True) except Exception as e: - check("Brief validates against schema", False, str(e)) + check("Research brief validates against schema", False, str(e)) cp_path = write_checkpoint( - PIPELINE_DIR, PROJECT_ID, "idea", "completed", - artifacts={"brief": brief}, + PIPELINE_DIR, PROJECT_ID, "research", "completed", + artifacts={"research_brief": research_brief}, pipeline_type="animated-explainer", style_playbook="clean-professional", ) -check("Idea checkpoint written", cp_path.exists()) -# Next uncompleted stage in global STAGES order (research/proposal come before idea) -check("Next stage after idea", get_next_stage(PIPELINE_DIR, PROJECT_ID) == "research") +check("Research checkpoint written", cp_path.exists()) + +# =================================================================== +# Stage 1: proposal -> proposal_packet +# =================================================================== +print("\n--- Stage 1: proposal ---") +proposal_packet = { + "version": "1.0", + "concept_options": [ + { + "id": "c1", + "title": "AI Video Production in 60 Seconds", + "hook": "What if you could create a professional video in 60 seconds?", + "narrative_structure": "problem_solution", + "visual_approach": "Clean motion graphics with side-by-side comparisons", + "suggested_playbook": "clean-professional", + "target_audience": "content creators and developers", + "target_platform": "youtube", + "target_duration_seconds": 60, + "why_this_works": "Direct problem/solution framing with clear visual proof points", + }, + { + "id": "c2", + "title": "The 60-Second Studio", + "hook": "Your entire production team, in a single prompt.", + "narrative_structure": "journey", + "visual_approach": "Animated workflow diagram that builds step by step", + "suggested_playbook": "flat-motion-graphics", + "target_audience": "content creators and developers", + "target_platform": "youtube", + "target_duration_seconds": 60, + "why_this_works": "Journey structure creates natural pacing for a process walkthrough", + }, + { + "id": "c3", + "title": "From Prompt to Premiere", + "hook": "Traditional video production takes weeks. This takes seconds.", + "narrative_structure": "comparison", + "visual_approach": "Split-screen timeline comparison: old way vs AI way", + "suggested_playbook": "clean-professional", + "target_audience": "content creators and developers", + "target_platform": "youtube", + "target_duration_seconds": 60, + "why_this_works": "Comparison structure makes the value proposition immediately tangible", + }, + ], + "selected_concept": {"concept_id": "c1", "rationale": "Direct problem/solution framing is most effective for this audience"}, + "production_plan": { + "pipeline": "animated-explainer", + "playbook": "clean-professional", + "stages": [ + {"stage": "script", "tools": [{"tool_name": "tts_selector", "role": "narration", "available": True}], "approach": "AI-written script with TTS narration"}, + {"stage": "scene_plan", "tools": [], "approach": "5 scenes with motion graphics"}, + {"stage": "assets", "tools": [{"tool_name": "image_selector", "role": "visuals", "available": True}], "approach": "AI-generated images"}, + {"stage": "edit", "tools": [], "approach": "Automated edit decisions"}, + {"stage": "compose", "tools": [{"tool_name": "video_compose", "role": "render", "available": True}], "approach": "Remotion render"}, + ], + }, + "cost_estimate": { + "total_estimated_usd": 0.50, + "line_items": [ + {"tool": "tts_selector", "operation": "narration", "estimated_usd": 0.10}, + {"tool": "image_selector", "operation": "5 images", "estimated_usd": 0.30}, + {"tool": "music_gen", "operation": "background track", "estimated_usd": 0.10}, + ], + "budget_verdict": "within_budget", + }, + "approval": { + "status": "approved", + "approved_budget_usd": 2.00, + }, +} + +try: + validate_artifact("proposal_packet", proposal_packet) + check("Proposal packet validates against schema", True) +except Exception as e: + check("Proposal packet validates against schema", False, str(e)) + +cp_path = write_checkpoint( + PIPELINE_DIR, PROJECT_ID, "proposal", "completed", + artifacts={"proposal_packet": proposal_packet}, + pipeline_type="animated-explainer", + style_playbook="clean-professional", +) +check("Proposal checkpoint written", cp_path.exists()) +check("Next stage after proposal", get_next_stage(PIPELINE_DIR, PROJECT_ID, "animated-explainer") == "script") # =================================================================== # Stage 2: script @@ -183,7 +286,7 @@ write_checkpoint( artifacts={"script": script}, pipeline_type="animated-explainer", ) -check("Completed stages", get_completed_stages(PIPELINE_DIR, PROJECT_ID) == ["idea", "script"]) # idea and script appear in STAGES order +check("Completed stages", get_completed_stages(PIPELINE_DIR, PROJECT_ID) == ["research", "proposal", "script"]) # research, proposal and script in pipeline order # =================================================================== # Stage 3: scene_plan @@ -520,13 +623,14 @@ write_checkpoint( # =================================================================== print("\n--- Final validation ---") +E2E_STAGES = ["research", "proposal", "script", "scene_plan", "assets", "edit", "compose", "publish"] completed = get_completed_stages(PIPELINE_DIR, PROJECT_ID) -check("All 7 stages completed", len(completed) == 7, f"completed={completed}") -check("Next stage is None (done)", get_next_stage(PIPELINE_DIR, PROJECT_ID) is None) -check("Stages in correct order", completed == STAGES, f"{completed}") +check("All 8 stages completed", len(completed) == 8, f"completed={completed}") +check("Next stage is None (done)", get_next_stage(PIPELINE_DIR, PROJECT_ID, "animated-explainer") is None) +check("Stages in correct order", completed == E2E_STAGES, f"{completed}") # Verify all checkpoints are readable -for stage in STAGES: +for stage in E2E_STAGES: cp = read_checkpoint(PIPELINE_DIR, PROJECT_ID, stage) check(f"Checkpoint {stage} readable", cp is not None) if cp: diff --git a/tools/audio/audio_mixer.py b/tools/audio/audio_mixer.py index 39be5a2..412433d 100644 --- a/tools/audio/audio_mixer.py +++ b/tools/audio/audio_mixer.py @@ -646,7 +646,7 @@ class AudioMixer(BaseTool): "-of", "csv=p=0", video_path, ] - total_dur = float(self.run_command(dur_cmd, capture=True).strip().split("\n")[0]) + total_dur = float(self.run_command(dur_cmd).stdout.strip().split("\n")[0]) # Build volume expression for each segment with smooth fades parts = [] diff --git a/tools/audio/music_gen.py b/tools/audio/music_gen.py index c155062..7b0e3e6 100644 --- a/tools/audio/music_gen.py +++ b/tools/audio/music_gen.py @@ -60,10 +60,13 @@ class MusicGen(BaseTool): }, "duration_seconds": { "type": "number", - "default": 60, "minimum": 3, "maximum": 600, - "description": "Target duration in seconds (API supports 3-600s)", + "description": ( + "Target duration in seconds (API supports 3-600s). " + "Should match the target video duration from the script/proposal. " + "Omitting this defaults to 60s which may not match your video." + ), }, "output_path": {"type": "string"}, }, @@ -86,7 +89,13 @@ class MusicGen(BaseTool): def estimate_cost(self, inputs: dict[str, Any]) -> float: # ElevenLabs music generation pricing is per generation - duration = inputs.get("duration_seconds", 60) + duration = inputs.get("duration_seconds") + if duration is None: + raise ValueError( + "music_gen.estimate_cost: duration_seconds is required. " + "Derive it from the approved target runtime in the script/proposal. " + "Silent defaults are not permitted." + ) # Approximate: ~$0.05 per 30 seconds return round(duration / 30 * 0.05, 4) @@ -110,10 +119,23 @@ class MusicGen(BaseTool): return result def _generate(self, inputs: dict[str, Any], api_key: str) -> ToolResult: + import logging import requests + logger = logging.getLogger(__name__) + prompt = inputs["prompt"] - duration = inputs.get("duration_seconds", 60) + duration = inputs.get("duration_seconds") + if duration is None: + return ToolResult( + success=False, + error=( + "music_gen: duration_seconds is required. " + "Derive it from the approved target runtime in the script/proposal. " + "Silent defaults to 60s are not permitted — the generated music " + "must match the actual video duration." + ), + ) url = "https://api.elevenlabs.io/v1/music" diff --git a/tools/audio/tts_selector.py b/tools/audio/tts_selector.py index 92df867..16af171 100644 --- a/tools/audio/tts_selector.py +++ b/tools/audio/tts_selector.py @@ -74,6 +74,12 @@ class TTSSelector(BaseTool): "type": "array", "items": {"type": "string"}, }, + "operation": { + "type": "string", + "enum": ["generate", "rank"], + "default": "generate", + "description": "Operation mode. 'rank' returns scored provider rankings without generating.", + }, "output_path": {"type": "string"}, }, } @@ -105,32 +111,74 @@ class TTSSelector(BaseTool): return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: - tool = self._select_tool(inputs) + candidates = self._providers() + if not candidates: + return 0.0 + tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {})) return tool.estimate_cost(inputs) if tool else 0.0 def execute(self, inputs: dict[str, Any]) -> ToolResult: - tool = self._select_tool(inputs) + from lib.scoring import rank_providers + + task_context = inputs.get("task_context", {}) + candidates = self._providers() + + # Rank mode — return scored provider rankings without generating + if inputs.get("operation") == "rank": + rankings = rank_providers(candidates, task_context) + return ToolResult( + success=True, + data={ + "rankings": [r.to_dict() for r in rankings], + "explanation": "\n".join(r.explain() for r in rankings[:5]), + }, + ) + + # Normal generation — use scored selection + tool, score = self._select_best_tool(inputs, candidates, task_context) if tool is None: return ToolResult(success=False, error="No TTS provider available.") + result = tool.execute(inputs) if result.success: result.data.setdefault("selected_tool", tool.name) + result.data["selection_reason"] = score.explain() if score else f"Selected {tool.provider} ({tool.name})" + if score: + result.data["provider_score"] = score.to_dict() + result.data["alternatives_considered"] = [ + t.name for t in candidates + if t.name != tool.name and t.get_status().value == "available" + ] return result - def _select_tool(self, inputs: dict[str, Any]) -> BaseTool | None: + def _select_best_tool( + self, + inputs: dict[str, Any], + candidates: list[BaseTool], + task_context: dict[str, Any], + ) -> tuple[BaseTool | None, object]: + """Select the best TTS provider using scored ranking.""" + from lib.scoring import rank_providers + preferred = inputs.get("preferred_provider", "auto") allowed = set(inputs.get("allowed_providers") or []) - candidates = self._providers() if allowed: candidates = [tool for tool in candidates if tool.provider in allowed] - if preferred != "auto": - ordered = [tool for tool in candidates if tool.provider == preferred] - ordered.extend([tool for tool in candidates if tool.provider != preferred]) - else: - ordered = candidates + rankings = rank_providers(candidates, task_context) - for tool in ordered: - if tool.get_status() == ToolStatus.AVAILABLE: - return tool - return None + tool_by_provider: dict[str, BaseTool] = {} + for tool in candidates: + if tool.provider not in tool_by_provider and tool.get_status() == ToolStatus.AVAILABLE: + tool_by_provider[tool.provider] = tool + + if preferred != "auto": + for score_item in rankings: + if score_item.provider == preferred and score_item.provider in tool_by_provider: + return tool_by_provider[score_item.provider], score_item + + for score_item in rankings: + if score_item.provider in tool_by_provider: + return tool_by_provider[score_item.provider], score_item + + return None, None diff --git a/tools/base_tool.py b/tools/base_tool.py index 88f7ddd..024a570 100644 --- a/tools/base_tool.py +++ b/tools/base_tool.py @@ -10,6 +10,7 @@ import hashlib import inspect import json import os +import platform import subprocess import shutil from abc import ABC, abstractmethod @@ -37,6 +38,12 @@ def _load_dotenv() -> None: key, _, value = line.partition("=") key = key.strip() value = value.strip().strip("'\"") + # Strip inline comments: VAR=value # comment + # But only if the # is preceded by whitespace (avoid stripping from values like colors) + if " #" in value: + value = value[:value.index(" #")].rstrip() + elif "\t#" in value: + value = value[:value.index("\t#")].rstrip() if key and key not in os.environ: os.environ[key] = value @@ -294,9 +301,18 @@ class BaseTool(ABC): timeout: Optional[int] = None, cwd: Optional[Path] = None, ) -> subprocess.CompletedProcess: - """Run a subprocess command with standard error handling.""" + """Run a subprocess command with standard error handling. + + On Windows, resolves .cmd/.bat wrappers (e.g. npx, npm) via + shutil.which() so subprocess.run() can find them without shell=True. + """ + resolved_cmd = list(cmd) + if platform.system() == "Windows" and resolved_cmd: + exe = shutil.which(resolved_cmd[0]) + if exe: + resolved_cmd[0] = exe return subprocess.run( - cmd, + resolved_cmd, capture_output=True, text=True, timeout=timeout, diff --git a/tools/graphics/google_imagen.py b/tools/graphics/google_imagen.py index eb90742..b8957db 100644 --- a/tools/graphics/google_imagen.py +++ b/tools/graphics/google_imagen.py @@ -158,11 +158,19 @@ class GoogleImagen(BaseTool): model = inputs.get("model", "imagen-4.0-generate-001") prompt = inputs["prompt"] + import logging + logger = logging.getLogger(__name__) + # Resolve aspect ratio: explicit > derived from width/height > default if "aspect_ratio" in inputs: aspect_ratio = inputs["aspect_ratio"] elif "width" in inputs and "height" in inputs: + requested_ratio = f"{inputs['width']}x{inputs['height']}" aspect_ratio = _dims_to_aspect_ratio(inputs["width"], inputs["height"]) + logger.info( + "google_imagen: remapped %s to nearest supported aspect ratio %s", + requested_ratio, aspect_ratio, + ) else: aspect_ratio = "1:1" diff --git a/tools/graphics/image_selector.py b/tools/graphics/image_selector.py index 0ea7650..80b164d 100644 --- a/tools/graphics/image_selector.py +++ b/tools/graphics/image_selector.py @@ -61,6 +61,12 @@ class ImageSelector(BaseTool): "type": "array", "items": {"type": "string"}, }, + "operation": { + "type": "string", + "enum": ["generate", "rank"], + "default": "generate", + "description": "Operation mode. 'rank' returns scored provider rankings without generating.", + }, "output_path": {"type": "string"}, }, } @@ -92,11 +98,33 @@ class ImageSelector(BaseTool): return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: - tool = self._select_tool(inputs) + candidates = self._providers() + if not candidates: + return 0.0 + tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {})) return tool.estimate_cost(inputs) if tool else 0.0 def execute(self, inputs: dict[str, Any]) -> ToolResult: - tool = self._select_tool(inputs) + import logging + from lib.scoring import rank_providers + + logger = logging.getLogger(__name__) + task_context = inputs.get("task_context", {}) + candidates = self._providers() + + # Rank mode — return scored provider rankings without generating + if inputs.get("operation") == "rank": + rankings = rank_providers(candidates, task_context) + return ToolResult( + success=True, + data={ + "rankings": [r.to_dict() for r in rankings], + "explanation": "\n".join(r.explain() for r in rankings[:5]), + }, + ) + + # Normal generation — use scored selection + tool, score = self._select_best_tool(inputs, candidates, task_context) if tool is None: return ToolResult(success=False, error="No image provider available.") @@ -111,32 +139,59 @@ class ImageSelector(BaseTool): adapted.pop("preferred_provider", None) adapted.pop("allowed_providers", None) - # Pass through generation params only to tools that accept them + # Pass through generation params only to tools that accept them. if hasattr(tool, 'input_schema'): props = tool.input_schema.get("properties", {}) + stripped = [] for passthrough_key in ("negative_prompt", "width", "height", "seed"): if passthrough_key in adapted and passthrough_key not in props: - adapted.pop(passthrough_key) + stripped.append(f"{passthrough_key}={adapted.pop(passthrough_key)}") + if stripped: + logger.warning( + "image_selector: stripped unsupported params for %s: %s", + tool.name, ", ".join(stripped), + ) result = tool.execute(adapted) if result.success: result.data.setdefault("selected_tool", tool.name) + result.data["selection_reason"] = score.explain() if score else f"Selected {tool.provider} ({tool.name})" + if score: + result.data["provider_score"] = score.to_dict() + result.data["alternatives_considered"] = [ + t.name for t in candidates + if t.name != tool.name and t.get_status().value == "available" + ] return result - def _select_tool(self, inputs: dict[str, Any]) -> BaseTool | None: + def _select_best_tool( + self, + inputs: dict[str, Any], + candidates: list[BaseTool], + task_context: dict[str, Any], + ) -> tuple[BaseTool | None, object]: + """Select the best provider using scored ranking.""" + from lib.scoring import rank_providers + preferred = inputs.get("preferred_provider", "auto") allowed = set(inputs.get("allowed_providers") or []) - candidates = self._providers() if allowed: candidates = [tool for tool in candidates if tool.provider in allowed] - if preferred != "auto": - ordered = [tool for tool in candidates if tool.provider == preferred] - ordered.extend([tool for tool in candidates if tool.provider != preferred]) - else: - ordered = candidates + rankings = rank_providers(candidates, task_context) - for tool in ordered: - if tool.get_status() == ToolStatus.AVAILABLE: - return tool - return None + tool_by_provider: dict[str, BaseTool] = {} + for tool in candidates: + if tool.provider not in tool_by_provider and tool.get_status() == ToolStatus.AVAILABLE: + tool_by_provider[tool.provider] = tool + + if preferred != "auto": + for score_item in rankings: + if score_item.provider == preferred and score_item.provider in tool_by_provider: + return tool_by_provider[score_item.provider], score_item + + for score_item in rankings: + if score_item.provider in tool_by_provider: + return tool_by_provider[score_item.provider], score_item + + return None, None diff --git a/tools/video/showcase_card.py b/tools/video/showcase_card.py index ec0f3f4..1e56355 100644 --- a/tools/video/showcase_card.py +++ b/tools/video/showcase_card.py @@ -139,7 +139,7 @@ class ShowcaseCard(BaseTool): "-of", "csv=p=0", input_path, ] - probe_out = self.run_command(probe_cmd, capture=True).strip() + probe_out = self.run_command(probe_cmd).stdout.strip() src_w, src_h = [int(x.strip()) for x in probe_out.split(",")[:2]] # Calculate letterbox dimensions — fit source into output width, diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index c744149..cc56001 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -13,6 +13,8 @@ For pure video cuts (talking-head, etc.), FFmpeg handles trimming and concat. from __future__ import annotations import json +import logging +import subprocess import time from pathlib import Path from typing import Any, Optional @@ -279,21 +281,26 @@ class VideoCompose(BaseTool): if not cuts: return ToolResult(success=False, error="No cuts in edit_decisions") - # Extract subtitle style from edit_decisions if not provided directly - if not inputs.get("subtitle_style"): - ed_subs = edit_decisions.get("subtitles", {}) - if ed_subs: - inputs = dict(inputs) - inputs["subtitle_style"] = { - k: v for k, v in ed_subs.items() - if k in ("font", "font_size", "color", "outline_color", "background") - } - if ed_subs.get("source") and not subtitle_path: - subtitle_path = ed_subs["source"] + # Resolve subtitle style using the layered priority resolver + # (explicit > edit_decisions > playbook > defaults) + playbook_data = inputs.get("playbook") + resolved_sub_style = self._resolve_subtitle_style( + inputs.get("subtitle_style"), + edit_decisions, + playbook_data, + ) + inputs = dict(inputs) + inputs["subtitle_style"] = resolved_sub_style + + ed_subs = edit_decisions.get("subtitles", {}) + if ed_subs.get("source") and not subtitle_path: + subtitle_path = ed_subs["source"] temp_dir = output_path.parent / ".compose_tmp" temp_dir.mkdir(parents=True, exist_ok=True) temp_segments: list[Path] = [] + concat_path: Path | None = None + concat_out: Path | None = None try: for i, cut in enumerate(cuts): @@ -412,7 +419,7 @@ class VideoCompose(BaseTool): if f.exists(): f.unlink() for f in [concat_path, concat_out]: - if f.exists(): + if f is not None and f.exists(): f.unlink() if temp_dir.exists(): try: @@ -424,6 +431,144 @@ class VideoCompose(BaseTool): "text_card", "stat_card", "callout", "comparison", "progress", "chart", } + # Maps renderer_family (set at proposal stage) to Remotion composition ID. + # Each family MUST map to a distinct composition — collapsing defeats visual grammar. + # Maps renderer_family → Remotion composition ID. + # Only compositions registered in remotion-composer/src/Root.tsx are valid. + # Current compositions: Explainer, CinematicRenderer, TalkingHead + RENDERER_FAMILY_MAP = { + "explainer-data": "Explainer", + "explainer-teacher": "Explainer", + "cinematic-trailer": "CinematicRenderer", + "product-reveal": "Explainer", + "screen-demo": "Explainer", + "presenter": "TalkingHead", + "animation-first": "Explainer", + } + + @classmethod + def _get_composition_id(cls, renderer_family: str) -> str: + """Resolve renderer_family to Remotion composition ID. + + Raises ValueError if renderer_family is not recognized — the caller + must set it at proposal stage. + """ + comp = cls.RENDERER_FAMILY_MAP.get(renderer_family) + if comp is None: + raise ValueError( + f"Unknown renderer_family {renderer_family!r}. " + f"Valid families: {sorted(cls.RENDERER_FAMILY_MAP)}. " + f"Set renderer_family at proposal stage." + ) + return comp + + @staticmethod + def _build_theme_from_playbook( + playbook_name: str | None, + composition_data: dict | None, + ) -> dict[str, Any] | None: + """Derive a Remotion ThemeConfig from a playbook's actual color values. + + Instead of passing a playbook name and hoping Remotion has a matching + preset, we read the playbook YAML and extract concrete colors/fonts. + This means custom playbooks, overridden palettes, and per-project + styles all flow through to Remotion automatically. + + Falls back to extracting colors from edit_decisions metadata if + no playbook is loadable. + """ + theme: dict[str, Any] = {} + + # Try to load the playbook YAML + playbook: dict[str, Any] = {} + if playbook_name: + try: + from styles.playbook_loader import load_playbook + playbook = load_playbook(playbook_name) + except Exception: + pass + + if playbook: + vl = playbook.get("visual_language", {}) + palette = vl.get("color_palette", {}) + typo = playbook.get("typography", {}) + + # Extract primary/accent — may be a list (gradient stops) or string + primary_raw = palette.get("primary", ["#2563EB"]) + accent_raw = palette.get("accent", ["#F59E0B"]) + primary = primary_raw[0] if isinstance(primary_raw, list) else primary_raw + accent = accent_raw[0] if isinstance(accent_raw, list) else accent_raw + + bg = palette.get("background", "#FFFFFF") + text = palette.get("text", "#1F2937") + surface = palette.get("surface", bg) + muted = palette.get("muted_text", "#6B7280") + + # Build chart colors from all palette entries + chart_colors = [] + for key in ["primary", "accent", "secondary", "success", "warning", "info"]: + val = palette.get(key) + if val: + chart_colors.append(val[0] if isinstance(val, list) else val) + if len(chart_colors) < 3: + chart_colors = [primary, accent, "#10B981", "#8B5CF6", "#EC4899", "#06B6D4"] + + theme = { + "primaryColor": primary, + "accentColor": accent, + "backgroundColor": bg, + "surfaceColor": surface, + "textColor": text, + "mutedTextColor": muted, + "headingFont": typo.get("heading", {}).get("font", "Inter"), + "bodyFont": typo.get("body", {}).get("font", "Inter"), + "monoFont": typo.get("code", {}).get("font", "JetBrains Mono"), + "chartColors": chart_colors[:6], + "springConfig": {"damping": 20, "stiffness": 120, "mass": 1}, + "transitionDuration": 0.4, + } + + # Derive caption colors from the palette + theme["captionHighlightColor"] = primary + # Caption background: semi-transparent version of the bg color + theme["captionBackgroundColor"] = ( + f"rgba(255, 255, 255, 0.85)" if bg.upper() in ("#FFFFFF", "#FAFAFA", "#F9FAFB") + else f"rgba(15, 23, 42, 0.75)" + ) + + # Motion style from playbook + motion = playbook.get("motion", {}) + pace = motion.get("pace", "moderate") + if pace == "fast": + theme["springConfig"] = {"damping": 12, "stiffness": 80, "mass": 1} + theme["transitionDuration"] = 0.3 + elif pace == "slow": + theme["springConfig"] = {"damping": 25, "stiffness": 150, "mass": 1} + theme["transitionDuration"] = 0.6 + + # Fallback: try to extract from edit_decisions metadata + if not theme and composition_data: + meta = composition_data.get("metadata", {}) + if meta.get("primary_color"): + theme = { + "primaryColor": meta["primary_color"], + "accentColor": meta.get("accent_color", "#F59E0B"), + "backgroundColor": meta.get("background_color", "#FFFFFF"), + "surfaceColor": meta.get("surface_color", "#F9FAFB"), + "textColor": meta.get("text_color", "#1F2937"), + "mutedTextColor": "#6B7280", + "headingFont": meta.get("heading_font", "Inter"), + "bodyFont": meta.get("body_font", "Inter"), + "monoFont": "JetBrains Mono", + "chartColors": meta.get("chart_colors", ["#2563EB", "#F59E0B", "#10B981"]), + "springConfig": {"damping": 20, "stiffness": 120, "mass": 1}, + "transitionDuration": 0.4, + "captionHighlightColor": meta["primary_color"], + "captionBackgroundColor": "rgba(255, 255, 255, 0.85)", + } + + return theme if theme else None + def _needs_remotion(self, cuts: list[dict]) -> bool: """Determine if the composition requires Remotion. @@ -444,6 +589,105 @@ class VideoCompose(BaseTool): return True return False + def _pre_compose_validation( + self, + edit_decisions: dict[str, Any], + resolved_cuts: list[dict], + scene_plan: list[dict] | None = None, + ) -> ToolResult | None: + """Pre-compose quality gate — blocks render on critical violations. + + Checks: + 1. Delivery promise violation: motion-required brief with >70% still cuts → BLOCK + 2. Slideshow risk score "fail" (average ≥ 4.0) → BLOCK + 3. Missing renderer_family → WARN (log only, don't block) + + Returns a failed ToolResult if render should be blocked, None if OK to proceed. + """ + log = logging.getLogger("video_compose") + warnings: list[str] = [] + blocks: list[str] = [] + + # --- 1. Delivery promise check --- + delivery_data = edit_decisions.get("metadata", {}).get("delivery_promise") + if not delivery_data: + # Also check top-level (proposal_packet nests it at top level) + delivery_data = edit_decisions.get("delivery_promise") + + if delivery_data: + try: + from lib.delivery_promise import DeliveryPromise + promise = DeliveryPromise.from_dict(delivery_data) + result = promise.validate_cuts(resolved_cuts) + if not result["valid"]: + for v in result["violations"]: + blocks.append(f"Delivery promise violation: {v}") + except Exception as e: + log.warning("Could not validate delivery promise: %s", e) + else: + warnings.append("No delivery_promise in edit_decisions — skipping promise validation") + + # --- 2. Slideshow risk check --- + renderer_family = edit_decisions.get("renderer_family") + scenes = scene_plan or [] + + # If no scene_plan passed, try to extract scene info from cuts + if not scenes and resolved_cuts: + scenes = [ + { + "type": c.get("type", ""), + "description": c.get("reason", ""), + "shot_language": c.get("shot_language", {}), + "shot_intent": c.get("shot_intent"), + "narrative_role": c.get("narrative_role"), + "information_role": c.get("information_role"), + "hero_moment": c.get("hero_moment", False), + } + for c in resolved_cuts + ] + + if scenes: + try: + from lib.slideshow_risk import score_slideshow_risk + risk = score_slideshow_risk(scenes, edit_decisions, renderer_family) + if risk["verdict"] == "fail": + blocks.append( + f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: fail). " + f"Video plan looks like a slideshow — revise scene plan before rendering." + ) + elif risk["verdict"] == "revise": + warnings.append( + f"Slideshow risk score {risk['average']:.1f}/5.0 (verdict: revise). " + f"Consider improving scene variety before final render." + ) + except Exception as e: + log.warning("Could not compute slideshow risk: %s", e) + + # --- 3. Missing renderer_family (BLOCK — must be set at proposal) --- + if not renderer_family: + blocks.append( + "No renderer_family in edit_decisions. " + "renderer_family must be set at proposal stage and locked before compose. " + "Re-run the proposal stage with a renderer_family selection." + ) + + # Log warnings + for w in warnings: + log.warning("[pre-compose] %s", w) + + # Block on critical violations + if blocks: + return ToolResult( + success=False, + error=( + "Pre-compose validation failed — render blocked.\n" + + "\n".join(f" • {b}" for b in blocks) + + ("\n\nWarnings:\n" + "\n".join(f" • {w}" for w in warnings) if warnings else "") + ), + ) + + return None + def _render(self, inputs: dict[str, Any]) -> ToolResult: """High-level render: assemble edit decisions + asset manifest into final video. @@ -480,6 +724,12 @@ class VideoCompose(BaseTool): resolved_cut["source"] = asset_lookup[source_id]["path"] resolved_cuts.append(resolved_cut) + # --- Pre-compose validation gate --- + scene_plan = inputs.get("scene_plan") + validation_block = self._pre_compose_validation(edit_decisions, resolved_cuts, scene_plan) + if validation_block is not None: + return validation_block + # Also accept profile as "output_profile" (skill convention) or "profile" profile = inputs.get("profile") or inputs.get("output_profile") @@ -491,30 +741,71 @@ class VideoCompose(BaseTool): } if profile: remotion_inputs["profile"] = profile - return self._remotion_render(remotion_inputs) + render_result = self._remotion_render(remotion_inputs) - # --- FFmpeg path: pure video cuts (talking-head, etc.) --- - # Handle options - options = inputs.get("options", {}) - subtitle_burn = options.get("subtitle_burn", True) + # Governance: NEVER silently fall back to FFmpeg when Remotion fails. + # The agent must decide the fallback path, not the tool. + if not render_result.success: + renderer_family = edit_decisions.get("renderer_family", "unknown") + return ToolResult( + success=False, + error=( + f"Remotion render failed for renderer_family={renderer_family!r}. " + f"Underlying error: {render_result.error}\n\n" + f"This composition requires Remotion (images, text cards, animations). " + f"Options:\n" + f" 1. Fix Remotion setup (cd remotion-composer && npm install)\n" + f" 2. Re-run with operation='compose' for FFmpeg-only (video cuts only)\n" + f" 3. Approve a degraded FFmpeg render (still images → Ken Burns)\n\n" + f"Per governance: renderer downgrade requires user approval." + ), + ) + else: + # --- FFmpeg path: pure video cuts (talking-head, etc.) --- + options = inputs.get("options", {}) + subtitle_burn = options.get("subtitle_burn", True) - # Resolve subtitle_path from edit_decisions if not provided - subtitle_path = inputs.get("subtitle_path") - if subtitle_burn and not subtitle_path: - ed_subs = edit_decisions.get("subtitles", {}) - if ed_subs.get("enabled") and ed_subs.get("source"): - subtitle_path = ed_subs["source"] + # Resolve subtitle_path from edit_decisions if not provided + subtitle_path = inputs.get("subtitle_path") + if subtitle_burn and not subtitle_path: + ed_subs = edit_decisions.get("subtitles", {}) + if ed_subs.get("enabled") and ed_subs.get("source"): + subtitle_path = ed_subs["source"] - # Build compose inputs - compose_inputs = dict(inputs) - compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts) - compose_inputs["output_path"] = str(output_path) - if subtitle_path: - compose_inputs["subtitle_path"] = subtitle_path - if profile: - compose_inputs["profile"] = profile + # Build compose inputs + compose_inputs = dict(inputs) + compose_inputs["edit_decisions"] = dict(edit_decisions, cuts=resolved_cuts) + compose_inputs["output_path"] = str(output_path) + if subtitle_path: + compose_inputs["subtitle_path"] = subtitle_path + if profile: + compose_inputs["profile"] = profile - return self._compose(compose_inputs) + render_result = self._compose(compose_inputs) + + # --- Post-render: mandatory final self-review --- + if render_result.success and output_path.exists(): + final_review = self._run_final_review(output_path, edit_decisions) + + # Attach final_review to the ToolResult data so the compose-director + # skill can include it in the checkpoint alongside the render_report. + if render_result.data is None: + render_result.data = {} + render_result.data["final_review"] = final_review + render_result.data["final_review_status"] = final_review["status"] + + # If the self-review says fail, downgrade the ToolResult + if final_review["status"] == "fail": + return ToolResult( + success=False, + error=( + "Post-render self-review FAILED. The output is not presentable.\n" + + "\n".join(f" • {i}" for i in final_review.get("issues_found", [])) + ), + data=render_result.data, + ) + + return render_result def _remotion_render(self, inputs: dict[str, Any]) -> ToolResult: """Render via Remotion (requires Node.js + npx). @@ -554,6 +845,19 @@ class VideoCompose(BaseTool): posix = resolved.as_posix() cut["source"] = f"file:///{posix}" if not posix.startswith("/") else f"file://{posix}" + # Build a custom themeConfig from the playbook's actual colors. + # This ensures every video gets a unique visual identity derived + # from its production decisions — not picked from a preset menu. + if "themeConfig" not in props: + playbook_name = ( + props.get("playbook") + or props.get("theme") + or props.get("metadata", {}).get("playbook") + ) + theme_config = self._build_theme_from_playbook(playbook_name, composition_data) + if theme_config: + props["themeConfig"] = theme_config + # Write props to temp file for Remotion CLI props_path = output_path.parent / ".remotion_props.json" with open(props_path, "w", encoding="utf-8") as f: @@ -567,10 +871,15 @@ class VideoCompose(BaseTool): error=f"Remotion composer project not found at {composer_dir}", ) + # Route to the correct Remotion composition based on renderer_family. + # This prevents all pipelines from collapsing into the Explainer visual grammar. + renderer_family = (composition_data or {}).get("renderer_family", "explainer-data") + composition_id = self._get_composition_id(renderer_family) + cmd = [ "npx", "remotion", "render", str(composer_dir / "src" / "index.tsx"), - "Explainer", + composition_id, str(output_path), "--props", str(props_path), ] @@ -609,6 +918,359 @@ class VideoCompose(BaseTool): artifacts=[str(output_path)], ) + # ------------------------------------------------------------------ + # Final self-review — mandatory post-render inspection + # ------------------------------------------------------------------ + + def _run_final_review( + self, + output_path: Path, + edit_decisions: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Run post-render self-review and produce a final_review artifact. + + This is the governance contract: the compose runtime MUST inspect + the actual rendered output before marking the stage complete. + Never claim a video is ready without a real probe + frame sample. + + Returns a dict conforming to final_review.schema.json. + """ + log = logging.getLogger("video_compose.final_review") + issues: list[str] = [] + + # --- 1. Technical probe via ffprobe --- + technical_probe: dict[str, Any] = { + "valid_container": False, + "issues": [], + } + try: + cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", str(output_path), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if proc.returncode == 0: + probe_data = json.loads(proc.stdout) + fmt = probe_data.get("format", {}) + streams = probe_data.get("streams", []) + video_stream = next( + (s for s in streams if s.get("codec_type") == "video"), {} + ) + audio_stream = next( + (s for s in streams if s.get("codec_type") == "audio"), {} + ) + + duration = float(fmt.get("duration", 0)) + width = int(video_stream.get("width", 0)) + height = int(video_stream.get("height", 0)) + fps_str = video_stream.get("r_frame_rate", "0/1") + fps = self._parse_probe_fps(fps_str) + + technical_probe = { + "valid_container": bool(video_stream), + "duration_seconds": round(duration, 2), + "resolution": f"{width}x{height}", + "fps": fps, + "has_audio": bool(audio_stream), + "codec": video_stream.get("codec_name", "unknown"), + "file_size_bytes": int(fmt.get("size", 0)), + "issues": [], + } + + # Sanity checks + if duration < 1.0: + technical_probe["issues"].append( + f"Output is only {duration:.1f}s — suspiciously short" + ) + + # Check target duration from edit_decisions + target_dur = None + if edit_decisions: + target_dur = ( + edit_decisions.get("total_duration_seconds") + or edit_decisions.get("metadata", {}).get("target_duration_seconds") + ) + if target_dur and target_dur > 0: + drift_pct = abs(duration - target_dur) / target_dur + if drift_pct > 0.25: + technical_probe["issues"].append( + f"Duration drift: rendered {duration:.1f}s vs target {target_dur}s " + f"({drift_pct:.0%} off). Review pacing or trim." + ) + technical_probe["target_duration"] = target_dur + technical_probe["duration_drift_pct"] = round(drift_pct * 100, 1) + if width < 320 or height < 240: + technical_probe["issues"].append( + f"Resolution {width}x{height} is very low" + ) + if not audio_stream: + technical_probe["issues"].append("No audio stream in output") + else: + technical_probe["issues"].append( + f"ffprobe failed with exit code {proc.returncode}" + ) + except FileNotFoundError: + technical_probe["issues"].append("ffprobe not found — cannot validate output") + except Exception as e: + technical_probe["issues"].append(f"ffprobe error: {e}") + + issues.extend(technical_probe.get("issues", [])) + + # --- 2. Visual spotcheck: sample 4 frames --- + visual_spotcheck: dict[str, Any] = { + "frames_sampled": 0, + "frame_paths": [], + "black_frames_detected": False, + "broken_overlays": False, + "missing_assets": False, + "unreadable_text": False, + "issues": [], + } + duration = technical_probe.get("duration_seconds", 0) + if duration > 0 and technical_probe.get("valid_container"): + try: + frame_dir = output_path.parent / ".final_review_frames" + frame_dir.mkdir(parents=True, exist_ok=True) + # Sample at 10%, 35%, 65%, 90% of duration + sample_points = [0.10, 0.35, 0.65, 0.90] + frame_paths = [] + for i, pct in enumerate(sample_points): + ts = round(duration * pct, 2) + frame_path = frame_dir / f"review_frame_{i}.png" + cmd = [ + "ffmpeg", "-y", "-ss", str(ts), + "-i", str(output_path), + "-frames:v", "1", "-q:v", "2", + str(frame_path), + ] + subprocess.run(cmd, capture_output=True, timeout=15) + if frame_path.exists(): + frame_paths.append(str(frame_path)) + + # Check for black frames (file size heuristic: + # a 1920x1080 PNG of pure black is ~5KB) + if frame_path.stat().st_size < 2000: + visual_spotcheck["black_frames_detected"] = True + + visual_spotcheck["frames_sampled"] = len(frame_paths) + visual_spotcheck["frame_paths"] = frame_paths + + if len(frame_paths) < 4: + visual_spotcheck["issues"].append( + f"Only {len(frame_paths)}/4 frames extracted — some timestamps may be out of range" + ) + if visual_spotcheck["black_frames_detected"]: + visual_spotcheck["issues"].append( + "Black frame detected — possible missing asset or failed render segment" + ) + except Exception as e: + visual_spotcheck["issues"].append(f"Frame sampling error: {e}") + + issues.extend(visual_spotcheck.get("issues", [])) + + # --- 3. Audio spotcheck --- + audio_spotcheck: dict[str, Any] = { + "narration_present": False, + "music_present": False, + "unexpected_silence": False, + "clipping_detected": False, + "mix_intelligible": True, + "issues": [], + } + if technical_probe.get("has_audio") and duration > 0: + try: + # Use ffmpeg volumedetect to check audio levels + cmd = [ + "ffmpeg", "-i", str(output_path), + "-af", "volumedetect", "-f", "null", "-", + ] + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) + stderr = proc.stderr or "" + # Parse mean_volume and max_volume + mean_vol = None + max_vol = None + for line in stderr.split("\n"): + if "mean_volume:" in line: + try: + mean_vol = float(line.split("mean_volume:")[1].strip().split()[0]) + except (ValueError, IndexError): + pass + if "max_volume:" in line: + try: + max_vol = float(line.split("max_volume:")[1].strip().split()[0]) + except (ValueError, IndexError): + pass + + if mean_vol is not None: + if mean_vol < -60: + audio_spotcheck["unexpected_silence"] = True + audio_spotcheck["issues"].append( + f"Mean volume {mean_vol:.1f} dB — effectively silent" + ) + # Assume narration present if mean volume is reasonable + if mean_vol > -40: + audio_spotcheck["narration_present"] = True + # Assume music present if audio exists (conservative) + if mean_vol > -50: + audio_spotcheck["music_present"] = True + + if max_vol is not None and max_vol > -0.5: + audio_spotcheck["clipping_detected"] = True + audio_spotcheck["issues"].append( + f"Max volume {max_vol:.1f} dB — possible clipping" + ) + except Exception as e: + audio_spotcheck["issues"].append(f"Audio analysis error: {e}") + + issues.extend(audio_spotcheck.get("issues", [])) + + # --- 4. Promise preservation --- + promise_preservation: dict[str, Any] = { + "delivery_promise_honored": True, + "silent_downgrade_detected": False, + "issues": [], + } + if edit_decisions: + renderer_family = edit_decisions.get("renderer_family", "") + promise_preservation["renderer_family_used"] = renderer_family + + delivery_data = ( + edit_decisions.get("metadata", {}).get("delivery_promise") + or edit_decisions.get("delivery_promise") + ) + if delivery_data: + try: + from lib.delivery_promise import DeliveryPromise + promise = DeliveryPromise.from_dict(delivery_data) + cuts = edit_decisions.get("cuts", []) + result = promise.validate_cuts(cuts) + motion_ratio = result.get("motion_ratio", 0) + promise_preservation["motion_ratio_actual"] = round(motion_ratio, 3) + + if not result["valid"]: + promise_preservation["delivery_promise_honored"] = False + for v in result["violations"]: + promise_preservation["issues"].append(v) + + # Detect silent downgrade: motion-led promise but <50% motion + if (delivery_data.get("type") == "motion_led" + and motion_ratio < 0.5): + promise_preservation["silent_downgrade_detected"] = True + promise_preservation["issues"].append( + f"Motion-led promise but only {motion_ratio:.0%} motion — " + f"silent downgrade to still-led" + ) + except Exception as e: + promise_preservation["issues"].append( + f"Could not validate delivery promise: {e}" + ) + + issues.extend(promise_preservation.get("issues", [])) + + # --- 5. Subtitle check --- + subtitle_check: dict[str, Any] = { + "subtitles_expected": False, + "subtitles_present": False, + "issues": [], + } + if edit_decisions: + ed_subs = edit_decisions.get("subtitles", {}) + subtitle_check["subtitles_expected"] = bool(ed_subs.get("enabled")) + + # Check if output has subtitle stream + if technical_probe.get("valid_container"): + try: + cmd = [ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "s", + str(output_path), + ] + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=15 + ) + if proc.returncode == 0: + sub_data = json.loads(proc.stdout) + sub_streams = sub_data.get("streams", []) + subtitle_check["subtitles_present"] = len(sub_streams) > 0 + + # If subtitles were expected but not found as a stream, + # they may be burned in (which is fine — not a failure) + if (subtitle_check["subtitles_expected"] + and not subtitle_check["subtitles_present"]): + # Check if subtitle_path was used (burned in) + sub_source = ed_subs.get("source") + if sub_source and Path(sub_source).exists(): + # Burned-in subtitles are not detectable as streams + subtitle_check["subtitles_present"] = True + subtitle_check["coverage_ratio"] = 1.0 + else: + subtitle_check["issues"].append( + "Subtitles expected but not found in output and " + "no subtitle source file exists for burn-in" + ) + except Exception as e: + subtitle_check["issues"].append(f"Subtitle check error: {e}") + + issues.extend(subtitle_check.get("issues", [])) + + # --- 6. Determine overall status --- + critical_issues = [ + i for i in issues + if any(kw in i.lower() for kw in [ + "silent downgrade", "delivery promise violation", + "effectively silent", "ffprobe failed", "suspiciously short", + ]) + ] + + if critical_issues: + status = "revise" + recommended_action = "re_render" + elif issues: + status = "pass" + recommended_action = "present_to_user" + else: + status = "pass" + recommended_action = "present_to_user" + + if not technical_probe.get("valid_container"): + status = "fail" + recommended_action = "re_render" + + final_review = { + "version": "1.0", + "output_path": str(output_path), + "status": status, + "checks": { + "technical_probe": technical_probe, + "visual_spotcheck": visual_spotcheck, + "audio_spotcheck": audio_spotcheck, + "promise_preservation": promise_preservation, + "subtitle_check": subtitle_check, + }, + "issues_found": issues, + "recommended_action": recommended_action, + } + + log.info( + "Final review: status=%s, issues=%d, action=%s", + status, len(issues), recommended_action, + ) + + return final_review + + @staticmethod + def _parse_probe_fps(fps_str: str) -> float: + """Parse ffprobe fps string like '30/1' or '24000/1001'.""" + try: + if "/" in fps_str: + num, den = fps_str.split("/") + return round(int(num) / max(int(den), 1), 2) + return float(fps_str) + except (ValueError, ZeroDivisionError): + return 0.0 + def _burn_subtitles(self, inputs: dict[str, Any]) -> ToolResult: """Burn subtitle file into video.""" input_path = Path(inputs["input_path"]) @@ -761,15 +1423,62 @@ class VideoCompose(BaseTool): ) @staticmethod - def _build_subtitle_style(style: dict) -> str: - """Build ASS force_style string from style dict. + def _resolve_subtitle_style( + explicit_style: dict | None, + edit_decisions: dict | None, + playbook: dict | None, + ) -> dict: + """Resolve subtitle style with layered priority. - Produces modern social-media-style captions by default: - bold, outlined, positioned in the lower portion of the frame. + Priority: explicit_style > edit_decisions.subtitles.style > playbook > defaults. + This prevents every video from looking identical (Arial bold white). """ + # Start with minimal fallback defaults + resolved = { + "font": "Inter", + "font_size": 28, + "bold": True, + "outline_width": 2, + "shadow": 0, + "margin_v": 40, + "alignment": 2, + } + + # Layer 1: Playbook-derived style + if playbook: + typo = playbook.get("typography", {}) + colors = playbook.get("visual_language", {}).get("color_palette", {}) + if typo.get("body", {}).get("family"): + resolved["font"] = typo["body"]["family"] + if colors.get("text"): + resolved["primary_color"] = colors["text"] + if colors.get("background"): + resolved["outline_color"] = colors["background"] + # Semi-transparent background for readability + bg = colors["background"] + resolved["back_color"] = bg + + # Layer 2: edit_decisions subtitle style + if edit_decisions: + ed_style = edit_decisions.get("subtitles", {}).get("style", {}) + for k, v in ed_style.items(): + if v is not None: + resolved[k] = v + + # Layer 3: Explicit override (highest priority) + if explicit_style: + for k, v in explicit_style.items(): + if v is not None: + resolved[k] = v + + return resolved + + @staticmethod + def _build_subtitle_style(style: dict) -> str: + """Build ASS force_style string from style dict.""" parts = [] - parts.append(f"FontName={style.get('font', 'Arial')}") - parts.append(f"FontSize={style.get('font_size', 16)}") + parts.append(f"FontName={style.get('font', 'Inter')}") + parts.append(f"FontSize={style.get('font_size', 28)}") parts.append(f"Bold={1 if style.get('bold', True) else 0}") if style.get("primary_color"): parts.append(f"PrimaryColour={style['primary_color']}") @@ -777,11 +1486,10 @@ class VideoCompose(BaseTool): parts.append(f"OutlineColour={style['outline_color']}") if style.get("back_color"): parts.append(f"BackColour={style['back_color']}") - # BorderStyle: 1=outline+shadow (default), 4=opaque box border_style = style.get("border_style", 1) parts.append(f"BorderStyle={border_style}") - parts.append(f"Outline={style.get('outline_width', 3)}") - parts.append(f"Shadow={style.get('shadow', 1)}") + parts.append(f"Outline={style.get('outline_width', 2)}") + parts.append(f"Shadow={style.get('shadow', 0)}") parts.append(f"MarginV={style.get('margin_v', 40)}") parts.append(f"Alignment={style.get('alignment', 2)}") return ",".join(parts) diff --git a/tools/video/video_selector.py b/tools/video/video_selector.py index ae4bc5d..dba23c9 100644 --- a/tools/video/video_selector.py +++ b/tools/video/video_selector.py @@ -49,7 +49,7 @@ class VideoSelector(BaseTool): "default": "auto", }, "allowed_providers": {"type": "array", "items": {"type": "string"}}, - "operation": {"type": "string", "enum": ["text_to_video", "image_to_video"], "default": "text_to_video"}, + "operation": {"type": "string", "enum": ["text_to_video", "image_to_video", "rank"], "default": "text_to_video"}, "output_path": {"type": "string"}, }, } @@ -81,15 +81,38 @@ class VideoSelector(BaseTool): return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, object]) -> float: - tool = self._select_tool(inputs) + candidates = self._providers() + if not candidates: + return 0.0 + tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {})) return tool.estimate_cost(inputs) if tool else 0.0 def estimate_runtime(self, inputs: dict[str, object]) -> float: - tool = self._select_tool(inputs) + candidates = self._providers() + if not candidates: + return 0.0 + tool, _ = self._select_best_tool(inputs, candidates, inputs.get("task_context", {})) return tool.estimate_runtime(inputs) if tool else 0.0 def execute(self, inputs: dict[str, object]) -> ToolResult: - tool = self._select_tool(inputs) + from lib.scoring import rank_providers + + task_context = inputs.get("task_context", {}) + candidates = self._providers() + + # Rank mode — return scored provider rankings without generating + if inputs.get("operation") == "rank": + rankings = rank_providers(candidates, task_context) + return ToolResult( + success=True, + data={ + "rankings": [r.to_dict() for r in rankings], + "explanation": "\n".join(r.explain() for r in rankings[:5]), + }, + ) + + # Normal generation — use scored selection + tool, score = self._select_best_tool(inputs, candidates, task_context) if tool is None: return ToolResult(success=False, error="No video generation provider available.") @@ -103,12 +126,30 @@ class VideoSelector(BaseTool): result = tool.execute(adapted) if result.success: result.data.setdefault("selected_tool", tool.name) + result.data["selection_reason"] = score.explain() if score else f"Selected {tool.provider} ({tool.name})" + if score: + result.data["provider_score"] = score.to_dict() + result.data["alternatives_considered"] = [ + t.name for t in candidates + if t.name != tool.name and t.get_status().value == "available" + ] return result - def _select_tool(self, inputs: dict[str, object]) -> BaseTool | None: + def _select_best_tool( + self, + inputs: dict[str, object], + candidates: list[BaseTool], + task_context: dict[str, object], + ) -> tuple[BaseTool | None, object]: + """Select the best provider using scored ranking. + + Respects preferred_provider and environment hints as tie-breakers, + but the scoring engine drives the primary selection. + """ + from lib.scoring import rank_providers, ProviderScore + preferred = inputs.get("preferred_provider", "auto") allowed = set(inputs.get("allowed_providers") or []) - candidates = self._providers() if allowed: candidates = [tool for tool in candidates if tool.provider in allowed] @@ -124,13 +165,24 @@ class VideoSelector(BaseTool): if preferred == "auto" and env_hint in env_map: preferred = env_map[env_hint] - if preferred != "auto": - ordered = [tool for tool in candidates if tool.provider == preferred] - ordered.extend([tool for tool in candidates if tool.provider != preferred]) - else: - ordered = candidates + rankings = rank_providers(candidates, task_context) - for tool in ordered: - if tool.get_status() == ToolStatus.AVAILABLE: - return tool - return None + # Build tool lookup: provider → tool (first available per provider) + tool_by_provider: dict[str, BaseTool] = {} + for tool in candidates: + if tool.provider not in tool_by_provider and tool.get_status() == ToolStatus.AVAILABLE: + tool_by_provider[tool.provider] = tool + + # If a preferred provider is explicitly requested and available, + # boost it to the top unless its score is drastically worse. + if preferred != "auto": + for score in rankings: + if score.provider == preferred and score.provider in tool_by_provider: + return tool_by_provider[score.provider], score + + # Return the highest-scored available provider + for score in rankings: + if score.provider in tool_by_provider: + return tool_by_provider[score.provider], score + + return None, None