promo-clip · git:20260904.ecb24f9 · 2026-09-04 · sha256 0b241ef95d748a04
promo-clip git:20260904.ecb24f9A
Immutable. This exact content is served forever at /api/v1/blob/0b241ef95d748a04.
---
name: promo-clip
description: Use when asked for a promo clip, ролик, announcement video, teaser or launch video for a product or plugin, or when a task involves Remotion.
---
# Promo clips with Remotion
## Overview
A promo clip is **a script approved on paper, then a Remotion composition reviewed one still
per scene before anything is rendered**. Both failures we hit — three invisible scenes and a
cut nobody could read — were caught by a still or by a human, never by the compiler.
Working reference, read it before writing scenes: `/Users/oleg/Dev/cv-promo/src/Promo.tsx`
(9 scenes, 1920×1080, 30 fps, `TransitionSeries`, inline styles, no CSS framework).
Script: `script-v5-final.md` · pain research + reference ad: `pains-v1.md` in the same repo.
## Workflow
1. **Script table first, no code.** One markdown table, columns `t · frames · on screen ·
motion/sound`, plus a footer naming palette, fonts and spice level. Get an explicit
approval on that table. Reason: rewriting a table is minutes, re-animating a scene is an hour.
2. **Scaffold, then install — the scaffold does not install.**
```bash
npx create-video@latest --yes --blank --no-tailwind ~/Dev/cv-promo
cd ~/Dev/cv-promo && npm install
npm i @remotion/transitions@4.0.520 # EXACTLY the version of `remotion` in package.json
```
Pin the transitions package to the same version as `remotion`; a drifted pair breaks types.
`--no-tailwind` because every style here is an inline `style={{}}` object — cv-promo
scaffolded *with* Tailwind and never used a single class.
3. **One composition, one `<Sequence>` (or `<TransitionSeries.Sequence>`) per scene.**
Scene durations live in one object; `Root.tsx` imports `FPS` and `DURATION` from it.
4. **Review stills before any render** — one frame per scene, mid-scene, at half scale:
```bash
npx remotion still Promo out/f200.png --frame=200 --scale=0.5
```
Read every PNG. A full render costs minutes; a still costs seconds and shows the same bug.
5. **Render only after the stills pass.**
```bash
npx remotion render Promo out/promo.mp4
```
## Three traps that cost us real time
### 1. `<TransitionSeries>` accepts only literal children
`<TransitionSeries>` takes `<TransitionSeries.Sequence>` and `<TransitionSeries.Transition>`
and nothing else. A wrapper component that renders a `Transition` throws at render time
(`only accepts a list of <TransitionSeries.Sequence /> and <TransitionSeries.Transition />`),
and a helper typed over a union of presentations (`fade | wipe | slide`) fails TypeScript.
**Inline every transition.** Verbose beats clever:
```tsx
<TransitionSeries.Sequence durationInFrames={SCENES.pains}><Pains /></TransitionSeries.Sequence>
<TransitionSeries.Transition presentation={wipe({ direction: "from-left" })}
timing={springTiming({ config: { damping: 200 }, durationInFrames: T })} />
<TransitionSeries.Sequence durationInFrames={SCENES.contrast}><Contrast /></TransitionSeries.Sequence>
```
### 2. Paint order — wrap scene content in its own `<AbsoluteFill>`
An `<AbsoluteFill>` background rendered first paints **above** later static siblings:
positioned elements paint after in-flow ones, so `<Bg />` swallows the text below it. We lost
three scenes to this and only saw it in the stills. Every scene is:
```tsx
<AbsoluteFill>
<Bg tint={BLUE} />
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
{/* content — now positioned, so it paints after the background */}
</AbsoluteFill>
</AbsoluteFill>
```
Any `position`/`transform` on the content works; a bare in-flow `<div>` does not.
### 3. Total duration is computed, never typed
Transitions overlap their neighbours, so the timeline is **shorter** than the sum of scenes:
```ts
const T = 12; // transition length, frames
const SCENES = { hook: 80, pains: 175, contrast: 65, v: 50, formula: 60,
powers: 155, regen: 50, end: 65, site: 56 };
export const DURATION = Object.values(SCENES).reduce((a, b) => a + b, 0)
- T * (Object.keys(SCENES).length - 1);
```
Hand-computing it desynchronises the audio and truncates the end card.
## Readability — the pacing the maintainer actually wants
- **~3.5 s per text screen.** A screen of six typed lines needs **~10 s** (300 frames at 30 fps).
- The shipped 22 s cut gave the six pains 175 frames (5.8 s); the maintainer could not read it
and asked for **~35 s** total. When in doubt, slow down — nobody complains a promo was legible.
- Retiming changes `DURATION`, so the soundtrack has to be recomposed to the new length too.
## Palette — take it from the product site, never invent
Extract, don't guess: `curl -s https://amiainative.dev` and its linked CSS, then
`grep -oE '#[0-9a-fA-F]{6}' | sort | uniq -c | sort -rn`. Cross-check against in-repo art
(`docs/routing.svg`). Introducing a colour the brand does not own is a rejection.
| token | hex | use |
|---|---|---|
| NAVY / NAVY2 | `#0b0b22` / `#14143a` | background gradient |
| BLUE | `#1195F2` | early nodes, mono labels |
| INDIGO | `#6565F2` | mid nodes, glows |
| MAGENTA | `#DC02DF` | the V, strikes, the payoff |
| GOLD | `#FFC53D` | accents, the gag, "=" |
| PAPER / GREY | `#f7f7fb` / `#8c8fa8` | headline text / secondary text |
**No red anywhere** — the maintainer rejected it, including the "red stamps" that were in the
approved script v5. The script is not the last word; the maintainer is.
## The structure that worked
hook (contrast question) → pains typed out and struck through → two-line contrast beat
("Some will … / Others …", borrowed from the Neoversity reference ad in `pains-v1.md`) →
the reveal → an equation slide (Claude Code + Superpowers + V = SUPE) → feature nodes strung
along a pipeline line with exactly one gag (a rogue write bouncing off, `BLOCKED`) →
"Regenerates. (Crash? Resume.)" → end card → a clean final screen with the site URL and a motto.
One gag per clip. The pains are the hook; the gate is the promise; the URL is last.
## Music — `agy` will compose a track, but only if the prompt forbids refusal
`agy` (Antigravity CLI) has **no audio tool**. It will still deliver a produced `.wav` by
writing and running a Python synth — but only when the prompt demands a produced file *by any
means*. A prompt that politely offers an out ("say CANNOT if you cannot") gets a refusal.
```bash
python3 /Users/oleg/Dev/superpowers-v/scripts/compound-v-run-with-timeout.py \
--timeout 900 --cwd /tmp/promo-music -- \
agy --add-dir /tmp/promo-music --print-timeout 900s -p \
"Produce a finished 22.000 s stereo WAV at /tmp/promo-music/promo-bg.wav by any means \
available to you, including writing and running a Python synthesiser. 120 BPM, D minor, \
dark heroic; a hard silence gap from 19.85 s to 20.00 s (power cut); a sting at 20.000 s; \
master to about -1.2 dBFS."
```
- Run it in an **isolated scratch directory** with `--add-dir` — without it the output lands in
agy's own scratch (`~/.gemini/antigravity-cli/scratch/`), not your path.
- Always under the process-group supervisor: agy spawns children that outlive a plain `timeout`.
- Ask for **exact duration, BPM, the gap and the sting at the frame the video needs them**.
Ours (`music/compose_promo_bg.py`, 686 lines of numpy/scipy) hits 22.000 s, gap 19.85–20.00 s,
sting at 20.0 s — the power-cut flicker of the Regenerates scene.
- Keep a **stdlib-only placeholder WAV generator** for iteration (`music/placeholder-stdlib.wav`)
so scene timing can be reviewed without re-running a 15-minute composition.
- Wire it as `<Audio src={staticFile("promo-bg.wav")} volume={0.8} />` with the file in `public/`.
## Brand safety (maintainer's rules)
- **No syringe, pill, vial or drug imagery** for the Compound V metaphor — use the magenta **V**
sigil (gradient text + drop-shadow glow). This is non-negotiable, not a style preference.
- **The Boys flavour in moderation** — "spice level: medium". One wink per scene, not a parody.
- **English on screen**, whatever language the conversation is in.
## Delivery
- Send each still and each MP4 with **SendUserFile as it is produced** — do not batch them to
the end. The maintainer's timing feedback arrived from watching v1, not from reading a summary.
- **Persist the project outside the session scratchpad** (`~/Dev/cv-promo`). Scratchpads vanish;
the next iteration needs the composition, the script, the palette and the synth script.
## Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Coding before the script table is approved | Scenes get re-animated | Table → approval → code |
| Wrapper component around a `Transition` | Runtime throw: "only accepts a list of…" | Inline the transitions |
| Scene content as a bare in-flow div | Text invisible, render looks "empty" | Wrap in its own `<AbsoluteFill>` |
| Hand-typed `durationInFrames` total | Audio desync, truncated end card | Compute: sum − T×(n−1) |
| Full render before stills | Minutes burned per iteration | `remotion still --scale=0.5` per scene |
| `npm i` skipped after `create-video` | `remotion: command not found` | The scaffold does not install |
| Transitions package on a different version | TypeScript errors on presentations | Pin to the `remotion` version |
| A colour picked by eye | Off-brand, rejected | Extract hexes from the live site CSS |
| Polite escape hatch in the agy music prompt | "I cannot generate audio" | Demand the file "by any means" |
## No strobes — motion the eye reads as a glitch
A per-frame or 3-frame opacity toggle ("power-cut flicker") at 30 fps reads as a rendering defect, not
an effect; the maintainer called v3's 27–30 s "twitching". The same goes for sine shakes above ~2 px.
Use ONE eased dip instead (`interpolate(frame, [0, 6, 12, 22], [1, 0.25, 0.25, 1], { easing: Easing.inOut(Easing.quad) })`),
ease every redraw (`Easing.out(Easing.cubic)`), keep sways ≤ 1.5 px and slow (`Math.sin(frame / 6)`), and
prefer `fade()` over `slide()` into a calm end card.
## Audio: never gate to digital zero
A composed "power-cut" that drops to exact silence in 100 ms and slams back with a full-scale step
(0.88 sample jump) is heard as the sound tearing — the maintainer flagged 27–28 s of v3. Check the
track with a 100 ms RMS envelope and the largest sample-to-sample jump around every hard cut
(`/usr/bin/python3` has numpy). Fix in post: an eased fade-out of ≥150 ms, a quiet low-passed bed
(≈−16 dB, one bar looped through a ~600 Hz one-pole) instead of zeros, and a 25–30 ms cosine fade-in
on the return — the punch stays, the click goes. But a patched gap still "felt cut" to the maintainer:
when a listener rejects a transition, RE-COMPOSE the track with the transition written as music (drums drop
out, the pad and a filter sweep carry through, a riser brings the beat back) and make the composer verify
programmatically — largest sample jump < 0.25 FS, no 50 ms window under −40 dBFS before the final fade —
rather than resurrecting the old file in post.