From 0f10381f2b3129c6a7c01d7ed27b17434fdfff74 Mon Sep 17 00:00:00 2001 From: calesthio Date: Sat, 27 Jun 2026 11:15:36 -0700 Subject: [PATCH] feat(atelier): repo-root projects/, scaffolder, schemas (F1 + F3 + F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining three atelier audit gaps. F1 — atelier projects live under repo-root projects// (the established OpenMontage convention), not under remotion-composer/. _render_via_atelier now auto-stages source files into remotion-composer/projects// via mtime-skip copy when the entry isn't already under the composer tree. Originally tried a directory junction, but webpack walks up from the entry's REAL location to resolve node_modules — junctions get dereferenced and module resolution fails. Copy keeps source-of-truth under projects/, is invisible to the user's project view, and is cheap (typical project is a few small .tsx files). Output stays wherever output_path says. effective_entry is returned in tool data for transparency. F3 — scripts/scaffold_atelier_project.py generates the engine boilerplate (index.tsx / Root.tsx / Composition.tsx / art-direction.md / props template / README) ONLY. No creative content emitted — the Composition.tsx is a deliberately blank black placeholder with prominent TODOs. This removes the friction that nudged agents back to templated mode while making it impossible to ship the scaffold unauthored (review correctly flags black frames / silent audio as 'revise'). Verified end-to-end: scaffold → render → review fires the right warnings. F5 — schemas extended to know about atelier: - decision_log: 'composition_mode' added to category enum. - proposal_packet.production_plan: composition_mode + art_direction. - edit_decisions: composition_mode + bespoke{entry, composition_id, art_direction, props_path, public_dir, scale, crf, concurrency}. - final_review.checks: 'atelier' block (stock_reuse_detected, offending_imports[], art_direction_declared, art_direction). - final_review.recommended_action: 're_author' added. - final_review.transcript_comparison: allow null word_accuracy / transcript_matches_script (pre-existing latent bug surfaced here). Both pass and fail shapes of the atelier render's final_review now validate against the updated schema. --- schemas/artifacts/decision_log.schema.json | 1 + schemas/artifacts/edit_decisions.schema.json | 20 ++ schemas/artifacts/final_review.schema.json | 27 +- schemas/artifacts/proposal_packet.schema.json | 9 + scripts/scaffold_atelier_project.py | 237 ++++++++++++++++++ tools/video/video_compose.py | 110 +++++++- 6 files changed, 389 insertions(+), 15 deletions(-) create mode 100644 scripts/scaffold_atelier_project.py diff --git a/schemas/artifacts/decision_log.schema.json b/schemas/artifacts/decision_log.schema.json index 7ae2dc0..c4076db 100644 --- a/schemas/artifacts/decision_log.schema.json +++ b/schemas/artifacts/decision_log.schema.json @@ -29,6 +29,7 @@ "provider_selection", "renderer_family_selection", "render_runtime_selection", + "composition_mode", "playbook_selection", "fallback_decision", "budget_tradeoff", diff --git a/schemas/artifacts/edit_decisions.schema.json b/schemas/artifacts/edit_decisions.schema.json index 8f20506..63eeca4 100644 --- a/schemas/artifacts/edit_decisions.schema.json +++ b/schemas/artifacts/edit_decisions.schema.json @@ -199,6 +199,26 @@ "enum": ["remotion", "hyperframes", "ffmpeg"], "description": "Locked at proposal stage — technical runtime that realizes renderer_family. Edit MUST carry this forward unchanged unless a logged render_runtime_selection decision overrides it." }, + "composition_mode": { + "type": "string", + "enum": ["templated", "atelier"], + "description": "Locked at proposal — HOW the composition is built. Edit MUST carry forward unchanged. 'atelier' routes video_compose to _render_via_atelier (no cut-schema, no stock registry); requires `bespoke` block below." + }, + "bespoke": { + "type": "object", + "description": "Required when composition_mode='atelier'. Contract for the hand-authored, project-local Remotion render. See tools/video/video_compose.py → _render_via_atelier docstring.", + "required": ["entry", "composition_id", "art_direction"], + "properties": { + "entry": { "type": "string", "description": "Path to the project-local Remotion entry .tsx (typically projects//index.tsx). If outside remotion-composer/, auto-staged via directory junction/symlink at render time." }, + "composition_id": { "type": "string", "description": "id registered in that entry's Root" }, + "art_direction": { "type": "string", "description": "Short commitment to a fresh visual language (or a path to art-direction.md). REQUIRED — enforced by _run_atelier_checks." }, + "props_path": { "type": "string", "description": "Absolute path to a props JSON (Remotion --props)" }, + "public_dir": { "type": "string", "description": "Per-project public dir (avoids copying the bloated shared remotion-composer/public/)" }, + "scale": { "type": "number", "minimum": 0.1, "maximum": 1.0 }, + "crf": { "type": "integer", "minimum": 0, "maximum": 51 }, + "concurrency": { "type": "integer", "minimum": 1 } + } + }, "slideshow_risk_score": { "type": "object", "description": "Slideshow risk assessment from lib/slideshow_risk.py", diff --git a/schemas/artifacts/final_review.schema.json b/schemas/artifacts/final_review.schema.json index 709e4ce..4514d55 100644 --- a/schemas/artifacts/final_review.schema.json +++ b/schemas/artifacts/final_review.schema.json @@ -122,13 +122,34 @@ "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 }, + "transcript_matches_script": { "type": ["boolean", "null"] }, + "word_accuracy": { "type": ["number", "null"], "minimum": 0, "maximum": 1 }, "issues": { "type": "array", "items": { "type": "string" } } } + }, + "atelier": { + "type": "object", + "description": "Atelier-only doctrine checks (populated when composition_mode='atelier'). Generated by tools/video/video_compose.py → _run_atelier_checks.", + "properties": { + "stock_reuse_detected": { "type": "boolean", "description": "TRUE if the bespoke project imports from the stock creative registry — fails the render." }, + "offending_imports": { + "type": "array", + "description": "Files + import paths that violated the doctrine.", + "items": { + "type": "object", + "properties": { + "file": { "type": "string" }, + "import": { "type": "string" } + } + } + }, + "art_direction_declared": { "type": "boolean" }, + "art_direction": { "type": ["string", "null"] }, + "issues": { "type": "array", "items": { "type": "string" } } + } } }, "additionalProperties": false @@ -140,7 +161,7 @@ }, "recommended_action": { "type": "string", - "enum": ["present_to_user", "re_render", "revise_edit", "revise_assets", "block"], + "enum": ["present_to_user", "re_render", "revise_edit", "revise_assets", "block", "re_author"], "description": "What the agent should do next based on the review findings" }, "metadata": { "type": "object" } diff --git a/schemas/artifacts/proposal_packet.schema.json b/schemas/artifacts/proposal_packet.schema.json index fb97c0f..55a1169 100644 --- a/schemas/artifacts/proposal_packet.schema.json +++ b/schemas/artifacts/proposal_packet.schema.json @@ -155,6 +155,15 @@ "enum": ["remotion", "hyperframes", "ffmpeg"], "description": "Locked at proposal stage — the technical runtime that realizes renderer_family. remotion=React scene components, hyperframes=HTML/CSS/GSAP, ffmpeg=simple concat/trim. Must be explicit and auditable; silent swaps are forbidden." }, + "composition_mode": { + "type": "string", + "enum": ["templated", "atelier"], + "description": "Locked at proposal — HOW the composition is built (orthogonal to render_runtime). templated=assemble stock cut.type scenes (Explainer/CinematicRenderer); atelier=hand-author a project-local composition from scratch with no creative-component reuse. Default atelier for hero work. See AGENT_GUIDE.md → 'Composition Authoring Mode' and skills/meta/bespoke-composition.md. The decision MUST be logged in decision_log with category='composition_mode' and BOTH options presented." + }, + "art_direction": { + "type": "string", + "description": "Required when composition_mode='atelier'. Short note (or path to art-direction.md) committing to a fresh visual language for THIS piece — palette, type, motion, signature device. Per skills/meta/bespoke-composition.md step 1, written down BEFORE authoring scenes." + }, "music_source": { "type": "object", "description": "Resolved music plan from the proposal stage", diff --git a/scripts/scaffold_atelier_project.py b/scripts/scaffold_atelier_project.py new file mode 100644 index 0000000..5cd6812 --- /dev/null +++ b/scripts/scaffold_atelier_project.py @@ -0,0 +1,237 @@ +"""Scaffold a project-local bespoke (atelier) Remotion composition. + +This is the ONLY thing reused across atelier videos: the engine plumbing +(registerRoot/Composition/calculateMetadata boilerplate and the project layout). +**No creative content is ever emitted.** The placeholder scene is deliberately +blank — the agent must hand-author it from scratch per +`skills/meta/bespoke-composition.md`. + +Why this exists: friction is what nudges agents back to the templated path. +Re-deriving the entry/Root/index boilerplate from memory every time is the kind +of friction this removes; emitting a finished scene would reintroduce the +template trap. + +Usage: + python scripts/scaffold_atelier_project.py [--composition-id CamelName] + +Creates: + projects// + index.tsx # registerRoot(Root) + Root.tsx # one Composition + calculateMetadata + Composition.tsx # EMPTY scene with TODO; no imports from src/ + art-direction.md # checklist to fill BEFORE authoring + artifacts/props.template.json + assets/{audio,music,footage}/ + public/ # narration/music get copied here for staticFile() + renders/ + README.md # render command + doctrine pointer +""" +from __future__ import annotations +import argparse +import re +import sys +from pathlib import Path + + +def to_camel(slug: str) -> str: + parts = re.split(r"[\s_\-]+", slug.strip()) + return "".join(p.capitalize() for p in parts if p) or "Bespoke" + + +def write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + print(f" skip (exists) {path.relative_to(Path.cwd())}") + return + path.write_text(content, encoding="utf-8") + print(f" wrote {path.relative_to(Path.cwd())}") + + +def scaffold(slug: str, comp_id: str, root: Path) -> Path: + proj = root / "projects" / slug + (proj / "artifacts").mkdir(parents=True, exist_ok=True) + for sub in ("assets/audio", "assets/music", "assets/footage", "public", "renders"): + (proj / sub).mkdir(parents=True, exist_ok=True) + + # --- index.tsx ------------------------------------------------------- + write(proj / "index.tsx", """\ +import { registerRoot } from "remotion"; +import { Root } from "./Root"; + +registerRoot(Root); +""") + + # --- Root.tsx -------------------------------------------------------- + write(proj / "Root.tsx", f"""\ +import {{ Composition }} from "remotion"; +import {{ Scene, calculateMetadata, SceneProps }} from "./Composition"; + +export const Root: React.FC = () => ( + +); +""") + + # --- Composition.tsx (EMPTY scene; no creative content) -------------- + write(proj / "Composition.tsx", """\ +import React from "react"; +import { AbsoluteFill, CalculateMetadataFunction } from "remotion"; + +// ---------------------------------------------------------------------------- +// ATELIER (BESPOKE) — hand-authored from scratch. +// +// HARD RULES (enforced by tools/video/video_compose.py → _run_atelier_checks +// and skills/meta/reviewer.md → Composition Authoring Mode Review): +// 1. Do NOT import from remotion-composer/src/components, src/Explainer, +// src/CinematicRenderer, src/{TitledVideo,TalkingHead,CollageBurst,...}. +// The stock registry is a mechanics codex, not a parts bin. +// 2. Read skills/meta/bespoke-composition.md FIRST. +// 3. Fill in art-direction.md BEFORE writing the scene. +// +// Engine knowledge you MAY reuse freely (from `remotion`, `@remotion/*`): +// useCurrentFrame, useVideoConfig, spring, interpolate, Sequence, +// AbsoluteFill, Audio, OffthreadVideo, Img, staticFile, random, Easing. +// ---------------------------------------------------------------------------- + +export interface SceneProps { + // TODO: define the props your composition consumes (timing, narration path, + // captions, etc.). Keep this minimal — props are data, not configuration. +} + +export const Scene: React.FC = () => { + // TODO: hand-stitch your scene here. The placeholder below renders solid + // black so the render pipeline can be validated end-to-end before authoring. + // Remove it before committing. + return ; +}; + +export const calculateMetadata: CalculateMetadataFunction = async ({ props }) => ({ + durationInFrames: 30 * 30, // TODO: derive from your props (e.g. total seconds * fps) + fps: 30, + width: 1920, + height: 1080, +}); +""") + + # --- art-direction.md (the divergence engine — fill BEFORE authoring) --- + write(proj / "art-direction.md", f"""\ +# Art Direction — {slug} + +> Fill this in BEFORE writing any scene code. It is the divergence engine: the +> reason this video looks like nothing you've made before. Per +> `skills/meta/bespoke-composition.md` step 1 and reviewer enforcement. + +## Subject +What this video is about, in one sentence. What makes its *visual* problem +unlike any other you've solved. + +## Palette +Three to five concrete hex colors. Why these specifically — what feeling do they +carry, what does the subject demand? + +## Type personality +Two or three concrete fonts (heading, body, accent). Why this voice, not +another? + +## Motion character +How things move and feel — adjectives + concrete physics (spring damping, +durations, easings). "Settling, ink-on-paper" feels different from "snap, neon" +even with the same Remotion primitives. + +## Layout & rhythm +Where the eye goes. Hierarchy. Negative space. Time signature of cuts. + +## Signature device +ONE bespoke visual element that belongs to *this* video and no other. +A hand-drawn diagram. An ink-settle reveal. A coin stamp. A custom transition. +If this slot is blank, the video will look like every other. + +## Anti-references +What this should NOT look like — including any prior video you've made. +Naming them keeps you from drifting into them. +""") + + # --- props template ------------------------------------------------------ + write(proj / "artifacts" / "props.template.json", """\ +{ + "// note": "Fill from your build pipeline. Put narration.mp3 / music.mp3 etc.", + "// note2": "in ./public/ so Remotion staticFile() can resolve them." +} +""") + + # --- README ------------------------------------------------------------- + rel_proj = f"projects/{slug}" + write(proj / "README.md", f"""\ +# {slug} — atelier (bespoke) composition + +Hand-authored Remotion composition. Source of truth lives here under +`{rel_proj}/`; at render time the atelier path auto-stages a junction at +`remotion-composer/projects/{slug}/` so the bundler can resolve `node_modules`. + +## Doctrine +- Read `skills/meta/bespoke-composition.md` first. +- Fill in `art-direction.md` BEFORE authoring scenes. +- No imports from `remotion-composer/src/*` (the tool will fail the render). +- Reuse engine knowledge only; hand-stitch every creative component. + +## Render + +```python +from tools.video.video_compose import VideoCompose +P = r"{rel_proj.replace('/', chr(92)*2)}" # absolute path on your machine +VideoCompose().execute({{ + "operation": "render", + "output_path": P + r"\\renders\\final.mp4", + "edit_decisions": {{ + "render_runtime": "remotion", + "composition_mode": "atelier", + "bespoke": {{ + "entry": P + r"\\index.tsx", + "composition_id": "{comp_id}", + "props_path": P + r"\\artifacts\\props.json", + "public_dir": P + r"\\public", + "art_direction": "", + "scale": 1.0, "crf": 18, "concurrency": 8 + }} + }} +}}) +``` +""") + + return proj + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("slug", help="kebab-case project name, e.g. 'compound-snowball'") + ap.add_argument("--composition-id", help="React composition id (default: CamelCase of slug)") + ap.add_argument("--root", default=".", help="Repo root (default: cwd)") + args = ap.parse_args(argv) + + slug = args.slug.strip().lower() + if not re.match(r"^[a-z][a-z0-9\-]*$", slug): + print(f"error: slug must be kebab-case, got {slug!r}", file=sys.stderr) + return 2 + comp_id = args.composition_id or to_camel(slug) + root = Path(args.root).resolve() + + print(f"Scaffolding atelier project '{slug}' (composition_id={comp_id}) under {root}\n") + proj = scaffold(slug, comp_id, root) + print(f"\nDone. Now:") + print(f" 1. Open {proj.relative_to(root)}/art-direction.md and fill it in.") + print(f" 2. Read skills/meta/bespoke-composition.md.") + print(f" 3. Hand-author Composition.tsx (replace the black placeholder).") + print(f" 4. Render — see {proj.relative_to(root)}/README.md.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/video/video_compose.py b/tools/video/video_compose.py index dc8bc67..7d424b4 100644 --- a/tools/video/video_compose.py +++ b/tools/video/video_compose.py @@ -735,25 +735,35 @@ class VideoCompose(BaseTool): if not entry_path.exists(): return ToolResult(success=False, error=f"atelier entry not found: {entry_path}") - # The entry must live under remotion-composer/ so Remotion's bundler can - # resolve `remotion` and friends from node_modules. Project-local - # bespoke compositions therefore live at remotion-composer/projects//. + # Remotion's bundler resolves `remotion` and friends by walking up from the + # entry file to find node_modules — so the entry must live under + # remotion-composer/ at render time. But OpenMontage's project convention is + # repo-root projects//, where artifacts/assets/renders/ already live. + # Resolution: keep the source of truth under projects// and auto-stage + # a directory junction (Windows) / symlink (Unix) at + # remotion-composer/projects// → projects// so the bundler sees + # the entry inside the composer tree without us copying files. Junctions are + # weightless, idempotent across renders, and need no admin/dev-mode on Windows. try: entry_path.relative_to(composer_dir) + effective_entry = entry_path except ValueError: - return ToolResult( - success=False, - error=( - f"atelier entry {entry_path} must live under {composer_dir} so the " - f"Remotion bundler can resolve node_modules. Place bespoke " - f"compositions under remotion-composer/projects// (gitignored)." - ), - ) + try: + effective_entry = self._stage_atelier_project(entry_path, composer_dir) + except Exception as e: + return ToolResult( + success=False, + error=( + f"atelier auto-stage failed for entry {entry_path}: {e}. " + f"Either place the entry under {composer_dir}/projects// " + f"directly, or fix the staging permission issue." + ), + ) output_path = Path(inputs.get("output_path", "renders/output.mp4")).resolve() output_path.parent.mkdir(parents=True, exist_ok=True) - cmd = ["npx", "remotion", "render", str(entry_path), str(comp_id), str(output_path)] + cmd = ["npx", "remotion", "render", str(effective_entry), str(comp_id), str(output_path)] props_path = bespoke.get("props_path") if props_path: @@ -819,6 +829,7 @@ class VideoCompose(BaseTool): "operation": "render", "composition_mode": "atelier", "entry": str(entry_path), + "effective_entry": str(effective_entry) if effective_entry != entry_path else None, "composition_id": comp_id, "output": str(output_path), "final_review": final_review, @@ -838,6 +849,81 @@ class VideoCompose(BaseTool): return ToolResult(success=True, data=data, artifacts=[str(output_path)]) + # Source-file extensions that get staged into the composer tree at render time. + # Anything not in this set lives only under the real project dir (assets, renders, + # artifacts) and is referenced via --public-dir or absolute paths. + _ATELIER_STAGE_EXTS = {".tsx", ".ts", ".jsx", ".js", ".css"} + + def _stage_atelier_project(self, entry_path: Path, composer_dir: Path) -> Path: + """Auto-stage a bespoke project under remotion-composer/projects//. + + The source of truth lives under the repo-root `projects//` (where + artifacts/, assets/, renders/ already are). Remotion's webpack bundler, + however, resolves modules (`remotion`, `@remotion/*`) by walking up from + the entry's REAL location — so a directory junction/symlink would + dereference and webpack would fail to find node_modules. We copy the + source files into a sibling dir inside the composer tree instead. + + mtime-skip semantics make repeat renders cheap (typical project is a + handful of small .tsx files). Non-source files (assets, renders, props + JSON) stay only in the real project dir and are referenced via + --public-dir or absolute paths in props. + + Resolves the slug as the first path segment under a `projects/` ancestor; + falls back to the entry's parent directory name. Returns the staged entry + path. + """ + import shutil + + real_project_dir = entry_path.parent.resolve() + + # Derive a stable slug. Prefer the first segment under a `projects/` ancestor. + slug = real_project_dir.name + try: + parts = real_project_dir.parts + if "projects" in parts: + i = parts.index("projects") + if i + 1 < len(parts): + slug = parts[i + 1] + except Exception: + pass + + staging_root = composer_dir / "projects" + staging_root.mkdir(parents=True, exist_ok=True) + staging_dir = staging_root / slug + + # If a stale junction/symlink is in the way from an earlier (failed) attempt, + # remove it before creating a real staging directory. + if staging_dir.is_symlink() or (staging_dir.exists() and staging_dir.is_dir() + and staging_dir.resolve() != staging_dir): + try: + staging_dir.unlink() + except (OSError, PermissionError): + # Some Windows junctions need rmdir + import subprocess as _sp + _sp.run(["cmd", "/c", "rmdir", str(staging_dir)], check=True) + + staging_dir.mkdir(parents=True, exist_ok=True) + + # mtime-skip copy of source files only. Mirrors directory structure so + # relative imports work identically. + for src in real_project_dir.rglob("*"): + if not src.is_file(): + continue + if src.suffix.lower() not in self._ATELIER_STAGE_EXTS: + continue + rel = src.relative_to(real_project_dir) + dst = staging_dir / rel + try: + if dst.exists() and dst.stat().st_mtime >= src.stat().st_mtime: + continue + except OSError: + pass + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + return staging_dir / entry_path.name + # Stock-registry import patterns that violate the atelier doctrine. # Any of these inside a bespoke project tree means a creative component # was reused instead of hand-stitched. Engine knowledge (the `remotion`