creator-editing-studio 1.3.7 → 1.4.0
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/bin/finish.mjs +132 -0
- package/bin/init.mjs +5 -1
- package/bin/update.mjs +18 -0
- package/history.json +1 -1
- package/package.json +2 -1
- package/template/CLAUDE.md +31 -7
- package/template/package.json +3 -1
- package/template/preview/lib.mjs +164 -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/lib/plan-gate.mjs +73 -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 +177 -0
- package/template/src/videos/whats-new/beats.ts +44 -0
- package/template/src/videos/whats-new/head-track.json +5195 -0
- package/template/src/videos/whats-new/preview.tsx +36 -0
- package/template/src/videos/whats-new/tweaks.json +1 -0
- package/template/src/videos/whats-new/words.json +1 -0
|
@@ -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
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand on the fly: the font and colours of an edit, switchable in the browser editor and applied everywhere
|
|
3
|
+
* at once.
|
|
4
|
+
*
|
|
5
|
+
* Edits and components colour themselves with CSS variables (with the house look as the fallback):
|
|
6
|
+
* var(--accent, your accent) the highlight colour var(--ink, #0f0f0d) the dark of cards and scenes
|
|
7
|
+
* var(--brand-font, …) every text (FONT.sans in src/design/tokens.ts already reads it)
|
|
8
|
+
* tints of the accent: color-mix(in srgb, var(--accent, your accent) 30%, transparent) — see tint()
|
|
9
|
+
* <LayoutProvider> sets the variables from layout.theme, so nothing in an edit has to know what was picked.
|
|
10
|
+
* A video with no theme looks exactly as built.
|
|
11
|
+
*/
|
|
12
|
+
import {loadFont as anton} from '@remotion/google-fonts/Anton';
|
|
13
|
+
import {loadFont as archivoBlack} from '@remotion/google-fonts/ArchivoBlack';
|
|
14
|
+
import {loadFont as bebasNeue} from '@remotion/google-fonts/BebasNeue';
|
|
15
|
+
import {loadFont as dmSerifDisplay} from '@remotion/google-fonts/DMSerifDisplay';
|
|
16
|
+
import {loadFont as instrumentSerif} from '@remotion/google-fonts/InstrumentSerif';
|
|
17
|
+
import {loadFont as inter} from '@remotion/google-fonts/Inter';
|
|
18
|
+
import {loadFont as manrope} from '@remotion/google-fonts/Manrope';
|
|
19
|
+
import {loadFont as montserrat} from '@remotion/google-fonts/Montserrat';
|
|
20
|
+
import {loadFont as oswald} from '@remotion/google-fonts/Oswald';
|
|
21
|
+
import {loadFont as outfit} from '@remotion/google-fonts/Outfit';
|
|
22
|
+
import {loadFont as playfairDisplay} from '@remotion/google-fonts/PlayfairDisplay';
|
|
23
|
+
import {loadFont as poppins} from '@remotion/google-fonts/Poppins';
|
|
24
|
+
import {loadFont as sora} from '@remotion/google-fonts/Sora';
|
|
25
|
+
import {loadFont as spaceGrotesk} from '@remotion/google-fonts/SpaceGrotesk';
|
|
26
|
+
import {loadFont as syne} from '@remotion/google-fonts/Syne';
|
|
27
|
+
|
|
28
|
+
export type BrandTheme = {font?: string; accent?: string; ink?: string};
|
|
29
|
+
|
|
30
|
+
export const HOUSE = {accent: 'your accent', ink: '#0f0f0d', font: 'your display typeface'} as const;
|
|
31
|
+
|
|
32
|
+
/** The fonts on offer: name → how to load it. 'your display typeface' is the house font (loaded by tokens.ts). */
|
|
33
|
+
type Loader = (style?: 'normal', opts?: {weights?: string[]; subsets?: string[]}) => {fontFamily: string};
|
|
34
|
+
const GOOGLE: Record<string, {load: Loader; weights: string[]; kind: string}> = {
|
|
35
|
+
Inter: {load: inter as Loader, weights: ['400', '600', '700', '800'], kind: 'Clean sans'},
|
|
36
|
+
Manrope: {load: manrope as Loader, weights: ['400', '600', '700', '800'], kind: 'Clean sans'},
|
|
37
|
+
Poppins: {load: poppins as Loader, weights: ['400', '600', '700', '800'], kind: 'Friendly sans'},
|
|
38
|
+
Montserrat: {load: montserrat as Loader, weights: ['400', '600', '700', '800'], kind: 'Bold sans'},
|
|
39
|
+
Outfit: {load: outfit as Loader, weights: ['400', '600', '700', '800'], kind: 'Modern sans'},
|
|
40
|
+
Sora: {load: sora as Loader, weights: ['400', '600', '700', '800'], kind: 'Tech sans'},
|
|
41
|
+
'Space Grotesk': {load: spaceGrotesk as Loader, weights: ['400', '600', '700'], kind: 'Tech sans'},
|
|
42
|
+
Syne: {load: syne as Loader, weights: ['400', '600', '700', '800'], kind: 'Editorial sans'},
|
|
43
|
+
'Archivo Black': {load: archivoBlack as Loader, weights: ['400'], kind: 'Heavy'},
|
|
44
|
+
Anton: {load: anton as Loader, weights: ['400'], kind: 'Tall & loud'},
|
|
45
|
+
'Bebas Neue': {load: bebasNeue as Loader, weights: ['400'], kind: 'Tall & loud'},
|
|
46
|
+
Oswald: {load: oswald as Loader, weights: ['400', '600', '700'], kind: 'Tall & loud'},
|
|
47
|
+
'Playfair Display': {load: playfairDisplay as Loader, weights: ['400', '600', '700', '800'], kind: 'Serif'},
|
|
48
|
+
'DM Serif Display': {load: dmSerifDisplay as Loader, weights: ['400'], kind: 'Serif'},
|
|
49
|
+
'Instrument Serif': {load: instrumentSerif as Loader, weights: ['400'], kind: 'Serif'},
|
|
50
|
+
};
|
|
51
|
+
export const BRAND_FONTS: {name: string; kind: string}[] = [{name: 'your display typeface', kind: 'House'}, ...Object.entries(GOOGLE).map(([name, g]) => ({name, kind: g.kind}))];
|
|
52
|
+
|
|
53
|
+
/** Colour sets that work on talking heads — one tap, or pick your own. */
|
|
54
|
+
export const PALETTES: {name: string; accent: string; ink: string}[] = [
|
|
55
|
+
{name: 'House accent', accent: 'your accent', ink: '#0f0f0d'},
|
|
56
|
+
{name: 'Signal orange', accent: '#ff7a1a', ink: '#120c08'},
|
|
57
|
+
{name: 'Electric blue', accent: '#4d8dff', ink: '#0a0e18'},
|
|
58
|
+
{name: 'Hot pink', accent: '#ff4fa3', ink: '#140a10'},
|
|
59
|
+
{name: 'Gold', accent: '#f2c14e', ink: '#12100a'},
|
|
60
|
+
{name: 'Mint', accent: '#5ff2c1', ink: '#08120f'},
|
|
61
|
+
{name: 'Red', accent: '#ff3b3b', ink: '#120808'},
|
|
62
|
+
{name: 'Clean white', accent: '#ffffff', ink: '#111111'},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const loaded = new Map<string, string>();
|
|
66
|
+
/** The CSS family for a font name — loading it the first time it's asked for. */
|
|
67
|
+
export function familyFor(name?: string): string | null {
|
|
68
|
+
if (!name || name === HOUSE.font) return null;
|
|
69
|
+
const g = GOOGLE[name];
|
|
70
|
+
if (!g) return null;
|
|
71
|
+
if (!loaded.has(name)) loaded.set(name, g.load('normal', {weights: g.weights, subsets: ['latin']}).fontFamily);
|
|
72
|
+
return loaded.get(name)!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The CSS variables for a theme — put them on the edit's outermost element. */
|
|
76
|
+
export function themeVars(t?: BrandTheme): React.CSSProperties {
|
|
77
|
+
if (!t) return {};
|
|
78
|
+
const vars: Record<string, string> = {};
|
|
79
|
+
if (t.accent) vars['--accent'] = t.accent;
|
|
80
|
+
if (t.ink) vars['--ink'] = t.ink;
|
|
81
|
+
const family = familyFor(t.font);
|
|
82
|
+
if (family) vars['--brand-font'] = `"${family}", -apple-system, Arial, sans-serif`;
|
|
83
|
+
return vars as React.CSSProperties;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The accent at some strength, as a CSS colour: tint(0.3) → 30% accent over transparent. */
|
|
87
|
+
export const tint = (amount: number, base = 'var(--accent, your accent)') => `color-mix(in srgb, ${base} ${Math.round(amount * 1000) / 10}%, transparent)`;
|
|
88
|
+
export const ACCENT = 'var(--accent, your accent)';
|
|
89
|
+
export const INK = 'var(--ink, #0f0f0d)';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser editor's layer: things on screen the person can grab, and effects they can switch.
|
|
3
|
+
*
|
|
4
|
+
* An edit names its movable pieces and its effect slots; the person drags, resizes, retimes and switches in
|
|
5
|
+
* the browser editor (editor/), which saves the result to the video's layout.json; the render reads the same
|
|
6
|
+
* file. No layout = the edit exactly as built.
|
|
7
|
+
*
|
|
8
|
+
* <LayoutProvider layout={layout}>
|
|
9
|
+
* <Movable id="hook-words" label="Hook words"><HookWords /></Movable>
|
|
10
|
+
* {useEffectChoice('opener', 'FlashBloom') === 'FlashBloom' ? <FlashBloom …/> : null}
|
|
11
|
+
* </LayoutProvider>
|
|
12
|
+
*
|
|
13
|
+
* The edit also exports EDITOR = {elements, slots} so the editor can list them (see a preview.tsx that exports it).
|
|
14
|
+
* Rule from <Tweaked>: never put a full-screen card that hides a join inside a Movable — move what's ON it.
|
|
15
|
+
*/
|
|
16
|
+
import React, {createContext, useContext} from 'react';
|
|
17
|
+
import {AbsoluteFill, Img, OffthreadVideo, Sequence, interpolate, staticFile, useCurrentFrame} from 'remotion';
|
|
18
|
+
import {themeVars, type BrandTheme} from './brand';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One piece's adjustments: x/y in px of the 1080×1920 frame, scale 0.3–3, shift in frames (+ later), hide,
|
|
22
|
+
* and start/end — composition frames outside which it isn't shown (trimmed on the editor's timeline).
|
|
23
|
+
*/
|
|
24
|
+
export type Placement = {x?: number; y?: number; scale?: number; shift?: number; hide?: boolean; start?: number; end?: number};
|
|
25
|
+
/** A clip or picture the person dropped on the editor's B-roll row. src is a staticFile path. */
|
|
26
|
+
export type Broll = {id: string; src: string; kind: 'video' | 'image'; from: number; to: number; offset?: number; mode?: 'full' | 'card'};
|
|
27
|
+
export type Layout = {elements?: Record<string, Placement>; effects?: Record<string, string>; broll?: Broll[]; theme?: BrandTheme};
|
|
28
|
+
|
|
29
|
+
/** What the editor lists: the pieces it can move and the effect slots it can switch. */
|
|
30
|
+
export type EditorSpec = {
|
|
31
|
+
elements: {id: string; label: string; from: number; to: number}[];
|
|
32
|
+
slots: {id: string; label: string; at: number; options: string[]; default: string}[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const Ctx = createContext<Layout>({});
|
|
36
|
+
|
|
37
|
+
/** Also applies the picked font and colours (layout.theme) to everything inside — see src/lib/brand.tsx. */
|
|
38
|
+
export const LayoutProvider: React.FC<{layout?: Layout; children: React.ReactNode}> = ({layout = {}, children}) => (
|
|
39
|
+
<Ctx.Provider value={layout}>{layout.theme ? <AbsoluteFill style={themeVars(layout.theme)}>{children}</AbsoluteFill> : children}</Ctx.Provider>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The effect chosen for a slot, or its default. */
|
|
43
|
+
export const useEffectChoice = (slot: string, fallback: string) => useContext(Ctx).effects?.[slot] ?? fallback;
|
|
44
|
+
|
|
45
|
+
/** The placement of one piece (for pieces that must apply it themselves, e.g. a split layer). */
|
|
46
|
+
export const usePlacement = (id: string): Placement => useContext(Ctx).elements?.[id] ?? {};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A piece the person can grab. Moves, scales (around the middle of the frame's upper half, where text sits)
|
|
50
|
+
* and retimes what's inside. data-movable lets the editor find it on screen.
|
|
51
|
+
*/
|
|
52
|
+
export const Movable: React.FC<{id: string; label?: string; origin?: string; children: React.ReactNode}> = ({id, origin = '50% 40%', children}) => {
|
|
53
|
+
const p = usePlacement(id);
|
|
54
|
+
const f = useCurrentFrame();
|
|
55
|
+
if (p.hide) return null;
|
|
56
|
+
if (p.start !== undefined && f < p.start) return null;
|
|
57
|
+
if (p.end !== undefined && f >= p.end) return null;
|
|
58
|
+
// a trimmed edge fades over 4 frames instead of popping
|
|
59
|
+
const fade = Math.min(p.start !== undefined ? interpolate(f, [p.start, p.start + 4], [0, 1], {extrapolateRight: 'clamp'}) : 1, p.end !== undefined ? interpolate(f, [p.end - 4, p.end], [1, 0], {extrapolateLeft: 'clamp'}) : 1);
|
|
60
|
+
const moved = p.x || p.y || (p.scale && p.scale !== 1);
|
|
61
|
+
const body = (
|
|
62
|
+
<AbsoluteFill data-movable={id} style={{pointerEvents: 'none', opacity: fade < 1 ? fade : undefined, transform: moved ? `translate(${p.x ?? 0}px, ${p.y ?? 0}px) scale(${p.scale ?? 1})` : undefined, transformOrigin: origin}}>
|
|
63
|
+
{children}
|
|
64
|
+
</AbsoluteFill>
|
|
65
|
+
);
|
|
66
|
+
return p.shift ? (
|
|
67
|
+
<Sequence from={p.shift} layout="none">
|
|
68
|
+
{body}
|
|
69
|
+
</Sequence>
|
|
70
|
+
) : (
|
|
71
|
+
body
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The B-roll the person dropped on the editor's timeline: full screen over the talking head, or a card
|
|
77
|
+
* (draggable/resizable like any piece, id "broll:<id>"). Muted — the voice carries on underneath.
|
|
78
|
+
* Put it above the footage and below the captions: <BrollLayer />
|
|
79
|
+
*/
|
|
80
|
+
export const BrollLayer: React.FC = () => {
|
|
81
|
+
const list = useContext(Ctx).broll ?? [];
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{list.map((b) => (
|
|
85
|
+
<Sequence key={b.id} from={b.from} durationInFrames={Math.max(1, b.to - b.from)} premountFor={45}>
|
|
86
|
+
<Movable id={`broll:${b.id}`} origin="50% 50%">
|
|
87
|
+
<BrollClip b={b} />
|
|
88
|
+
</Movable>
|
|
89
|
+
</Sequence>
|
|
90
|
+
))}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const BrollClip: React.FC<{b: Broll}> = ({b}) => {
|
|
96
|
+
const f = useCurrentFrame(); // local to the clip
|
|
97
|
+
const len = b.to - b.from;
|
|
98
|
+
const fade = Math.min(interpolate(f, [0, 5], [0, 1], {extrapolateRight: 'clamp'}), interpolate(f, [len - 5, len], [1, 0], {extrapolateLeft: 'clamp'}));
|
|
99
|
+
const media =
|
|
100
|
+
b.kind === 'video' ? (
|
|
101
|
+
<OffthreadVideo src={staticFile(b.src)} startFrom={b.offset ?? 0} muted style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
102
|
+
) : (
|
|
103
|
+
<Img src={staticFile(b.src)} style={{width: '100%', height: '100%', objectFit: 'cover'}} />
|
|
104
|
+
);
|
|
105
|
+
if (b.mode === 'card')
|
|
106
|
+
return (
|
|
107
|
+
<AbsoluteFill style={{alignItems: 'center', justifyContent: 'center', opacity: fade}}>
|
|
108
|
+
<div style={{width: 820, height: 1000, borderRadius: 40, overflow: 'hidden', boxShadow: '0 40px 90px rgba(0,0,0,0.55)', border: '3px solid rgba(255,255,255,0.85)', transform: `scale(${0.94 + 0.06 * fade})`}}>{media}</div>
|
|
109
|
+
</AbsoluteFill>
|
|
110
|
+
);
|
|
111
|
+
// full screen: a slight push so stills don't sit dead
|
|
112
|
+
return <AbsoluteFill style={{opacity: fade, transform: `scale(${1.04 + 0.04 * (f / Math.max(1, len))})`}}>{media}</AbsoluteFill>;
|
|
113
|
+
};
|