references/assets.md
references/assets.mdBrowse 27 files
26,930 bytes
Token encoding: o200k_base
Snapshot 24fd22b
Hermes adaptation note: upstream auteur routed asset generation to several local image CLIs by strength. In Hermes there is one route: the built-in
image_generatetool for every still and edit (move the returned file into the project'sassets/gen/path), theterminaltool forffmpeg/node/npx, andbrowser_execor Playwright-via-terminal for screenshot loops. The taste guidance below transfers; the per-CLI shootout tables were dropped in the port.
assets.md — producing visual assets
The storyboard's asset: lines are a shot list. This file turns them into files on disk: generated keyframes, consistent A→B pairs, optimized video/sequences. Two disciplines rule everything: one frame first (approve art direction at the cost of one image, then batch) and cache everything (generation costs money and minutes; never regenerate what exists).
0. The crew — probe once, route by strength
Probe with a real round trip, never with a version flag. The failure that actually happens is an unconfigured or expired backend, not a missing binary. Before the shoot, run one tiny image_generate call and confirm a file or URL actually came back; run ffmpeg -version in terminal for the encode leg. Read the OUTPUT, not the exit code.
A route that does not return a real asset is unavailable. Note it as unavailable in the asset plan and route around it — §0.5 and §4 — rather than discovering it mid-shoot.
MiniMax music (ambient score) needs MINIMAX_API_KEY — skip the audio leg if unset.
Route each asset to its strength:
| Asset | Route | Why |
|---|---|---|
| Hero / brand-critical stills — peak scene, abstract hero background (needs clean negative space for text), product mockup / UI screen, premium transparent element or icon | image_generate | one frame first, approve, then batch style-locked to the anchor. Watch for warm palette drift on cool briefs — anchor the palette in the prompt |
| Any scene that becomes VIDEO or needs a consistent A→B edit pair; exact brand-COLOR adherence | image_generate edit of frame A (§2) | editing keeps the world intact; a second generation never does |
| Volume & CONTEXT — lifestyle/environmental shots (room, hands, props, in-situ), bulk backgrounds, fast iteration | image_generate | cheap iteration; keep the anchor still as the style reference |
| Real video | an image→video model on an approved keyframe (6–10s) — user-supplied backend or browser tool | animate an approved keyframe; Hermes ships no native video tool |
| Ambient score | MiniMax music | one loopable bed matched to the commit-sheet mood |
Transparent PNG (alpha) depends on the configured backend. Match the asset's background to the page — generate the subject on the SAME ground the page uses (white-on-white, or true alpha) so it melts into the layout with no visible frame; a photographic rectangle floating on a flat page is an instant slop tell.
Missing a route → don't fake it: descend the ladder (§4), ask the user for assets, or pivot to type-led/CSS scenes (a great film can be shot entirely in typography). Note what's available in the asset plan.
0.5 Source before you generate — the routing decision
Generation is not the only tool and for a whole class of assets it is the wrong one. You cannot
generate a glTF mesh, a 16-bit HDRI that actually lights a WebGL scene, or a seamlessly tiling PBR
material with matching normal/rough/AO maps — and CC0 versions of all three exist at production
quality. scripts/source.mjs fetches them and writes a licence ledger for every file.
Search first with --list (prints a shortlist to stdout, downloads nothing, writes no ledger), then
fetch. Always pass --out — the default is assets/sourced relative to the current directory,
which drops a ledger and a 2.6MB font-metadata cache wherever you happened to be standing.
O=assets/sourced
node scripts/source.mjs model "microscope" --list # read the shortlist, then commit to one
node scripts/source.mjs hdri "coastal dusk 03" --res 1k --out $O # numeric suffixes work
node scripts/source.mjs model "vintage microscope" --res 1k --out $O
node scripts/source.mjs texture "concrete rough" --res 1k --out $O
node scripts/source.mjs icon "bottle" --out $O # single noun — the index is one keyword
node scripts/source.mjs font "serif variable" --out $O # downloads the woff2 and prints the @font-face
node scripts/source.mjs image "whisky barrel" --out $O # CC — attribution REQUIRED
node scripts/source.mjs video "snow forest" --out $O # stock — ambient only, never the peak
Inspect a mesh before you write the scene it appears in. node -e "console.log(JSON.parse(require('fs').readFileSync('x.gltf')).nodes.map(n=>n.name))" costs nothing and changes films: a mesh whose parts are named can come apart, label itself, and be re-assembled on scroll, which is a scene no image model can express at any budget. A mesh that is one welded blob can only spin. Poly Haven's listing also carries condition (clean / worn / weathered / rusted) and material — a worn asset reads as an antique, not as a product someone can buy this week.
Sourced masters are heavy — budget for the conversion, not the download. A 1k mesh + HDRI + PBR set is ~7MB of masters, which is most of a page budget. Three moves take that to well under 1MB:
# HDRI → 512×256 is indistinguishable once PMREM blurs it by roughness anyway
ffmpeg -i env_1k.hdr -vf scale=512:256 -c:v hdr -update 1 -frames:v 1 env_512.hdr
# mesh textures: 1024 for albedo/ARM, 512 for normals, q4
ffmpeg -i tex_diff_1k.jpg -vf scale=1024:1024 -q:v 4 tex_diff.jpg
# drop maps you do not sample: `arm` already carries AO+roughness+metal, so `rough` and `disp` are dead weight
Getting three.js into a no-build page. Sourcing a mesh means you now need a renderer, and there are three tempting wrong answers: ES modules with an import map (CORS-blocked over file://), a CDN (a third-party origin most briefs forbid), and the old UMD build (wrong colour management). Bundle once, commit the output:
npm i three@latest && npx esbuild entry.js --bundle --format=iife --global-name=THREEX --minify > assets/vendor/three-bundle.js
Name the ~20 symbols you actually use in entry.js rather than export * from 'three' — that alone was 731KB → 560KB. Note RGBELoader is a deprecation shim in recent releases; the class is HDRLoader.
| The asset is | Route | Why |
|---|---|---|
| a 3D mesh (glTF/GLB) | source — Poly Haven, CC0 | no image model produces geometry |
| an HDRI to light a WebGL scene | source — Poly Haven, CC0 | a generated "sky picture" is not an IBL; the lighting will look wrong and you won't know why |
| a tiling PBR material (diff/nor/rough/arm) | source — Poly Haven, CC0 | seamlessness and matched map sets are not generation outputs |
| an icon set | source — Iconify | generated icons drift in weight and stroke across a set (§7) |
| a typeface | source — Google Fonts | and check it against ban #8/#18 before falling in love |
| the peak scene keyframe | generate | it has to be this brand's world and nobody else's — this is the whole point |
| the hero video | generate (§3) | the wow moment cannot be a clip three thousand pages already use |
| an environmental / lifestyle still | generate (image_generate) | unless the brief needs a real, identifiable place |
| a documentary photo of a real thing or place | source — Openverse | generation invents; if it must be true, it must be photographed |
| an ambient background loop or video texture | source ok — Coverr | supporting layer only |
The stock-video rule is not optional. Stock footage is generic by construction. Auteur exists to ship committed, specific assets, so sourced video is an ambient loop, a texture, or a reduced-motion fallback — never the peak. If your wow moment is stock, you do not have a wow moment.
The ledger ships with the site. assets/sourced/ASSETS-SOURCED.md records the licence of every
downloaded file. CC0 (Poly Haven) and OFL (Google Fonts) need nothing. Openverse images are
CC-BY / CC-BY-SA: the credit line in the ledger must appear on the page — a footer credits block is
fine, no credit is a licence violation. Coverr and Mixkit permit use but prohibit redistribution,
which means the clip goes in your page, not in your public asset repo or template. Before shipping,
read the ledger and clear every "attribution required" line.
Do not confuse sourced assets with the moodboard. design/moodboard/ is other people's work, used
only to decide direction and then thrown away (recon.md). assets/sourced/ is licensed material
that genuinely ships.
Sourced assets are gitignored by default, which is exactly how the scene 404s in production. The fetch script writes into an ignored directory; the deploy builds from the repository; the page arrives on the host without its textures, HDRI or meshes — and a missing HDRI does not degrade gracefully, it throws. Decide it once, in writing, before the first deploy: either commit the optimized assets (after §5 they are small enough to) or run the fetch as a build step. "It works locally" is this bug's signature, and it always surfaces in front of the client.
1. Generating keyframes
Build the prompt FROM the scene-sheet — subject + camera + lighting are literal prompt parameters, plus palette anchors from the commit-sheet:
"⟨subject⟩, ⟨camera: low-angle close shot / orbital view / macro detail⟩, ⟨lighting: hard rim light at dusk / soft studio / neon-soaked⟩, color palette anchored on ⟨primary OKLCH → describe as human color⟩, photographic, no text, no watermark, 16:9"
Generate with image_generate: pass the prompt above, then move the returned file to assets/gen/s3-peak-a.png (project-relative). Verify the file actually landed on disk before building on it — re-run once if missing.
Default split: cheap iteration for volume; spend the retries on the peak scene and anything the viewer will stare at.
2. The consistency trick: frame B is an EDIT of frame A, never a second generation
Two independent generations of "the same scene" are never the same scene — lighting, geometry and lens drift. Editing frame A into frame B keeps the world intact and is what makes the two-keyframe cinema moves (displacement morph, before/after scrub) look like camera work instead of a jump cut.
Edit — word the change HARSHLY. Image models ignore soft phrasing ("replace X with Y" often returns the original). Call image_generate with frame A as the input image and the REQUIRED CHANGE pattern:
"REQUIRED CHANGE: the laptop is now open, screen glowing, and the room lights have dimmed. KEEP IDENTICAL: camera angle, framing, composition, every other object, lighting direction, color grade."
…then move the result to assets/gen/s3-peak-b.png.
Verify the pair eyes-on before building on it: open A and B side by side. Same camera? Same composition? Only the intended state changed? Small texture drift is fine — the displacement transition tolerates it (it hides mid-morph mush). A camera/framing shift is a FAIL: re-edit with harder KEEP IDENTICAL wording, then descend the ladder.
Retry policy: any generation/edit gets ONE sharpened retry, then descend the ladder. Do not burn ten generations chasing a frame — reshape the scene instead.
N-frame chains (for the scroll-cinema state-machine engine)
Extend the A→B pair to a chain: A→B→C→D…, each an EDIT of the previous frame (never a fresh gen), so
the whole world stays photographically consistent while it ages / opens / transforms / gets crowded. 4–6
frames covers most stories. Verify each link same-camera before editing the next; keep the chain in scroll
order (s1-a … s1-d). This chain IS the input to scroll-cinema's Tier-1 scrubber — do the whole chain in
ONE sitting with the same backend so the image model never drifts.
2.5 Depth maps (for the 2.5D composite / rack-focus)
A hero still becomes dimensional with a grayscale depth map (0 = far … 1 = near). Generate it locally — on this machine (no discrete GPU) Depth-Anything V2 Small runs on CPU in seconds per hero image:
py -3.13 -m pip install -q transformers torch pillow # one-time (~torch is heavy but CPU-only is fine)
py -3.13 - <<'PY'
from transformers import pipeline; from PIL import Image
dep = pipeline('depth-estimation', model='depth-anything/Depth-Anything-V2-Small-hf')
dep(Image.open('assets/gen/s1-hero.png'))['depth'].save('assets/gen/s1-hero-depth.png')
PY
Alternatives: a Blender Z-pass when the scene is a 3D render (Blender CLI; §9); or ask the generator for a grayscale "depth-style" version (fast, imperfect — ok for subtle pointer-parallax, NOT for rack-focus). Depth is a cached master like any still. Feed color + depth to scroll-cinema §3 (2.5D composite).
3. Video — animate an approved keyframe
An image→video model animates an approved keyframe (6 or 10 seconds); Hermes has no native video tool, so use whatever image→video backend the user has (or a browser tool via browser_exec), save into assets/gen/, then optimize (§5). Spend video like the motion budget spends attention — one hero clip + at most a couple supporting; a video that isn't the wow peak is usually a still that should have stayed a still.
Directed A→B state change (before/after, "first+last frame"). ⚠️ Most image→video models have NO true first+last-frame interpolator: image-to-video animates ONE source frame with no end frame; reference-to-video modes treat extra images as style/content references, not strict start/end keyframes — an A+B reference clip is organic drift, not a controlled morph. Routes, best first:
- Controlled, on the web (preferred): the WebGL displacement morph between frame A and frame B (scroll-cinema.md) — exact, scroll-scrubbable, no video model, and it's the skill's signature move anyway. This is the real answer to "we have two frames and want the transition".
- Organic video: reference-to-video with A+B as references for a loose transition, or image-to-video on A for pure motion (push-in, steam, drift) — endpoints not guaranteed.
- A TRUE controlled first→last VIDEO (hard requirement) still means browser Kling (first+last mode) / Runway / Veo: package frame A + frame B + the motion prompt for the user, continue other scenes, drop the clip in when it arrives.
Going past 10s — chain segments. Clips cap at 6–10s: image_to_video frame A, generate/edit the next state, animate that, ffmpeg concat. Each segment starts on the previous last frame so the seams hide.
Other honest sources: user-provided footage (ask at intake — real footage still beats gen for truly photographic hero shots) and Remotion (local render) for graphic/typographic motion (kinetic type, animated diagrams, UI mockup motion) — it's code: consistent, revisable, free.
If video still isn't right — the ladder (§4) covers you; scroll-scrubbed sequences read as "video" anyway.
4. The degradation ladder (per scene, stop at the first rung you can execute)
| Rung | What | Needs | Feels like |
|---|---|---|---|
| 1 | Scroll-scrubbed video | a real clip (§3) | full cinema |
| 2 | Canvas image sequence | a clip to explode into frames, or 6–12 generated in-between edits | Apple-grade product cinema |
| 3 | WebGL displacement morph A→B | just TWO keyframes (§2) | a living transition; the skill's signature move |
| 4 | Layered depth parallax | one keyframe cut into 2–4 layers (subject/bg), or CSS layers | dimensional, quietly premium |
| 5 | Kinetic typography / computed / pure CSS scene | nothing | still cinema, if the type system is strong |
When NO generator answers the probe, rungs 1–4 are all unreachable at once — every one of them needs at least one generated keyframe. Do not treat that as "descend one rung": go back to §0.5 and re-read the source-vs-generate table as a fallback table rather than a spending decision, then land on rung 5. And drop the idea that rung 5 is a consolation prize: for a brand whose claim is precision, a scene computed from the same data the product is about is more honest than any photograph, because nothing in it could have been someone else's object. Measured on a real run — three planned generations became three computed scenes and the page got better.
Rung 3 is the default answer to "we generated two images and want the video feel" — recipe (full GLSL) in scroll-cinema.md.
5. Optimization recipes (run for every heavy asset)
# Hero video → H.264 baseline (plays everywhere incl. iOS), streaming-ready, target ≤2MB
ffmpeg -i src.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -movflags +faststart -crf 23 -an hero.mp4
# SCROLL-SCRUBBED video is different — a tiny GOP makes frame-accurate seeking cheap (see scroll-flight.md)
ffmpeg -i src.mp4 -an -vf "unsharp=5:5:0.8:5:5:0.0" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -g 8 -keyint_min 8 -sc_threshold 0 -movflags +faststart scrub.mp4
# WebM alternative for Chromium (smaller at same quality)
ffmpeg -i src.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 -an hero.webm
# Poster (first frame) for instant paint + reduced-motion fallback
ffmpeg -i hero.mp4 -frames:v 1 poster.png && ffmpeg -i poster.png -quality 82 poster.webp
# Explode a clip into a canvas sequence (target 60–240 frames total; ≤150KB/frame at 1440w)
ffmpeg -i hero.mp4 -vf "fps=30,scale=1440:-1" frames/f_%04d.webp
# Any still → WebP for the page (keep PNG originals in assets/gen as masters)
ffmpeg -i in.png -quality 82 out.webp
Budgets (verify.md re-checks): hero video ≤2MB · poster ≤300KB · sequence frame ≤150KB @1440w · any static hero image ≤400KB · mobile variants at 720w for every asset >500KB.
When a 4K texture still looks soft, resolution is not the problem. Check two things, in this order. First, anisotropic filtering — off by default in three.js, and without it any surface viewed at a grazing angle (ground under a low camera, a floor receding to the horizon) smears no matter how many pixels the map holds: tex.anisotropy = renderer.capabilities.getMaxAnisotropy(). Second, the tiling scale, counted as metres per repeat rather than repeats per plane — a 260m ground plane with 28 repeats is a 9-metre tile, and at 9 metres the detail is gone at any texture size; ~3m per repeat is a working default for ground. Both mistakes look identical to "the texture is too low-res", which is why the reflex fix (download the 8K version) makes the page heavier and no sharper.
6. Cache & bookkeeping
- Names:
assets/gen/s<scene>-<slug>-<a|b>.png. Before ANY generation, check the path — exists means reuse (iterating on layout must not re-bill image generation). - Keep
assets/gen/ASSETS.log.md: one line per asset — file, tool, full prompt, date. Makes retries reproducible and hands the user the recipe to regenerate at higher quality later. - Masters stay PNG in
assets/gen/; the page consumes optimized WebP/AVIF/mp4 fromassets/. - Rights note for the user (once, in the log header): generated media follows each generator's terms (Gemini / OpenAI / xAI / MiniMax); fine for product marketing, but flag it if the client needs exclusive IP or has legal review.
7. Generated elements & mockups (not just full scenes)
The crew also produces the small stuff — but every generated element must survive slopscan; a generated gradient/texture that's just decoration is banned like any other. Spend it, then make it earn its place.
- Textures / grain / noise / abstract shapes →
image_generate(transparent where the backend allows). Use as CSSbackground,mask-image, or a low-opacity overlay. Generate once, cache, reuse. - UI mockups in a scene (device frame + screen, product-in-hand) →
image_generatefor the still; Remotion when the mockup must move. - Hero mockup gate (phase 0) can now be a generated frame, not only a hand-built HTML screen — one throwaway, screenshotted, approved before the real build.
- Iconography / brand marks → generate a set, then hand-pick: generated icon sets drift in weight/style, so treat them as sketches to redraw in SVG, not final assets.
8. Ambient score (MiniMax music)
If MINIMAX_API_KEY is set, generate ONE short, loopable ambient bed matched to the commit-sheet mood (tempo, key, tension). Playback recipe lives in scroll-cinema.md; generation rules:
- Off by default; start on a user gesture — never autoplay with sound. Provide an honest, visible mute/unmute.
- Loop seamlessly: generate a phrase that resolves to its own start, then trim on a zero-crossing with ffmpeg.
- Budget it: ≤ ~1MB, mono is fine for ambience, lazy-load after LCP.
- It's set dressing, not content — the page must be complete and comprehensible with sound off.
- No key / not wanted → skip silently. Sound is the least load-bearing layer; never gate meaning on it.
9. 3D scenes & camera paths (Blender CLI)
Blender 5.1 is already on PATH; cli-anything-blender is also available for inspection. Keep the asset build reproducible with one native headless command:
blender --background --python tools\build_dolly.py
This complete tools/build_dolly.py builds a lit scene, moves PathCamera along a Bezier curve while tracking an Empty, bakes the camera transform, exports glTF 2.0 with camera + animation, then renders a 16-bit near-white/far-black depth master:
from pathlib import Path
import math
import bpy
ROOT = Path(bpy.path.abspath("//")).resolve()
OUT = ROOT / "assets" / "gen"
OUT.mkdir(parents=True, exist_ok=True)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.frame_start, scene.frame_end = 1, 180
scene.render.engine = "BLENDER_EEVEE_NEXT"
scene.render.resolution_x, scene.render.resolution_y = 1920, 1080
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.world.color = (0.008, 0.01, 0.012)
def material(name, color, metallic=0.0, roughness=0.45):
mat = bpy.data.materials.new(name)
mat.diffuse_color = (*color, 1.0)
mat.metallic, mat.roughness = metallic, roughness
return mat
bpy.ops.mesh.primitive_plane_add(size=30, location=(0, 0, 0))
bpy.context.object.data.materials.append(material("Floor", (0.025, 0.03, 0.035), 0.0, 0.28))
bronze = material("Bronze", (0.32, 0.12, 0.035), 0.72, 0.2)
for i, xyz in enumerate(((-4, -1, 1), (-2, 2, 1.6), (0, -2, 1.2), (2, 1, 2.1), (4, -1, 1.4))):
bpy.ops.mesh.primitive_cube_add(location=xyz, scale=(0.8, 0.8, xyz[2]))
box = bpy.context.object
box.name = f"Monolith_{i:02d}"
box.data.materials.append(bronze)
for name, location, energy, size in (
("Key", (-4, -3, 8), 1500, 5),
("Rim", (5, 2, 5), 900, 3),
):
data = bpy.data.lights.new(name, "AREA")
data.energy, data.shape, data.size = energy, "DISK", size
light = bpy.data.objects.new(name, data)
light.location = location
scene.collection.objects.link(light)
curve_data = bpy.data.curves.new("DollyPath", "CURVE")
curve_data.dimensions, curve_data.resolution_u = "3D", 32
spline = curve_data.splines.new("BEZIER")
points = ((-7, -7, 2.2), (-4, 2, 3.0), (1, -4, 2.5), (7, 5, 3.8), (2, 8, 4.4))
spline.bezier_points.add(len(points) - 1)
for point, co in zip(spline.bezier_points, points):
point.co = co
point.handle_left_type = point.handle_right_type = "AUTO"
path = bpy.data.objects.new("DollyPath", curve_data)
scene.collection.objects.link(path)
target = bpy.data.objects.new("LookTarget", None)
target.empty_display_type = "SPHERE"
target.location = (0, 0, 1.5)
scene.collection.objects.link(target)
camera_data = bpy.data.cameras.new("PathCamera")
camera_data.lens, camera_data.clip_start, camera_data.clip_end = 42, 0.1, 40
camera = bpy.data.objects.new("PathCamera", camera_data)
scene.collection.objects.link(camera)
scene.camera = camera
follow = camera.constraints.new("FOLLOW_PATH")
follow.target, follow.use_fixed_location = path, True
follow.forward_axis, follow.up_axis = "FORWARD_NEGATIVE_Z", "UP_Y"
follow.offset_factor = 0.0
follow.keyframe_insert("offset_factor", frame=scene.frame_start)
follow.offset_factor = 1.0
follow.keyframe_insert("offset_factor", frame=scene.frame_end)
track = camera.constraints.new("TRACK_TO")
track.target, track.track_axis, track.up_axis = target, "TRACK_NEGATIVE_Z", "UP_Y"
bpy.ops.object.select_all(action="DESELECT")
camera.select_set(True)
bpy.context.view_layer.objects.active = camera
bpy.ops.nla.bake(
frame_start=scene.frame_start,
frame_end=scene.frame_end,
step=1,
only_selected=True,
visual_keying=True,
clear_constraints=True,
bake_types={"OBJECT"},
)
camera.animation_data.action.name = "CameraPath"
bpy.ops.export_scene.gltf(
filepath=str(OUT / "dolly.glb"),
export_format="GLB",
export_cameras=True,
export_animations=True,
)
scene.view_layers[0].use_pass_z = True
scene.use_nodes = True
nodes, links = scene.node_tree.nodes, scene.node_tree.links
nodes.clear()
layers = nodes.new("CompositorNodeRLayers")
depth_range = nodes.new("CompositorNodeMapRange")
depth_range.inputs["From Min"].default_value = camera_data.clip_start
depth_range.inputs["From Max"].default_value = camera_data.clip_end
depth_range.inputs["To Min"].default_value = 1.0
depth_range.inputs["To Max"].default_value = 0.0
depth_range.use_clamp = True
depth = nodes.new("CompositorNodeOutputFile")
depth.base_path = str(OUT)
depth.format.file_format, depth.format.color_mode, depth.format.color_depth = "PNG", "BW", "16"
depth.file_slots[0].path = "dolly-depth-"
composite = nodes.new("CompositorNodeComposite")
links.new(layers.outputs["Depth"], depth_range.inputs["Value"])
links.new(depth_range.outputs["Value"], depth.inputs[0])
links.new(layers.outputs["Image"], composite.inputs["Image"])
scene.frame_set((scene.frame_start + scene.frame_end) // 2)
scene.render.filepath = str(OUT / "dolly-poster.png")
bpy.ops.render.render(write_still=True)
dolly.glb is the cached master; Three's GLTFLoader handles Blender→Three axis conversion. Keep the GLB ≤3MB, keep dolly-poster.png as the no-WebGL/reduced-motion fallback, and feed dolly-depth-0090.png to the 2.5D recipe when the live scene is too expensive.
Rules (or it's slop): bake constraints before export; export one camera action, not per-shot GLBs; verify first/middle/last frames eyes-on; render depth from the same camera and frame; never rebuild a cached master during layout iteration.
Referenced from SKILL.md
Source excerpt starting at line 37.SKILL.mdView in source ↗37- **Playwright (for the QA gates)** — in the project directory: `npm install playwright` then `npx playwright install chromium`. Required by `scripts/shoot.mjs`, `motionqa.mjs`, `systemscan.mjs`, `refscout.mjs`, `chromadiff.mjs`, `moodboard.mjs` (the scripts import `playwright` at runtime; `slopscan.mjs` and `source.mjs` are dependency-light).38- **ffmpeg** — optional; only for the video/score paths in `references/assets.md` and `references/scroll-flight.md`.39- **Hermes tools** — use `image_generate` for image generation/editing, `terminal` for node/ffmpeg/npm, `write_file`/`read_file` for project files, `vision_analyze` to actually look at screenshots, and `browser_exec` for live-page inspection when a script isn't the right fit.
Source excerpt starting at line 151.151- `references/direct.md` — the cinematic register: screenplay contract, scene-sheets, dramaturgy, assembly order. Load when routed to direct.152- `references/assets.md` — the media crew and routing (in Hermes: `image_generate` for all image generation and edits, `terminal` for ffmpeg/node; video via whatever image→video backend the user has), **§0.5 source-vs-generate** (`scripts/source.mjs`: CC0 glTF meshes, HDRIs and PBR materials from Poly Haven, icons, fonts, CC images, stock video — with a licence ledger, because generation cannot make geometry or an IBL and stock video must never be the peak), the consistency trick (edit frame A into frame B), local video via the first→last-frame chain, generated elements/mockups, the ambient score, the degradation ladder, and asset caching. Load during direct phase 1.153- `references/scroll-cinema.md` — working code recipes: scroll-scrubbed video, canvas sequences, GSAP+Lenis foundation, CSS scroll-driven animations, text reveals, the two-keyframe WebGL displacement transition, view transitions, ambient audio, and the cinematic transition library (wipe, curtain, letterbox, shutter, depth parallax). Load during assembly.