diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..ad117ba --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,72 @@ +# Dolt database (managed by Dolt, not git) +dolt/ +dolt-access.lock + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +export-state/ + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..dbfe363 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --claim +bd update --status done + +# Sync with Dolt remote +bd dolt push +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in Dolt database with version control and branching +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Dolt-native three-way merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..5565398 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,3 @@ +issue_prefix: op +issue-prefix: op +dolt.auto-start: false diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout new file mode 100755 index 0000000..601ed4e --- /dev/null +++ b/.beads/hooks/post-checkout @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.62.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-checkout "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-checkout "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.62.0 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge new file mode 100755 index 0000000..6b44980 --- /dev/null +++ b/.beads/hooks/post-merge @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.62.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-merge "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-merge "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-merge'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.62.0 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit new file mode 100755 index 0000000..888174e --- /dev/null +++ b/.beads/hooks/pre-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.62.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-commit "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-commit "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.62.0 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push new file mode 100755 index 0000000..300ecc0 --- /dev/null +++ b/.beads/hooks/pre-push @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.62.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-push "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-push "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-push'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.62.0 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg new file mode 100755 index 0000000..d76e816 --- /dev/null +++ b/.beads/hooks/prepare-commit-msg @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.62.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.62.0 --- diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..ed46a03 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,9 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "server", + "dolt_database": "op", + "project_id": "5d457f3f-e857-448f-9c56-6f22eb30cf9a", + "dolt_host": "127.0.0.1", + "dolt_user": "root" +} diff --git a/.gitignore b/.gitignore index 8e3a0fb..207d40a 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ remotion-composer/public/* remotion-composer/public/demo-props/test-* remotion-composer/public/demo-props/talking-head-* remotion-composer/public/demo-props/caption-burn-* + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key diff --git a/AGENTS.md b/AGENTS.md index 7649447..5f9c92f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,50 @@ It contains routing rules that determine your first action based on what the use Skipping it WILL cause you to take the wrong action. There are no instructions in this file. All instructions are in AGENT_GUIDE.md. + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/lib/clotho_adapter.py b/lib/clotho_adapter.py new file mode 100644 index 0000000..39e26d0 --- /dev/null +++ b/lib/clotho_adapter.py @@ -0,0 +1,443 @@ +"""Clotho adapter — converts an OpenMontage scene_plan into a Clotho flow.yaml. + +Pure-Python library module. Does NOT import the Clotho package. +Mirrors the pattern of lib/shot_prompt_builder.py — a library, not a BaseTool. + +Primary use case: cinematic-ad-30s pipeline + 6 scenes × 5s clips → still → i2v → concat → flow.yaml → clotho run +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from lib.shot_prompt_builder import build_motion_prompt, build_shot_prompt + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + +# Flux image_size param for each aspect ratio (Clotho B1 gotcha: +# Flux uses image_size, not aspect_ratio) +_ASPECT_TO_IMAGE_SIZE: dict[str, str] = { + "9:16": "portrait_16_9", + "16:9": "landscape_16_9", + "1:1": "square_1_1", +} + +# OM scene types that generate video via still → i2v +_GENERATIVE_TYPES = {"generated", "broll"} + +# OM scene types that are local passthrough (trim existing source footage) +_LOCAL_PASSTHROUGH_TYPES = {"talking_head", "screen_recording"} + +# OM scene types skipped (Remotion/FFmpeg territory, not Clotho) +_SKIP_TYPES = {"text_card", "animation", "diagram", "transition"} + +# Lightweight cost table (USD per call). +# Keep in sync with ~/repos/clotho/src/clotho/data/model_costs.yaml. +# Last synced: 2026-04-12 +_MODEL_COSTS_USD: dict[str, float] = { + "flux-2-pro": 0.04, + "kling-2.5-turbo-pro": 0.70, + "kling-3.0-pro": 2.00, + "seedance-2.0": 0.40, + "veo-3.1-image": 1.25, +} +_IMAGE_GEN_COST_USD = 0.04 # flux-2-pro balanced default + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class ClothoAdapterOptions: + """Options for the clotho_adapter.adapt() call.""" + + project_name: str + aspect_ratio: str = "9:16" + tier: str = "balanced" + consumer: str = "openmontage-cinematic" + output_path: str | None = None + save_output: str | None = None + scene_refs: dict[str, list[str]] = field(default_factory=dict) + style_context: dict[str, Any] | None = None + project_root: str = "." + + +@dataclass +class ClothoAdapterResult: + """Return value of clotho_adapter.adapt().""" + + flow_yaml: str + node_count: int + skipped_scenes: list[str] + estimated_cost_usd: float + warnings: list[str] + + +# --------------------------------------------------------------------------- +# Error +# --------------------------------------------------------------------------- + +class ClothoAdapterError(ValueError): + """Raised on fatal errors (no generatable scenes, invalid aspect_ratio, etc.).""" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _slugify(name: str) -> str: + """Produce a Clotho-safe slug: lowercase, hyphens, no special chars. + + Output satisfies Clotho node id regex: [a-z0-9][a-z0-9_-]* + """ + slug = name.lower() + slug = re.sub(r"[\s_]+", "-", slug) + slug = re.sub(r"[^a-z0-9-]", "", slug) + slug = slug.strip("-") + # Ensure starts with [a-z0-9] + if slug and not re.match(r"[a-z0-9]", slug): + slug = "x" + slug + return slug or "unnamed" + + +def _clamp_duration(seconds: float) -> str: + """Clamp scene duration to Clotho Kling string enum: '5' or '10'. + + Clotho B4 gotcha: duration is a string enum, not an int. + Returns '5' if seconds <= 7.5, else '10'. + """ + return "5" if seconds <= 7.5 else "10" + + +def _select_model(scene: dict[str, Any], has_refs: bool) -> str | None: + """Select a Clotho model id for a video.image_to_video node. + + Returns a model id string, or None to let Clotho tier default resolve. + + Priority order: + 1. talking_head → kling-2.5-turbo-pro (human face, Seedance B2 risk) + 2. has_refs + wide shot → kling-3.0-pro (multi-char wide drift) + 3. has_refs → kling-2.5-turbo-pro (any human scene with char refs) + 4. otherwise → None (Seedance OK for non-human scenes) + """ + scene_type = scene.get("type", "") + sl = scene.get("shot_language", {}) + shot_size = sl.get("shot_size", "") + + if scene_type == "talking_head": + return "kling-2.5-turbo-pro" + + wide_shots = {"wide", "extreme_wide", "medium_wide"} + if has_refs and shot_size in wide_shots: + return "kling-3.0-pro" + + if has_refs: + return "kling-2.5-turbo-pro" + + return None + + +def _build_still_node( + n: int, + scene: dict[str, Any], + aspect_ratio: str, + style_context: dict[str, Any] | None, +) -> dict[str, Any]: + """Build a Clotho image.generate node dict for scene index N (1-based).""" + image_size = _ASPECT_TO_IMAGE_SIZE.get(aspect_ratio, "portrait_16_9") + return { + "id": f"s{n}-still", + "kind": "image.generate", + "provider": "fal", + "params": { + "prompt": build_shot_prompt(scene, style_context), + "image_size": image_size, + }, + } + + +def _build_clip_node( + n: int, + scene: dict[str, Any], + aspect_ratio: str, + refs: list[str], + style_context: dict[str, Any] | None, +) -> dict[str, Any]: + """Build a Clotho video.image_to_video node dict for scene index N (1-based). + + Critical invariants (Clotho v1.2 contract): + - references field OMITTED (not []) when refs is empty + - model field OMITTED when _select_model returns None + - duration is STRING enum '5' or '10' (B4 gotcha) + """ + duration_s = scene.get("end_seconds", 5) - scene.get("start_seconds", 0) + node: dict[str, Any] = { + "id": f"s{n}-clip", + "kind": "video.image_to_video", + "provider": "fal", + "inputs": { + "image": f"{{{{ s{n}-still.output }}}}", + }, + "params": { + "prompt": build_motion_prompt(scene, style_context), + "duration": _clamp_duration(duration_s), + "aspect_ratio": aspect_ratio, + }, + } + + model = _select_model(scene, bool(refs)) + if model is not None: + node["model"] = model + + if refs: + node["references"] = refs + + return node + + +def _build_passthrough_node( + n: int, + scene: dict[str, Any], + asset_manifest: dict[str, Any] | None, +) -> tuple[dict[str, Any] | None, list[str]]: + """Build a Clotho video.trim node for a local passthrough scene. + + Returns (node_dict | None, warnings). + Clotho video.trim uses 'from'/'to' params (not 'start'/'end'). + """ + warnings_out: list[str] = [] + scene_id = scene.get("id", "unknown") + + if asset_manifest is None: + warnings_out.append( + f"scene {scene_id!r} (type={scene.get('type')!r}): no asset_manifest provided — " + "scene excluded from concat" + ) + return None, warnings_out + + # Search asset_manifest["assets"] for a video asset matching this scene + assets = asset_manifest.get("assets", []) + video_asset = next( + (a for a in assets if a.get("scene_id") == scene_id and a.get("type") == "video"), + None, + ) + + if video_asset is None: + warnings_out.append( + f"scene {scene_id!r} (type={scene.get('type')!r}): no video asset found in " + "asset_manifest — scene excluded from concat" + ) + return None, warnings_out + + node: dict[str, Any] = { + "id": f"s{n}-clip", + "kind": "video.trim", + "provider": "local", + "tool": "ffmpeg", + "inputs": { + "video": video_asset["path"], + }, + "params": { + "from": scene.get("start_seconds", 0), + "to": scene.get("end_seconds", 5), + }, + } + return node, warnings_out + + +def _build_concat_node(clip_node_ids: list[str]) -> dict[str, Any]: + """Build the Clotho video.concat node that stitches all clips.""" + return { + "id": "full-cut", + "kind": "video.concat", + "provider": "local", + "tool": "ffmpeg", + "inputs": { + "videos": [f"{{{{ {cid}.output }}}}" for cid in clip_node_ids], + }, + } + + +def _estimate_cost(nodes: list[dict[str, Any]]) -> float: + """Naive cost estimate from local model cost table.""" + total = 0.0 + for node in nodes: + kind = node.get("kind", "") + if kind == "image.generate": + total += _IMAGE_GEN_COST_USD + elif kind == "video.image_to_video": + model = node.get("model", "seedance-2.0") + total += _MODEL_COSTS_USD.get(model, 0.40) + # video.concat / video.trim → free + return total + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def adapt( + scene_plan: dict[str, Any], + options: ClothoAdapterOptions, + asset_manifest: dict[str, Any] | None = None, +) -> ClothoAdapterResult: + """Convert an OpenMontage scene_plan artifact into a Clotho flow.yaml. + + Args: + scene_plan: OM scene_plan artifact dict (validates against + schemas/artifacts/scene_plan.schema.json). + options: Adapter configuration (ClothoAdapterOptions). + asset_manifest: Optional OM asset_manifest dict. Required for + talking_head/screen_recording scenes with + source="source" (existing footage). Absent scenes + are excluded with a loud warning. + + Returns: + ClothoAdapterResult with flow_yaml, node_count, skipped_scenes, + estimated_cost_usd, and warnings. + + Raises: + ClothoAdapterError: No generatable scenes found, invalid aspect_ratio, + or empty scene list. + """ + if options.aspect_ratio not in _ASPECT_TO_IMAGE_SIZE: + raise ClothoAdapterError( + f"Invalid aspect_ratio {options.aspect_ratio!r}. " + f"Must be one of: {sorted(_ASPECT_TO_IMAGE_SIZE)}" + ) + + raw_scenes = scene_plan.get("scenes", []) + if not raw_scenes: + raise ClothoAdapterError("scene_plan contains no scenes") + + # Sort by start_seconds + scenes = sorted(raw_scenes, key=lambda s: s.get("start_seconds", 0)) + + nodes: list[dict[str, Any]] = [] + clip_ids: list[str] = [] + skipped: list[str] = [] + all_warnings: list[str] = [] + + for idx, scene in enumerate(scenes, start=1): + scene_id = scene.get("id", f"scene-{idx}") + scene_type = scene.get("type", "") + + # --- Skip non-Clotho-native types --- + if scene_type in _SKIP_TYPES: + skipped.append(scene_id) + all_warnings.append( + f"scene {scene_id!r} (type={scene_type!r}) skipped — " + "not a Clotho-native kind (handled by Remotion/FFmpeg)" + ) + continue + + # --- Resolve character refs --- + refs = options.scene_refs.get(scene_id, []) + valid_refs: list[str] = [] + for ref in refs: + if not Path(ref).is_absolute(): + all_warnings.append( + f"scene {scene_id!r}: ref path {ref!r} is not absolute — dropped" + ) + else: + valid_refs.append(ref) + + # --- Determine if talking_head should be generative --- + is_generative_talking_head = scene_type == "talking_head" and any( + a.get("source") == "generate" + for a in scene.get("required_assets", []) + ) + + # --- Generative path: still + clip nodes --- + if scene_type in _GENERATIVE_TYPES or is_generative_talking_head: + still_node = _build_still_node(idx, scene, options.aspect_ratio, options.style_context) + clip_node = _build_clip_node(idx, scene, options.aspect_ratio, valid_refs, options.style_context) + + # Warn if duration clamped significantly + duration_s = scene.get("end_seconds", 5) - scene.get("start_seconds", 0) + clamped = int(_clamp_duration(duration_s)) + if abs(duration_s - clamped) > 2: + all_warnings.append( + f"scene {scene_id!r}: duration {duration_s}s clamped to {clamped}s" + ) + + nodes.append(still_node) + nodes.append(clip_node) + clip_ids.append(clip_node["id"]) + + # --- Local passthrough path: video.trim --- + elif scene_type in _LOCAL_PASSTHROUGH_TYPES: + passthrough_node, pw = _build_passthrough_node(idx, scene, asset_manifest) + all_warnings.extend(pw) + if passthrough_node is not None: + nodes.append(passthrough_node) + clip_ids.append(passthrough_node["id"]) + else: + # Warning already added by _build_passthrough_node; also record skipped + import sys + print( + f"WARNING: scene {scene_id!r} excluded from Clotho flow — " + "no matching video asset in asset_manifest", + file=sys.stderr, + ) + + else: + all_warnings.append( + f"scene {scene_id!r} has unrecognised type {scene_type!r} — skipped" + ) + skipped.append(scene_id) + + if not clip_ids: + raise ClothoAdapterError( + "no generatable scenes found in scene_plan — cannot build flow.yaml. " + f"Skipped: {skipped}" + ) + + # --- Concat node --- + concat_node = _build_concat_node(clip_ids) + nodes.append(concat_node) + + # --- Outputs --- + output_entry: dict[str, Any] = { + "name": "final", + "from": "{{ full-cut.output }}", + } + if options.save_output is not None: + output_entry["save"] = options.save_output + + # --- Top-level flow dict --- + flow: dict[str, Any] = { + "version": 1, + "name": _slugify(options.project_name), + "consumer": options.consumer, + "tier": options.tier, + "nodes": nodes, + "outputs": [output_entry], + } + + flow_yaml = yaml.safe_dump(flow, sort_keys=False, allow_unicode=True) + + # --- Write to disk if requested --- + if options.output_path is not None: + out_path = Path(options.output_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(flow_yaml, encoding="utf-8") + + cost = _estimate_cost(nodes) + + return ClothoAdapterResult( + flow_yaml=flow_yaml, + node_count=len(nodes), + skipped_scenes=skipped, + estimated_cost_usd=cost, + warnings=all_warnings, + ) diff --git a/lib/shot_prompt_builder.py b/lib/shot_prompt_builder.py index ca154f1..a3c9c76 100644 --- a/lib/shot_prompt_builder.py +++ b/lib/shot_prompt_builder.py @@ -143,6 +143,38 @@ def build_shot_prompt( return ". ".join(filter(None, layers)) +def build_motion_prompt( + scene: dict[str, Any], + style_context: dict[str, Any] | None = None, +) -> str: + """Build the motion/video prompt for a Clotho video.image_to_video node. + + Distinct from build_shot_prompt (which describes the LOOK). + This describes the MOVEMENT — what changes, how the camera moves. + """ + sl = scene.get("shot_language", {}) + parts: list[str] = [] + + # Primary: explicit movement string from scene plan + movement = scene.get("movement", "") + if movement: + parts.append(movement) + elif sl.get("camera_movement") and sl["camera_movement"] != "static": + parts.append(_MOVEMENT_PHRASES.get(sl["camera_movement"], sl["camera_movement"])) + + # Subject action: use description as the action basis + description = scene.get("description", "") + if description: + parts.append(description) + + # Texture keywords + texture = scene.get("texture_keywords", []) + if texture: + parts.append(", ".join(texture)) + + return ". ".join(filter(None, parts)) + + def build_batch_prompts( scenes: list[dict[str, Any]], style_context: dict[str, Any] | None = None, diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/test_clotho_adapter.py b/tests/lib/test_clotho_adapter.py new file mode 100644 index 0000000..30b26f4 --- /dev/null +++ b/tests/lib/test_clotho_adapter.py @@ -0,0 +1,324 @@ +"""Unit tests for lib/clotho_adapter.py. + +All tests are pure-Python — no network, no file I/O (except test_output_write +which writes to tmp_path). +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +import yaml + +from lib.clotho_adapter import ( + ClothoAdapterError, + ClothoAdapterOptions, + adapt, + _clamp_duration, + _select_model, +) + + +# --------------------------------------------------------------------------- +# Scene fixtures +# --------------------------------------------------------------------------- + +def _make_scene( + scene_id: str = "s01", + scene_type: str = "generated", + start: float = 0, + end: float = 5, + description: str = "Test scene", + shot_size: str = "medium", + camera_movement: str = "static", + lighting_key: str = "natural", + required_assets: list | None = None, +) -> dict: + return { + "id": scene_id, + "type": scene_type, + "description": description, + "start_seconds": start, + "end_seconds": end, + "shot_language": { + "shot_size": shot_size, + "camera_movement": camera_movement, + "lighting_key": lighting_key, + }, + "required_assets": required_assets or [{"type": "video", "source": "generate"}], + } + + +def _make_scene_plan(scenes: list) -> dict: + return {"version": "1.0", "scenes": scenes} + + +def _default_options(**kwargs) -> ClothoAdapterOptions: + return ClothoAdapterOptions(project_name="test-project", **kwargs) + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +def test_adapt_single_generated_scene(): + """Single generated scene → s1-still, s1-clip, full-cut nodes.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options()) + + flow = yaml.safe_load(result.flow_yaml) + node_ids = [n["id"] for n in flow["nodes"]] + + assert "s1-still" in node_ids + assert "s1-clip" in node_ids + assert "full-cut" in node_ids + assert result.node_count == 3 + + still = next(n for n in flow["nodes"] if n["id"] == "s1-still") + assert still["kind"] == "image.generate" + assert still["provider"] == "fal" + + clip = next(n for n in flow["nodes"] if n["id"] == "s1-clip") + assert clip["kind"] == "video.image_to_video" + assert clip["provider"] == "fal" + + +def test_model_selection_talking_head(): + """talking_head + refs → kling-2.5-turbo-pro.""" + scene = _make_scene(scene_type="talking_head") + model = _select_model(scene, has_refs=True) + assert model == "kling-2.5-turbo-pro" + + +def test_model_selection_talking_head_no_refs(): + """talking_head without refs → still kling-2.5-turbo-pro (human face).""" + scene = _make_scene(scene_type="talking_head") + model = _select_model(scene, has_refs=False) + assert model == "kling-2.5-turbo-pro" + + +def test_model_selection_wide_multichar(): + """generated type + wide shot + refs → kling-3.0-pro.""" + scene = _make_scene(scene_type="generated", shot_size="wide") + model = _select_model(scene, has_refs=True) + assert model == "kling-3.0-pro" + + +def test_model_selection_no_humans(): + """broll, no refs → None (let Clotho tier default; Seedance OK).""" + scene = _make_scene(scene_type="broll") + model = _select_model(scene, has_refs=False) + assert model is None + + +def test_references_omitted_when_empty(): + """No refs → 'references' key absent from clip node.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options()) + + flow = yaml.safe_load(result.flow_yaml) + clip = next(n for n in flow["nodes"] if n["id"] == "s1-clip") + assert "references" not in clip + + +def test_references_flat_absolute(tmp_path): + """scene_refs with 2 abs paths → references is flat list of those paths.""" + ref1 = str(tmp_path / "ref1.jpg") + ref2 = str(tmp_path / "ref2.jpg") + # Files don't need to exist — adapter only validates they are absolute + + scene_plan = _make_scene_plan([_make_scene(scene_id="s01")]) + opts = _default_options(scene_refs={"s01": [ref1, ref2]}) + result = adapt(scene_plan, opts) + + flow = yaml.safe_load(result.flow_yaml) + clip = next(n for n in flow["nodes"] if n["id"] == "s1-clip") + assert clip["references"] == [ref1, ref2] + + +def test_references_relative_dropped(): + """Relative ref paths are dropped with a warning.""" + scene_plan = _make_scene_plan([_make_scene(scene_id="s01")]) + opts = _default_options(scene_refs={"s01": ["relative/path/ref.jpg"]}) + result = adapt(scene_plan, opts) + + flow = yaml.safe_load(result.flow_yaml) + clip = next(n for n in flow["nodes"] if n["id"] == "s1-clip") + assert "references" not in clip + assert any("not absolute" in w for w in result.warnings) + + +def test_skip_text_card(): + """text_card scene → in skipped_scenes, absent from flow nodes.""" + text_card = _make_scene(scene_id="tc01", scene_type="text_card") + generated = _make_scene(scene_id="s01", scene_type="generated") + scene_plan = _make_scene_plan([text_card, generated]) + result = adapt(scene_plan, _default_options()) + + assert "tc01" in result.skipped_scenes + flow = yaml.safe_load(result.flow_yaml) + node_ids = [n["id"] for n in flow["nodes"]] + # No still/clip for tc01 + assert "s1-still" not in node_ids or True # s2-still (generated is idx 2) + # More precisely: no node should reference tc01 + assert not any("tc01" in n["id"] for n in flow["nodes"]) + + +def test_duration_clamping(): + """3.5s scene → '5', 8s scene → '10'.""" + assert _clamp_duration(3.5) == "5" + assert _clamp_duration(7.5) == "5" + assert _clamp_duration(7.51) == "10" + assert _clamp_duration(8.0) == "10" + + +def test_duration_in_clip_node(): + """Clip node params.duration matches clamped string.""" + scene_plan = _make_scene_plan([_make_scene(start=0, end=3.5)]) + result = adapt(scene_plan, _default_options()) + flow = yaml.safe_load(result.flow_yaml) + clip = next(n for n in flow["nodes"] if n["id"] == "s1-clip") + assert clip["params"]["duration"] == "5" + + +def test_aspect_ratio_flux_param(): + """aspect_ratio='9:16' → image_size: portrait_16_9 in still node params.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options(aspect_ratio="9:16")) + + flow = yaml.safe_load(result.flow_yaml) + still = next(n for n in flow["nodes"] if n["id"] == "s1-still") + assert still["params"]["image_size"] == "portrait_16_9" + assert "aspect_ratio" not in still["params"] + + +def test_aspect_ratio_landscape(): + """aspect_ratio='16:9' → image_size: landscape_16_9.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options(aspect_ratio="16:9")) + flow = yaml.safe_load(result.flow_yaml) + still = next(n for n in flow["nodes"] if n["id"] == "s1-still") + assert still["params"]["image_size"] == "landscape_16_9" + + +def test_output_write(tmp_path): + """With output_path set → file written to disk with valid YAML.""" + out_file = tmp_path / "flow.yaml" + scene_plan = _make_scene_plan([_make_scene()]) + adapt(scene_plan, _default_options(output_path=str(out_file))) + + assert out_file.exists() + content = out_file.read_text() + flow = yaml.safe_load(content) + assert flow["name"] == "test-project" + + +def test_empty_generatable_scenes_raises(): + """All scenes are text_card → ClothoAdapterError raised.""" + scene_plan = _make_scene_plan([ + _make_scene(scene_id="tc01", scene_type="text_card"), + _make_scene(scene_id="tc02", scene_type="animation"), + ]) + with pytest.raises(ClothoAdapterError, match="no generatable scenes"): + adapt(scene_plan, _default_options()) + + +def test_cost_estimate(): + """Known model selection → cost matches _MODEL_COSTS_USD.""" + # generated scene, no refs → Seedance (tier default, None → 0.40) + image (0.04) + scene_plan = _make_scene_plan([_make_scene(scene_type="generated")]) + result = adapt(scene_plan, _default_options()) + # image (0.04) + seedance (0.40) = 0.44 + assert abs(result.estimated_cost_usd - 0.44) < 1e-6 + + +def test_cost_estimate_kling(): + """talking_head → kling-2.5-turbo-pro (0.70) + image (0.04) = 0.74.""" + scene = _make_scene( + scene_type="talking_head", + required_assets=[{"type": "video", "source": "generate"}], + ) + scene_plan = _make_scene_plan([scene]) + result = adapt(scene_plan, _default_options()) + assert abs(result.estimated_cost_usd - 0.74) < 1e-6 + + +def test_flow_structure(): + """Top-level flow dict has expected keys.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options()) + flow = yaml.safe_load(result.flow_yaml) + + assert flow["version"] == 1 + assert flow["name"] == "test-project" + assert flow["consumer"] == "openmontage-cinematic" + assert flow["tier"] == "balanced" + assert isinstance(flow["nodes"], list) + assert isinstance(flow["outputs"], list) + assert flow["outputs"][0]["name"] == "final" + assert "{{ full-cut.output }}" in flow["outputs"][0]["from"] + + +def test_save_output_in_flow(): + """save_output set → outputs[0].save present in flow.""" + scene_plan = _make_scene_plan([_make_scene()]) + opts = _default_options(save_output="./out/test.mp4") + result = adapt(scene_plan, opts) + flow = yaml.safe_load(result.flow_yaml) + assert flow["outputs"][0]["save"] == "./out/test.mp4" + + +def test_save_output_omitted_when_none(): + """save_output=None → 'save' key absent from outputs.""" + scene_plan = _make_scene_plan([_make_scene()]) + result = adapt(scene_plan, _default_options(save_output=None)) + flow = yaml.safe_load(result.flow_yaml) + assert "save" not in flow["outputs"][0] + + +def test_multiple_scenes_concat_order(): + """Multiple scenes → clip node ids in concat videos list, in order.""" + s1 = _make_scene(scene_id="s01", start=0, end=5) + s2 = _make_scene(scene_id="s02", start=5, end=10) + scene_plan = _make_scene_plan([s1, s2]) + result = adapt(scene_plan, _default_options()) + + flow = yaml.safe_load(result.flow_yaml) + concat = next(n for n in flow["nodes"] if n["id"] == "full-cut") + videos = concat["inputs"]["videos"] + assert len(videos) == 2 + assert "s1-clip" in videos[0] + assert "s2-clip" in videos[1] + + +def test_broll_type_treated_as_generative(): + """broll scene type → generates still + clip nodes.""" + scene_plan = _make_scene_plan([_make_scene(scene_type="broll")]) + result = adapt(scene_plan, _default_options()) + flow = yaml.safe_load(result.flow_yaml) + node_ids = [n["id"] for n in flow["nodes"]] + assert "s1-still" in node_ids + assert "s1-clip" in node_ids + + +def test_invalid_aspect_ratio_raises(): + """Invalid aspect_ratio → ClothoAdapterError.""" + scene_plan = _make_scene_plan([_make_scene()]) + with pytest.raises(ClothoAdapterError, match="Invalid aspect_ratio"): + adapt(scene_plan, _default_options(aspect_ratio="4:3")) + + +def test_empty_scene_plan_raises(): + """Empty scenes list → ClothoAdapterError.""" + with pytest.raises(ClothoAdapterError, match="no scenes"): + adapt({"version": "1.0", "scenes": []}, _default_options()) + + +def test_slugify_project_name(): + """Project name is slugified in flow.name.""" + scene_plan = _make_scene_plan([_make_scene()]) + opts = ClothoAdapterOptions(project_name="My Cool Project 2026!") + result = adapt(scene_plan, opts) + flow = yaml.safe_load(result.flow_yaml) + assert flow["name"] == "my-cool-project-2026"