Merge pull request #203 from calesthio/feat/atelier-bespoke-compositions
Atelier (bespoke) composition mode — hand-stitched every time
This commit is contained in:
@@ -81,6 +81,9 @@ node_modules/
|
||||
|
||||
# Remotion build output and copied project assets (regenerable at render time)
|
||||
remotion-composer/out/
|
||||
# Project-local bespoke ("atelier") compositions — hand-authored per video,
|
||||
# throwaway and regenerable. Keeps the shared src/ free of one-off scenes.
|
||||
remotion-composer/projects/
|
||||
remotion-composer/public/*
|
||||
# But keep demo props (shipped with the project for zero-key demos)
|
||||
!remotion-composer/public/demo-props/
|
||||
|
||||
@@ -130,6 +130,15 @@ Exception: if only one runtime is available on the machine, the agent proceeds w
|
||||
|
||||
This rule applies to every pipeline that invokes `video_compose` — not just Wave 1. A pipeline's director skill may recommend a runtime, but that recommendation is input to the conversation with the user, not a decision.
|
||||
|
||||
### Composition Authoring Mode — Templated vs Atelier
|
||||
|
||||
Orthogonal to *runtime* is *authoring mode*: **how** the composition is built. Present it as its own proposal decision and log it in `decision_log` (`category: "composition_mode"`).
|
||||
|
||||
- **Templated** — assemble the stock `cut.type` scene-types (`text_card`, `stat_card`, `bar_chart`, …) into the `Explainer`/`CinematicRenderer` compositions. Fast, cheap, reliable — and the reason most videos look alike. Right for batch output, localization variants, quick drafts, and low-stakes internal clips.
|
||||
- **Atelier** — **hand-author the composition from scratch**: bespoke scenes, a one-off theme, and motion written for this piece, rendered via `composition_mode: "atelier"` (see `video_compose` → `_render_via_atelier`). No reusable creative components; a fresh visual language every time.
|
||||
|
||||
**Default to atelier for hero work** — marketing, launches, brand pieces, any single-deliverable explainer that must impress. The deciding rule: *reuse engine knowledge, never creative components.* In atelier mode the stock scene-type catalog, `hyperframes-registry` blocks, fixtures, and finished components are **off-limits** — they are frozen looks that reintroduce sameness. Before building, route through **`skills/meta/bespoke-composition.md`**, which sequences: art direction (`visual-style`) → motion principles (Disney 12 via `framer-motion`/`lottie-bodymovin`) → engine mechanics (`remotion-best-practices` + the stock components read *only as a mechanics codex*) → render via the atelier path. Close with a **distinctness review**: *could this be any other product's video? does it reuse a look I've made before?* — the inverse of "does it match the reference." Atelier costs more tokens and iteration than templated; say so at proposal so the user opts in knowingly.
|
||||
|
||||
### Escalate Blockers Explicitly
|
||||
|
||||
When a blocker occurs, the agent must surface it immediately using this structure:
|
||||
@@ -396,6 +405,8 @@ For these requests:
|
||||
See `remotion-composer/SCENE_TYPES.md` for the authoritative list and their cut schemas. Current scene types usable via `cut.type`:
|
||||
`text_card`, `stat_card`, `callout`, `comparison`, `hero_title`, `terminal_scene`, `anime_scene`, `bar_chart`, `line_chart`, `pie_chart`, `kpi_grid`, `progress_bar`. Overlay types include `section_title`, `stat_reveal`, `hero_title`, `provider_chip`.
|
||||
|
||||
These stock scene-types are the **templated** path — fast and reliable, but they are why videos look alike. For **hero work, prefer atelier mode** (hand-authored composition) over this catalog; read those types as a *mechanics codex*, not a menu to assemble. See "Composition Authoring Mode" above and `skills/meta/bespoke-composition.md`.
|
||||
|
||||
**When Remotion is NOT available** and `render_runtime="remotion"` was NOT locked, `video_compose` may use FFmpeg Ken Burns motion on still images. This still works but produces less engaging visuals. Mention this tradeoff in the proposal. When `render_runtime="remotion"` IS locked and Remotion is unavailable, that's a blocker — escalate, don't silently swap.
|
||||
|
||||
When `render_runtime="hyperframes"` is locked and HyperFrames is unavailable (Node < 22, missing `ffmpeg`/`npx`, or `hyperframes doctor` reports issues), that's also a blocker. Do not substitute Remotion or FFmpeg without user approval + a logged `render_runtime_selection` decision.
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"provider_selection",
|
||||
"renderer_family_selection",
|
||||
"render_runtime_selection",
|
||||
"composition_mode",
|
||||
"playbook_selection",
|
||||
"fallback_decision",
|
||||
"budget_tradeoff",
|
||||
|
||||
@@ -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/<slug>/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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <slug> [--composition-id CamelName]
|
||||
|
||||
Creates:
|
||||
projects/<slug>/
|
||||
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
|
||||
id="{comp_id}"
|
||||
component={{Scene}}
|
||||
durationInFrames={{30 * 30}}
|
||||
fps={{30}}
|
||||
width={{1920}}
|
||||
height={{1080}}
|
||||
defaultProps={{ {{ /* fill from artifacts/props.json at render time */ }} as SceneProps }}
|
||||
calculateMetadata={{calculateMetadata}}
|
||||
/>
|
||||
);
|
||||
""")
|
||||
|
||||
# --- 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<SceneProps> = () => {
|
||||
// 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 <AbsoluteFill style={{ background: "#000" }} />;
|
||||
};
|
||||
|
||||
export const calculateMetadata: CalculateMetadataFunction<SceneProps> = 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": "<short note OR path to art-direction.md>",
|
||||
"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())
|
||||
@@ -280,6 +280,8 @@ Cross-cutting skills that apply to all pipelines:
|
||||
| Reviewer | `meta/reviewer.md` | Self-review protocol after every stage |
|
||||
| Checkpoint Protocol | `meta/checkpoint-protocol.md` | When/how to checkpoint and request human approval |
|
||||
| Skill Creator | `meta/skill-creator.md` | Dynamically create new skills during pipeline runs |
|
||||
| Animation Runtime Selector | `meta/animation-runtime-selector.md` | Choose render runtime + animation library per scene |
|
||||
| Bespoke Composition (Atelier) | `meta/bespoke-composition.md` | Hand-author a composition from scratch (hero work) — no stock scene-types; routes art-direction → motion principles → engine mechanics → atelier render |
|
||||
|
||||
## Style Playbooks
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ Meta-skill that answers two questions:
|
||||
|
||||
Read this before authoring any animated component or composition, and whenever you're choosing `render_runtime` at proposal time. It routes you to the right Layer 3 skill so you don't waste time hand-rolling what a plugin already solves.
|
||||
|
||||
> **Authoring mode comes first.** Before runtime or library, decide *how* the composition is
|
||||
> built: **templated** (assemble stock `cut.type` scenes) vs **atelier** (hand-author from
|
||||
> scratch). Default to atelier for hero work and follow `skills/meta/bespoke-composition.md`.
|
||||
> The routing below applies within either mode — but in atelier mode the stock scene-types and
|
||||
> registry blocks are off-limits; you write your own. "Does a stock cut-type fit?" is **not** a
|
||||
> valid shortcut for a hero piece. See `AGENT_GUIDE.md` → "Composition Authoring Mode".
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Apply when:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Bespoke Composition (Atelier Mode)
|
||||
|
||||
Meta-skill for **hand-authoring a composition from scratch** instead of assembling stock
|
||||
scene-types. This is the "hand-stitched every time" path: for hero pieces, every pixel of the
|
||||
look is written fresh so no two videos share a visual language.
|
||||
|
||||
Read this whenever you've chosen **atelier mode** for a piece (see "When to use"). It does not
|
||||
hand you components — it routes you to the *principles, engine mechanics, and tool wiring* you
|
||||
need so that what you build is correct, and distinct.
|
||||
|
||||
> The single rule that governs everything below: **reuse engine knowledge, never creative
|
||||
> components.** How Remotion resolves an asset is engine knowledge — reuse it freely. How a
|
||||
> previous video looked is a creative decision — never reuse it.
|
||||
|
||||
## When to use this skill (authoring mode is a proposal decision)
|
||||
|
||||
OpenMontage now separates three orthogonal axes, all locked at proposal:
|
||||
|
||||
- `renderer_family` — creative grammar
|
||||
- `render_runtime` — technical engine (remotion / hyperframes / ffmpeg)
|
||||
- **`composition_mode`** — **templated** (assemble stock `cut.type` scenes) **vs. atelier** (hand-author)
|
||||
|
||||
Pick **atelier** by default for: marketing, launches, explainers that must impress, brand
|
||||
pieces, anything single-deliverable where quality is the point. Pick **templated** for: batch
|
||||
output, localization variants, quick drafts, low-stakes internal clips — places where reliable
|
||||
sameness is fine and bespoke cost is unjustified. Present the choice to the user at proposal and
|
||||
log it in `decision_log` (`category: "composition_mode"`), the same way you present runtime.
|
||||
|
||||
If atelier is chosen, the stock scene-type catalog, the `hyperframes-registry` blocks, fixtures,
|
||||
and any finished component are **off-limits** — they are frozen looks and reintroduce sameness.
|
||||
|
||||
## The construction route
|
||||
|
||||
Author in this order. Each step routes you to existing knowledge — do not skip the first one.
|
||||
|
||||
### 1. Commit to an art direction *for this subject* — the divergence engine
|
||||
Before writing any component, decide a visual language that fits **this** topic and no other.
|
||||
Use the **`visual-style`** Layer 3 skill (CREATE mode) to lock: palette, type personality,
|
||||
motion character, layout system, and **one signature device** unique to this piece. Difference
|
||||
between videos is guaranteed here — not by withholding components, but by forcing a fresh
|
||||
direction each time. Write it down (a short `art-direction.md` in the project) and build to it.
|
||||
|
||||
Ask yourself: *what visual metaphor belongs to this subject that I have not used before?* If the
|
||||
answer resembles a past piece, you haven't found the direction yet.
|
||||
|
||||
### 1.5 Plan each scene as its own composition — no hero-component spine
|
||||
The most insidious form of templating sneaks back in at the *scene* level: pick one striking
|
||||
visual (a candle, a browser frame, a score ring), then re-use it every scene with different
|
||||
text underneath. The piece feels custom because the hero is custom — but every scene is
|
||||
mechanically the same composition. That's branded slides, not a film. **Don't do that.**
|
||||
|
||||
The signature device named in your art-direction is meant to appear in **one or at most two
|
||||
beats** — typically the climactic moment — not as the visual scaffolding of every scene. It
|
||||
earns its weight by being scarce.
|
||||
|
||||
For each scene in the plan, answer concretely *before* writing code:
|
||||
|
||||
- **What is this scene's primary visual subject?** It must be *different* from the previous
|
||||
scene's. A character. A diagram. A piece of evidence. A landscape. A typographic moment. The
|
||||
signature device. A void. Each scene's primary subject is its job.
|
||||
- **Why does this beat exist?** What does it do for the story that no other beat does? If you
|
||||
can collapse two scenes into one without losing meaning, you should.
|
||||
- **How does it differ visually from the scene before and after?** Different composition (rule
|
||||
of thirds vs centered vs split). Different scale (intimate close vs wide field). Different
|
||||
motion register (still vs busy). Different palette emphasis. Different type treatment.
|
||||
- **If you removed the signature device from this scene, would the scene still work?** If yes,
|
||||
the signature device probably doesn't belong in this scene — it's there as filler. Cut it.
|
||||
|
||||
The reviewer enforces this as a "scene_distinctness" check (see
|
||||
`skills/meta/reviewer.md` → Composition Authoring Mode Review): a recorded inventory of
|
||||
each scene's primary subject + first frame, and an explicit answer to "do any two scenes
|
||||
share their primary visual subject?" Yes ⇒ CRITICAL ⇒ re-plan.
|
||||
|
||||
The corollary: the per-scene plan is a *first-class artifact*, not implied. Write it down
|
||||
(in `art-direction.md` or a sibling `scenes.md`) before authoring `Composition.tsx`.
|
||||
|
||||
### 2. Decide the motion language — principles, not presets
|
||||
Reach for **principle** skills, never finished animations:
|
||||
- **`framer-motion`** and **`lottie-bodymovin`** — Disney's 12 principles (anticipation, staging,
|
||||
follow-through, slow-in/out, arc, timing, exaggeration, appeal). Runtime-agnostic; apply the
|
||||
*principles* in your own Remotion `spring()`/`interpolate()` code.
|
||||
- The HyperFrames `references/motion-principles.md` — easing as emotion, timing as weight.
|
||||
|
||||
### 3. Reach for a richer vocabulary only when the concept demands it
|
||||
Most scenes are Remotion primitives. Escalate when the *idea* needs it, not by default:
|
||||
`gsap-*` (kinetic typography via SplitText, shape morph via MorphSVG, curved motion via
|
||||
MotionPath, line-draw via DrawSVG, custom easing), `threejs-*` (3D), `d3-viz` (data-driven
|
||||
custom charts — build the chart by hand; do **not** drop in the stock `bar_chart`/`line_chart`),
|
||||
`manim-*` (math), `canvas-procedural-animation` (particles/weather).
|
||||
|
||||
### 4. Get the engine mechanics right — the gotcha codex
|
||||
This is the only place you "reuse": the engine's solved problems. These are facts about how
|
||||
Remotion works, not looks. Study `.agents/skills/remotion-best-practices` (19 rule files:
|
||||
timing, transitions, text-animations, transparent video, fonts, audio, sequencing, measuring
|
||||
text). You may also read the stock components in `remotion-composer/src/components/` **as a
|
||||
mechanics codex — to learn idioms, never to import or imitate a look.**
|
||||
|
||||
Recurring mechanics that bite if you don't know them:
|
||||
- **Determinism**: no `Math.random()` / `Date.now()` per frame — use Remotion `random(seed)` or a
|
||||
seeded helper, or particles/easing flicker across the render.
|
||||
- **Per-scene duration**: `useVideoConfig().durationInFrames` returns the *composition* length, not
|
||||
your scene's. Drive scene-local timing from a passed `durationInFrames`/`Sequence`, not the global.
|
||||
- **Asset paths**: URLs and `staticFile()` (public/) work everywhere; **`<Audio>` rejects `file://`**
|
||||
(only `<OffthreadVideo>`/`<Img>` accept absolute `file://`). Put audio/video in a per-project
|
||||
public dir and reference via `staticFile`. Mirror the `resolveAsset` helper.
|
||||
- **GSAP-in-Remotion**: use a `paused` timeline and `.seek(frame/fps)` — never `requestAnimationFrame`
|
||||
— so frames render deterministically.
|
||||
- **Fonts**: `loadFont()` from `@remotion/google-fonts/<Name>` at module scope, once.
|
||||
- **Captions vs on-screen text — pick one role, never both for the same content.** Decide
|
||||
once per piece, before authoring: are captions adding meaning the spoken words can't carry
|
||||
(a number, a name, a translation, a quote attribution), OR are they accessibility subtitles
|
||||
echoing the narration? If your scene already displays a SerifLine that reads the script
|
||||
verbatim, do NOT also emit an auto-caption with the same text — the doubled phrase looks
|
||||
amateurish even when the rest of the scene is beautiful. Empty `captions=[]` in props, or
|
||||
scope captions only to scenes where the on-screen text differs from what's being said.
|
||||
|
||||
### 5. Render through the atelier path (project-local, throwaway)
|
||||
Bespoke scenes are **throwaway and project-local** — they never enter the shared `src/` registry.
|
||||
|
||||
- Author under `remotion-composer/projects/<slug>/` (gitignored). It needs its own Remotion entry
|
||||
(`index.tsx` + a `Root` registering only this composition) so it reuses the composer's
|
||||
`node_modules` and stays out of the global `Root.tsx`. The entry MUST live under
|
||||
`remotion-composer/` for the bundler to resolve `remotion`.
|
||||
- Keep media in a small per-project public dir and pass it as `public_dir` so renders don't copy
|
||||
the bloated shared `public/`.
|
||||
- Render via `video_compose` `operation="render"` with:
|
||||
|
||||
```json
|
||||
edit_decisions = {
|
||||
"render_runtime": "remotion",
|
||||
"composition_mode": "atelier",
|
||||
"bespoke": {
|
||||
"entry": "remotion-composer/projects/<slug>/index.tsx",
|
||||
"composition_id": "<id registered in that entry's Root>",
|
||||
"props_path": "<absolute path to props.json>",
|
||||
"public_dir": "<absolute path to the project's public dir>",
|
||||
"scale": 0.5, // 0.5 for a fast draft; drop for the 1080p final
|
||||
"crf": 18, // crisp final
|
||||
"concurrency": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `asset_manifest` or `cuts` are required in atelier mode — the composition owns its own assets.
|
||||
|
||||
## Guardrails so this doesn't backfire
|
||||
|
||||
- **Distinctness review (replaces conformance review).** Before final render, ask: *could this be
|
||||
any other product's video? Does it reuse a look I've made before?* If yes, the art direction
|
||||
failed — return to step 1. This is the inverse of "does it match the reference."
|
||||
- **No silent fallback to stock.** "Keep it simple" applies to *mechanics* (a 10-line spring is
|
||||
fine), never to *design* (simple ≠ reaching for `text_card`). If you catch yourself adding a
|
||||
stock `cut.type` to a hero piece, stop.
|
||||
- **Cost honesty.** Atelier costs more agent tokens and iteration than templated. Say so at proposal
|
||||
so the user opts in knowingly. Quality varies more without a stock baseline — mitigate with strong
|
||||
principle skills (above) and the distinctness review, not by reintroducing reuse.
|
||||
- **Checkpoint cadence.** Follow `skills/meta/checkpoint-protocol.md`: present script + scene plan
|
||||
for approval BEFORE generating assets, then a footage/asset checkpoint, then a first-render
|
||||
checkpoint. Do not batch-generate ahead of sign-off.
|
||||
|
||||
## Worked precedent (for the *workflow*, not the look)
|
||||
|
||||
The first atelier piece was the Phantom Reach explainer (`projects/phantom-reach-explainer/`):
|
||||
Playwright-captured app footage with a PII-blur layer → per-sentence TTS stitched with silence
|
||||
beats → free Pixabay music → hand-authored Remotion scenes (custom intro, score-ring, agentic
|
||||
flow, CTA) on a one-off violet theme. Study its **process** (capture → timing → props builder →
|
||||
bespoke scenes → render). **Do not reproduce its visual language** — the next piece must look
|
||||
nothing like it. That is the whole point.
|
||||
|
||||
See also: `skills/meta/animation-runtime-selector.md` (runtime + library routing),
|
||||
`AGENT_GUIDE.md` → "Composition Authoring Mode".
|
||||
@@ -305,3 +305,39 @@ Run at **compose** and **publish** stages. Ensures the agent reviewed the actual
|
||||
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"
|
||||
|
||||
## Composition Authoring Mode Review
|
||||
|
||||
The templated→atelier inversion (`AGENT_GUIDE.md` → "Composition Authoring Mode" + `skills/meta/bespoke-composition.md`) is governance, not a suggestion. The reviewer is the enforcement point: without these checks, the next agent quietly defaults back to the stock cut-schema and every video starts looking the same again.
|
||||
|
||||
### At proposal stage:
|
||||
1. `decision_log` must contain a `composition_mode` decision with `options_considered: ["templated","atelier"]` and a `selected` value with a real reason tied to the brief.
|
||||
- Missing `composition_mode` decision entirely: **CRITICAL** — "Proposal missing composition_mode choice. Atelier vs templated is a mandatory presented decision (see AGENT_GUIDE.md → Composition Authoring Mode)."
|
||||
- Decision logged with only one option considered: **CRITICAL** — "composition_mode decision logged without presenting both templated and atelier alternatives."
|
||||
2. For **hero work** (brief tagged marketing / launch / brand piece / explainer-with-quality-bar / any single-deliverable where quality is the point) where `selected == "templated"`: **CRITICAL** — "Hero brief locked composition_mode='templated'. Default is atelier per doctrine; templated requires an explicit reason in `decision_log.<entry>.reason` (e.g. localization variant, batch, time-boxed draft)." Only suppress if the reason field names a sanctioned exception.
|
||||
3. If `composition_mode == "atelier"` and `proposal_packet` lacks an `art_direction` declaration (palette, type, motion, signature device): **CRITICAL** — "Atelier proposal missing art-direction commitment. Per `skills/meta/bespoke-composition.md` step 1, art direction must be written down *before* authoring scenes."
|
||||
|
||||
### At scene_plan / edit stage (when composition_mode == "atelier"):
|
||||
1. `edit_decisions.composition_mode` must equal `"atelier"` and `edit_decisions.bespoke.{entry, composition_id, art_direction}` must all be set.
|
||||
- Missing any of `entry`/`composition_id`: **CRITICAL** — "Atelier compose contract incomplete; render will be rejected by `_render_via_atelier`."
|
||||
- Missing `art_direction`: **CRITICAL** — "Atelier without an art-direction declaration; reviewer cannot evaluate distinctness."
|
||||
2. Any presence of stock `cut.type` scene-types (`text_card`, `stat_card`, `bar_chart`, `kpi_grid`, `callout`, `comparison`, `hero_title`, `terminal_scene`, `anime_scene`, `progress_bar`, `pie_chart`, `line_chart`) in `edit_decisions.cuts`: **CRITICAL** — "Atelier piece reaches for stock cut.type {name}. Hand-author the scene; the stock registry is a mechanics codex, not a parts bin (`skills/meta/bespoke-composition.md`)."
|
||||
|
||||
### At compose stage (when composition_mode == "atelier"):
|
||||
1. The compose stage's `final_review.checks.atelier` block must exist. If absent: **CRITICAL** — "Atelier render skipped doctrine checks — `_render_via_atelier` returned without `atelier` checks; investigate tool wiring."
|
||||
2. If `final_review.checks.atelier.stock_reuse_detected == true`: **CRITICAL** — "Stock-registry import inside bespoke project ({offending_imports[0].file} → {offending_imports[0].import}). Hand-author the scene; do not import from the stock src/."
|
||||
3. If `final_review.checks.atelier.art_direction_declared == false`: **CRITICAL** — "Atelier render with no art-direction declaration. Set `edit_decisions.bespoke.art_direction` before re-render."
|
||||
4. **Scene distinctness — no hero-component spine (mandatory record).** Sample one representative frame per scene (e.g. mid-window of each `props.sections[i]`) and answer in the review record:
|
||||
- *Does each scene have a distinct primary visual subject?* If two or more scenes share their primary visual (same hero element merely re-captioned — the candle that never leaves, the browser frame on every beat, the score ring as scaffolding): **CRITICAL** — "Hero-component spine detected: scenes {ids} share their primary visual subject. Per `skills/meta/bespoke-composition.md` step 1.5, each scene must earn its own composition; the signature device belongs to one climactic beat, not as scaffolding. Re-plan the affected scenes."
|
||||
- *Is the signature device named in `art_direction` actually present in at least one beat?* (no ⇒ CRITICAL, re-author or update the declaration to match what was actually built)
|
||||
- *Is the signature device present in **most** beats?* (yes ⇒ CRITICAL — see hero-component-spine above; signature is meant to be scarce)
|
||||
This check cannot be skipped silently; absence of a recorded scene-by-scene inventory is itself **CRITICAL** ("scene_distinctness inventory not recorded").
|
||||
5. **Captions / on-screen text dedup (mandatory check).** Compare the active caption text to any on-screen text rendered in the same time window:
|
||||
- If they are the same content (caption echoes the scene's title/headline that the narration is already reading aloud): **CRITICAL** — "Caption duplicates on-screen text at {t}s ('{text}'). Decide once per piece whether captions add meaning (numbers, names, translations) or are accessibility subtitles; do not do both for the same line. Either clear `captions=[]` for these scenes or remove the redundant on-screen SerifLine."
|
||||
6. **Distinctness review (human-judged, mandatory).** Before approving the render, the reviewer must explicitly answer in the review record:
|
||||
- *"Could this video be any other product's video?"* (yes ⇒ CRITICAL, re-author art direction)
|
||||
- *"Does its visual language reuse a look from a prior piece I've made?"* (yes ⇒ CRITICAL, re-author)
|
||||
Distinctness is taste-call territory the tool can't automate; reviewer absence on this question is itself a **CRITICAL** finding ("distinctness review not recorded").
|
||||
|
||||
### At publish stage (when composition_mode == "atelier"):
|
||||
1. All six atelier compose-stage checks above (existence of `atelier` block, stock_reuse, art_direction_declared, scene_distinctness, captions/text dedup, human distinctness review) must show `resolved` in the review record. Any unresolved: **CRITICAL** — "Cannot publish atelier piece with unresolved doctrine or distinctness findings."
|
||||
|
||||
@@ -333,6 +333,12 @@ class BaseTool(ABC):
|
||||
resolved_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# Force UTF-8 decoding. The default uses the OS locale (cp1252 on
|
||||
# Windows), which raises UnicodeDecodeError on a subprocess that
|
||||
# emits Unicode/emoji (e.g. Remotion's progress output), killing the
|
||||
# reader thread and potentially swallowing the real error text.
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
|
||||
@@ -15,6 +15,12 @@ Routing is driven by `edit_decisions.render_runtime` (locked at proposal):
|
||||
- `ffmpeg` → FFmpeg concat/trim. Used only for simple video cuts without
|
||||
composition, or when the approved path explicitly names FFmpeg.
|
||||
|
||||
Authoring mode is orthogonal to runtime. Setting
|
||||
`edit_decisions.composition_mode = "atelier"` (or `renderer_family="bespoke"`)
|
||||
routes to a hand-authored, project-local Remotion composition that BYPASSES the
|
||||
cut-schema and the stock scene-type registry entirely — the "hand-stitched
|
||||
every time" path for hero/bespoke pieces. See `_render_via_atelier`.
|
||||
|
||||
Silent runtime swaps are forbidden by governance. If the chosen runtime is
|
||||
unavailable or fails, this tool surfaces a structured blocker and waits for
|
||||
the agent to re-ask the user rather than substituting a different engine.
|
||||
@@ -667,6 +673,332 @@ class VideoCompose(BaseTool):
|
||||
)
|
||||
return comp
|
||||
|
||||
def _render_via_atelier(
|
||||
self,
|
||||
inputs: dict[str, Any],
|
||||
edit_decisions: dict[str, Any],
|
||||
) -> ToolResult:
|
||||
"""Render a hand-authored, project-local Remotion composition ("atelier" mode).
|
||||
|
||||
Unlike the cut-schema path, atelier mode does NOT route through the
|
||||
stock Explainer/CinematicRenderer compositions, the cut.type scene
|
||||
registry, or RENDERER_FAMILY_MAP. The agent hand-authors a bespoke
|
||||
composition — its own scenes, theme, and motion — and points this
|
||||
renderer at the project-local entry. This is the deliberate
|
||||
"hand-stitched every time" path: zero reusable creative components,
|
||||
a fresh visual language per video.
|
||||
|
||||
Contract — edit_decisions["bespoke"] = {
|
||||
"entry": <path to the project-local Remotion entry .tsx;
|
||||
MUST live under remotion-composer/ so the
|
||||
Remotion bundler can resolve node_modules.
|
||||
Convention: remotion-composer/projects/<slug>/index.tsx>,
|
||||
"composition_id": <id registered in that entry's Root>,
|
||||
"props_path": <optional absolute path to a props JSON (--props)>,
|
||||
"public_dir": <optional path to a SMALL per-project public dir,
|
||||
avoids copying the bloated shared public/>,
|
||||
"scale": <optional float, e.g. 0.5 for a fast draft>,
|
||||
"crf": <optional int, e.g. 18 for a crisp final>,
|
||||
"concurrency": <optional int>,
|
||||
}
|
||||
"""
|
||||
bespoke = edit_decisions.get("bespoke") or {}
|
||||
entry = bespoke.get("entry")
|
||||
comp_id = bespoke.get("composition_id")
|
||||
if not entry or not comp_id:
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"atelier mode requires edit_decisions.bespoke.entry (path to the "
|
||||
"project-local Remotion entry .tsx) and edit_decisions.bespoke."
|
||||
"composition_id (the id registered in that entry's Root)."
|
||||
),
|
||||
)
|
||||
|
||||
composer_dir = Path(__file__).resolve().parent.parent.parent / "remotion-composer"
|
||||
if not composer_dir.exists() or not (composer_dir / "node_modules").exists():
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"remotion-composer or its node_modules is missing at {composer_dir}. "
|
||||
f"Run `cd remotion-composer && npm install` first."
|
||||
),
|
||||
)
|
||||
|
||||
entry_path = Path(entry)
|
||||
if not entry_path.is_absolute():
|
||||
# Resolve relative to repo root first, then to the composer dir.
|
||||
repo_root = composer_dir.parent
|
||||
cand = (repo_root / entry).resolve()
|
||||
entry_path = cand if cand.exists() else (composer_dir / entry).resolve()
|
||||
entry_path = entry_path.resolve()
|
||||
if not entry_path.exists():
|
||||
return ToolResult(success=False, error=f"atelier entry not found: {entry_path}")
|
||||
|
||||
# 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/<slug>/, where artifacts/assets/renders/ already live.
|
||||
# Resolution: keep the source of truth under projects/<slug>/ and auto-stage
|
||||
# a directory junction (Windows) / symlink (Unix) at
|
||||
# remotion-composer/projects/<slug>/ → projects/<slug>/ 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:
|
||||
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/<slug>/ "
|
||||
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(effective_entry), str(comp_id), str(output_path)]
|
||||
|
||||
props_path = bespoke.get("props_path")
|
||||
if props_path:
|
||||
pp = Path(props_path).resolve()
|
||||
if not pp.exists():
|
||||
return ToolResult(success=False, error=f"atelier props_path not found: {pp}")
|
||||
# Equals form is required for cross-platform path parsing (see _remotion_render).
|
||||
cmd.append(f"--props={pp}")
|
||||
|
||||
public_dir = bespoke.get("public_dir")
|
||||
if public_dir:
|
||||
pd = Path(public_dir).resolve()
|
||||
if pd.exists():
|
||||
cmd.append(f"--public-dir={pd}")
|
||||
|
||||
if bespoke.get("scale"):
|
||||
cmd.append(f"--scale={bespoke['scale']}")
|
||||
if bespoke.get("crf") is not None:
|
||||
cmd.append(f"--crf={bespoke['crf']}")
|
||||
if bespoke.get("concurrency"):
|
||||
cmd.append(f"--concurrency={bespoke['concurrency']}")
|
||||
|
||||
try:
|
||||
# Run from inside the composer dir so npx resolves the local
|
||||
# remotion binary (mirrors _remotion_render).
|
||||
self.run_command(cmd, timeout=1800, cwd=composer_dir)
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, error=f"Atelier (bespoke) Remotion render failed: {e}")
|
||||
|
||||
if not output_path.exists():
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=f"Atelier render completed but output file missing: {output_path}",
|
||||
)
|
||||
|
||||
# --- Atelier post-render review -------------------------------------
|
||||
# The cut-schema paths run _run_final_review (technical/visual/audio
|
||||
# probes + transcript-vs-script). Atelier MUST do the same so hero
|
||||
# renders aren't shipped without the safety net — and additionally
|
||||
# enforce the bespoke doctrine: no stock-registry imports, an
|
||||
# art-direction declaration must exist. The distinctness review
|
||||
# ("could this be any other product's video?") stays human; what we
|
||||
# automate here is the *doctrine bypass*, not the taste call.
|
||||
final_review = self._run_final_review(
|
||||
output_path=output_path,
|
||||
edit_decisions=edit_decisions,
|
||||
proposal_packet=inputs.get("proposal_packet"),
|
||||
narration_transcript_path=inputs.get("narration_transcript_path"),
|
||||
script_text=inputs.get("script_text"),
|
||||
)
|
||||
|
||||
atelier_checks = self._run_atelier_checks(entry_path, bespoke)
|
||||
final_review.setdefault("checks", {})["atelier"] = atelier_checks
|
||||
final_review["issues_found"] = list(final_review.get("issues_found", [])) + atelier_checks.get("issues", [])
|
||||
|
||||
# Escalate atelier-critical issues (stock reuse) to the overall status.
|
||||
# Missing art-direction is a warning, not a fail — it shows in issues_found.
|
||||
if atelier_checks.get("stock_reuse_detected"):
|
||||
final_review["status"] = "fail"
|
||||
final_review["recommended_action"] = "re_author"
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"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,
|
||||
"final_review_status": final_review.get("status"),
|
||||
}
|
||||
|
||||
if final_review.get("status") == "fail":
|
||||
return ToolResult(
|
||||
success=False,
|
||||
error=(
|
||||
"Atelier render produced an invalid output:\n"
|
||||
+ "\n".join(f" • {i}" for i in final_review.get("issues_found", []))
|
||||
),
|
||||
data=data,
|
||||
artifacts=[str(output_path)],
|
||||
)
|
||||
|
||||
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/<slug>/.
|
||||
|
||||
The source of truth lives under the repo-root `projects/<slug>/` (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`
|
||||
# package, `@remotion/*`, project-local files) is fine.
|
||||
_ATELIER_STOCK_IMPORT_RE = (
|
||||
r"""from\s+["']("""
|
||||
# parent-traversed paths into the stock src/
|
||||
r"""(?:\.\./)+src/(?:components|Explainer|CinematicRenderer|"""
|
||||
r"""TitledVideo|TalkingHead|CollageBurst|LyricOverlay|cinematic|crucix|phantom)"""
|
||||
# or absolute-ish paths into the same
|
||||
r"""|remotion-composer/src/(?:components|Explainer|CinematicRenderer|"""
|
||||
r"""TitledVideo|TalkingHead|CollageBurst|LyricOverlay|cinematic|crucix|phantom)"""
|
||||
r""")"""
|
||||
)
|
||||
|
||||
def _run_atelier_checks(self, entry_path: Path, bespoke: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Doctrine-enforcement checks specific to atelier mode.
|
||||
|
||||
Returns a dict with two checks:
|
||||
- stock_reuse_detected (bool) + offending_imports (list) — CRITICAL,
|
||||
fails the render. Catches `import X from "../../src/components/..."`
|
||||
and similar reuse of stock creative components.
|
||||
- art_direction_declared (bool) + art_direction (str|None) — WARNING.
|
||||
Forces step 1 of the bespoke-composition skill (commit to a fresh
|
||||
art direction per video) to be written down rather than skipped.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
issues: list[str] = []
|
||||
offending: list[dict[str, str]] = []
|
||||
project_dir = entry_path.parent
|
||||
pat = _re.compile(self._ATELIER_STOCK_IMPORT_RE)
|
||||
|
||||
try:
|
||||
for f in project_dir.rglob("*"):
|
||||
if not f.is_file() or f.suffix.lower() not in {".tsx", ".ts", ".jsx", ".js"}:
|
||||
continue
|
||||
try:
|
||||
txt = f.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
for m in pat.finditer(txt):
|
||||
offending.append({"file": str(f.relative_to(project_dir)), "import": m.group(1)})
|
||||
except Exception as e: # pragma: no cover — never let the check itself break a render
|
||||
issues.append(f"atelier stock-reuse scan errored: {e}")
|
||||
|
||||
stock_reuse_detected = bool(offending)
|
||||
if stock_reuse_detected:
|
||||
issues.append(
|
||||
"atelier doctrine violation: bespoke project imports from the stock "
|
||||
"creative registry. Hand-author the scene instead — the registry is "
|
||||
"a mechanics codex, not a parts bin. Offending imports: "
|
||||
+ ", ".join(f"{o['file']} → {o['import']}" for o in offending[:5])
|
||||
+ ("…" if len(offending) > 5 else "")
|
||||
)
|
||||
|
||||
art_direction = bespoke.get("art_direction") or bespoke.get("art_direction_note")
|
||||
art_direction_declared = bool(art_direction and str(art_direction).strip())
|
||||
if not art_direction_declared:
|
||||
issues.append(
|
||||
"atelier warning: no bespoke.art_direction declared. Per "
|
||||
"skills/meta/bespoke-composition.md step 1, every atelier piece must "
|
||||
"commit to a fresh art direction (palette, type, motion, signature "
|
||||
"device) before authoring. Pass edit_decisions.bespoke.art_direction "
|
||||
"as a short note or a path to art-direction.md."
|
||||
)
|
||||
|
||||
return {
|
||||
"stock_reuse_detected": stock_reuse_detected,
|
||||
"offending_imports": offending,
|
||||
"art_direction_declared": art_direction_declared,
|
||||
"art_direction": str(art_direction) if art_direction else None,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_theme_from_playbook(
|
||||
playbook_name: str | None,
|
||||
@@ -937,6 +1269,19 @@ class VideoCompose(BaseTool):
|
||||
asset_manifest = inputs.get("asset_manifest")
|
||||
if not edit_decisions:
|
||||
return ToolResult(success=False, error="edit_decisions required for render")
|
||||
|
||||
# --- Atelier (bespoke) mode -------------------------------------
|
||||
# Hand-authored, project-local Remotion composition. Deliberately
|
||||
# bypasses the cut-schema, the stock scene-type registry, and the
|
||||
# RENDERER_FAMILY_MAP. This is the "hand-stitched every time" path:
|
||||
# the agent writes a fresh composition (its own scenes, theme, motion)
|
||||
# under remotion-composer/projects/<slug>/ and points this renderer at
|
||||
# it. No reusable creative components; a new visual language per video.
|
||||
# Triggered by composition_mode="atelier" (or renderer_family="bespoke").
|
||||
if (edit_decisions.get("composition_mode") == "atelier"
|
||||
or edit_decisions.get("renderer_family") == "bespoke"):
|
||||
return self._render_via_atelier(inputs, edit_decisions)
|
||||
|
||||
if not asset_manifest:
|
||||
return ToolResult(success=False, error="asset_manifest required for render")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user