Claude Skill

motion-design

Code-only motion design pipeline (no After Effects) for Raphaël / Howseen: launch films, showreels, product promos, LinkedIn/X videos, meme clips. Use when asked to make a motion design video, a promo/launch film, a showreel, "remake this video", a video from a prompt (e.g. "make

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download howseen-ai-claude-motion-design-skill_motion-design-3d90d34.zip · 36 KB

Install

skills CLI npx skills add https://github.com/howseen-ai/claude-motion-design/tree/main/skill/motion-design
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install howseen-ai-claude-motion-design@llmmart
Git git clone https://github.com/howseen-ai/claude-motion-design.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole howseen-ai/claude-motion-design collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Motion design, 100 % code (Howseen pipeline)

Built and battle-tested 25-27/09/2026 on: promo60 (60 s VO ad), loop, launch film, showreel v1/v2, frame. (Apple-keynote prompt), Crave (food-app prompt), reel15 (howseen.ai in 1 prompt + "make it better" pass), Baguette Pro (Apple framework parody), Howseen LinkedIn v1→v5 (4:5). Workdir: ~/Desktop/Howseen AI/howseen-video/ (one folder per film). Every film folder = <name>.html + render.py + audio.py + out/.

Our stack vs the "AI motion" stack people post (Opus + Higgsfield + Blender + After Effects + Suno + Soundly): we replace Blender/After Effects with a deterministic HTML engine rendered frame by frame, Higgsfield with real stock (Pexels/Unsplash) or coded visuals, Suno with Mixkit music, Soundly with Mixkit SFX. 0 € and fully reproducible. Suno/Envato/Higgsfield only if Raphaël asks and has credits.

00. Start from the library (fastest path)

  • Read howseen-video/INDEX.md first: every film we made, its final file and what to reuse. Copy the closest folder instead of starting from zero.
  • Brief given as frame tables (f0–fN, hard cuts, per-frame px lists, "motion law p(n)=…"): copy launch20/ (02/10/2026). It has the frame-number engine (core.js: T/L/kf registered tables, law(), settleT/exitT/joinT, scanTables() that blocks one-frame outliers, makeField colour field, makeBadge, macOS cursor), fictional Mac apps in em units (ui.js: chat, call, notes, files, board, dock icons, lineIcon set, tx helper), shot registry split in files so 3 agents build shot groups in parallel (one file each), render.py (deviceScaleFactor 2 = 4K, PROBE=<dir> per agent, frames a b in 3 parallel processes, encode with frame count check, no -shortest), audio.py (music + SFX 100 % synthesized on exact frames, two-pass −14 LUFS / TP −1.3).
  • Workflow that worked on launch20 (≈2 h brief → v2): inputs (AskUserQuestion) → 4 stills → BRIEF.md in the folder → 3 build agents + 1 side task in parallel → scan → parallel render → encode → motion critic + design critic as separate read-only agents (default reject) → FIXES file split per agent → v2.
  • Lessons from the critics (apply from the first pass): never key settles in 3-frame steps (per-frame law, fastest step first), exits geometric ×1.5, no frozen frame anywhere except the final hold (keep a micro settle/drift alive, also on still end cards), the next shot enters already moving in the exit's direction, real icons (no empty placeholder squares), one face per name everywhere, grade photos to remove banned hues with a continuous per-pixel formula (hue thresholds blotch JPEG blocks), traffic lights/details sized in the component's em units.
  • People in fictional UIs: AI-generated faces (thispersondoesnotexist.com/random-person.jpeg, crop centre 76 % to drop the watermark), names chosen to match each face, never a real person. Photos: picsum (Unsplash licence) when Unsplash napi returns 401; build a contact sheet and pick by eye (an agent can do it).

0. Non-negotiables

  • Zero fabrication on screen: real data is sourced on screen (e.g. "12 logged-out ChatGPT answers · 25 Sep 2026"); anything illustrative is labelled "Example data" / "Example answer" / "Illustration". Never claim product features that don't exist (check the app code). Native CMS = WordPress, Shopify, Ghost, BigCommerce; others "via webhook".
  • Captions must stay true: no "made in 10 minutes" if it wasn't, no "0 external tools" if Cartesia/Mixkit were used, no "one shot" after iterations. Mixkit SFX are placed by code, not generated.
  • Illustrations/covers: no Howseen name/logo in AI-generated images (rule 25/09). Howseen can appear in our own coded promo films.
  • No em/en dashes in any copy we write.

1. Flow (always in this order)

  1. Inputs: if the brief has an <inputs> block, ask for them (AskUserQuestion, recommended defaults first). Otherwise pick sensible defaults and say so.
  2. Beat map (BEATMAP.md): BPM → beat length, every scene on a beat, the music drop on the key visual moment (flood, logo, big reveal). Nothing still for > 1 s.
  3. 4 stills (or a one-frame-per-beat sheet) → look at them (Read) → fix → only then the full render.
  4. Full render → pops scan → audio → mux → open -R the file and give the path + a true caption.

2. The engine (one HTML file)

  • Everything computed from time inside window.seek = async (t) => {…}; no CSS transitions, no timers, no state between frames. Declare all constants before the first seek(). Set window.ready = true after fonts/images load.
  • Springs = closed-form step response step(tau, f, z); a value with many targets = sum of one spring per change. Easings: io (cubic in-out), out, in, o5, expo. Linear motion = cheap, never.
  • Camera = one transform on a container, keys [t, zoom, x, y], eased segments, zoom interpolated in log space, never zoom in/out back-to-back. Beat punches: +0.012 per beat, +0.03 per bar after the drop, exp decay.
  • Shared elements for every handoff (the bubble carries its words into the flood, the button carries its label into the page). Text that swaps inside a morphing shape gets its own mask.
  • Masked text rise (translateY 105% inside overflow:hidden), word-by-word stagger (55 ms) with a small rotation; accent words with a moving gradient (background-clip:text).
  • Floods: circle from the source object, must clear the farthest corner (hypot to the 4 corners ×1.05) in ~0.3-0.35 s, then contract into the next object.
  • Glass / goo / iris / variable-font squeeze / 3D cube / equalizer / blob mask / animated beam / border beam: reference implementations in frame/frame.html (liquid glass via canvas displacement, goo, 6-blade iris, Archivo wdth squeeze), reel2/reel2.html (morph shapes, cube, EQ, blob), h20/h28.html (21st.dev Animated Beam + Border Beam ported to seek(t), dotted grid, drifting blobs, sheen sweep, sparkles).
  • z-index on every layer. visibility:inherit (not visible) on children of hidden parents.
  • Look: warm off-white #f5f5f2/#f7f7f5 or ink #0b0b0c; Howseen sky #38bdf8, ink #0f172a, lime #cdf24f, orange #ff6a2a, violet #a78bfa. Fonts in crave/fonts/geist-latin.woff2, frame/fonts/archivo-var.woff2 (wdth 62-125), crave/fonts/instrument-serif.woff2.

3. Render (scripts/render_template.py)

  • Serve the folder over HTTP (python -m http.server 876x --directory …, background), Playwright Chromium, viewport = video size (1920×1080, 1080×1350 for LinkedIn 4:5, 1440×1440 square).
  • probe t1 t2… → probe/sheet.png; beats → one frame per beat; draft → 30 fps, 1 capture/frame, 960×540, crf 23 veryfast (always before the master: judge rhythm, not sharpness); full → N subframes per frame blended with tmix (6-8 for fast moves, 4 = ghosting) spread over SHUTTER = 0.5 of the frame (180° film shutter; 1.0 = smeary), 60 fps; pops → frame-diff spikes > 3× neighbours (intentional beat cuts show up too: say so, don't hide); loop → seam check in position (last vs frame 0) AND velocity (motion into vs out of frame 0).
  • Capture hygiene (built into the template): wait document.fonts.ready, pause document.getAnimations() and kill CSS transitions after load, and wait 2 requestAnimationFrame after every seek(t) before the screenshot. WEBGL=1 launches Chromium with SwiftShader/ANGLE flags (headless WebGL otherwise renders black).
  • Every film page also gets preview controls for Raphaël: Space = play/pause, ←/→ = previous/next frame, R = replay, a time readout. Playback calls the same seek(t) from a rAF loop (the only place a clock is allowed); the render never touches it.
  • Films > 25 s: always render in 3 parallel chunks (h20/lm55/render_par.py chunk <html> <T> k 3 x3 in background, then concat): ~20 min for 55 s instead of ~55 min sequential. Raphaël expects fast turnarounds; never launch a long single-process full render. Re-render only the changed seconds when possible (part).
  • Use a separate sub*/ folder per version so parallel renders don't clash. ~1-1.5 min of wall time per second of film at 8 subframes; run long renders in the background.
  • Final encode: scale=in_range=pc:out_range=tv:out_color_matrix=bt709,format=yuv420p, -color_range tv -colorspace bt709, libx264 crf 16, AAC 256k, +faststart.

4. Music & SFX (scripts/audio_template.py, analyze_song.py)

  • Music = Mixkit (free commercial). Direct file: https://assets.mixkit.co/music/<id>/<id>.mp3. IDs: grep music/[0-9]+/[0-9]+\.mp3 in the listing page HTML (page order = WebFetch list order).
  • Used & measured: audio/mixkit-207.mp3 120 BPM (drop song 31.97 s), mixkit-190 120 BPM (drop bar 8 = 16.01 s), mixkit-129 120 BPM (drop 16.09 s), minimal-techno-01 119.99 BPM (true drop 39.98 s, auto grid is 2 beats off), Cat Walk (Arulo #371) 130 BPM drop 14.769 s (crave/assets/audio/cat-walk.mp3), Waka Floka Type (Arulo #364, trap/US rap) drop 14.75 s (reel2/assets/m364.mp3), Driving Ambition (#32, piano uplifting ~99 BPM) hit 37.66 s, Classical vibes 4 (#684, Apple-ish classical ~94 BPM) lift ~7.95 s, Head Bang (#357, hip-hop half-time 74 BPM, 4-bar quiet intro, drop 12.96 s = beat 16; used by the viral "STOP PAYING FOR MOTION DESIGN SOFTWARE" loop prompt). Mixkit search ignores ?q=: crawl genre pages (/free-stock-music/<genre>/) and match item-grid-card__title to music/<id>/<id>.mp3. Leo's framework: 60-80 BPM regal, 90-110 smooth, 115-123 elite/sophisticated, > 125 hype.
  • Find the drop by energy, never trust an auto grid: per-bar low/full band energy, then 20-50 ms windows around the jump. Start the song at drop_in_song - drop_in_film.
  • SFX = Mixkit, downloaded to howseen-video/sfx/ (https://assets.mixkit.co/active_storage/sfx/<id>/<id>-preview.mp3); search with scripts/mixkit_sfx_search.py <tag>. Map so far: click 1125, key 2568, soft tick 1117, check 1113, toggle 1120, toast 2573, pop 2364 / bubble 2357 / soap 2925, whoosh w1490, rise w1489, flip w1485, impact 1143, shutter 1430 / lens 1433, sparkle 3083, success 2865, bread crunch 118.
  • Place every SFX by its measured peak (argmax of |s|), gain 0.04-0.3, keystrokes follow the same per-character rhythm as the typing animation. Fade the tail, two-pass loudnorm to −14 LUFS. Voice-over: Cartesia (Katie) with word timestamps → cues.json (promo60), music ducked ~9 dB under the voice.
  • Minimal sound design for "premium/Apple" films: a handful of soft hits, remove anything that feels loud or out of place.

5. Assets

  • Photos: Unsplash https://unsplash.com/napi/search/photos?query=…&per_page=30 (curl ok) → urls.raw + &w=2600&q=85&fm=jpg; Pexels CDN https://images.pexels.com/photos/<ID>/pexels-photo-<ID>.jpeg?auto=compress&cs=tinysrgb&w=1600 (search pages block curl: use WebFetch/WebSearch for IDs). Always build a contact sheet and look at it before using. Cutouts from dark backgrounds: luminance+warmth alpha, largest component, trim 5 px (see baguette/assets/hero_cut_3k.png).
  • Video: Mixkit assets.mixkit.co/videos/<ID>/<ID>-1080.mp4, Pexels pexels.com/download/video/<id>/. Re-encode all-intra (-g 1), load as blob URL, await seeked.
  • Logos: scripts/svgl_logos.py (svgl.app API, colour SVGs: openai, gemini, perplexity, google, claude, youtube, reddit, trustpilot, linkedin, shopify, wordpress, webflow, framer, nextjs); fallback simple-icons (cdn.jsdelivr.net/npm/simple-icons@13/icons/<name>.svg); Howseen marks in promo60/logos/logo-mark*.png. 21st.dev search_logo currently returns nothing: go to svgl directly.
  • 21st.dev components (Animated Beam id 919, Border Beam 1268, Orbiting Circles 1411…): scripts/mcp21_client.py tools | call search '{…}' | call get_component '{"id":…}', key in ~/.config/21st.key (free tier: 2 code retrievals/day). They're React/framer-motion: port the idea to seek(t), never run them live.
  • Memes: yt_dlp from the video venv (if YouTube says "page needs to be reloaded", pass extractor_args={"youtube":{"player_client":["tv","web_safari","android","ios"]}} and ffmpeg_location=imageio_ffmpeg.get_ffmpeg_exe()), ytsearch6:<meme> meme template, check a contact sheet (no burned-in captions, no watermarks, cut "Subscribe / link in description" end cards), re-encode H.264 1280 wide + AAC + setsar=1. Library in ~/Desktop/Howseen AI/illustrations/memes/ (clips/ ready to post, legendes/ captions, sources-brutes/ raw downloads, planches/ check sheets) (Michael Scott, DiCaprio pointing, Travolta, Keanu whoa, Bateman walk, This is fine, Homer bushes, Carrey typing fast, Gatsby toast, Peele sweating).

5b. Resource shortlist (checked 28/09/2026)

  • 3D icons: 3dicons.co, CC0 (commercial use, no attribution), 1,500+ renders. Fits the Howseen "glossy 3D on cream" look for videos, LinkedIn visuals and article covers. Download PNGs, cut-out already transparent; look at them on a contact sheet first.
  • seek-compatible animation libs (can be driven frame by frame, so they fit the deterministic render):
    • Anime.js (animejs.com): create with autoplay: false, then anim.seek(ms) from window.seek(t) (t*1000, or set engine.defaults.timeUnit to seconds). Use timelines the same way.
    • Theatre.js (theatrejs.com): keyframes edited visually in Studio, then in the render build drop the Studio and set sheet.sequence.position = t (seconds) inside window.seek(t). Good for complex hand-tuned camera moves. Check the sequence API before first use.
    • Never use libs that only animate in real time (Spline runtime, Unicorn Studio, CSS/framer-motion live): they can't be seeked, so frames drift.
  • Ideas to port to seek(t) (don't run them live): Kinetics (kinetics.colorion.co, 150+ motion effects), CSS Text Effects (text-effects.colorion.co), Liquid Glass (glass.samasante.com, refraction), Motion Primitives, Magic UI, Aceternity, 21st.dev (MCP, see above).
  • For the Howseen site/app, not videos: Magic UI / Aceternity / Motion Primitives (copy-paste animated React), Component Gallery + Navbar Gallery (references). Avoid Spline/Unicorn embeds on the site (kills the Lighthouse 100).

6. Gotchas (all hit for real)

  • Unsplash napi can return 401: fallback = picsum.photos/id/<id>/<w>/<h> (Unsplash photos, same licence; /id/<id>/info gives the author). Covers that worked: 184 (desert night, orange), 1041 (wave, blue).
  • An easing solved by bisection returns ~1e-9 at x=0: if (e > 0) guards fire early (a whole dive disc appeared 2 s early). Return exact 0/1 at the ends.
  • Logo reveal from a line: squash the real mark vertically (scaleY 0.014 → 1, "eye opening") and overlay the solid line for the first 12 %; never crossfade a drawn lens into the PNG (grey ghost). Reference: loop22/loop22.html (canvas 1440², goo via ctx.filter='url(#goo)', per-time motion-blur subframes 4/12, loop seam check in render.py pops).
  • Worktree sandbox: no heredocs / cd && … chains / loops with computed commands / $(…) in Bash → write .py scripts and run plain commands. Paths with spaces: use the symlink $CLAUDE_JOB_DIR/tmp/hv → howseen-video.
  • No brew ffmpeg: imageio_ffmpeg.get_ffmpeg_exe() or howseen-video/bin/ffmpeg. Python venv: howseen-video/.venv.
  • Cloudflare blocks Python's default UA on some APIs: send User-Agent: claude-code-mcp-client/1.0.
  • Hash-only goto doesn't reload: set state via evaluate. Measure text with canvas (measureText) not DOM rects when a camera scale is applied.
  • Text that must stay sharp during a handoff: never scale a blurry copy, crossfade only the fill.
  • LinkedIn video: 4:5 1080×1350; X: 16:9 or 1:1, ≤ 2:20; captions go in the post, burned banners ("Commente MOTION") only for LinkedIn lead magnets.

7. Delivery checklist

☐ stills approved ☐ 0 unexplained pops ☐ drop on the key moment ☐ −14 LUFS ☐ TV-range BT.709 ☐ "Example data" labels ☐ caption true ☐ file revealed in Finder + path given.

8. Critique loop (make the model watch its own frames)

Before any full render, and after it:

ffmpeg -i out/final.mp4 -vf "fps=2,scale=270:-1,tile=6x5" -frames:v 1 out/contact.png      # overview
ffmpeg -ss <t-0.1> -i out/final.mp4 -vf "scale=320:-1,tile=12x1" -frames:v 1 out/strip.png  # 12 frames around a fast move
ffmpeg -i out/final.mp4 -vf "fps=1,scale=360:-1,tile=5x3" -frames:v 1 out/phone.png         # readability at phone width
ffmpeg -stream_loop 1 -i out/final.mp4 -c copy out/loop_check.mp4                            # loop seam (loops only)

Open them and score 1-10: hook in the first 2 s · readability at 360 px · motion quality (springs, no dead frames) · variety (something new every 2-4 s) · composition · brand/data accuracy · sound sync. Write the 3 worst problems with timestamps (hunt for: text overlapping during swaps, anything moving linearly, corner labels/frame borders, centred title on a gradient, blurry scaled text, a dead beat, a loop stutter). Fix, re-render only the affected seconds, re-score. Repeat until every score is 8+. Be a harsh motion director, not a proud author.

9. Extra rules

  • Determinism: never Math.random; use a seeded PRNG (mulberry32). Rendering the same second twice must give identical frames.
  • Reference first: with a reference video/frame, extract a frame every 0.5 s with ffmpeg, write docs/style_guide.md (palette hex, type, shot lengths, transitions, camera, texture, text in/out) and docs/shotlist.md on the beat grid. Take the grammar, never the content or logos. Wait for OK before code.
  • Real product only: capture the real UI (Playwright screenshots of the site/app) into ./assets and list what you found; never invent screens. If a paywall blocks it, ask the user for screenshots or clearly label a recreated UI as illustrative.
  • Spring presets (stiffness k, damping d): snappy UI 320/30, default containers/camera 170/26, heavy type/logos 120/24, playful mascots 180/12. Leading and trailing edges of a stretching indicator on different springs.
  • Formats: write scenes against a layout function, then render 9:16, 1:1, 16:9 and 4:5 from the same timeline, reframing type and UI per format (never crop).
  • Synthesized sound option: when no track is supplied, SFX can be synthesized in code (click = short decaying sine, pop = rising sine, thump = falling sine, whoosh = windowed noise) on the same timeline.
  • Effort: medium for small fixes, xhigh for a new film, max when the first 3 seconds carry a launch.

9b. Studio conventions & director's brief (from the "Playbook Opus 5.5" doc, 02/10/2026)

"The prompt is 10 %, the harness is 90 %."

  • Film folder = brand/ (logo.svg, palette.txt, screenshots/, reference.mp4 optional but decisive) + facts.md (every number with its source + date; "no invented metric") + <name>.html + render.py + out/. No facts.md → no numbers on screen. For Howseen, facts come from the app/PostHog/Stripe or a sourced page.
  • Director's brief before any code (first message, effort max): duration, master format, deliverable formats from the SAME timeline (16:9, 1:1, 9:16), subject + one-line promise, audience, channel, a named reference style ("Linear launch", "Stripe docs", "Apple bumper", never "premium modern"), then states with timestamps (hook type 0-2.5 → shape becomes screen 1 → transforms to screen 2 with cursor click → proof with a real number → logo + URL, back to state 0 if loop). Expected output: 6 beats + layer list + sound plan (silence / coded SFX / Mixkit track), then STOP for OK.
  • Anti-"AI motion" rules (on top of §0/§2): ONE accent colour; one thing moves at a time (unless a slider drives a continuous transform); one shape/visual system from start to end, transformations not cuts; springs with damping ratio ≥ 0.72, tiny overshoot, never cartoon bounce; banned: rainbow gradients, particles, glowing chrome, emoji, lorem ipsum, gratuitous 3D flips; type big enough for a phone; 9:16 safe zone decided at storyboard (text inside the centre 1080×1080).
  • Silent "one sentence" test: show the loop muted to someone; if they summarise it in one sentence ("the button became a player"), it works; if they hesitate, too much moves.
  • Prompt library (adapt, never paste raw for a client):
    • One-shot viral (Stephan Livera, 24/09): "make a dynamic 15-second motion graphics video that shows what an incredible motion designer you are, like it's your showreel for a résumé. go all out." Use once to see the engine; generic output.
    • One-shot branded: "Make a 15-second showreel at 1920x1080, 30fps. Keep it in one self-contained index.html. HTML, CSS, JS, SVG or Canvas only. No CDN, no external URLs. Bold type and simple shapes. End on [BRAND]. Play and Replay. window.seek(t). Do not render the MP4 yet." + one accent hex + "no gradient template".
    • UI morph (@twoclipping): one shape through 8-12 UI states (button → loader → player → slider → toggle → tabs → chart → command palette → toast), a real slider, ~120 BPM, exact loop.
    • Product launch / landing reveal (@notdwd open prompt): inputs → direction → frame-by-frame structure → seek(t) build → gotchas → 4 stills before the full film.
    • LinkedIn loops (Eric Djavid): pure HTML/CSS, one charter, one "A becomes B" transform, 5-9 s loop, one thing moves, easings cubic-bezier(.6,0,.2,1) and cubic-bezier(.2,.8,.2,1); micro-prompts: button → player, search → results, card → workspace, SVG curve, magnetic dock, hidden title.
    • SaaS / app: web = point at the repo (real design tokens); mobile = screenshots + "invent no feature"; storyboard first.
  • Critique prompt (2-4 rounds, where the "AI look" goes away): capture a frame at every beat and 0.3 s after, contact sheet, score each shot /10 on readability, rhythm, shape continuity, branding, loop; fix only ≤ 7, don't touch shots at 9+.
  • Other routes (only if asked): HyperFrames (HTML + GSAP, npx skills add heygen-com/hyperframes, CLI preview/render: brand content factory) and Remotion (React templates, npx skills add remotion-dev/skills: data-driven template library). Our route = bare HTML + seek(t), the one Opus picks on its own.

Diagnostic when it's ugly

Symptom Cause → fix
Same film as all of X Livera prompt pasted with no charter/states → brief + facts + named style
Blurry text / washed logo Went through a pixel video model → back to code, real SVG
Stutters on export Timers / CSS transitions → pure seek(t), 2 rAF wait
Loop jumps Position matched, velocity not → add the spring tails of the previous 2 cycles; render.py loop
Cheap, bouncy Bounce easing → springs, damping ≥ 0.72
Invented feature No screenshots / repo / facts.md
Black WebGL WEBGL=1 (SwiftShader/ANGLE flags)
Cost explodes Effort max on micro-fixes → medium for patches

9c. Stolen from the other public motion skills (scan 02/10/2026)

  • Seek third-party libs instead of banning them (HyperFrames adapters): GSAP skips the redraw when asked for the same time, so tl.pause(); tl.totalTime(t + 0.001, true); tl.totalTime(t, true). WAAPI/CSS: document.getAnimations().forEach(a => { a.pause(); a.currentTime = t * 1000 }). Lottie goToAndStop(t*1000), anime.js seek. Video sources: pre-extract frames with ffmpeg rather than waiting on seeked when it flakes.
  • Deterministic Chromium flags for pixel-identical reruns: --deterministic-mode --run-all-compositor-stages-before-draw --disable-threaded-animation --disable-checker-imaging --font-render-hinting=none --force-color-profile=srgb. GPU WebGL on the Mac: --use-angle=metal.
  • Adaptive subframes (Kimeur): measure the fastest element's px/frame; still → 1 sample, ≤ 20 px → 4, above → ceil(px/5) up to 64. Huge time saver vs a fixed 8 on mostly static films. Never blur across a hard cut: list the cuts in CUTS (template clamps subframes to the frame's side of the cut).
  • Targeted blur > full-frame blur (HyperFrames): blur only the 1-3 slam moves; never blur text meant to be read or any move < one element width per frame. Supersample 2× (device_scale_factor=2, Lanczos down) for razor text, cheaper than tmix on static type.
  • Cut seams (HyperFrames seam gate): cut at peak velocity; direction AND speed match on both sides; zoom keeps the sign of d(scale)/dt across the cut; seam blur scales with size (≈ 10 px text, 18-20 px full frame).
  • QA additions: one-frame flash detector (built into pops: frame n differs from both neighbours while n-1 ≈ n+1); loop checks seek(0) vs seek(T), T-1f, T-1.75f; cycles must be integer cyc(t, n). Judge ≠ builder: run the critique as a separate read-only sub-agent, and have a fresh agent restate the message from the frames alone (if it can't, the film fails).
  • Poster in frame 0 (X/Slack/Discord ignore the cover): -filter_complex "[0:v][1:v]overlay=0:0:enable='eq(n,0)'[v]" with the poster PNG as input 1.
  • Music cues (brag): onset strength per track, intensity = 0.45·onset + 0.25·contrast + 0.20·rms + 0.10·bass; reveals may shift ≤ 0.15 s (small entrances ≤ 0.10 s) to snap onto a cue. Silence detection adaptive: read input_thresh from loudnorm print_format=json, feed silencedetect=noise=<thresh>dB.
  • Captions (Kimeur/HyperFrames): pop 1-3 words or karaoke 3-7, ≥ 0.7 s on screen, ≤ 17 chars/s, no 1-3 frame gaps, a word lights on its start frame never before; Whisper word timings, drop words hallucinated over trailing silence.
  • Preview HUD (QasimTalkin): clickable scrubber, ←/→ ±0.5 s (Shift = 1 frame), clock synced to out/mix.wav, beat number shown, ?t= deep link.
  • B-roll over a talking head (Barty-Bart /motion-broll): full-frame cutaways 3-10 s with ≥ 2 s of face between, or transparent panels in the empty space; alpha clips via screenshot(omit_background=True) → format=gbrap → ProRes 4444; never will-change on an element the camera scales.

9d. From the YouTube walkthroughs (Bart Slodyczka, Moritz, Jack Roberts, 24-25/09; transcripts in vault 30 Ressources/.raw/)

  • B-roll over an existing VO / talking head: ask the density first (≈ 33 s: medium = 3 clips, heavy = 4-5; pick heavy, cutting is easier than adding). Input = word-timestamped transcript (whisper). Plan line by line, each graphic tagged full cutaway / face stays / transparent panel in the empty space, approved before rendering. Graphics land on the spoken word. The creator pre-edits (cuts + shrinks themself to a third of the frame) so the empty space is known. Deliver all clips in one local review page. Medium effort is enough.
  • VO pipeline: TTS (Cartesia/ElevenLabs), speed up slightly + trim silences with ffmpeg (adaptive silencedetect, §9c), local whisper.cpp for word timings → ONE timing file that drives both captions and animation cues. Check words clipped at edits.
  • Long → Short: one topic per Short (split rather than compress), faster cuts, quick real-face shots from the original footage, regenerate VO in pieces, model the creator's own short-form scripts.
  • Brand intake: Firecrawl scrape with the branding format (not markdown) pulls a site's colours/fonts/identity into brand/ (needs a Firecrawl key; otherwise Playwright screenshots + pixel sampling as in §11).
  • Fight the fingerprint: Claude's default font and crowded vertical layouts are recognisable: always set the brand font, and on 9:16 talking heads keep speaker bottom / one graphic top / captions, nothing else.
  • Use cases that worked: explainer from a doc/SOP/skill ("make it fun, not too serious"); a mascot SVG narrator carrying the story; a video version of a sales proposal with real discovery-call numbers (anonymised); static HTML slides → animated slide loops (5-20 s); 3 s logo sting + jingle (batchable: 100 logos, each in the brand's main colour); restyle from a reference image (savee.com / Pinterest), add a grain/film-burn pass if it falls flat. One-prompt launch films can fan out to parallel sub-agents per section (like remake Phase 2).

10. Remake mode — frame-locked 1:1 copy of an existing video (scripts/remake/)

Use when asked to "remake / recreate this launch video for my brand" (the split-screen "original | opus 5.5 copy" format). Proven on the Gojiberry launch (65 s, 28 shots) on 28/09/2026.

  • Phase 0, analysis (no building): download REF (yt_dlp in the video venv, no browser cookies) → remake_analyze.py extracts all frames 0-based to ref/full, audio to ref/audio.wav, detects hard cuts (mean-abs-diff spikes) and writes 6-frame contact sheets. Read the sheets, write SPEC.md: shot table (id, f0–f1, REF content, brand swap), swap rules. Most "cuts" in modern launch films are continuous camera/morph moves: expect only ~10-15 hard cuts, and expect SPEC boundaries to be a few frames off (agents fix them).
  • Phase 1, engine (you, before agents): copy core.js + index.html (seek(F) pure, SHOT registry, camera, cursor, words, pixelDissolve, palette filter that re-hues any leftover old-brand colour) and remake_stub.py (one placeholder file per group). Serve the folder, smoke-test with remake_render.py compare out/test 10 600 1200.
  • Phase 2, parallel build: split shots into 4 contiguous groups, one agent each (fill BRIEF_TEMPLATE.md), each writes ONLY shots/Gx.js and verifies with side-by-side compare sheets. 5th agent = audio: analyse REF (BPM, drop, hard stop, SFX hits, VO slots via STT timings only), royalty-free Mixkit track stretched ≤8% and cut on bars so drops land on REF times, numpy SFX on REF hits, -14 LUFS. Never reuse REF music/voice. Typical wall time: ~25 min per agent in parallel.
  • Phase 3, integrate: full render in 3 parallel chunks (remake_render.py full out/full a b), remake_sync.py encode (muxes out/mix.wav), split (the post format: two panels with a gap, black labels "original" / "opus 5.5 copy", setsar=1 or X distorts it), stacked (QA). remake_qa.py: ref|ours one frame per second + group seams + old-brand colour scan. Fix, re-render, deliver.
  • Honesty rules: no fake "made in 15 minutes" if it wasn't; tag/credit the original brand in the post; never show "OpenAI × YourBrand"-style co-marks that imply a partnership; no REF people photos.
  • Helpers every agent re-invented (add locally until core has them): text placed by ink edge + fitFont, hex colour mix, REF-shaped cursor, per-frame keyframe tables.

11. Product film mode — homepage SaaS film (from the "PROMPT MOTION DESIGN SAAS" brief, 29/09/2026)

Use for a 45-75 s product film that shows the whole product in action. Lean pass first (one language, 16:9, no VO, ~45-60 min), full treatment only when asked (overnight, Mac awake).

  • Story = a chain: problem in the client's own words (3-5 s) → each step PRODUCES what the next one uses (the object leaving a step becomes the next scene, one continuous camera, no hard cuts) → the measurable result on screen → price twist → final CTA. Each step 2-7 s. Every element finishes animating and stays readable ≥ 1.5 s.
  • Screens are rebuilt in code, never pasted: scan each screenshot in zoomed tiles, extract ONE ui-tokens file (exact colours by pixel sampling, fonts, radii, shadows, borders, spacing), rebuild components that animate element by element (rows cascading, counters, gauges, typing, cursor, toggles). Check each rebuilt screen side by side with its screenshot until it's recognisable at first glance.
  • i18n from day one: no hard-coded text, one FR/EN dictionary; English written like a US SaaS UI, French = exact labels of the screenshots.
  • Anonymise: one fictional company used everywhere (same domain, products, competitors in both languages); no real client, competitor or person; neutral avatar. Images containing text are rebuilt (code or photo part only + text re-typed).
  • Muted-first: the homepage version autoplays without sound, so kinetic type carries the message. Safe margins 110 px (16:9) / 80 px (9:16); every title on ONE line.
  • Full treatment extras: write a "motion bible" (30-40 checkable rules) from the references; 5 competing concepts (one-take, beat montage, metaphor, glass world, director's cut) scored by a jury and merged; after v1, 7 critics (motion, image, sound, story, muted readability, UI fidelity + translations, brand/copy rules), ≥ 3 rounds, each defect with frame numbers + severity + measurable fix; a new version only replaces the previous one if side-by-side judges find it better (keep a version log). Deliver 16:9 + recomposed 9:16 (not a crop), no-VO + VO EN/FR, separate WAV stems, README.
  • Machine hygiene: one render at a time machine-wide (shared lock file), never parallel Whisper/heavy ffmpeg, delete superseded renders (keep current + previous), no built-in browser for sub-agents at night.
Files (claude-motion-design)
  • scripts
    • remake
      • BRIEF_TEMPLATE.md 2.1 KB
        # BRIEF — build agents (frame-locked remake of [REF VIDEO] for [BRAND])
        
        Workdir (absolute): [WORKDIR] · Python: [VENV]/bin/python · page: http://localhost:[PORT]/[DIR]/index.html (server already running).
        
        ## Acceptance bar
        Our render must match REF frame-by-frame in LAYOUT, SIZES, POSITIONS, TIMING, EASING, CAMERA, CUTS, BLUR, CURSOR PATH, TYPING CADENCE when shown side by side in sync ("original | copy" split-screen). Only content is swapped. Target: position/size error ≤1-2% of frame, cuts 0 frames off. Differences allowed only where the swap forces them (word widths) — report each.
        
        ## Swap rules (edit per project)
        - [OLD BRAND] → [BRAND] (CORE.appIcon / CORE.mark), accent colours → brand tokens (CORE.T).
        - Partner/co-brand marks: never imply a partnership that doesn't exist (e.g. "OpenAI ×" → a true co-mark like "Opus 5.5 ×").
        - No photos of real people from REF: replace with logo tiles, cards or neutral shapes of the same size/motion.
        - Copy must be TRUE for the brand; no invented numbers (mock UI numbers must read as examples).
        - Never reuse REF music, voice or images.
        
        ## File rules
        Write ONLY shots/<G>.js (IIFE, helpers prefixed <G>_, one SHOT({id,f0,f1,render}) per shot, contiguous range). Pure function of F (CORE.rand, never Math.random/Date/timers). Never edit core.js/index.html/scripts. Scratch in out/<G>/ and measure/<G>_*.
        
        ## Method
        1. Read SPEC.md + core.js API.
        2. MEASURE with numpy on ref/full frames (ink bboxes, typing char count, cursor tip, camera scale/offset, fade timing) → drive motion with CORE.samples.
        3. Verify loop per shot: `remake_render.py compare out/<G> <frames…>` (first/last, keyframes, 2 frames into every transition, ≤15 per call); read compare_sheet.jpg; iterate. Never claim a match without viewing ref|ours.
        4. Pitfalls: measure text widths only after fonts load (lazy); REF cursor is often not the macOS arrow (draw it from REF); REF whips may be crisp; seeded bursts; check REF's real cut frames (SPEC boundaries are often ±4 frames off).
        
        ## Report
        Table: shot | frames | MATCHES/CLOSE/ROUGH | residual diff | frames that would drift | spec errors.
        
      • core.js 9.1 KB
        /* core.js — shared, deterministic helpers for the frame-locked remake.
           Everything is a PURE function of the global frame F (24 fps). No timers, no Date, no Math.random.
           Shot files register with SHOT({id, f0, f1, render(lf, F)}) and return an HTML string for the 1920x1080 stage. */
        (function () {
          const FPS = 24, W = 1920, H = 1080;
          const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
          const lerp = (a, b, t) => a + (b - a) * t;
          const inv = (a, b, x) => clamp((x - a) / (b - a));
        
          // ---- easing set
          const E = {
            lin: t => t,
            inQ: t => t * t, outQ: t => 1 - (1 - t) * (1 - t), ioQ: t => t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
            inC: t => t * t * t, outC: t => 1 - Math.pow(1 - t, 3), ioC: t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
            outQuart: t => 1 - Math.pow(1 - t, 4), ioQuart: t => t < .5 ? 8 * t ** 4 : 1 - Math.pow(-2 * t + 2, 4) / 2,
            outExpo: t => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t), ioExpo: t => t <= 0 ? 0 : t >= 1 ? 1 : t < .5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2,
            outBack: t => { const c = 1.70158, c3 = c + 1; return 1 + c3 * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); },
          };
          // spring (closed form, underdamped) from 0 -> 1, tau in frames
          const spring = (tau, f = 2.2, z = 0.55) => { if (tau <= 0) return 0; const t = tau / FPS; const w = 2 * Math.PI * f; return 1 - Math.exp(-z * w * t) * Math.cos(w * Math.sqrt(1 - z * z) * t); };
        
          // kf(F, [[f, v], [f, v], ...], ease|easeArray) -> piecewise interpolation, holds ends
          function kf(F, keys, ease = E.ioC) {
            if (F <= keys[0][0]) return keys[0][1];
            for (let i = 1; i < keys.length; i++) {
              if (F <= keys[i][0]) {
                const [f0, v0] = keys[i - 1], [f1, v1] = keys[i];
                const e = Array.isArray(ease) ? (ease[i - 1] || E.ioC) : ease;
                return lerp(v0, v1, e((F - f0) / (f1 - f0)));
              }
            }
            return keys[keys.length - 1][1];
          }
          // samples(F, f0, arr): linear interp inside a per-frame measured array starting at frame f0
          function samples(F, f0, arr) {
            const x = F - f0; if (x <= 0) return arr[0]; if (x >= arr.length - 1) return arr[arr.length - 1];
            const i = Math.floor(x), t = x - i; return lerp(arr[i], arr[i + 1], t);
          }
          // seeded hash rand in [0,1)
          const rand = (n, s = 1) => { let x = Math.sin(n * 127.1 + s * 311.7) * 43758.5453; return x - Math.floor(x); };
        
          // ---- brand tokens (Howseen)
          const T = {
            bg: '#ffffff', ink: '#0f172a', sub: '#64748b', line: '#e5e7eb', soft: '#f5f7fa',
            a1: '#38bdf8', a2: '#a78bfa', aDeep: '#0369a1', blue: '#2f7bf5', green: '#22c55e',
            grad: 'linear-gradient(90deg,#38bdf8 0%,#a78bfa 100%)',
            font: "'Geist', 'Inter', -apple-system, 'SF Pro Display', Helvetica, Arial, sans-serif",
          };
          const A = n => 'assets/' + n; // asset path
        
          // ---- camera: wraps inner HTML, scale around origin + translate, optional blur
          function camera(inner, { s = 1, tx = 0, ty = 0, ox = W / 2, oy = H / 2, blur = 0, op = 1, dblur = null } = {}) {
            const f = [];
            if (blur > 0.01) f.push(`blur(${blur.toFixed(2)}px)`);
            const filt = f.length ? `filter:${f.join(' ')};` : '';
            const db = dblur ? `filter:url(#${dblur});` : '';
            return `<div style="position:absolute;inset:0;transform-origin:${ox}px ${oy}px;transform:translate(${tx}px,${ty}px) scale(${s});opacity:${op};${filt}${db}">${inner}</div>`;
          }
          // directional motion blur via SVG filter defs; returns [defsHTML, id]
          function mblurDefs(id, dx, dy) {
            return `<svg width="0" height="0" style="position:absolute"><filter id="${id}" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${Math.abs(dx).toFixed(2)} ${Math.abs(dy).toFixed(2)}"/></filter></svg>`;
          }
          // ---- cursor (macOS arrow), tip at (x,y); press 0..1 scales down slightly
          function cursor(x, y, { press = 0, scale = 1, op = 1 } = {}) {
            const s = scale * (1 - 0.12 * press);
            return `<svg style="position:absolute;left:${x}px;top:${y}px;transform-origin:0 0;transform:scale(${s});opacity:${op};overflow:visible;z-index:50" width="28" height="40" viewBox="0 0 28 40">
              <path d="M2 2 L2 31 L9.5 24 L14.5 36 L19 34 L14 22.5 L24 22.5 Z" fill="#000" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`;
          }
          // ---- text helpers
          // reveal chars: returns text truncated to n chars (typing)
          const typed = (s, n) => s.slice(0, Math.max(0, Math.floor(n)));
          // per-word reveal spans with opacity/blur/y driven by t (0..1) staggered
          function words(s, t, { stagger = 0.12, dur = 0.35, y = 14, blur = 6, color = null, accent = [] } = {}) {
            const ws = s.split(' ');
            return ws.map((w, i) => {
              const p = clamp((t - i * stagger) / dur);
              const e = E.outC(p);
              const col = accent.includes(i) ? `background:${T.grad};-webkit-background-clip:text;background-clip:text;color:transparent;` : (color ? `color:${color};` : '');
              return `<span style="display:inline-block;opacity:${e};transform:translateY(${(1 - e) * y}px);filter:blur(${(1 - e) * blur}px);${col}">${w}</span>`;
            }).join('<span style="display:inline-block;width:.28em"></span>');
          }
          // gradient text span
          const gtext = s => `<span style="background:${T.grad};-webkit-background-clip:text;background-clip:text;color:transparent">${s}</span>`;
          // ---- logos
          // Howseen mark (black glyph PNG used as mask so any fill works)
          function mark(size, fill = T.ink) {
            return `<div style="width:${size}px;height:${size}px;background:${fill};-webkit-mask:url(${A('logo-mark.png')}) center/contain no-repeat;mask:url(${A('logo-mark.png')}) center/contain no-repeat"></div>`;
          }
          // app icon tile (rounded square with gradient + white mark), like the REF app icon
          function appIcon(size, { grad = T.grad } = {}) {
            return `<div style="width:${size}px;height:${size}px;border-radius:${size * .24}px;background:${grad};display:flex;align-items:center;justify-content:center;box-shadow:0 ${size * .06}px ${size * .18}px rgba(56,189,248,.25)">${mark(size * .62, '#fff')}</div>`;
          }
          const img = (n, size, extra = '') => `<img src="${A(n)}" style="width:${size}px;height:${size}px;object-fit:contain;${extra}">`;
          const ENGINES = [['logo-chatgpt-v2.png', 'ChatGPT'], ['logo-gemini.png', 'Gemini'], ['logo-perplexity.png', 'Perplexity'], ['logo-google.png', 'AI Overviews'], ['logo-claude.png', 'Claude']];
        
          // ---- pixel dissolve: grid of squares covering a rect, each square flips at a seeded threshold
          function pixelDissolve(x, y, w, h, t, { cell = 24, colors = [T.a1, T.a2, '#e0f2fe', '#ede9fe'], seed = 3 } = {}) {
            const cols = Math.ceil(w / cell), rows = Math.ceil(h / cell); let out = '';
            for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
              const k = r * cols + c, th = rand(k, seed);
              const on = t > th * 0.8 && t < th * 0.8 + 0.35;
              if (!on) continue;
              out += `<div style="position:absolute;left:${x + c * cell}px;top:${y + r * cell}px;width:${cell}px;height:${cell}px;background:${colors[k % colors.length]};opacity:${(0.35 + 0.65 * rand(k, seed + 1)).toFixed(2)}"></div>`;
            }
            return out;
          }
        
          // ---- palette filter: re-hue any leftover Gojiberry reds/oranges to Howseen sky/violet (applied to final HTML string)
          function hexToRgb(h) { h = h.replace('#', ''); if (h.length === 3) h = h.split('').map(c => c + c).join(''); const n = parseInt(h, 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }
          function rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h = 0, s = 0; const l = (mx + mn) / 2; if (mx !== mn) { const d = mx - mn; s = l > .5 ? d / (2 - mx - mn) : d / (mx + mn); h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h /= 6; } return [h * 360, s, l]; }
          function hslToHex(h, s, l) { h /= 360; const f = n => { const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l); const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); return Math.round(c * 255).toString(16).padStart(2, '0'); }; return '#' + f(0) + f(8) + f(4); }
          function paletteFilter(html) {
            return html.replace(/#[0-9a-fA-F]{6}\b/g, m => {
              const [r, g, b] = hexToRgb(m); const [h, s, l] = rgbToHsl(r, g, b);
              if (s > 0.35 && (h < 45 || h > 330)) { const nh = h > 330 || h < 15 ? 262 : 199; return hslToHex(nh, Math.min(s, .9), l); }
              return m;
            });
          }
        
          // ---- registry + seek
          const SHOTS = [];
          window.SHOT = def => { SHOTS.push(def); SHOTS.sort((a, b) => a.f0 - b.f0); };
          window.CORE = { FPS, W, H, clamp, lerp, inv, E, spring, kf, samples, rand, T, A, camera, mblurDefs, cursor, typed, words, gtext, mark, appIcon, img, ENGINES, pixelDissolve, paletteFilter };
          window.seek = t => {
            const F = Math.round(t * FPS);
            const st = document.getElementById('stage');
            const s = SHOTS.find(x => F >= x.f0 && F < x.f1) || SHOTS[SHOTS.length - 1];
            let html = '';
            if (s && F >= s.f0 && F < s.f1) { try { html = s.render(F - s.f0, F); } catch (e) { html = `<div style="color:red;font:30px monospace;padding:40px">${s.id} error: ${e}</div>`; console.error(s.id, e); } }
            else html = `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:40px monospace;color:#bbb">F${F} — no shot</div>`;
            st.innerHTML = paletteFilter(html);
            return F;
          };
        })();
        
      • index.html 1.2 KB · in bundle
      • remake_analyze.py 2.1 KB
        """Phase 0: extract all frames + audio, detect cuts by mean-abs-diff spikes, build per-cut contact sheets."""
        import subprocess, json
        from pathlib import Path
        import numpy as np, imageio_ffmpeg
        from PIL import Image
        FF = imageio_ffmpeg.get_ffmpeg_exe()
        H = Path(__file__).parent
        ref = H / "ref/reference.mp4"
        full = H / "ref/full"; full.mkdir(parents=True, exist_ok=True)
        if not any(full.iterdir()):
            subprocess.run([FF, "-v", "error", "-i", str(ref), "-start_number", "0", "-q:v", "3", str(full / "f%04d.jpg")], check=True)
            subprocess.run([FF, "-v", "error", "-y", "-i", str(ref), "-vn", "-ac", "2", "-ar", "48000", str(H / "ref/audio.wav")], check=True)
        frames = sorted(full.glob("f*.jpg"))
        print("frames", len(frames))
        small = [np.asarray(Image.open(f).convert("L").resize((192, 108)), dtype=np.float32) for f in frames]
        d = np.array([0.0] + [np.abs(small[i] - small[i - 1]).mean() for i in range(1, len(small))])
        np.save(H / "ref/diff.npy", d)
        # spike = diff much larger than local median
        cuts = []
        for i in range(1, len(d)):
            lo, hi = max(1, i - 6), min(len(d), i + 7)
            loc = np.median(np.concatenate([d[lo:i], d[i + 1:hi]])) if hi - lo > 1 else 0
            if d[i] > 6 and d[i] > 3.5 * (loc + 0.5):
                cuts.append(i)
        print("cuts", cuts)
        json.dump({"fps": 24, "n": len(frames), "cuts": cuts, "diff": [round(float(x), 2) for x in d]}, open(H / "ref/cuts.json", "w"))
        # contact sheet: every 6th frame with frame numbers, 2 per second
        thumbs = []
        for i in range(0, len(frames), 6):
            im = Image.open(frames[i]).resize((256, 144))
            thumbs.append((i, im))
        cols = 10
        from PIL import ImageDraw
        for page in range(0, len(thumbs), 80):
            chunk = thumbs[page:page + 80]
            rows = (len(chunk) + cols - 1) // cols
            sheet = Image.new("RGB", (cols * 256, rows * 160), "white")
            dr = ImageDraw.Draw(sheet)
            for k, (i, im) in enumerate(chunk):
                x, y = (k % cols) * 256, (k // cols) * 160
                sheet.paste(im, (x, y))
                dr.text((x + 4, y + 146), f"f{i}" + (" CUT" if i in cuts else ""), fill="red")
            sheet.save(H / f"ref/sheet/sheet_{page // 80}.jpg", quality=80)
        print("sheets done")
        
      • remake_qa.py 1.5 KB
        """QA sheets: ref|ours per second (2 pages) + group seams (last 2 / first 2 frames) + old-brand colour scan."""
        from pathlib import Path
        import numpy as np
        from PIL import Image, ImageDraw
        H = Path(__file__).parent
        Q = H / "out/qa"; Q.mkdir(parents=True, exist_ok=True)
        def pair(F):
            r = Image.open(H / f"ref/full/f{F:04d}.jpg").convert("RGB").resize((480, 270))
            o = Image.open(H / f"out/full/o_f{F:04d}.png").convert("RGB").resize((480, 270))
            c = Image.new("RGB", (970, 290), "white"); c.paste(r, (0, 20)); c.paste(o, (490, 20))
            ImageDraw.Draw(c).text((4, 4), f"F{F}", fill="red"); return c
        def sheet(frames, name, cols=3):
            tiles = [pair(F) for F in frames]; rows = -(-len(tiles) // cols)
            s = Image.new("RGB", (cols * 970, rows * 290), "white")
            for i, t in enumerate(tiles): s.paste(t, ((i % cols) * 970, (i // cols) * 290))
            s.save(Q / name, quality=78)
        secs = list(range(0, 1557, 24))
        sheet(secs[:33], "persec_1.jpg"); sheet(secs[33:], "persec_2.jpg")
        sheet([526, 527, 528, 529, 902, 903, 904, 905, 906, 907, 1152, 1153, 1154, 1155], "seams.jpg", cols=2)
        # old-brand colour scan: reddish/orange saturated pixels in ours
        bad = []
        for F in range(0, 1557, 4):
            a = np.asarray(Image.open(H / f"out/full/o_f{F:04d}.png").convert("RGB").resize((480, 270)), dtype=np.int16)
            r, g, b = a[..., 0], a[..., 1], a[..., 2]
            m = (r > 180) & (r - g > 60) & (r - b > 60) & (g > 60)
            if m.sum() > 150: bad.append((F, int(m.sum())))
        print("old-brand-colour frames:", bad[:40], "count", len(bad))
        
      • remake_render.py 3.2 KB
        """render.py — Playwright renderer for the frame-locked remake.
        usage:
          render.py stills  OUT F1 F2 ...      -> OUT/o_FNNNN.png
          render.py compare OUT F1 F2 ...      -> OUT/c_FNNNN.jpg (ref | ours, labelled) + OUT/compare_sheet.jpg
          render.py full    OUT F0 F1          -> OUT/fNNNN.png for F0 <= F < F1
        Use a separate OUT dir per agent (e.g. out/G2) so parallel runs never collide. Max ~15 frames per call for stills/compare.
        """
        import asyncio, sys, subprocess
        from pathlib import Path
        from PIL import Image, ImageDraw, ImageFont
        import imageio_ffmpeg
        from playwright.async_api import async_playwright
        
        H = Path(__file__).parent
        FF = imageio_ffmpeg.get_ffmpeg_exe()
        import os
        URL = os.environ.get("REMAKE_URL", "http://localhost:8768/remake/index.html")  # set REMAKE_URL to your served index.html
        FPS = 24
        
        
        async def run(frames, out: Path, fmt="png"):
            out.mkdir(parents=True, exist_ok=True)
            async with async_playwright() as p:
                b = await p.chromium.launch(args=["--disable-gpu-vsync", "--font-render-hinting=none"])
                pg = await b.new_page(viewport={"width": 1920, "height": 1080}, device_scale_factor=1)
                errs = []
                pg.on("pageerror", lambda e: errs.append(str(e)))
                pg.on("console", lambda m: errs.append("console: " + m.text) if m.type == "error" else None)
                await pg.goto(URL)
                await pg.wait_for_function("window.ready === true", timeout=120000)
                el = await pg.query_selector("#stage")
                paths = []
                for F in frames:
                    await pg.evaluate(f"window.seek({F / FPS + 1e-6})")
                    await pg.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
                    pth = out / f"o_f{F:04d}.png"
                    await el.screenshot(path=str(pth))
                    paths.append(pth)
                await b.close()
            if errs: print("PAGE ERRORS:", errs[:10])
            return paths
        
        
        def compare(frames, out: Path):
            paths = asyncio.run(run(frames, out))
            try: font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 34)
            except Exception: font = ImageFont.load_default()
            tiles = []
            for F, p in zip(frames, paths):
                ref = Image.open(H / f"ref/full/f{F:04d}.jpg").convert("RGB").resize((960, 540))
                ours = Image.open(p).convert("RGB").resize((960, 540))
                c = Image.new("RGB", (1930, 590), "white"); c.paste(ref, (0, 50)); c.paste(ours, (970, 50))
                d = ImageDraw.Draw(c); d.text((10, 8), f"REF f{F}", fill="red", font=font); d.text((980, 8), f"OURS f{F}", fill="blue", font=font)
                cp = out / f"c_f{F:04d}.jpg"; c.save(cp, quality=85); tiles.append(c)
            cols = 2; rows = -(-len(tiles) // cols)
            sheet = Image.new("RGB", (cols * 965, rows * 295), "white")
            for i, t in enumerate(tiles): sheet.paste(t.resize((965, 295)), ((i % cols) * 965, (i // cols) * 295))
            sheet.save(out / "compare_sheet.jpg", quality=80)
            print("compare ->", out / "compare_sheet.jpg")
        
        
        if __name__ == "__main__":
            mode, out = sys.argv[1], H / sys.argv[2]
            nums = [int(x) for x in sys.argv[3:]]
            if mode == "stills":
                asyncio.run(run(nums, out)); print("stills ->", out)
            elif mode == "compare":
                compare(nums, out)
            elif mode == "full":
                asyncio.run(run(list(range(nums[0], nums[1])), out)); print("full", nums, "->", out)
        
      • remake_stub.py 627 B
        from pathlib import Path
        H = Path(__file__).parent
        groups = {"G1": (0, 528), "G2": (528, 906), "G3": (906, 1154), "G4": (1154, 1557)}
        for g, (a, b) in groups.items():
            p = H / f"shots/{g}.js"
            if not p.exists():
                p.write_text(f"""(function () {{
          const C = CORE;
          // {g}: frames {a}-{b}. Replace this placeholder with real shots (SHOT per shot id).
          SHOT({{ id: '{g}_placeholder', f0: {a}, f1: {b}, render: (lf, F) =>
            `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:48px Geist;color:#94a3b8">{g} · F${{F}}</div>` }});
        }})();
        """)
                print("stub", g)
        
      • remake_sync.py 2.5 KB
        """sync.py — build the deliverables from full-render frames in out/full/o_fNNNN.png.
          sync.py encode   -> out/remake_silent.mp4 (24 fps, h264 BT.709) and out/remake.mp4 (muxed with out/mix.wav if present)
          sync.py split    -> out/split_screen.mp4 : REF left | OURS right, labelled "original" / "opus 5.5 copy", white bg (the X post format)
          sync.py stacked  -> out/sync_check.mp4 : REF top / OURS bottom, frame-locked, for QA
        """
        import subprocess, sys
        from pathlib import Path
        import imageio_ffmpeg
        H = Path(__file__).parent
        FF = imageio_ffmpeg.get_ffmpeg_exe()
        OUT = H / "out"; FULL = OUT / "full"; REF = H / "ref/reference.mp4"
        ENC = ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16", "-preset", "slow", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-movflags", "+faststart"]
        mode = sys.argv[1]
        if mode == "encode":
            subprocess.run([FF, "-v", "error", "-y", "-framerate", "24", "-i", str(FULL / "o_f%04d.png"), *ENC, str(OUT / "remake_silent.mp4")], check=True)
            mix = OUT / "mix.wav"
            if mix.exists():
                subprocess.run([FF, "-v", "error", "-y", "-i", str(OUT / "remake_silent.mp4"), "-i", str(mix), "-c:v", "copy", "-c:a", "aac", "-b:a", "256k", "-shortest", "-movflags", "+faststart", str(OUT / "remake.mp4")], check=True)
            print("encoded")
        elif mode == "split":
            font = "/System/Library/Fonts/Helvetica.ttc"
            lab = lambda txt, x: f"drawbox=x={x}:y=246:w={len(txt) * 13 + 24}:h=34:color=black@1:t=fill,drawtext=fontfile={font}:text='{txt}':x={x + 12}:y=253:fontsize=22:fontcolor=white"
            vf = ("[0:v]scale=880:-2,pad=920:496:0:0:0xF3F4F6[a];[1:v]scale=880:-2,pad=880:496:0:0:white[b];"
                  "[a][b]hstack=inputs=2:shortest=1,pad=1920:1080:60:292:0xF3F4F6[s];"
                  f"[s]{lab('original', 60)},{lab('opus 5.5 copy', 980)},setsar=1[v]")
            src = OUT / ("remake.mp4" if (OUT / "remake.mp4").exists() else "remake_silent.mp4")
            audio = ["-map", "1:a?"] if (OUT / "remake.mp4").exists() else []
            subprocess.run([FF, "-v", "error", "-y", "-i", str(REF), "-i", str(src), "-filter_complex", vf, "-map", "[v]", *audio, *ENC, "-c:a", "aac", "-b:a", "256k", str(OUT / "split_screen.mp4")], check=True)
            print("split ->", OUT / "split_screen.mp4")
        elif mode == "stacked":
            src = OUT / "remake_silent.mp4"
            subprocess.run([FF, "-v", "error", "-y", "-i", str(REF), "-i", str(src), "-filter_complex", "[0:v]scale=960:540[a];[1:v]scale=960:540[b];[a][b]vstack=inputs=2:shortest=1[v]", "-map", "[v]", *ENC, str(OUT / "sync_check.mp4")], check=True)
            print("stacked ->", OUT / "sync_check.mp4")
        
    • analyze_song.py 1.2 KB
      """Estimate BPM (onset autocorrelation) and print a 1-second energy profile (full + low band) for each file."""
      import subprocess, sys
      import numpy as np, imageio_ffmpeg
      FF, SR = imageio_ffmpeg.get_ffmpeg_exe(), 22050
      for f in sys.argv[1:]:
          x = np.frombuffer(subprocess.run([FF, "-v", "quiet", "-i", f, "-ac", "1", "-ar", str(SR), "-f", "f32le", "-"], capture_output=True).stdout, np.float32)
          hop = 256; fr = len(x) // hop
          env = np.array([np.sqrt((x[i * hop:(i + 1) * hop] ** 2).mean()) for i in range(fr)])
          on = np.maximum(0, np.diff(np.log(env + 1e-6))); on -= on.mean()
          ac = np.correlate(on[:6000], on[:6000], "full")[5999:]
          fps = SR / hop; lags = np.arange(len(ac)) / fps
          best = max(((60 / l, ac[i]) for i, l in enumerate(lags) if 0.33 < l < 1.0), key=lambda z: z[1])
          print(f"\n== {f.split('/')[-1]}  dur {len(x) / SR:.1f}s  BPM~{best[0]:.1f}")
          lp = np.convolve(x, np.ones(60) / 60, "same")
          row = []
          for s in range(0, int(len(x) / SR)):
              seg = x[s * SR:(s + 1) * SR]; ls = lp[s * SR:(s + 1) * SR]
              row.append(f"{s:3d}:{10 * np.log10((seg ** 2).mean() + 1e-12):5.1f}/{10 * np.log10((ls ** 2).mean() + 1e-12):5.1f}")
          for i in range(0, min(len(row), 48), 6): print("  " + "  ".join(row[i:i + 6]))
      
    • audio_template.py 3.2 KB
      """Howseen LinkedIn v4 soundtrack (24 s): Mixkit 'Cat Walk. (Arulo, 130 BPM) from 6.77 s so its
      big hit (song 37.66 s) lands on the sky flood at 8.0 s. SFX by measured peak, fade the last 0.9 s, loudnorm -14 LUFS."""
      import json, subprocess
      from pathlib import Path
      import numpy as np
      import imageio_ffmpeg
      
      HERE = Path(__file__).parent; ROOT = HERE.parent
      FF, SR, T = imageio_ffmpeg.get_ffmpeg_exe(), 48000, 24.0
      N = int(round(T * SR)); START = 14.769 - 8.0
      
      
      def load(p):
          raw = subprocess.run([FF, "-v", "quiet", "-i", str(p), "-ac", "2", "-ar", str(SR), "-f", "f32le", "-"], capture_output=True, check=True).stdout
          return np.frombuffer(raw, np.float32).reshape(-1, 2).copy()
      
      
      mix = load(HERE / "assets/audio/cat-walk.mp3")[int(START * SR):][:N].copy()
      fi = int(0.25 * SR); mix[:fi] *= np.linspace(0, 1, fi)[:, None]
      S = {k: load(HERE / f"assets/sfx/{v}.mp3") for k, v in dict(soft=1117, key=2568, click=1125, pop=2357, rise="w1489", impact=1143,
                                                             whoosh="w1490", success=2865, sparkle=3083).items()}
      EV = [(0.1, "soft", .08), (0.35, "soft", .08)] + [(0.5 + i * .05, "pop", .04) for i in range(8)] + [(1.35, "soft", .08)]
      EV += [(2.5, "whoosh", .05)] + [(2.8 + i * .054, "key", .05) for i in range(13)] + [(3.85, "click", .2)]
      EV += [(4.55, "whoosh", .05)] + [(4.7 + i * .06, "pop", .06) for i in range(5)] + [(5.3 + i * .09, "soft", .04) for i in range(8)]
      EV += [(6.6 + i * .08, "pop", .05) for i in range(5)] + [(7.0 + i * .08, "soft", .05) for i in range(5)] + [(7.2, "rise", .12)]
      EV += [(8.0, "impact", .2), (8.45, "sparkle", .09)] + [(8.45 + i * .1, "pop", .06) for i in range(3)]
      EV += [(10.05, "whoosh", .05)] + [(10.45 + i * .07, "pop", .035) for i in range(6)] + [(12.05, "whoosh", .05)]
      EV += [(12.35 + i * .05, "pop", .035) for i in range(6)] + [(14.05, "whoosh", .05)] + [(14.3 + i * .08, "soft", .05) for i in range(5)]
      EV += [(16.05, "whoosh", .05)] + [(16.3 + i * .04, "key", .045) for i in range(16)] + [(17.2, "click", .2), (17.35, "success", .12)]
      EV += [(18.1, "whoosh", .05)] + [(18.15 + i * .06, "pop", .05) for i in range(9)]
      EV += [(20.1, "sparkle", .11), (21.1, "pop", .09), (22.35, "click", .2)]
      for t, name, gain in EV:
          s = S[name] / (np.abs(S[name]).max() + 1e-9) * gain
          i0 = int(round(t * SR)) - int(np.abs(s).sum(1).argmax())
          lo, hi = max(0, i0), min(N, i0 + len(s))
          if hi > lo: mix[lo:hi] += s[lo - i0:hi - i0]
      f0 = int(23.1 * SR); mix[f0:] *= np.linspace(1, 0, N - f0)[:, None] ** 1.3
      rawp = HERE / "out/raw.wav"; rawp.parent.mkdir(exist_ok=True)
      subprocess.run([FF, "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "2", "-i", "-", str(rawp)], input=mix.astype(np.float32).tobytes(), check=True)
      m = subprocess.run([FF, "-hide_banner", "-i", str(rawp), "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], capture_output=True, text=True).stderr
      j = json.loads(m[m.rindex("{"):m.rindex("}") + 1])
      af = (f"loudnorm=I=-14:TP=-1:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}:measured_LRA={j['input_lra']}"
            f":measured_thresh={j['input_thresh']}:offset={j['target_offset']}:linear=true")
      subprocess.run([FF, "-v", "error", "-y", "-i", str(rawp), "-af", af, "-ar", str(SR), str(HERE / "out/audio.wav")], check=True)
      print("audio ->", HERE / "out/audio.wav")
      
    • mcp21_client.py 2.1 KB
      """Minimal MCP client for 21st.dev (streamable HTTP + x-api-key). usage: mcp21.py tools | mcp21.py call <tool> '<json>'"""
      import json, os, sys, urllib.request
      KEY = os.environ.get("API_KEY_21ST") or open(os.path.expanduser("~/.config/21st.key")).read().strip()
      URL = "https://21st.dev/api/mcp"
      SID = None
      
      
      def rpc(method, params=None, id_=1):
          global SID
          body = {"jsonrpc": "2.0", "method": method}
          if id_ is not None: body["id"] = id_
          if params is not None: body["params"] = params
          h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "x-api-key": KEY, "User-Agent": "claude-code-mcp-client/1.0"}
          if SID: h["Mcp-Session-Id"] = SID
          try:
              r = urllib.request.urlopen(urllib.request.Request(URL, data=json.dumps(body).encode(), headers=h), timeout=90)
          except urllib.error.HTTPError as e:
              print("HTTP", e.code, e.read().decode()[:600]); sys.exit(1)
          SID = r.headers.get("Mcp-Session-Id") or SID
          raw = r.read().decode()
          if not raw.strip(): return None
          if raw.lstrip().startswith("{"): return json.loads(raw)
          for line in raw.splitlines():
              if line.startswith("data:"):
                  d = json.loads(line[5:].strip())
                  if d.get("id") == id_: return d
          return raw
      
      
      rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "claude-code-raph", "version": "1"}})
      rpc("notifications/initialized", None, None)
      if sys.argv[1] == "tools":
          for t in rpc("tools/list", {}, 2)["result"]["tools"]:
              props = list((t.get("inputSchema") or {}).get("properties", {}).keys())
              print(f"- {t['name']}({', '.join(props)}): {(t.get('description') or '')[:260]}")
      else:
          res = rpc("tools/call", {"name": sys.argv[2], "arguments": json.loads(sys.argv[3] if len(sys.argv) > 3 else "{}")}, 3)
          r = res.get("result", res) if isinstance(res, dict) else res
          if isinstance(r, dict) and "content" in r:
              for c in r["content"]: print(c.get("text", c)[:12000] if isinstance(c.get("text"), str) else c)
          else: print(json.dumps(r, ensure_ascii=False)[:12000])
      
    • mixkit_sfx_search.py 1011 B
      """List Mixkit SFX (id + title): each card's title is searched between its own id marker and the next one, and before it."""
      import re, sys, urllib.request
      for q in sys.argv[1:]:
          html = urllib.request.urlopen(urllib.request.Request(f"https://mixkit.co/free-sound-effects/{q}/", headers={"User-Agent": "Mozilla/5.0"}), timeout=20).read().decode("utf8", "ignore")
          marks = [(m.start(), m.group(1)) for m in re.finditer(r'data-audio-player-item-id-value="(\d+)"', html)]
          titles = [(m.start(), m.group(1).strip()) for m in re.finditer(r'item-grid-card__title">\s*(?:<[^>]+>\s*)*([^<]+?)\s*<', html)]
          print("==", q, len(marks), "ids", len(titles), "titles")
          for i, (p, sid) in enumerate(marks[:14]):
              nxt = marks[i + 1][0] if i + 1 < len(marks) else len(html)
              prv = marks[i - 1][0] if i else 0
              after = [t for pos, t in titles if p < pos < nxt]
              before = [t for pos, t in titles if prv < pos < p]
              print(f"   {sid:>5}  after={after[:1]}  before={before[-1:]}")
      
    • render_template.py 8 KB
      """frame. film renderer.
      probe t1 t2 ...  -> probe/p_XX.png + probe/sheet.png (4 columns)
      beats            -> one frame per beat (54) -> probe/beats.png
      draft            -> 30 fps, 1 capture per frame, 960x540, crf 23 veryfast -> out/draft.mp4 (judge rhythm, not sharpness)
      full             -> SUB subframes per frame on a SHUTTER (0.5 = 180 deg) blended with tmix, 60 fps -> out/crave_video.mp4
      pops             -> scan out/crave_video.mp4 for single-frame pops (diff spikes 3x their neighbours)
      loop             -> loop seam check: frame 0 vs last (position) and first vs last motion (velocity)
      WEBGL=1 env      -> launch Chromium with SwiftShader/ANGLE flags (headless WebGL renders black otherwise)
      """
      import asyncio, os, shutil, subprocess, sys
      from pathlib import Path
      import numpy as np
      import imageio_ffmpeg
      from playwright.async_api import async_playwright
      
      HERE = Path(__file__).parent
      FF = imageio_ffmpeg.get_ffmpeg_exe()
      URL = "http://localhost:8767/crave.html"
      FPS, T, SUB = 60, 32 * 60 / 130, 8
      SHUTTER = 0.5  # fraction of the frame interval the subframes cover (0.5 = 180 deg film look, 1.0 = smeary)
      CUTS = []      # hard-cut times (s): subframes never straddle a cut (no double exposure on the cut frame)
      ARGS = ["--autoplay-policy=no-user-gesture-required"]
      if os.environ.get("WEBGL"): ARGS += ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"]
      
      
      async def open_page(p):
          b = await p.chromium.launch(args=ARGS)
          pg = await b.new_page(viewport={"width": 1920, "height": 1080}, device_scale_factor=1)
          errs = []
          pg.on("pageerror", lambda e: errs.append(str(e)))
          pg.on("console", lambda m: errs.append("console: " + m.text) if m.type in ("error", "warning") else None)
          await pg.goto(URL)
          await pg.wait_for_function("window.ready === true", timeout=120000)
          await pg.evaluate("document.fonts.ready")
          # freeze anything that would run on the wall clock (stray CSS animations/transitions, Web Animations)
          await pg.add_style_tag(content="*,*::before,*::after{transition:none!important;caret-color:transparent!important}")
          await pg.evaluate("document.getAnimations().forEach(a => a.pause())")
          return b, pg, errs
      
      
      async def shot(pg, t, path, fmt="png"):
          await pg.evaluate(f"window.seek({t})")
          # 2 rAF: let layout/paint of the seeked frame land before the capture
          await pg.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")
          el = await pg.query_selector("#stage")
          await el.screenshot(path=str(path), type=fmt, **({"quality": 95} if fmt == "jpeg" else {}))
      
      
      def sheet(folder, pattern, n, out, cols=4, size=360):
          rows = -(-n // cols)
          subprocess.run([FF, "-v", "error", "-y", "-i", str(folder / pattern), "-vf",
                          f"scale={size}:-1,tile={cols}x{rows}:padding=6:color=white", "-frames:v", "1", str(out)], check=True)
      
      
      async def probe(times, name="sheet.png", cols=4):
          out = HERE / "probe"; shutil.rmtree(out, ignore_errors=True); out.mkdir()
          async with async_playwright() as p:
              b, pg, errs = await open_page(p)
              for i, t in enumerate(times): await shot(pg, t, out / f"p_{i:02d}.png")
              await b.close()
          if errs: print("PAGE ERRORS:", errs[:8])
          sheet(out, "p_%02d.png", len(times), out / name, cols=cols)
          print("probe:", len(times), "->", out / name)
      
      
      async def full():
          sub = HERE / "sub"; shutil.rmtree(sub, ignore_errors=True); sub.mkdir()
          n, k = int(round(T * FPS)), 0
          offs = [(j - (SUB - 1) / 2) * SHUTTER / (FPS * SUB) for j in range(SUB)]
          async with async_playwright() as p:
              b, pg, errs = await open_page(p)
              for i in range(n):
                  for o in offs:
                      t = min(T - 1e-3, max(0.0, i / FPS + o))
                      for c in CUTS:  # keep every subframe on the same side of a cut as the frame centre
                          if (t < c) != (i / FPS < c): t = c if i / FPS >= c else c - 1e-4
                      await shot(pg, t, sub / f"s_{k:05d}.jpg", "jpeg"); k += 1
                  if i % 60 == 0: print(f"frame {i}/{n}", flush=True)
              await b.close()
          if errs: print("PAGE ERRORS:", errs[:8])
          (HERE / "out").mkdir(exist_ok=True)
          subprocess.run([FF, "-v", "error", "-y", "-framerate", str(FPS * SUB), "-i", str(sub / "s_%05d.jpg"),
                          "-vf", f"tmix=frames={SUB},select='eq(mod(n\\,{SUB})\\,{SUB - 1})',setpts=N/{FPS}/TB", "-r", str(FPS),
                          "-c:v", "libx264", "-crf", "14", "-preset", "slow", "-pix_fmt", "yuv420p", str(HERE / "out/crave_video.mp4")], check=True)
          print("video ->", HERE / "out/crave_video.mp4")
      
      
      async def draft(fps=30):
          sub = HERE / "sub_draft"; shutil.rmtree(sub, ignore_errors=True); sub.mkdir()
          n = int(round(T * fps))
          async with async_playwright() as p:
              b, pg, errs = await open_page(p)
              for i in range(n): await shot(pg, min(T - 1e-3, i / fps), sub / f"d_{i:05d}.jpg", "jpeg")
              await b.close()
          if errs: print("PAGE ERRORS:", errs[:8])
          (HERE / "out").mkdir(exist_ok=True)
          subprocess.run([FF, "-v", "error", "-y", "-framerate", str(fps), "-i", str(sub / "d_%05d.jpg"), "-vf", "scale=960:-2",
                          "-c:v", "libx264", "-crf", "23", "-preset", "veryfast", "-pix_fmt", "yuv420p", str(HERE / "out/draft.mp4")], check=True)
          print("draft ->", HERE / "out/draft.mp4")
      
      
      def frames_gray(path, size=180):
          raw = subprocess.run([FF, "-v", "quiet", "-i", str(path), "-vf", f"scale={size}:{size},format=gray", "-f", "rawvideo", "-"],
                               capture_output=True, check=True).stdout
          return np.frombuffer(raw, np.uint8).reshape(-1, size, size).astype(np.float32)
      
      
      def loop_check(path=None):
          """A loop must match in position AND velocity: last frame ~ frame 0, and the motion entering frame 0
          (last -> 0) ~ the motion leaving it (0 -> 1). Fix velocity by adding the spring tails of the previous cycle."""
          fr = frames_gray(path or HERE / "out/crave_video.mp4")
          steps = np.abs(np.diff(fr, axis=0)).mean(axis=(1, 2))
          seam = np.abs(fr[-1] - fr[0]).mean()
          med = float(np.median(steps)) or 0.3
          print(f"seam diff {seam:.2f} (median frame step {med:.2f}) -> {'OK' if seam <= 2 * med else 'JUMP: fix positions at t=0/T'}")
          vin, vout = np.abs(fr[-1] - fr[-2]).mean(), np.abs(fr[1] - fr[0]).mean()
          print(f"velocity in {vin:.2f} / out {vout:.2f} -> {'OK' if abs(vin - vout) <= max(1.0, 0.5 * max(vin, vout)) else 'SPEED BREAK at the seam'}")
      
      
      def pops(path=None):
          path = path or HERE / "out/crave_video.mp4"
          raw = subprocess.run([FF, "-v", "quiet", "-i", str(path), "-vf", "scale=180:180,format=gray", "-f", "rawvideo", "-"],
                               capture_output=True, check=True).stdout
          fr = np.frombuffer(raw, np.uint8).reshape(-1, 180, 180).astype(np.float32)
          d = np.abs(np.diff(fr, axis=0)).mean(axis=(1, 2))
          hits = []
          for i in range(1, len(d) - 1):
              nb = max(d[i - 1], d[i + 1], 0.3)
              if d[i] > 3 * nb and d[i] > 2.0: hits.append((i + 1, round((i + 1) / FPS, 3), round(float(d[i]), 2), round(float(nb), 2)))
          print("pops:", len(hits))
          for h in hits: print("  frame", h[0], "t", h[1], "diff", h[2], "neighbours", h[3])
          # one-frame flash: two big diffs side by side mask each other above; frame n differs from both
          # neighbours while n-1 and n+1 look alike
          flashes = []
          for n in range(1, len(fr) - 1):
              a, b = d[n - 1], d[n]
              skip = np.abs(fr[n + 1] - fr[n - 1]).mean()
              if min(a, b) > 2.0 and skip < 0.35 * min(a, b): flashes.append((n, round(n / FPS, 3), round(float(min(a, b)), 2), round(float(skip), 2)))
          print("one-frame flashes:", len(flashes))
          for f in flashes: print("  frame", f[0], "t", f[1], "diff", f[2], "n-1 vs n+1", f[3])
      
      
      if __name__ == "__main__":
          cmd = sys.argv[1]
          if cmd == "probe": asyncio.run(probe([float(x) for x in sys.argv[2:]]))
          elif cmd == "beats": asyncio.run(probe([b * 60 / 130 + 0.45 for b in range(32)], "beats.png", cols=9))
          elif cmd == "draft": asyncio.run(draft())
          elif cmd == "full": asyncio.run(full())
          elif cmd == "pops": pops()
          elif cmd == "loop": loop_check()
      
    • svgl_logos.py 1.2 KB
      """Download colour SVG logos from svgl.app for sources + CMS into h20/assets/svg/."""
      import json, urllib.parse, urllib.request
      from pathlib import Path
      OUT = Path("assets/svg"); OUT.mkdir(parents=True, exist_ok=True)
      UA = {"User-Agent": "Mozilla/5.0"}
      WANT = {"youtube": "youtube", "reddit": "reddit", "trustpilot": "trustpilot", "wikipedia": "wikipedia", "linkedin": "linkedin",
              "shopify": "shopify", "wordpress": "wordpress", "webflow": "webflow", "wix": "wix", "ghost": "ghost", "bigcommerce": "bigcommerce",
              "framer": "framer", "nextjs": "next"}
      for name, q in WANT.items():
          try:
              res = json.loads(urllib.request.urlopen(urllib.request.Request("https://api.svgl.app?search=" + urllib.parse.quote(q), headers=UA), timeout=30).read())
          except Exception as e:
              print(name, "ERR", e); continue
          pick = next((r for r in res if r["title"].lower().replace(".", "").startswith(q.replace(".", ""))), res[0] if res else None)
          if not pick: print(name, "none"); continue
          route = pick["route"] if isinstance(pick["route"], str) else pick["route"].get("light")
          (OUT / f"{name}.svg").write_bytes(urllib.request.urlopen(urllib.request.Request(route, headers=UA), timeout=30).read())
          print(name, "<-", pick["title"], route)
      
  • SKILL.md 33.1 KB
    ---
    name: motion-design
    description: Code-only motion design pipeline (no After Effects) for Raphaël / Howseen: launch films, showreels, product promos, LinkedIn/X videos, meme clips. Use when asked to make a motion design video, a promo/launch film, a showreel, "remake this video", a video from a prompt (e.g. "make a 15s motion graphics video…"), to change a video's music/SFX, or to prepare memes for posts. Covers the brief → beat map → stills → seek(t) HTML engine → Playwright render → ffmpeg → music/SFX → QA flow, plus asset sourcing (Mixkit, Pexels, Unsplash, svgl, 21st.dev) and every gotcha hit so far.
    ---
    
    # Motion design, 100 % code (Howseen pipeline)
    
    Built and battle-tested 25-27/09/2026 on: promo60 (60 s VO ad), loop, launch film, showreel v1/v2, frame. (Apple-keynote prompt), Crave (food-app prompt), reel15 (howseen.ai in 1 prompt + "make it better" pass), Baguette Pro (Apple framework parody), Howseen LinkedIn v1→v5 (4:5).
    Workdir: `~/Desktop/Howseen AI/howseen-video/` (one folder per film). Every film folder = `<name>.html` + `render.py` + `audio.py` + `out/`.
    
    **Our stack vs the "AI motion" stack people post** (Opus + Higgsfield + Blender + After Effects + Suno + Soundly): we replace Blender/After Effects with a deterministic HTML engine rendered frame by frame, Higgsfield with real stock (Pexels/Unsplash) or coded visuals, Suno with Mixkit music, Soundly with Mixkit SFX. 0 € and fully reproducible. Suno/Envato/Higgsfield only if Raphaël asks and has credits.
    
    ## 00. Start from the library (fastest path)
    - **Read `howseen-video/INDEX.md` first**: every film we made, its final file and what to reuse. Copy the closest folder instead of starting from zero.
    - **Brief given as frame tables** (f0–fN, hard cuts, per-frame px lists, "motion law p(n)=…"): copy `launch20/` (02/10/2026). It has the frame-number engine (`core.js`: `T`/`L`/`kf` registered tables, `law()`, `settleT`/`exitT`/`joinT`, `scanTables()` that blocks one-frame outliers, `makeField` colour field, `makeBadge`, macOS cursor), fictional Mac apps in em units (`ui.js`: chat, call, notes, files, board, dock icons, `lineIcon` set, `tx` helper), shot registry split in files so **3 agents build shot groups in parallel** (one file each), `render.py` (deviceScaleFactor 2 = 4K, `PROBE=<dir>` per agent, `frames a b` in 3 parallel processes, `encode` with frame count check, no `-shortest`), `audio.py` (music + SFX 100 % synthesized on exact frames, two-pass −14 LUFS / TP −1.3).
    - Workflow that worked on launch20 (≈2 h brief → v2): inputs (AskUserQuestion) → 4 stills → BRIEF.md in the folder → 3 build agents + 1 side task in parallel → scan → parallel render → encode → **motion critic + design critic as separate read-only agents (default reject)** → FIXES file split per agent → v2.
    - Lessons from the critics (apply from the first pass): never key settles in 3-frame steps (per-frame law, fastest step first), exits geometric ×1.5, **no frozen frame anywhere except the final hold** (keep a micro settle/drift alive, also on still end cards), the next shot enters already moving in the exit's direction, real icons (no empty placeholder squares), one face per name everywhere, grade photos to remove banned hues with a continuous per-pixel formula (hue thresholds blotch JPEG blocks), traffic lights/details sized in the component's em units.
    - People in fictional UIs: AI-generated faces (thispersondoesnotexist.com/random-person.jpeg, crop centre 76 % to drop the watermark), names chosen to match each face, never a real person. Photos: picsum (Unsplash licence) when Unsplash napi returns 401; build a contact sheet and pick by eye (an agent can do it).
    
    ## 0. Non-negotiables
    - **Zero fabrication on screen**: real data is sourced on screen (e.g. "12 logged-out ChatGPT answers · 25 Sep 2026"); anything illustrative is labelled **"Example data" / "Example answer" / "Illustration"**. Never claim product features that don't exist (check the app code). Native CMS = WordPress, Shopify, Ghost, BigCommerce; others "via webhook".
    - **Captions must stay true**: no "made in 10 minutes" if it wasn't, no "0 external tools" if Cartesia/Mixkit were used, no "one shot" after iterations. Mixkit SFX are *placed* by code, not generated.
    - Illustrations/covers: **no Howseen name/logo** in AI-generated images (rule 25/09). Howseen can appear in our own coded promo films.
    - No em/en dashes in any copy we write.
    
    ## 1. Flow (always in this order)
    1. **Inputs**: if the brief has an `<inputs>` block, ask for them (AskUserQuestion, recommended defaults first). Otherwise pick sensible defaults and say so.
    2. **Beat map** (`BEATMAP.md`): BPM → beat length, every scene on a beat, the **music drop on the key visual moment** (flood, logo, big reveal). Nothing still for > 1 s.
    3. **4 stills** (or a one-frame-per-beat sheet) → look at them (Read) → fix → only then the full render.
    4. Full render → pops scan → audio → mux → **open -R** the file and give the path + a true caption.
    
    ## 2. The engine (one HTML file)
    - Everything computed from time inside `window.seek = async (t) => {…}`; **no CSS transitions, no timers, no state between frames**. Declare all constants before the first `seek()`. Set `window.ready = true` after fonts/images load.
    - **Springs** = closed-form step response `step(tau, f, z)`; a value with many targets = sum of one spring per change. Easings: `io` (cubic in-out), `out`, `in`, `o5`, `expo`. Linear motion = cheap, never.
    - **Camera** = one transform on a container, keys `[t, zoom, x, y]`, eased segments, **zoom interpolated in log space**, never zoom in/out back-to-back. Beat punches: `+0.012` per beat, `+0.03` per bar after the drop, exp decay.
    - **Shared elements** for every handoff (the bubble carries its words into the flood, the button carries its label into the page). Text that swaps inside a morphing shape gets its own mask.
    - **Masked text rise** (translateY 105% inside overflow:hidden), word-by-word stagger (55 ms) with a small rotation; accent words with a moving gradient (`background-clip:text`).
    - **Floods**: circle from the source object, must **clear the farthest corner** (`hypot` to the 4 corners ×1.05) in ~0.3-0.35 s, then contract into the next object.
    - Glass / goo / iris / variable-font squeeze / 3D cube / equalizer / blob mask / animated beam / border beam: reference implementations in `frame/frame.html` (liquid glass via canvas displacement, goo, 6-blade iris, Archivo wdth squeeze), `reel2/reel2.html` (morph shapes, cube, EQ, blob), `h20/h28.html` (21st.dev Animated Beam + Border Beam ported to seek(t), dotted grid, drifting blobs, sheen sweep, sparkles).
    - `z-index` on every layer. `visibility:inherit` (not `visible`) on children of hidden parents.
    - Look: warm off-white `#f5f5f2`/`#f7f7f5` or ink `#0b0b0c`; Howseen sky `#38bdf8`, ink `#0f172a`, lime `#cdf24f`, orange `#ff6a2a`, violet `#a78bfa`. Fonts in `crave/fonts/geist-latin.woff2`, `frame/fonts/archivo-var.woff2` (wdth 62-125), `crave/fonts/instrument-serif.woff2`.
    
    ## 3. Render (scripts/render_template.py)
    - Serve the folder over HTTP (`python -m http.server 876x --directory …`, background), Playwright Chromium, viewport = video size (1920×1080, 1080×1350 for LinkedIn 4:5, 1440×1440 square).
    - `probe t1 t2…` → `probe/sheet.png`; `beats` → one frame per beat; `draft` → 30 fps, 1 capture/frame, 960×540, crf 23 veryfast (**always before the master**: judge rhythm, not sharpness); `full` → **N subframes per frame blended with `tmix`** (6-8 for fast moves, 4 = ghosting) spread over `SHUTTER = 0.5` of the frame (180° film shutter; 1.0 = smeary), 60 fps; `pops` → frame-diff spikes > 3× neighbours (intentional beat cuts show up too: say so, don't hide); `loop` → seam check in **position (last vs frame 0) AND velocity (motion into vs out of frame 0)**.
    - Capture hygiene (built into the template): wait `document.fonts.ready`, pause `document.getAnimations()` and kill CSS transitions after load, and **wait 2 `requestAnimationFrame` after every `seek(t)`** before the screenshot. `WEBGL=1` launches Chromium with SwiftShader/ANGLE flags (headless WebGL otherwise renders black).
    - Every film page also gets **preview controls** for Raphaël: Space = play/pause, ←/→ = previous/next frame, R = replay, a time readout. Playback calls the same `seek(t)` from a rAF loop (the only place a clock is allowed); the render never touches it.
    - **Films > 25 s: always render in 3 parallel chunks** (`h20/lm55/render_par.py chunk <html> <T> k 3` x3 in background, then `concat`): ~20 min for 55 s instead of ~55 min sequential. Raphaël expects fast turnarounds; never launch a long single-process full render. Re-render only the changed seconds when possible (`part`).
    - Use a separate `sub*/` folder per version so parallel renders don't clash. ~1-1.5 min of wall time per second of film at 8 subframes; run long renders in the background.
    - Final encode: `scale=in_range=pc:out_range=tv:out_color_matrix=bt709,format=yuv420p`, `-color_range tv -colorspace bt709`, libx264 crf 16, AAC 256k, `+faststart`.
    
    ## 4. Music & SFX (scripts/audio_template.py, analyze_song.py)
    - **Music = Mixkit** (free commercial). Direct file: `https://assets.mixkit.co/music/<id>/<id>.mp3`. IDs: grep `music/[0-9]+/[0-9]+\.mp3` in the listing page HTML (page order = WebFetch list order).
    - Used & measured: `audio/mixkit-207.mp3` 120 BPM (drop song 31.97 s), `mixkit-190` 120 BPM (drop bar 8 = 16.01 s), `mixkit-129` 120 BPM (drop 16.09 s), `minimal-techno-01` 119.99 BPM (true drop 39.98 s, auto grid is 2 beats off), **Cat Walk** (Arulo #371) 130 BPM drop **14.769 s** (`crave/assets/audio/cat-walk.mp3`), **Waka Floka Type** (Arulo #364, trap/US rap) drop **14.75 s** (`reel2/assets/m364.mp3`), **Driving Ambition** (#32, piano uplifting ~99 BPM) hit 37.66 s, **Classical vibes 4** (#684, Apple-ish classical ~94 BPM) lift ~7.95 s, **Head Bang** (#357, hip-hop half-time **74 BPM**, 4-bar quiet intro, drop **12.96 s** = beat 16; used by the viral "STOP PAYING FOR MOTION DESIGN SOFTWARE" loop prompt). Mixkit search ignores `?q=`: crawl genre pages (`/free-stock-music/<genre>/`) and match `item-grid-card__title` to `music/<id>/<id>.mp3`. Leo's framework: 60-80 BPM regal, 90-110 smooth, 115-123 elite/sophisticated, > 125 hype.
    - **Find the drop by energy**, never trust an auto grid: per-bar low/full band energy, then 20-50 ms windows around the jump. Start the song at `drop_in_song - drop_in_film`.
    - **SFX = Mixkit**, downloaded to `howseen-video/sfx/` (`https://assets.mixkit.co/active_storage/sfx/<id>/<id>-preview.mp3`); search with `scripts/mixkit_sfx_search.py <tag>`. Map so far: click 1125, key 2568, soft tick 1117, check 1113, toggle 1120, toast 2573, pop 2364 / bubble 2357 / soap 2925, whoosh w1490, rise w1489, flip w1485, impact 1143, shutter 1430 / lens 1433, sparkle 3083, success 2865, bread crunch 118.
    - **Place every SFX by its measured peak** (argmax of |s|), gain 0.04-0.3, keystrokes follow the same per-character rhythm as the typing animation. Fade the tail, **two-pass loudnorm to −14 LUFS**. Voice-over: Cartesia (Katie) with word timestamps → cues.json (promo60), music ducked ~9 dB under the voice.
    - Minimal sound design for "premium/Apple" films: a handful of soft hits, remove anything that feels loud or out of place.
    
    ## 5. Assets
    - **Photos**: Unsplash `https://unsplash.com/napi/search/photos?query=…&per_page=30` (curl ok) → `urls.raw + &w=2600&q=85&fm=jpg`; Pexels CDN `https://images.pexels.com/photos/<ID>/pexels-photo-<ID>.jpeg?auto=compress&cs=tinysrgb&w=1600` (search pages block curl: use WebFetch/WebSearch for IDs). Always build a contact sheet and **look at it** before using. Cutouts from dark backgrounds: luminance+warmth alpha, largest component, trim 5 px (see `baguette/assets/hero_cut_3k.png`).
    - **Video**: Mixkit `assets.mixkit.co/videos/<ID>/<ID>-1080.mp4`, Pexels `pexels.com/download/video/<id>/`. Re-encode all-intra (`-g 1`), load as blob URL, await `seeked`.
    - **Logos**: `scripts/svgl_logos.py` (svgl.app API, colour SVGs: openai, gemini, perplexity, google, claude, youtube, reddit, trustpilot, linkedin, shopify, wordpress, webflow, framer, nextjs); fallback simple-icons (`cdn.jsdelivr.net/npm/simple-icons@13/icons/<name>.svg`); Howseen marks in `promo60/logos/logo-mark*.png`. 21st.dev `search_logo` currently returns nothing: go to svgl directly.
    - **21st.dev** components (Animated Beam id 919, Border Beam 1268, Orbiting Circles 1411…): `scripts/mcp21_client.py tools | call search '{…}' | call get_component '{"id":…}'`, key in `~/.config/21st.key` (free tier: 2 code retrievals/day). They're React/framer-motion: **port the idea to seek(t)**, never run them live.
    - **Memes**: yt_dlp from the video venv (if YouTube says "page needs to be reloaded", pass `extractor_args={"youtube":{"player_client":["tv","web_safari","android","ios"]}}` and `ffmpeg_location=imageio_ffmpeg.get_ffmpeg_exe()`), `ytsearch6:<meme> meme template`, check a contact sheet (no burned-in captions, no watermarks, cut "Subscribe / link in description" end cards), re-encode H.264 1280 wide + AAC + setsar=1. Library in `~/Desktop/Howseen AI/illustrations/memes/` (clips/ ready to post, legendes/ captions, sources-brutes/ raw downloads, planches/ check sheets) (Michael Scott, DiCaprio pointing, Travolta, Keanu whoa, Bateman walk, This is fine, Homer bushes, Carrey typing fast, Gatsby toast, Peele sweating).
    
    ### 5b. Resource shortlist (checked 28/09/2026)
    - **3D icons: 3dicons.co**, CC0 (commercial use, no attribution), 1,500+ renders. Fits the Howseen "glossy 3D on cream" look for videos, LinkedIn visuals and article covers. Download PNGs, cut-out already transparent; look at them on a contact sheet first.
    - **seek-compatible animation libs** (can be driven frame by frame, so they fit the deterministic render):
      - **Anime.js** (animejs.com): create with `autoplay: false`, then `anim.seek(ms)` from `window.seek(t)` (`t*1000`, or set `engine.defaults.timeUnit` to seconds). Use timelines the same way.
      - **Theatre.js** (theatrejs.com): keyframes edited visually in Studio, then in the render build drop the Studio and set `sheet.sequence.position = t` (seconds) inside `window.seek(t)`. Good for complex hand-tuned camera moves. Check the sequence API before first use.
      - Never use libs that only animate in real time (Spline runtime, Unicorn Studio, CSS/framer-motion live): they can't be seeked, so frames drift.
    - **Ideas to port to seek(t)** (don't run them live): Kinetics (kinetics.colorion.co, 150+ motion effects), CSS Text Effects (text-effects.colorion.co), Liquid Glass (glass.samasante.com, refraction), Motion Primitives, Magic UI, Aceternity, 21st.dev (MCP, see above).
    - **For the Howseen site/app, not videos**: Magic UI / Aceternity / Motion Primitives (copy-paste animated React), Component Gallery + Navbar Gallery (references). Avoid Spline/Unicorn embeds on the site (kills the Lighthouse 100).
    
    ## 6. Gotchas (all hit for real)
    - Unsplash napi can return 401: fallback = `picsum.photos/id/<id>/<w>/<h>` (Unsplash photos, same licence; `/id/<id>/info` gives the author). Covers that worked: 184 (desert night, orange), 1041 (wave, blue).
    - An easing solved by bisection returns ~1e-9 at x=0: `if (e > 0)` guards fire early (a whole dive disc appeared 2 s early). Return exact 0/1 at the ends.
    - Logo reveal from a line: squash the real mark vertically (scaleY 0.014 → 1, "eye opening") and overlay the solid line for the first 12 %; never crossfade a drawn lens into the PNG (grey ghost). Reference: `loop22/loop22.html` (canvas 1440², goo via `ctx.filter='url(#goo)'`, per-time motion-blur subframes 4/12, loop seam check in `render.py pops`).
    - Worktree sandbox: no heredocs / `cd && …` chains / loops with computed commands / `$(…)` in Bash → write `.py` scripts and run plain commands. Paths with spaces: use the symlink `$CLAUDE_JOB_DIR/tmp/hv` → howseen-video.
    - No brew ffmpeg: `imageio_ffmpeg.get_ffmpeg_exe()` or `howseen-video/bin/ffmpeg`. Python venv: `howseen-video/.venv`.
    - Cloudflare blocks Python's default UA on some APIs: send `User-Agent: claude-code-mcp-client/1.0`.
    - Hash-only `goto` doesn't reload: set state via `evaluate`. Measure text with canvas (`measureText`) not DOM rects when a camera scale is applied.
    - Text that must stay sharp during a handoff: never scale a blurry copy, crossfade only the fill.
    - LinkedIn video: 4:5 1080×1350; X: 16:9 or 1:1, ≤ 2:20; captions go in the post, burned banners ("Commente MOTION") only for LinkedIn lead magnets.
    
    ## 7. Delivery checklist
    ☐ stills approved ☐ 0 unexplained pops ☐ drop on the key moment ☐ −14 LUFS ☐ TV-range BT.709 ☐ "Example data" labels ☐ caption true ☐ file revealed in Finder + path given.
    
    ## 8. Critique loop (make the model watch its own frames)
    Before any full render, and after it:
    ```
    ffmpeg -i out/final.mp4 -vf "fps=2,scale=270:-1,tile=6x5" -frames:v 1 out/contact.png      # overview
    ffmpeg -ss <t-0.1> -i out/final.mp4 -vf "scale=320:-1,tile=12x1" -frames:v 1 out/strip.png  # 12 frames around a fast move
    ffmpeg -i out/final.mp4 -vf "fps=1,scale=360:-1,tile=5x3" -frames:v 1 out/phone.png         # readability at phone width
    ffmpeg -stream_loop 1 -i out/final.mp4 -c copy out/loop_check.mp4                            # loop seam (loops only)
    ```
    Open them and **score 1-10**: hook in the first 2 s · readability at 360 px · motion quality (springs, no dead frames) · variety (something new every 2-4 s) · composition · brand/data accuracy · sound sync. Write the 3 worst problems with timestamps (hunt for: text overlapping during swaps, anything moving linearly, corner labels/frame borders, centred title on a gradient, blurry scaled text, a dead beat, a loop stutter). Fix, re-render only the affected seconds, re-score. **Repeat until every score is 8+.** Be a harsh motion director, not a proud author.
    
    ## 9. Extra rules
    - **Determinism**: never `Math.random`; use a seeded PRNG (mulberry32). Rendering the same second twice must give identical frames.
    - **Reference first**: with a reference video/frame, extract a frame every 0.5 s with ffmpeg, write `docs/style_guide.md` (palette hex, type, shot lengths, transitions, camera, texture, text in/out) and `docs/shotlist.md` on the beat grid. Take the grammar, never the content or logos. Wait for OK before code.
    - **Real product only**: capture the real UI (Playwright screenshots of the site/app) into `./assets` and list what you found; never invent screens. If a paywall blocks it, ask the user for screenshots or clearly label a recreated UI as illustrative.
    - **Spring presets** (stiffness k, damping d): snappy UI 320/30, default containers/camera 170/26, heavy type/logos 120/24, playful mascots 180/12. Leading and trailing edges of a stretching indicator on different springs.
    - **Formats**: write scenes against a layout function, then render 9:16, 1:1, 16:9 and 4:5 from the same timeline, reframing type and UI per format (never crop).
    - **Synthesized sound option**: when no track is supplied, SFX can be synthesized in code (click = short decaying sine, pop = rising sine, thump = falling sine, whoosh = windowed noise) on the same timeline.
    - **Effort**: medium for small fixes, xhigh for a new film, max when the first 3 seconds carry a launch.
    
    ## 9b. Studio conventions & director's brief (from the "Playbook Opus 5.5" doc, 02/10/2026)
    "The prompt is 10 %, the harness is 90 %."
    - **Film folder** = `brand/` (logo.svg, palette.txt, screenshots/, reference.mp4 optional but decisive) + **`facts.md`** (every number with its source + date; "no invented metric") + `<name>.html` + `render.py` + `out/`. No facts.md → no numbers on screen. For Howseen, facts come from the app/PostHog/Stripe or a sourced page.
    - **Director's brief before any code** (first message, effort max): duration, master format, deliverable formats from the SAME timeline (16:9, 1:1, 9:16), subject + one-line promise, audience, channel, **a named reference style** ("Linear launch", "Stripe docs", "Apple bumper", never "premium modern"), then **states** with timestamps (hook type 0-2.5 → shape becomes screen 1 → transforms to screen 2 with cursor click → proof with a real number → logo + URL, back to state 0 if loop). Expected output: 6 beats + **layer list** + **sound plan** (silence / coded SFX / Mixkit track), then STOP for OK.
    - **Anti-"AI motion" rules** (on top of §0/§2): ONE accent colour; **one thing moves at a time** (unless a slider drives a continuous transform); one shape/visual system from start to end, transformations not cuts; springs with damping ratio ≥ 0.72, tiny overshoot, never cartoon bounce; banned: rainbow gradients, particles, glowing chrome, emoji, lorem ipsum, gratuitous 3D flips; type big enough for a phone; 9:16 safe zone decided at storyboard (text inside the centre 1080×1080).
    - **Silent "one sentence" test**: show the loop muted to someone; if they summarise it in one sentence ("the button became a player"), it works; if they hesitate, too much moves.
    - **Prompt library** (adapt, never paste raw for a client):
      - *One-shot viral* (Stephan Livera, 24/09): "make a dynamic 15-second motion graphics video that shows what an incredible motion designer you are, like it's your showreel for a résumé. go all out." Use once to see the engine; generic output.
      - *One-shot branded*: "Make a 15-second showreel at 1920x1080, 30fps. Keep it in one self-contained index.html. HTML, CSS, JS, SVG or Canvas only. No CDN, no external URLs. Bold type and simple shapes. End on [BRAND]. Play and Replay. window.seek(t). Do not render the MP4 yet." + one accent hex + "no gradient template".
      - *UI morph* (@twoclipping): one shape through 8-12 UI states (button → loader → player → slider → toggle → tabs → chart → command palette → toast), a real slider, ~120 BPM, exact loop.
      - *Product launch / landing reveal* (@notdwd open prompt): inputs → direction → frame-by-frame structure → seek(t) build → gotchas → 4 stills before the full film.
      - *LinkedIn loops* (Eric Djavid): pure HTML/CSS, one charter, one "A becomes B" transform, 5-9 s loop, one thing moves, easings `cubic-bezier(.6,0,.2,1)` and `cubic-bezier(.2,.8,.2,1)`; micro-prompts: button → player, search → results, card → workspace, SVG curve, magnetic dock, hidden title.
      - *SaaS / app*: web = point at the repo (real design tokens); mobile = screenshots + "invent no feature"; storyboard first.
    - **Critique prompt** (2-4 rounds, where the "AI look" goes away): capture a frame at every beat and 0.3 s after, contact sheet, score each shot /10 on readability, rhythm, shape continuity, branding, loop; fix only ≤ 7, don't touch shots at 9+.
    - **Other routes** (only if asked): HyperFrames (HTML + GSAP, `npx skills add heygen-com/hyperframes`, CLI preview/render: brand content factory) and Remotion (React templates, `npx skills add remotion-dev/skills`: data-driven template library). Our route = bare HTML + seek(t), the one Opus picks on its own.
    
    **Diagnostic when it's ugly**
    | Symptom | Cause → fix |
    |---|---|
    | Same film as all of X | Livera prompt pasted with no charter/states → brief + facts + named style |
    | Blurry text / washed logo | Went through a pixel video model → back to code, real SVG |
    | Stutters on export | Timers / CSS transitions → pure seek(t), 2 rAF wait |
    | Loop jumps | Position matched, velocity not → add the spring tails of the previous 2 cycles; `render.py loop` |
    | Cheap, bouncy | Bounce easing → springs, damping ≥ 0.72 |
    | Invented feature | No screenshots / repo / facts.md |
    | Black WebGL | `WEBGL=1` (SwiftShader/ANGLE flags) |
    | Cost explodes | Effort max on micro-fixes → medium for patches |
    
    ## 9c. Stolen from the other public motion skills (scan 02/10/2026)
    - **Seek third-party libs instead of banning them** (HyperFrames adapters): GSAP skips the redraw when asked for the same time, so `tl.pause(); tl.totalTime(t + 0.001, true); tl.totalTime(t, true)`. WAAPI/CSS: `document.getAnimations().forEach(a => { a.pause(); a.currentTime = t * 1000 })`. Lottie `goToAndStop(t*1000)`, anime.js `seek`. Video sources: pre-extract frames with ffmpeg rather than waiting on `seeked` when it flakes.
    - **Deterministic Chromium flags** for pixel-identical reruns: `--deterministic-mode --run-all-compositor-stages-before-draw --disable-threaded-animation --disable-checker-imaging --font-render-hinting=none --force-color-profile=srgb`. GPU WebGL on the Mac: `--use-angle=metal`.
    - **Adaptive subframes** (Kimeur): measure the fastest element's px/frame; still → 1 sample, ≤ 20 px → 4, above → `ceil(px/5)` up to 64. Huge time saver vs a fixed 8 on mostly static films. **Never blur across a hard cut**: list the cuts in `CUTS` (template clamps subframes to the frame's side of the cut).
    - **Targeted blur > full-frame blur** (HyperFrames): blur only the 1-3 slam moves; never blur text meant to be read or any move < one element width per frame. **Supersample 2×** (`device_scale_factor=2`, Lanczos down) for razor text, cheaper than tmix on static type.
    - **Cut seams** (HyperFrames seam gate): cut at peak velocity; direction AND speed match on both sides; zoom keeps the sign of d(scale)/dt across the cut; seam blur scales with size (≈ 10 px text, 18-20 px full frame).
    - **QA additions**: one-frame flash detector (built into `pops`: frame n differs from both neighbours while n-1 ≈ n+1); `loop` checks seek(0) vs seek(T), T-1f, T-1.75f; cycles must be integer `cyc(t, n)`. **Judge ≠ builder**: run the critique as a separate read-only sub-agent, and have a fresh agent restate the message from the frames alone (if it can't, the film fails).
    - **Poster in frame 0** (X/Slack/Discord ignore the cover): `-filter_complex "[0:v][1:v]overlay=0:0:enable='eq(n,0)'[v]"` with the poster PNG as input 1.
    - **Music cues** (brag): onset strength per track, intensity = 0.45·onset + 0.25·contrast + 0.20·rms + 0.10·bass; reveals may shift ≤ 0.15 s (small entrances ≤ 0.10 s) to snap onto a cue. Silence detection adaptive: read `input_thresh` from `loudnorm print_format=json`, feed `silencedetect=noise=<thresh>dB`.
    - **Captions** (Kimeur/HyperFrames): pop 1-3 words or karaoke 3-7, ≥ 0.7 s on screen, ≤ 17 chars/s, no 1-3 frame gaps, a word lights on its start frame never before; Whisper word timings, drop words hallucinated over trailing silence.
    - **Preview HUD** (QasimTalkin): clickable scrubber, ←/→ ±0.5 s (Shift = 1 frame), clock synced to `out/mix.wav`, beat number shown, `?t=` deep link.
    - **B-roll over a talking head** (Barty-Bart `/motion-broll`): full-frame cutaways 3-10 s with ≥ 2 s of face between, or transparent panels in the empty space; alpha clips via `screenshot(omit_background=True)` → `format=gbrap` → ProRes 4444; never `will-change` on an element the camera scales.
    
    ## 9d. From the YouTube walkthroughs (Bart Slodyczka, Moritz, Jack Roberts, 24-25/09; transcripts in vault `30 Ressources/.raw/`)
    - **B-roll over an existing VO / talking head**: ask the **density** first (≈ 33 s: medium = 3 clips, heavy = 4-5; pick heavy, cutting is easier than adding). Input = word-timestamped transcript (whisper). Plan line by line, each graphic tagged *full cutaway* / *face stays* / *transparent panel in the empty space*, approved before rendering. Graphics land on the spoken word. The creator pre-edits (cuts + shrinks themself to a third of the frame) so the empty space is known. Deliver all clips in one local review page. Medium effort is enough.
    - **VO pipeline**: TTS (Cartesia/ElevenLabs), speed up slightly + trim silences with ffmpeg (adaptive `silencedetect`, §9c), local whisper.cpp for word timings → ONE timing file that drives both captions and animation cues. Check words clipped at edits.
    - **Long → Short**: one topic per Short (split rather than compress), faster cuts, quick real-face shots from the original footage, regenerate VO in pieces, model the creator's own short-form scripts.
    - **Brand intake**: Firecrawl `scrape` with the **`branding` format** (not markdown) pulls a site's colours/fonts/identity into `brand/` (needs a Firecrawl key; otherwise Playwright screenshots + pixel sampling as in §11).
    - **Fight the fingerprint**: Claude's default font and crowded vertical layouts are recognisable: always set the brand font, and on 9:16 talking heads keep speaker bottom / one graphic top / captions, nothing else.
    - **Use cases that worked**: explainer from a doc/SOP/skill ("make it fun, not too serious"); a **mascot SVG narrator** carrying the story; a **video version of a sales proposal** with real discovery-call numbers (anonymised); static HTML slides → animated slide loops (5-20 s); 3 s logo sting + jingle (batchable: 100 logos, each in the brand's main colour); restyle from a reference image (savee.com / Pinterest), add a grain/film-burn pass if it falls flat. One-prompt launch films can fan out to parallel sub-agents per section (like remake Phase 2).
    
    ## 10. Remake mode — frame-locked 1:1 copy of an existing video (scripts/remake/)
    Use when asked to "remake / recreate this launch video for my brand" (the split-screen "original | opus 5.5 copy" format). Proven on the Gojiberry launch (65 s, 28 shots) on 28/09/2026.
    - **Phase 0, analysis (no building):** download REF (yt_dlp in the video venv, no browser cookies) → `remake_analyze.py` extracts all frames 0-based to ref/full, audio to ref/audio.wav, detects hard cuts (mean-abs-diff spikes) and writes 6-frame contact sheets. Read the sheets, write SPEC.md: shot table (id, f0–f1, REF content, brand swap), swap rules. Most "cuts" in modern launch films are continuous camera/morph moves: expect only ~10-15 hard cuts, and expect SPEC boundaries to be a few frames off (agents fix them).
    - **Phase 1, engine (you, before agents):** copy `core.js` + `index.html` (seek(F) pure, SHOT registry, camera, cursor, words, pixelDissolve, palette filter that re-hues any leftover old-brand colour) and `remake_stub.py` (one placeholder file per group). Serve the folder, smoke-test with `remake_render.py compare out/test 10 600 1200`.
    - **Phase 2, parallel build:** split shots into 4 contiguous groups, one agent each (fill `BRIEF_TEMPLATE.md`), each writes ONLY shots/Gx.js and verifies with side-by-side compare sheets. 5th agent = audio: analyse REF (BPM, drop, hard stop, SFX hits, VO slots via STT timings only), royalty-free Mixkit track stretched ≤8% and cut on bars so drops land on REF times, numpy SFX on REF hits, -14 LUFS. Never reuse REF music/voice. Typical wall time: ~25 min per agent in parallel.
    - **Phase 3, integrate:** full render in 3 parallel chunks (`remake_render.py full out/full a b`), `remake_sync.py encode` (muxes out/mix.wav), `split` (the post format: two panels with a gap, black labels "original" / "opus 5.5 copy", setsar=1 or X distorts it), `stacked` (QA). `remake_qa.py`: ref|ours one frame per second + group seams + old-brand colour scan. Fix, re-render, deliver.
    - **Honesty rules:** no fake "made in 15 minutes" if it wasn't; tag/credit the original brand in the post; never show "OpenAI × YourBrand"-style co-marks that imply a partnership; no REF people photos.
    - Helpers every agent re-invented (add locally until core has them): text placed by ink edge + fitFont, hex colour mix, REF-shaped cursor, per-frame keyframe tables.
    
    ## 11. Product film mode — homepage SaaS film (from the "PROMPT MOTION DESIGN SAAS" brief, 29/09/2026)
    Use for a 45-75 s product film that shows the whole product in action. Lean pass first (one language, 16:9, no VO, ~45-60 min), full treatment only when asked (overnight, Mac awake).
    - **Story = a chain**: problem in the client's own words (3-5 s) → each step PRODUCES what the next one uses (the object leaving a step becomes the next scene, one continuous camera, no hard cuts) → the measurable result on screen → price twist → final CTA. Each step 2-7 s. Every element finishes animating and stays readable ≥ 1.5 s.
    - **Screens are rebuilt in code, never pasted**: scan each screenshot in zoomed tiles, extract ONE `ui-tokens` file (exact colours by pixel sampling, fonts, radii, shadows, borders, spacing), rebuild components that animate element by element (rows cascading, counters, gauges, typing, cursor, toggles). Check each rebuilt screen side by side with its screenshot until it's recognisable at first glance.
    - **i18n from day one**: no hard-coded text, one FR/EN dictionary; English written like a US SaaS UI, French = exact labels of the screenshots.
    - **Anonymise**: one fictional company used everywhere (same domain, products, competitors in both languages); no real client, competitor or person; neutral avatar. Images containing text are rebuilt (code or photo part only + text re-typed).
    - **Muted-first**: the homepage version autoplays without sound, so kinetic type carries the message. Safe margins 110 px (16:9) / 80 px (9:16); every title on ONE line.
    - **Full treatment extras**: write a "motion bible" (30-40 checkable rules) from the references; 5 competing concepts (one-take, beat montage, metaphor, glass world, director's cut) scored by a jury and merged; after v1, 7 critics (motion, image, sound, story, muted readability, UI fidelity + translations, brand/copy rules), ≥ 3 rounds, each defect with frame numbers + severity + measurable fix; a new version only replaces the previous one if side-by-side judges find it better (keep a version log). Deliver 16:9 + recomposed 9:16 (not a crop), no-VO + VO EN/FR, separate WAV stems, README.
    - **Machine hygiene**: one render at a time machine-wide (shared lock file), never parallel Whisper/heavy ffmpeg, delete superseded renders (keep current + previous), no built-in browser for sub-agents at night.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related