feat(atelier): repo-root projects/, scaffolder, schemas (F1 + F3 + F5)

Closes the remaining three atelier audit gaps.

F1 — atelier projects live under repo-root projects/<slug>/ (the
established OpenMontage convention), not under remotion-composer/.
_render_via_atelier now auto-stages source files into
remotion-composer/projects/<slug>/ 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.
This commit is contained in:
calesthio
2026-06-27 11:15:36 -07:00
parent 5481226faf
commit 0f10381f2b
6 changed files with 389 additions and 15 deletions
+237
View File
@@ -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())