creator-editing-studio 1.3.8 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/history.json +1 -1
- package/package.json +1 -1
- package/template/CLAUDE.md +42 -8
- package/template/package.json +4 -1
- package/template/preview/lib.mjs +161 -19
- package/template/preview/player.html +71 -68
- package/template/preview/server.mjs +246 -23
- package/template/remotion.config.ts +5 -0
- package/template/scripts/approve.mjs +39 -0
- package/template/scripts/final-check.mjs +69 -0
- package/template/scripts/lib/plan-gate.mjs +99 -0
- package/template/scripts/plan-sheet.mjs +99 -0
- package/template/src/components/CommentBox.tsx +31 -0
- package/template/src/components/FilmBurn.tsx +48 -0
- package/template/src/components/FlashBloom.tsx +45 -0
- package/template/src/components/MergeToPill.tsx +56 -0
- package/template/src/components/NotificationDrop.tsx +49 -0
- package/template/src/components/PillCaptions.tsx +62 -0
- package/template/src/components/ReelRing.tsx +81 -0
- package/template/src/components/WhipScene.tsx +90 -0
- package/template/src/design/motion.ts +8 -0
- package/template/src/lib/brand.tsx +89 -0
- package/template/src/lib/layout.tsx +113 -0
- package/template/src/lib/recolor.ts +173 -0
- package/template/src/lib/tweaks.tsx +108 -6
- package/template/src/plan/entry.tsx +66 -0
- package/template/src/videos/whats-new/WhatsNewEdit.tsx +4 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The motion sheet for a plan: one real rendered frame per beat, numbered and labelled, as one PNG.
|
|
4
|
+
*
|
|
5
|
+
* npm run plan-sheet -- <CompositionId> --beats <beats.json> [--entry src/index.ts] [--public-dir assets]
|
|
6
|
+
*
|
|
7
|
+
* beats.json: [{"frame": 105, "says": "same handful of tools", "what": "...", "beat": "Text behind me"}, ...]
|
|
8
|
+
* (or just a list of frame numbers).
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS IS A COMMAND AND NOT A SENTENCE IN THE RULEBOOK. The rule "show a motion sheet with the plan"
|
|
11
|
+
* existed for weeks, and on 2026-10-22 the plan still went out as a table only — a rule that is only text
|
|
12
|
+
* gets skipped. This makes the sheet one command, and remotion.config.ts refuses a render until the person
|
|
13
|
+
* has approved the sheet this command made (scripts/approve.mjs). See "The plan gate" in the rulebook.
|
|
14
|
+
*
|
|
15
|
+
* Renders every still in ONE browser session (one copy of the assets — a copy per still filled a disk).
|
|
16
|
+
* Writes work/plans/<Comp>/motion-sheet.png and work/plans/<Comp>/sheet.json (with the sheet's hash).
|
|
17
|
+
*/
|
|
18
|
+
import {createHash} from 'node:crypto';
|
|
19
|
+
import {existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
|
20
|
+
import {tmpdir} from 'node:os';
|
|
21
|
+
import {dirname, join, resolve} from 'node:path';
|
|
22
|
+
import {spawnSync} from 'node:child_process';
|
|
23
|
+
import {createRequire} from 'node:module';
|
|
24
|
+
import {bundle} from '@remotion/bundler';
|
|
25
|
+
import {openBrowser, renderStill, selectComposition} from '@remotion/renderer';
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const flag = (name, fallback) => (args.includes(`--${name}`) ? args[args.indexOf(`--${name}`) + 1] : fallback);
|
|
29
|
+
const comp = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
|
|
30
|
+
const beatsFile = flag('beats', null);
|
|
31
|
+
if (!comp || !beatsFile) {
|
|
32
|
+
console.error('Usage: npm run plan-sheet -- <CompositionId> --beats <beats.json> [--entry src/index.ts] [--public-dir assets]');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const raw = JSON.parse(readFileSync(beatsFile, 'utf8'));
|
|
36
|
+
const beats = raw.map((b) => (typeof b === 'number' ? {frame: b} : b));
|
|
37
|
+
const entry = resolve(flag('entry', 'src/index.ts'));
|
|
38
|
+
const publicDir = resolve(flag('public-dir', 'assets'));
|
|
39
|
+
const dir = resolve('work', 'plans', comp);
|
|
40
|
+
const stillsDir = join(dir, 'stills');
|
|
41
|
+
rmSync(stillsDir, {recursive: true, force: true});
|
|
42
|
+
mkdirSync(stillsDir, {recursive: true});
|
|
43
|
+
|
|
44
|
+
// The CLI file is not in the package's exports map, so find it next to the package's main entry.
|
|
45
|
+
const CLI = join(dirname(createRequire(import.meta.url).resolve('@remotion/cli')), '..', 'remotion-cli.js');
|
|
46
|
+
const STARTED = Date.now() - 1000;
|
|
47
|
+
/** Remotion copies the media into a fresh temp folder for EVERY still (400 MB each for a long clip) and never
|
|
48
|
+
* removes it — 15 stills filled a disk on 2026-10-22. Delete the copies this run made, after each still. */
|
|
49
|
+
const cleanTemp = () => {
|
|
50
|
+
for (const d of readdirSync(tmpdir())) {
|
|
51
|
+
const full = join(tmpdir(), d);
|
|
52
|
+
try {
|
|
53
|
+
if (/^remotion-v[0-9.]+-assets/.test(d) && statSync(full).mtimeMs >= STARTED) rmSync(full, {recursive: true, force: true});
|
|
54
|
+
} catch {
|
|
55
|
+
/* still in use — the next pass gets it */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const tc = (frame, fps) => {
|
|
60
|
+
const s = frame / fps;
|
|
61
|
+
return `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
console.log(`Bundling ${entry}…`);
|
|
65
|
+
// Bundles go next to the sheet and are deleted at the end: each one copies the whole assets folder, and
|
|
66
|
+
// leaving them behind is how a nearly full disk ran out mid-render.
|
|
67
|
+
const serveUrl = await bundle({entryPoint: entry, publicDir, outDir: join(dir, '.bundle')});
|
|
68
|
+
const browser = await openBrowser('chrome');
|
|
69
|
+
try {
|
|
70
|
+
const composition = await selectComposition({serveUrl, id: comp, puppeteerInstance: browser});
|
|
71
|
+
const tiles = [];
|
|
72
|
+
for (const [i, b] of beats.entries()) {
|
|
73
|
+
const file = `s${String(i + 1).padStart(2, '0')}.jpg`;
|
|
74
|
+
// Each still in its own short process: Remotion copies the media into temp for every still and holds the
|
|
75
|
+
// copy until the process ends, so one process for all stills piled up 14 x 400 MB and filled the disk.
|
|
76
|
+
const r = spawnSync(process.execPath, [CLI, 'still', serveUrl, comp, join(stillsDir, file), `--frame=${b.frame}`, '--scale=0.5', '--log=error'], {stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8'});
|
|
77
|
+
if (r.status !== 0 || !existsSync(join(stillsDir, file))) throw new Error(`still at frame ${b.frame} failed: ${(r.stderr || '').slice(0, 400)}`);
|
|
78
|
+
cleanTemp();
|
|
79
|
+
tiles.push({file, n: i + 1, time: tc(b.frame, composition.fps), frame: b.frame, says: b.says, what: b.what, beat: b.beat});
|
|
80
|
+
console.log(` ${i + 1}/${beats.length} frame ${b.frame}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The sheet itself, drawn by Remotion with the stills folder as its public dir.
|
|
84
|
+
const sheetUrl = await bundle({entryPoint: resolve('src/plan/entry.tsx'), publicDir: stillsDir, outDir: join(dir, '.bundle-sheet')});
|
|
85
|
+
const props = {title: flag('title', comp), subtitle: flag('subtitle', 'One real rendered frame per beat. Point at a number to change it.'), tiles, vertical: composition.height > composition.width};
|
|
86
|
+
const sheetComp = await selectComposition({serveUrl: sheetUrl, id: 'PlanSheet', inputProps: props, puppeteerInstance: browser});
|
|
87
|
+
const out = join(dir, 'motion-sheet.png');
|
|
88
|
+
await renderStill({serveUrl: sheetUrl, composition: sheetComp, frame: 0, output: out, imageFormat: 'png', inputProps: props, puppeteerInstance: browser});
|
|
89
|
+
|
|
90
|
+
const hash = createHash('sha256').update(readFileSync(out)).digest('hex').slice(0, 16);
|
|
91
|
+
writeFileSync(join(dir, 'sheet.json'), JSON.stringify({composition: comp, hash, beats: beats.length, frames: beats.map((b) => b.frame), at: new Date().toISOString()}, null, 2) + '\n');
|
|
92
|
+
// A new sheet means a new plan: any earlier approval no longer covers it.
|
|
93
|
+
if (existsSync(join(dir, 'approved.json'))) console.log('A new sheet replaces the approved one — ask for a yes on this sheet before rendering.');
|
|
94
|
+
console.log(`\nMotion sheet: ${out}\nShow it to them with the plan table, then wait for a yes and record it: npm run approve -- ${comp} --said "<their words>"`);
|
|
95
|
+
} finally {
|
|
96
|
+
await browser.close({silent: true});
|
|
97
|
+
for (const b of ['.bundle', '.bundle-sheet']) rmSync(join(dir, b), {recursive: true, force: true});
|
|
98
|
+
cleanTemp();
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {FONT} from '../design/tokens';
|
|
5
|
+
import {progress} from '../lib/animate';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Comment box — the "comment WORD" call to action as a real comment field: it rises over a soft bottom
|
|
9
|
+
* gradient, the word types itself in (accent colour), and "Post" lights up when it's complete.
|
|
10
|
+
* Sound: a key click per letter, a click on Post.
|
|
11
|
+
*
|
|
12
|
+
* <CommentBox from={2474} to={2545} typeAt={2496} word="EDIT" />
|
|
13
|
+
*/
|
|
14
|
+
export const CommentBox: React.FC<{from: number; to: number; typeAt: number; word: string; accent?: string}> = ({from, to, typeAt, word, accent = 'var(--accent, your accent)'}) => {
|
|
15
|
+
const f = useCurrentFrame();
|
|
16
|
+
if (f < from || f >= to) return null;
|
|
17
|
+
const a = progress(f, from, 12, EASE.out);
|
|
18
|
+
const o = Math.min(a, 1 - progress(f, to - 6, 6, EASE.in));
|
|
19
|
+
const typed = word.slice(0, Math.max(0, Math.min(word.length, Math.floor((f - typeAt + 3) / 3))));
|
|
20
|
+
const done = typed.length === word.length;
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<AbsoluteFill style={{background: 'linear-gradient(to top, rgba(15,15,13,0.85), rgba(15,15,13,0) 45%)', opacity: o}} />
|
|
24
|
+
<div style={{position: 'absolute', left: 50, right: 50, bottom: 300 - (1 - a) * 24, opacity: o, borderRadius: 999, padding: '22px 30px', display: 'flex', alignItems: 'center', gap: 18, background: 'rgba(28,28,25,0.92)', border: '1px solid rgba(255,255,255,0.09)', boxShadow: '0 30px 60px rgba(0,0,0,0.45)', fontFamily: FONT.sans}}>
|
|
25
|
+
<div style={{width: 60, height: 60, borderRadius: 999, background: 'linear-gradient(135deg,#f9ce34,#ee2a7b,#6228d7)'}} />
|
|
26
|
+
<div style={{flex: 1, fontSize: 52, fontWeight: 800, color: typed ? accent : 'rgba(255,255,255,0.35)', letterSpacing: '0.02em'}}>{typed || 'Add a comment…'}</div>
|
|
27
|
+
<div style={{fontSize: 34, fontWeight: 700, color: done ? accent : 'rgba(255,255,255,0.3)', transform: `scale(${done ? 1.06 : 1})`}}>Post</div>
|
|
28
|
+
</div>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Film burn — a warm light leak that flares across the frame and burns out, the old "end of the reel" flash.
|
|
6
|
+
* Use it to hide a join or a short pause: the eye goes to the flare, and the shot after it reads as a new one.
|
|
7
|
+
* First built (2026-10-22) over a 0.3 s hesitation after the hook, with a camera shutter on it.
|
|
8
|
+
*
|
|
9
|
+
* <FilmBurn at={138} frames={20} /> // above the footage and the graphics, below the captions
|
|
10
|
+
*
|
|
11
|
+
* Peak brightness lands at 40% of its length — put the join or the pause there. Sound: a shutter click at
|
|
12
|
+
* `at`, a soft whoosh under the peak.
|
|
13
|
+
*/
|
|
14
|
+
export const FilmBurn: React.FC<{at: number; frames?: number; from?: 'left' | 'right'}> = ({at, frames = 20, from = 'left'}) => {
|
|
15
|
+
const f = useCurrentFrame();
|
|
16
|
+
const t = (f - at) / frames;
|
|
17
|
+
if (t < 0 || t > 1) return null;
|
|
18
|
+
const peak = 0.4;
|
|
19
|
+
const a = t < peak ? interpolate(t, [0, peak], [0, 1]) : interpolate(t, [peak, 1], [1, 0]);
|
|
20
|
+
const ease = a * a * (3 - 2 * a);
|
|
21
|
+
const dir = from === 'left' ? 1 : -1;
|
|
22
|
+
// the leak travels across the frame while it burns
|
|
23
|
+
const x = 50 + dir * interpolate(t, [0, 1], [-45, 45]);
|
|
24
|
+
return (
|
|
25
|
+
<AbsoluteFill style={{pointerEvents: 'none'}}>
|
|
26
|
+
{/* the burn: hot core, orange body, deep red edge — screened onto the picture */}
|
|
27
|
+
<AbsoluteFill
|
|
28
|
+
style={{
|
|
29
|
+
mixBlendMode: 'screen',
|
|
30
|
+
opacity: ease,
|
|
31
|
+
background: `radial-gradient(ellipse 55% 70% at ${x}% 45%, rgba(255,246,214,1) 0%, rgba(255,170,70,0.95) 28%, rgba(226,72,24,0.75) 55%, rgba(120,20,10,0) 80%)`,
|
|
32
|
+
filter: 'blur(18px)',
|
|
33
|
+
}}
|
|
34
|
+
/>
|
|
35
|
+
{/* a second, offset flare so it never looks like one clean circle */}
|
|
36
|
+
<AbsoluteFill
|
|
37
|
+
style={{
|
|
38
|
+
mixBlendMode: 'screen',
|
|
39
|
+
opacity: ease * 0.8,
|
|
40
|
+
background: `radial-gradient(ellipse 35% 45% at ${100 - x}% 75%, rgba(255,214,140,0.9) 0%, rgba(240,110,40,0.6) 40%, rgba(0,0,0,0) 75%)`,
|
|
41
|
+
filter: 'blur(28px)',
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
{/* the burnt-out moment: everything goes warm white for a beat */}
|
|
45
|
+
<AbsoluteFill style={{background: 'rgb(255,236,205)', opacity: Math.max(0, ease - 0.55) * 1.6}} />
|
|
46
|
+
</AbsoluteFill>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, Img, useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Flash bloom — the opening hit: the SPEAKER blooms to white (not the whole screen) while the camera zooms out
|
|
8
|
+
* from very close, then the glow drains off them and the shot settles. Measured from a reference opener
|
|
9
|
+
* (references.md 19, 00:00–00:01): ~2 frames of full bloom, glow gone by ~frame 10, zoom settled by ~16.
|
|
10
|
+
*
|
|
11
|
+
* Two parts, because the zoom has to move EVERYTHING and the bloom sits on the speaker only:
|
|
12
|
+
* const z = flashZoom(frame); // wrap footage + graphics in transform: scale(z.scale)
|
|
13
|
+
* <FlashBloom cutout={(f) => cutoutUrl(f)} /> // above the footage, from the speaker's cutout matte
|
|
14
|
+
*
|
|
15
|
+
* Needs a cutout for the first ~14 frames (scripts/make_cutout.py). Sound: a short bright swoosh on frame 0.
|
|
16
|
+
*/
|
|
17
|
+
export const flashZoom = (frame: number, at = 0, frames = 16) => {
|
|
18
|
+
const p = progress(frame, at, frames, EASE.outDecisive);
|
|
19
|
+
return {scale: 1 + 0.42 * (1 - p), blur: 6 * (1 - p)};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const FlashBloom: React.FC<{
|
|
23
|
+
/** URL of the speaker's cutout at a composition frame, or null when there is none. */
|
|
24
|
+
cutout: (frame: number) => string | null;
|
|
25
|
+
at?: number;
|
|
26
|
+
frames?: number;
|
|
27
|
+
}> = ({cutout, at = 0, frames = 12}) => {
|
|
28
|
+
const f = useCurrentFrame();
|
|
29
|
+
if (f < at || f > at + frames + 2) return null;
|
|
30
|
+
const src = cutout(f);
|
|
31
|
+
// full white for two frames, then drains
|
|
32
|
+
const glow = f - at < 2 ? 1 : 1 - progress(f, at + 2, frames - 2, EASE.inOut);
|
|
33
|
+
const z = flashZoom(f, at);
|
|
34
|
+
return (
|
|
35
|
+
<AbsoluteFill style={{transform: `scale(${z.scale})`, transformOrigin: '50% 38%'}}>
|
|
36
|
+
{/* a soft lift over the whole frame, much weaker than the bloom on the person */}
|
|
37
|
+
<AbsoluteFill style={{background: `rgba(255,252,240,${0.35 * glow})`}} />
|
|
38
|
+
{src ? (
|
|
39
|
+
<AbsoluteFill style={{opacity: glow}}>
|
|
40
|
+
<Img src={src} style={{position: 'absolute', inset: 0, width: '100%', height: '100%', filter: `brightness(0) invert(1) blur(${2 + 10 * (1 - glow)}px) drop-shadow(0 0 ${40 * glow}px rgba(255,255,255,0.95))`}} />
|
|
41
|
+
</AbsoluteFill>
|
|
42
|
+
) : null}
|
|
43
|
+
</AbsoluteFill>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merge to pill — scattered items (logos, chips) drift apart, then rush to the centre, blur together and BECOME
|
|
8
|
+
* one pill (₹0, FREE, ONE SYSTEM…). Morph, not swap: the eye follows the items into the thing they become
|
|
9
|
+
* (references.md pattern 11 "one thing becomes the next").
|
|
10
|
+
*
|
|
11
|
+
* <MergeToPill items={[<Logo/>, …]} from={0} mergeAt={34} pill={<span>₹0</span>} />
|
|
12
|
+
* Frames are local to the parent (use inside a WhipScene). Sound: a quick swoosh into the merge, a pop on the pill.
|
|
13
|
+
*/
|
|
14
|
+
export const MergeToPill: React.FC<{
|
|
15
|
+
items: React.ReactNode[];
|
|
16
|
+
from: number;
|
|
17
|
+
mergeAt: number;
|
|
18
|
+
pill: React.ReactNode;
|
|
19
|
+
pillStyle?: React.CSSProperties;
|
|
20
|
+
cx?: number;
|
|
21
|
+
cy?: number;
|
|
22
|
+
/** frame override — pass the parent's local frame when used inside a scene */
|
|
23
|
+
frame?: number;
|
|
24
|
+
}> = ({items, from, mergeAt, pill, pillStyle, cx = 540, cy = 960, frame}) => {
|
|
25
|
+
const raw = useCurrentFrame();
|
|
26
|
+
const f = frame ?? raw;
|
|
27
|
+
const spots = [
|
|
28
|
+
[-300, -420, -12],
|
|
29
|
+
[290, -300, 10],
|
|
30
|
+
[-310, 260, 9],
|
|
31
|
+
[300, 400, -8],
|
|
32
|
+
[0, -560, 4],
|
|
33
|
+
[0, 560, -4],
|
|
34
|
+
];
|
|
35
|
+
const drift = progress(f, from, mergeAt - from, EASE.out);
|
|
36
|
+
const pull = progress(f, mergeAt, 12, EASE.in);
|
|
37
|
+
const pop = progress(f, mergeAt + 10, 14, EASE.overshoot);
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{items.map((item, i) => {
|
|
41
|
+
const [sx, sy, r] = spots[i % spots.length];
|
|
42
|
+
const k = 0.75 + drift * 0.25;
|
|
43
|
+
const x = sx * k * (1 - pull);
|
|
44
|
+
const y = sy * k * (1 - pull);
|
|
45
|
+
return (
|
|
46
|
+
<div key={i} style={{position: 'absolute', left: cx, top: cy, transform: `translate(-50%,-50%) translate(${x}px, ${y}px) rotate(${r * (1 - pull)}deg) scale(${1 - pull * 0.7})`, opacity: Math.min(progress(f, from + i * 2, 10, EASE.out), 1 - pull * 0.9), filter: `blur(${pull * 10}px)`}}>
|
|
47
|
+
{item}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
{pop > 0 ? (
|
|
52
|
+
<div style={{position: 'absolute', left: cx, top: cy, transform: `translate(-50%,-50%) scale(${pop})`, ...pillStyle}}>{pill}</div>
|
|
53
|
+
) : null}
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {FONT} from '../design/tokens';
|
|
5
|
+
import {progress} from '../lib/animate';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Notification drop — a phone-style notification slides down from the top over the talking head, holds, and
|
|
9
|
+
* lifts away. "UI in the room" (references.md 15): the product's moment shows up in the shot instead of a
|
|
10
|
+
* cutaway. Sound: a soft click/ding on arrival.
|
|
11
|
+
*
|
|
12
|
+
* <NotificationDrop from={2040} to={2140} icon="E" title="Creator Editing OS" body="New update ready — v1.3.8" />
|
|
13
|
+
*/
|
|
14
|
+
export const NotificationDrop: React.FC<{from: number; to: number; icon: React.ReactNode; title: string; body: string; accent?: string}> = ({from, to, icon, title, body, accent = 'var(--accent, your accent)'}) => {
|
|
15
|
+
const f = useCurrentFrame();
|
|
16
|
+
if (f < from || f >= to) return null;
|
|
17
|
+
const drop = progress(f, from, 16, EASE.outDecisive);
|
|
18
|
+
const lift = progress(f, to - 10, 10, EASE.in);
|
|
19
|
+
const y = -180 + drop * 250 - lift * 250;
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
position: 'absolute',
|
|
24
|
+
left: 40,
|
|
25
|
+
right: 40,
|
|
26
|
+
top: y,
|
|
27
|
+
display: 'flex',
|
|
28
|
+
alignItems: 'center',
|
|
29
|
+
gap: 22,
|
|
30
|
+
padding: '26px 30px',
|
|
31
|
+
borderRadius: 36,
|
|
32
|
+
background: 'rgba(28,28,25,0.86)',
|
|
33
|
+
backdropFilter: 'blur(18px)',
|
|
34
|
+
border: '1px solid rgba(255,255,255,0.09)',
|
|
35
|
+
boxShadow: '0 30px 60px rgba(0,0,0,0.45)',
|
|
36
|
+
fontFamily: FONT.sans,
|
|
37
|
+
color: '#fff',
|
|
38
|
+
opacity: 1 - lift,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<div style={{width: 84, height: 84, borderRadius: 22, background: accent, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 44, fontWeight: 800, color: '#0f0f0d', flexShrink: 0}}>{icon}</div>
|
|
42
|
+
<div style={{flex: 1}}>
|
|
43
|
+
<div style={{fontSize: 31, fontWeight: 700}}>{title}</div>
|
|
44
|
+
<div style={{fontSize: 29, color: 'rgba(255,255,255,0.68)', marginTop: 2}}>{body}</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div style={{fontSize: 25, color: 'rgba(255,255,255,0.4)'}}>now</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {FONT} from '../design/tokens';
|
|
5
|
+
import {progress} from '../lib/animate';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pill captions — a small dark-pill subtitle, up to 4 words, the word being said in the accent; words still to
|
|
9
|
+
* come wait faint. The launch-film / premium talking-head look (references.md 13, 14, pattern 14: "small
|
|
10
|
+
* captions, big ideas") — big type is saved for the moments that matter.
|
|
11
|
+
*
|
|
12
|
+
* <PillCaptions words={[{text, f, t}]} quiet={[[0, 14], [2470, 2600]]} top={1600} />
|
|
13
|
+
*
|
|
14
|
+
* look="clean": the same timing with no pill — bigger white words on a soft shadow (his first named style
|
|
15
|
+
* request, 2026-10-22: "captions without the pill"). More looks plug in here as CAPTION_LOOKS grows.
|
|
16
|
+
*
|
|
17
|
+
* Breaks at pauses (> 8 frames), at sentence/clause ends and every 4 words, so no word is ever left alone on
|
|
18
|
+
* screen (a lone word held ~0.5s reads as a repeat — STYLE.md §7).
|
|
19
|
+
*/
|
|
20
|
+
type Word = {text: string; f: number; t: number};
|
|
21
|
+
export const CAPTION_LOOKS = ['pill', 'clean'] as const;
|
|
22
|
+
export type CaptionLook = (typeof CAPTION_LOOKS)[number];
|
|
23
|
+
|
|
24
|
+
export const chunkWords = (words: Word[], max = 4) => {
|
|
25
|
+
const out: Word[][] = [];
|
|
26
|
+
let cur: Word[] = [];
|
|
27
|
+
words.forEach((w, i) => {
|
|
28
|
+
const prev = words[i - 1];
|
|
29
|
+
if (cur.length && (cur.length >= max || w.f - prev.t > 8 || /[.?!,]$/.test(prev.text))) {
|
|
30
|
+
out.push(cur);
|
|
31
|
+
cur = [];
|
|
32
|
+
}
|
|
33
|
+
cur.push(w);
|
|
34
|
+
});
|
|
35
|
+
if (cur.length) out.push(cur);
|
|
36
|
+
return out;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const PillCaptions: React.FC<{words: Word[]; quiet?: [number, number][]; top?: number; accent?: string; size?: number; look?: CaptionLook}> = ({words, quiet = [], top = 1600, accent = 'var(--accent, your accent)', size = 46, look = 'pill'}) => {
|
|
40
|
+
const f = useCurrentFrame();
|
|
41
|
+
const chunks = React.useMemo(() => chunkWords(words), [words]);
|
|
42
|
+
if (quiet.some(([a, b]) => f >= a && f < b)) return null;
|
|
43
|
+
const ci = chunks.findIndex((c, k) => f >= c[0].f - 2 && f < Math.min((chunks[k + 1]?.[0].f ?? 1e9) - 1, c[c.length - 1].t + 16));
|
|
44
|
+
if (ci < 0) return null;
|
|
45
|
+
const chunk = chunks[ci];
|
|
46
|
+
const pop = progress(f, chunk[0].f - 2, 6, EASE.out);
|
|
47
|
+
return (
|
|
48
|
+
<div style={{position: 'absolute', left: 0, right: 0, top, display: 'flex', justifyContent: 'center', pointerEvents: 'none'}}>
|
|
49
|
+
<div style={{...(look === 'pill' ? {background: 'rgba(12,12,10,0.66)', backdropFilter: 'blur(14px)', borderRadius: 22, padding: '12px 24px', boxShadow: '0 12px 30px rgba(0,0,0,0.25)'} : {padding: '0 40px', flexWrap: 'wrap', justifyContent: 'center', textShadow: '0 2px 18px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.6)'}), display: 'flex', gap: look === 'pill' ? 14 : 16, fontFamily: FONT.sans, transform: `translateY(${(1 - pop) * 10}px) scale(${0.96 + pop * 0.04})`, opacity: pop}}>
|
|
50
|
+
{chunk.map((w, i) => {
|
|
51
|
+
const said = f >= w.f;
|
|
52
|
+
const active = said && f < (chunk[i + 1]?.f ?? w.t + 6);
|
|
53
|
+
return (
|
|
54
|
+
<span key={i} style={{fontSize: look === 'pill' ? size : size * 1.3, fontWeight: look === 'pill' ? 700 : 800, letterSpacing: '-0.01em', color: active ? accent : said ? '#fff' : look === 'pill' ? 'rgba(255,255,255,0.38)' : 'rgba(255,255,255,0.55)'}}>
|
|
55
|
+
{w.text.replace(/[,.]$/, '')}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, Loop, OffthreadVideo, useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reel ring — phone-shaped clips orbiting the speaker on a tilted ellipse, turning and easing to a stop as the
|
|
8
|
+
* line lands. First built for "every Claude-edited reel you've seen…" (2026-10-22).
|
|
9
|
+
*
|
|
10
|
+
* A real orbit is split around the speaker: the far half of the ring goes BEHIND the cutout, the near half
|
|
11
|
+
* IN FRONT of it, so the cards pass behind him and come round in front — "revolving around me" (his words,
|
|
12
|
+
* 2026-10-22; all-behind read as a flat backdrop, all-in-front as clips stuck over him). A card changes side
|
|
13
|
+
* at the ring's left/right ends, clear of the body, so the switch never shows.
|
|
14
|
+
*
|
|
15
|
+
* <ReelRing side="back" reels={…} from={0} to={230} />
|
|
16
|
+
* <Cutout />
|
|
17
|
+
* <ReelRing side="front" reels={…} from={0} to={230} />
|
|
18
|
+
*
|
|
19
|
+
* Reels: muted proxies (~360x640, a few seconds) looped; they are played, not rendered from originals.
|
|
20
|
+
*/
|
|
21
|
+
export const ReelRing: React.FC<{
|
|
22
|
+
reels: string[];
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
cx?: number;
|
|
26
|
+
cy?: number;
|
|
27
|
+
rx?: number;
|
|
28
|
+
ry?: number;
|
|
29
|
+
tileW?: number;
|
|
30
|
+
turns?: number;
|
|
31
|
+
/** which half to draw: 'back' (far side, before the cutout), 'front' (near side, after it), or 'all' */
|
|
32
|
+
side?: 'all' | 'back' | 'front';
|
|
33
|
+
/** resolve a reel path to a URL (staticFile) */
|
|
34
|
+
url: (path: string) => string;
|
|
35
|
+
}> = ({reels, from, to, cx = 540, cy = 860, rx = 480, ry = 150, tileW = 230, turns = 0.55, side = 'all', url}) => {
|
|
36
|
+
const f = useCurrentFrame();
|
|
37
|
+
if (f < from || f >= to) return null;
|
|
38
|
+
const n = reels.length;
|
|
39
|
+
const spin = progress(f, from, to - from, EASE.out) * Math.PI * 2 * turns;
|
|
40
|
+
const tiles = reels
|
|
41
|
+
.map((r, i) => {
|
|
42
|
+
const a = (i / n) * Math.PI * 2 + spin - Math.PI / 2;
|
|
43
|
+
return {r, i, a, depth: Math.sin(a)};
|
|
44
|
+
})
|
|
45
|
+
.filter(({depth}) => side === 'all' || (side === 'back' ? depth < 0 : depth >= 0))
|
|
46
|
+
.sort((p, q) => p.depth - q.depth);
|
|
47
|
+
const tileH = (tileW * 16) / 9;
|
|
48
|
+
return (
|
|
49
|
+
<AbsoluteFill style={{perspective: 1500}}>
|
|
50
|
+
{tiles.map(({r, i, a, depth}) => {
|
|
51
|
+
const near = (depth + 1) / 2; // 0 far … 1 near
|
|
52
|
+
const s = 0.6 + 0.4 * near;
|
|
53
|
+
const fade = Math.min(progress(f, from + 2 + i * 2, 12, EASE.out), 1 - progress(f, to - 10, 10, EASE.in));
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
key={i}
|
|
57
|
+
style={{
|
|
58
|
+
position: 'absolute',
|
|
59
|
+
left: cx + Math.cos(a) * rx,
|
|
60
|
+
top: cy + depth * ry,
|
|
61
|
+
width: tileW,
|
|
62
|
+
height: tileH,
|
|
63
|
+
transform: `translate(-50%,-50%) scale(${s}) rotateY(${Math.cos(a) * -32}deg)`,
|
|
64
|
+
opacity: fade,
|
|
65
|
+
filter: `brightness(${0.55 + 0.45 * near}) blur(${(1 - near) * 2.5}px)`,
|
|
66
|
+
borderRadius: 26,
|
|
67
|
+
overflow: 'hidden',
|
|
68
|
+
boxShadow: '0 24px 50px rgba(0,0,0,0.45)',
|
|
69
|
+
border: '3px solid rgba(255,255,255,0.9)',
|
|
70
|
+
background: '#111',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Loop durationInFrames={240}>
|
|
74
|
+
<OffthreadVideo src={url(r)} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
75
|
+
</Loop>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</AbsoluteFill>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, useCurrentFrame} from 'remotion';
|
|
3
|
+
import {EASE} from '../design/motion';
|
|
4
|
+
import {progress} from '../lib/animate';
|
|
5
|
+
import {VelocityBlur} from './VelocityBlur';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whip scene — a full-screen graphics scene cut into a talking head with the join hidden inside the move:
|
|
9
|
+
* the whole frame whips in from the right and out to the left, motion-blurred, behind a thin bright light
|
|
10
|
+
* streak (references.md 9 & 13: "joins hide inside motion or light"). A wide coloured smear was tried and read
|
|
11
|
+
* as a green curtain; keep the streak thin.
|
|
12
|
+
*
|
|
13
|
+
* <WhipScene from={232} to={330}>{(local) => <MyScene local={local} />}</WhipScene>
|
|
14
|
+
*
|
|
15
|
+
* StudioSpace is the backdrop every scene of one video should share — ink, a slow accent glow, a dot floor in
|
|
16
|
+
* perspective, a breath of grain — so the graphics read as one film (references.md pattern 15).
|
|
17
|
+
* Sound: a short swoosh ~3 frames before `from` and ~6 before `to`.
|
|
18
|
+
*
|
|
19
|
+
* mode (switchable in the browser editor): 'whip' (default), 'zoom' (scales in from 115% and fades, out the
|
|
20
|
+
* reverse), 'cut' (hard cut — pair it with a FilmBurn on the join for the film-burn look).
|
|
21
|
+
*/
|
|
22
|
+
export type SceneMode = 'whip' | 'zoom' | 'cut';
|
|
23
|
+
export const WHIP_FRAMES = 9;
|
|
24
|
+
|
|
25
|
+
export const StudioSpace: React.FC<{local: number; accent?: string; ink?: string; children: React.ReactNode}> = ({local, accent = 'var(--accent, your accent)', ink = 'var(--ink, #0f0f0d)', children}) => (
|
|
26
|
+
<AbsoluteFill style={{background: ink, overflow: 'hidden'}}>
|
|
27
|
+
<AbsoluteFill style={{background: `radial-gradient(60% 40% at ${50 + Math.sin(local / 40) * 6}% 38%, color-mix(in srgb, ${accent} 16%, transparent), transparent 70%)`}} />
|
|
28
|
+
<AbsoluteFill style={{perspective: 900}}>
|
|
29
|
+
<div
|
|
30
|
+
style={{
|
|
31
|
+
position: 'absolute',
|
|
32
|
+
left: -400,
|
|
33
|
+
right: -400,
|
|
34
|
+
top: 1180,
|
|
35
|
+
height: 1400,
|
|
36
|
+
transform: 'rotateX(72deg)',
|
|
37
|
+
transformOrigin: '50% 0%',
|
|
38
|
+
backgroundImage: 'radial-gradient(rgba(255,255,255,0.16) 1.6px, transparent 1.8px)',
|
|
39
|
+
backgroundSize: '46px 46px',
|
|
40
|
+
backgroundPosition: `0 ${-local * 1.2}px`,
|
|
41
|
+
maskImage: 'linear-gradient(to bottom, #000 0%, transparent 70%)',
|
|
42
|
+
WebkitMaskImage: 'linear-gradient(to bottom, #000 0%, transparent 70%)',
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</AbsoluteFill>
|
|
46
|
+
{children}
|
|
47
|
+
<AbsoluteFill style={{opacity: 0.05, backgroundImage: 'repeating-radial-gradient(circle at 17% 31%, #fff 0 1px, transparent 1px 3px)', mixBlendMode: 'overlay'}} />
|
|
48
|
+
</AbsoluteFill>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const WhipScene: React.FC<{from: number; to: number; accent?: string; children: (local: number) => React.ReactNode; backdrop?: boolean; mode?: SceneMode}> = ({from, to, accent = 'var(--accent, your accent)', children, backdrop = true, mode = 'whip'}) => {
|
|
52
|
+
const f = useCurrentFrame();
|
|
53
|
+
if (f < from || f >= to) return null;
|
|
54
|
+
if (mode !== 'whip') {
|
|
55
|
+
const inP = progress(f, from, 7, EASE.outDecisive);
|
|
56
|
+
const outP = progress(f, to - 6, 6, EASE.in);
|
|
57
|
+
const zoom = mode === 'zoom';
|
|
58
|
+
const plain = children(f - from);
|
|
59
|
+
return (
|
|
60
|
+
<AbsoluteFill style={zoom ? {transform: `scale(${1 + 0.15 * (1 - inP) - 0.08 * outP})`, opacity: Math.min(inP, 1 - outP), filter: `blur(${(1 - inP) * 8 + outP * 8}px)`} : undefined}>
|
|
61
|
+
{backdrop ? <StudioSpace local={f - from} accent={accent}>{plain}</StudioSpace> : plain}
|
|
62
|
+
</AbsoluteFill>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const xAt = (g: number) => {
|
|
66
|
+
const i = 1 - progress(g, from, WHIP_FRAMES, EASE.outDecisive);
|
|
67
|
+
const o = progress(g, to - WHIP_FRAMES, WHIP_FRAMES, EASE.in);
|
|
68
|
+
return (i - o) * 1080;
|
|
69
|
+
};
|
|
70
|
+
const x = xAt(f);
|
|
71
|
+
const vx = x - xAt(f - 1);
|
|
72
|
+
const streak = Math.max(0, 1 - Math.abs(x) / 1080) * Math.min(1, Math.abs(vx) / 60);
|
|
73
|
+
const body = children(f - from);
|
|
74
|
+
return (
|
|
75
|
+
<AbsoluteFill>
|
|
76
|
+
<VelocityBlur vx={vx} bleed={0} style={{position: 'absolute', inset: 0, transform: `translateX(${x}px)`}}>
|
|
77
|
+
{backdrop ? (
|
|
78
|
+
<StudioSpace local={f - from} accent={accent}>
|
|
79
|
+
{body}
|
|
80
|
+
</StudioSpace>
|
|
81
|
+
) : (
|
|
82
|
+
body
|
|
83
|
+
)}
|
|
84
|
+
</VelocityBlur>
|
|
85
|
+
{streak > 0.02 ? (
|
|
86
|
+
<AbsoluteFill style={{background: `linear-gradient(90deg, transparent 40%, color-mix(in srgb, ${accent} ${35 * streak}%, transparent) 47%, rgba(255,255,255,${0.9 * streak}) 50%, color-mix(in srgb, ${accent} ${35 * streak}%, transparent) 53%, transparent 60%)`, transform: `translateX(${x}px)`, filter: 'blur(10px)', mixBlendMode: 'screen'}} />
|
|
87
|
+
) : null}
|
|
88
|
+
</AbsoluteFill>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
@@ -14,6 +14,7 @@ export const DUR = {
|
|
|
14
14
|
slow: 600, // hero elements, long travel, line draws
|
|
15
15
|
scene: 800, // camera moves, scene handoffs
|
|
16
16
|
drift: 2400, // slow push-ins on the footage
|
|
17
|
+
letterTurn: 1300, // Moving Letters #10: individual letter Y turn
|
|
17
18
|
} as const;
|
|
18
19
|
export type Dur = keyof typeof DUR;
|
|
19
20
|
|
|
@@ -45,11 +46,18 @@ export const SPRING = {
|
|
|
45
46
|
} as const;
|
|
46
47
|
export type SpringKind = keyof typeof SPRING;
|
|
47
48
|
|
|
49
|
+
// Tobias Ahlin Moving Letters #10 (MIT): staggered rotateY, elastic settle.
|
|
50
|
+
export const LETTER_TURN = {
|
|
51
|
+
angle: -90,
|
|
52
|
+
ease: (t:number) => t===0||t===1?t:Math.pow(2,-10*t)*Math.sin((t-.125)*2*Math.PI/.5)+1,
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
48
55
|
/** Delay between items in a group. */
|
|
49
56
|
export const STAGGER = {
|
|
50
57
|
tight: 67, // ~2 frames @30fps
|
|
51
58
|
normal: 100, // ~3 frames @30fps
|
|
52
59
|
dramatic: 167, // ~5 frames @30fps
|
|
60
|
+
letter: 45, // Moving Letters #10
|
|
53
61
|
} as const;
|
|
54
62
|
export type StaggerKind = keyof typeof STAGGER;
|
|
55
63
|
|