creator-editing-studio 1.0.20260930

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.
Files changed (69) hide show
  1. package/README.md +15 -0
  2. package/bin/init.mjs +72 -0
  3. package/package.json +18 -0
  4. package/template/CLAUDE.md +341 -0
  5. package/template/STYLE.md +165 -0
  6. package/template/package.json +45 -0
  7. package/template/remotion.config.ts +7 -0
  8. package/template/requirements.txt +17 -0
  9. package/template/scripts/align_script.py +106 -0
  10. package/template/scripts/assemble.mjs +364 -0
  11. package/template/scripts/beats.mjs +273 -0
  12. package/template/scripts/blur_regions.py +40 -0
  13. package/template/scripts/check_pair.py +90 -0
  14. package/template/scripts/doctor.mjs +141 -0
  15. package/template/scripts/find_cuts.py +45 -0
  16. package/template/scripts/grade.py +202 -0
  17. package/template/scripts/lib/media.mjs +228 -0
  18. package/template/scripts/lib/media.py +99 -0
  19. package/template/scripts/make_cutout.py +68 -0
  20. package/template/scripts/motion-check.mjs +268 -0
  21. package/template/scripts/motion-lint.mjs +122 -0
  22. package/template/scripts/new-video.mjs +49 -0
  23. package/template/scripts/prep.mjs +287 -0
  24. package/template/scripts/ramp.mjs +149 -0
  25. package/template/scripts/refs.mjs +72 -0
  26. package/template/scripts/refstyle.mjs +177 -0
  27. package/template/scripts/sounddesign.mjs +332 -0
  28. package/template/scripts/stills.mjs +64 -0
  29. package/template/scripts/sync-audio.mjs +134 -0
  30. package/template/scripts/transcribe.py +103 -0
  31. package/template/scripts/trim.mjs +374 -0
  32. package/template/src/Root.tsx +42 -0
  33. package/template/src/components/BrandBackground.tsx +22 -0
  34. package/template/src/components/Captions.tsx +141 -0
  35. package/template/src/components/Cursor.tsx +93 -0
  36. package/template/src/components/DrawnCircle.tsx +55 -0
  37. package/template/src/components/FilmOverlay.tsx +139 -0
  38. package/template/src/components/Hero.tsx +114 -0
  39. package/template/src/components/HeroTag.tsx +92 -0
  40. package/template/src/components/HighlightMark.tsx +34 -0
  41. package/template/src/components/LogoIcon.tsx +45 -0
  42. package/template/src/components/MacWindow.tsx +94 -0
  43. package/template/src/components/MaskReveal.tsx +34 -0
  44. package/template/src/components/MotionBlur.tsx +34 -0
  45. package/template/src/components/PlatformTile.tsx +153 -0
  46. package/template/src/components/Presence.tsx +27 -0
  47. package/template/src/components/RollingNumber.tsx +89 -0
  48. package/template/src/components/ScreenView.tsx +52 -0
  49. package/template/src/components/Stage.tsx +103 -0
  50. package/template/src/components/Transition.tsx +100 -0
  51. package/template/src/components/VelocityBlur.tsx +53 -0
  52. package/template/src/compositions/FilmTest.tsx +67 -0
  53. package/template/src/compositions/PresetTest.tsx +52 -0
  54. package/template/src/design/color.ts +6 -0
  55. package/template/src/design/glow.ts +11 -0
  56. package/template/src/design/logos.json +18 -0
  57. package/template/src/design/motion.ts +90 -0
  58. package/template/src/design/overlays.ts +121 -0
  59. package/template/src/design/presets.ts +65 -0
  60. package/template/src/design/tokens.ts +200 -0
  61. package/template/src/index.ts +4 -0
  62. package/template/src/lib/animate.ts +194 -0
  63. package/template/src/lib/camera.ts +58 -0
  64. package/template/src/lib/continuity.ts +86 -0
  65. package/template/src/lib/project.ts +13 -0
  66. package/template/src/lib/spine.ts +37 -0
  67. package/template/src/videos/sample/Sample.tsx +58 -0
  68. package/template/templates/BRIEF.md +88 -0
  69. package/template/tsconfig.json +17 -0
@@ -0,0 +1,200 @@
1
+ import {fontFamily, loadFont} from '@remotion/google-fonts/Inter';
2
+
3
+ /**
4
+ * YOUR LOOK. Every colour, size, radius and shadow in every video comes from this file.
5
+ * Nothing else in the studio is allowed to hand-type a colour or a font size.
6
+ *
7
+ * This is the one file that is yours rather than the system's. Change these values and
8
+ * every component, every caption and every video changes with them. Everything else —
9
+ * the components, the motion laws, the checks — stays the same for everybody.
10
+ *
11
+ * To set it up from a brand you like, ask: "build my design system from <url>".
12
+ *
13
+ * Sizes are for a 1080-wide frame watched on a phone. Web body 16px ≈ video body 42px,
14
+ * which is roughly a 2.6x scale if you are translating a website.
15
+ */
16
+
17
+ /**
18
+ * The starting font. It loads from Google Fonts, so there is no file to find and your
19
+ * first render works immediately.
20
+ *
21
+ * When you switch to your real brand font, put the files in `assets/fonts/` and swap
22
+ * these three lines for:
23
+ *
24
+ * import {loadFont} from '@remotion/fonts';
25
+ * import {staticFile} from 'remotion';
26
+ * const fontFamily = 'YourFont';
27
+ * loadFont({family: fontFamily, url: staticFile('fonts/YourFont.ttf'), weight: '100 900', style: 'normal'});
28
+ *
29
+ * A font that fails to load does not error — it silently falls back and nothing looks
30
+ * like your brand, so check a still after changing this.
31
+ */
32
+ // Only the weights and characters actually used. Left unbounded this fires 126 network
33
+ // requests on every render, which makes a customer's first video crawl for no reason.
34
+ loadFont('normal', {weights: ['400', '500', '600', '700', '800'], subsets: ['latin']});
35
+
36
+ export const FONT = {
37
+ sans: `${fontFamily}, -apple-system, "Helvetica Neue", Arial, sans-serif`,
38
+ } as const;
39
+
40
+ export const WEIGHT = {
41
+ regular: 400,
42
+ medium: 500,
43
+ semibold: 600,
44
+ bold: 700,
45
+ black: 800,
46
+ } as const;
47
+
48
+ /**
49
+ * Your raw brand colours. Name them however your brand names them.
50
+ *
51
+ * These are deliberately plain placeholders — a neutral grey and one restrained blue.
52
+ * They are meant to be replaced, not shipped. Nothing here belongs to anyone else's
53
+ * brand, so a video made before you set your own look is obviously unbranded rather
54
+ * than quietly wearing somebody else's colours.
55
+ */
56
+ export const PALETTE = {
57
+ ink900: '#14161a',
58
+ ink800: '#1d2026',
59
+ ink600: '#3c414b',
60
+ stone600: '#6b7280',
61
+ stone400: '#9ca3af',
62
+ stone200: '#e3e6ea',
63
+ cream: '#f4f5f7',
64
+ paper: '#fbfcfd',
65
+ white: '#ffffff',
66
+ accent: '#3b82f6',
67
+ accentStrong: '#2563eb',
68
+ } as const;
69
+
70
+ /**
71
+ * What each colour is FOR. Components read these, never PALETTE directly, so you can
72
+ * change your accent in one place. Keep one accent colour — two is already a design
73
+ * problem, three is a mess.
74
+ */
75
+ export const COLOR = {
76
+ accent: PALETTE.accent,
77
+ accentStrong: PALETTE.accentStrong,
78
+ onAccent: PALETTE.ink900,
79
+ fg: PALETTE.ink800,
80
+ /** Secondary text. Must stay above 4.5:1 on your background or it is decoration, not text. */
81
+ fgSecondary: PALETTE.stone600,
82
+ /** Decoration only — too light to read. Never put words people must read in this. */
83
+ fgMuted: PALETTE.stone400,
84
+ fgOnDark: PALETTE.white,
85
+ surface: PALETTE.white,
86
+ surfaceAlt: PALETTE.cream,
87
+ surfaceDark: PALETTE.ink900,
88
+ paper: PALETTE.paper,
89
+ borderSubtle: PALETTE.stone200,
90
+ borderStrong: PALETTE.ink800,
91
+ } as const;
92
+
93
+ /** Display is capped so a long word still fits a 1080 frame. */
94
+ export const TYPE = {
95
+ display: {size: 144, lineHeight: 1.05, tracking: '-0.03em', weight: WEIGHT.black},
96
+ h1: {size: 104, lineHeight: 1.08, tracking: '-0.025em', weight: WEIGHT.black},
97
+ h2: {size: 78, lineHeight: 1.1, tracking: '-0.02em', weight: WEIGHT.bold},
98
+ h3: {size: 57, lineHeight: 1.2, tracking: '-0.015em', weight: WEIGHT.semibold},
99
+ bodyLg: {size: 48, lineHeight: 1.4, tracking: '-0.005em', weight: WEIGHT.regular},
100
+ body: {size: 42, lineHeight: 1.45, tracking: '0em', weight: WEIGHT.regular},
101
+ caption: {size: 34, lineHeight: 1.3, tracking: '0em', weight: WEIGHT.medium},
102
+ eyebrow: {size: 30, lineHeight: 1.2, tracking: '0.06em', weight: WEIGHT.medium},
103
+ stat: {size: 120, lineHeight: 1, tracking: '-0.02em', weight: WEIGHT.black},
104
+ } as const;
105
+
106
+ /** 10px base. Use these, not arbitrary numbers. */
107
+ export const SPACE = {
108
+ 1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60, 8: 80, 10: 100, 12: 120, 16: 160, 20: 200,
109
+ } as const;
110
+
111
+ export const RADIUS = {
112
+ sm: 20,
113
+ md: 36,
114
+ lg: 52,
115
+ xl: 72,
116
+ pill: 999,
117
+ } as const;
118
+
119
+ /** Soft, large-radius, low-contrast. Never a hard drop shadow. */
120
+ export const SHADOW = {
121
+ card: '0 3px 6px rgba(23,23,20,0.04), 0 20px 60px -20px rgba(23,23,20,0.08)',
122
+ pop: '0 5px 10px rgba(23,23,20,0.06), 0 40px 100px -30px rgba(23,23,20,0.14)',
123
+ } as const;
124
+
125
+ /** The marker that wipes behind a key phrase. em units so it scales with the text. */
126
+ export const HIGHLIGHT = {
127
+ color: PALETTE.accent,
128
+ padX: '0.12em',
129
+ radius: '0.12em',
130
+ top: '0.1em',
131
+ bottom: '-0.02em',
132
+ } as const;
133
+
134
+ export const BACKGROUND = {
135
+ heroGradient: `linear-gradient(135deg, ${PALETTE.accent} 0%, ${PALETTE.cream} 45%, ${PALETTE.paper} 100%)`,
136
+ creamGlow: `radial-gradient(60% 40% at 85% 12%, ${PALETTE.accent}55 0%, ${PALETTE.cream}00 70%), ${PALETTE.cream}`,
137
+ gridLine: 'rgba(255,255,255,0.6)',
138
+ gridSize: 108,
139
+ gridLineWidth: 2,
140
+ gridMask: 'radial-gradient(circle at 70% 28%, rgba(0,0,0,0.9) 0%, transparent 62%)',
141
+ } as const;
142
+
143
+ /** Captions: readable on light walls and dark footage alike. */
144
+ export const CAPTION = {
145
+ size: 46,
146
+ weight: WEIGHT.bold,
147
+ lineHeight: 1.16,
148
+ tracking: '-0.01em',
149
+ color: PALETTE.white,
150
+ shadow: '0 2px 2px rgba(18,18,16,0.35), 0 4px 18px rgba(18,18,16,0.45)',
151
+ } as const;
152
+
153
+ /** Big headline text behind the subject. */
154
+ export const HERO = {
155
+ size: 250,
156
+ color: PALETTE.accent,
157
+ /** RGB of the glow, so it can fade with the text. */
158
+ /** Must match `accent` above, as r,g,b. Change both together. */
159
+ glowRgb: '59,130,246',
160
+ /** Dark lift under the glow, so the colour separates from a light wall. */
161
+ liftRgb: '18,18,16',
162
+ } as const;
163
+
164
+ /** Dark fade at the bottom of a video card, so white captions sit on it. */
165
+ export const SCRIM =
166
+ 'linear-gradient(to top, rgba(18,18,16,0.72) 0%, rgba(18,18,16,0.35) 22%, rgba(18,18,16,0) 42%)';
167
+
168
+ /** The browser window mockup. Light Mac-style chrome. */
169
+ export const MOCKUP = {
170
+ bar: '#E9E9EB',
171
+ barDivider: '#DCDCDF',
172
+ close: '#E5484D',
173
+ minimize: '#F2B21B',
174
+ maximize: '#2EB67D',
175
+ navButton: '#FFFFFF',
176
+ navIcon: '#77777C',
177
+ urlField: '#FFFFFF',
178
+ urlText: '#8A8A8F',
179
+ content: '#FFFFFF',
180
+ barHeight: 64,
181
+ radius: 28,
182
+ } as const;
183
+
184
+ /** 9:16 — Reels, Shorts, TikTok. safeTop/safeBottom keep text out of the platform UI. */
185
+ export const LAYOUT = {
186
+ width: 1080,
187
+ height: 1920,
188
+ fps: 30,
189
+ margin: 96,
190
+ safeTop: 250,
191
+ safeBottom: 420,
192
+ } as const;
193
+
194
+ /** 16:9 — YouTube and landscape deliverables. */
195
+ export const WIDE = {
196
+ width: 1920,
197
+ height: 1080,
198
+ fps: 30,
199
+ margin: 96,
200
+ } as const;
@@ -0,0 +1,4 @@
1
+ import {registerRoot} from 'remotion';
2
+ import {RemotionRoot} from './Root';
3
+
4
+ registerRoot(RemotionRoot);
@@ -0,0 +1,194 @@
1
+ import type {CSSProperties} from 'react';
2
+ import {interpolate, measureSpring, spring} from 'remotion';
3
+ import {
4
+ DUR,
5
+ EASE,
6
+ EXIT_RATIO,
7
+ LIMITS,
8
+ OPACITY_LEAD,
9
+ READ,
10
+ SPRING,
11
+ STAGGER,
12
+ type Dur,
13
+ type SpringKind,
14
+ type StaggerKind,
15
+ } from '../design/motion';
16
+
17
+ const clamp = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
18
+
19
+ export const msToFrames = (ms: number, fps: number): number =>
20
+ Math.max(1, Math.round((ms / 1000) * fps));
21
+
22
+ export const durFrames = (dur: Dur, fps: number): number => msToFrames(DUR[dur], fps);
23
+
24
+ /** Exit length for an element that entered with `dur` (law 3). */
25
+ export const exitFrames = (dur: Dur, fps: number): number =>
26
+ Math.max(1, Math.round(durFrames(dur, fps) * EXIT_RATIO));
27
+
28
+ export const staggerFrames = (index: number, fps: number, kind: StaggerKind = 'normal'): number =>
29
+ index * msToFrames(STAGGER[kind], fps);
30
+
31
+ /** How long a piece of text must stay fully visible (law 11). */
32
+ export const readFrames = (text: string, fps: number): number => {
33
+ const words = text.trim().split(/\s+/).filter(Boolean).length;
34
+ return msToFrames(Math.max(READ.minHoldMs, READ.baseMs + READ.perWordMs * words), fps);
35
+ };
36
+
37
+ /** Sub-linear distance-to-duration coupling (law 9). */
38
+ export const travelFrames = (px: number, fps: number): number => {
39
+ const ms = DUR.base * Math.sqrt(Math.abs(px) / LIMITS.referenceTravelPx);
40
+ return msToFrames(Math.min(DUR.scene, Math.max(DUR.fast, ms)), fps);
41
+ };
42
+
43
+ /** Spring driven by a token config; returns 0 to 1. */
44
+ export const settle = (frame: number, fps: number, at: number, kind: SpringKind = 'snap'): number =>
45
+ spring({frame: frame - at, fps, config: SPRING[kind]});
46
+
47
+ export const springFrames = (fps: number, kind: SpringKind = 'snap'): number =>
48
+ measureSpring({fps, config: SPRING[kind]});
49
+
50
+ /** 0 to 1 progress for draw-ons, counters, wipes — always eased. */
51
+ export const progress = (frame: number, at: number, frames: number, easing = EASE.out): number =>
52
+ interpolate(frame - at, [0, frames], [0, 1], {...clamp, easing});
53
+
54
+ /** Rate of change per frame of any frame-driven value (drives velocity motion blur). */
55
+ export const perFrame = (fn: (frame: number) => number, frame: number): number =>
56
+ fn(frame + 0.5) - fn(frame - 0.5);
57
+
58
+ /**
59
+ * Vertical offset for text rising from behind a mask, as a fraction of the line height.
60
+ * 1.3 (not 1.0) so descenders (g, p, y, commas) fully clear the mask.
61
+ * Prefer the <MaskReveal> component, which adds the mask and motion blur.
62
+ */
63
+ export const maskRevealOffset = ({
64
+ frame,
65
+ fps,
66
+ at,
67
+ until,
68
+ dur = 'slow',
69
+ }: {
70
+ frame: number;
71
+ fps: number;
72
+ at: number;
73
+ until?: number;
74
+ dur?: Dur;
75
+ }): number => {
76
+ const travel = 1.3;
77
+ let y = (1 - progress(frame, at, durFrames(dur, fps), EASE.out)) * travel;
78
+ if (until !== undefined) {
79
+ y -= progress(frame, until, exitFrames(dur, fps), EASE.in) * travel;
80
+ }
81
+ return y;
82
+ };
83
+
84
+ /**
85
+ * Left-to-right reveal mask with a motion-blurred leading edge: the edge softens in
86
+ * proportion to its speed (180-degree shutter), so fast wipes don't strobe.
87
+ * `p` is 0-1 progress; `pPerFrame` its change per frame (use perFrame()).
88
+ */
89
+ export const wipeMask = (p: number, pPerFrame: number): CSSProperties => {
90
+ const feather = Math.abs(pPerFrame) * 100 * 0.5;
91
+ const edge = p * (100 + feather);
92
+ const mask = `linear-gradient(90deg, black ${(edge - feather).toFixed(3)}%, transparent ${edge.toFixed(3)}%)`;
93
+ return {maskImage: mask, WebkitMaskImage: mask};
94
+ };
95
+
96
+ export type Place = 'below' | 'above' | 'left' | 'right' | 'none';
97
+
98
+ const placeOffset = (place: Place, px: number): [number, number] => {
99
+ switch (place) {
100
+ case 'below':
101
+ return [0, px];
102
+ case 'above':
103
+ return [0, -px];
104
+ case 'left':
105
+ return [-px, 0];
106
+ case 'right':
107
+ return [px, 0];
108
+ default:
109
+ return [0, 0];
110
+ }
111
+ };
112
+
113
+ export type PresenceOptions = {
114
+ frame: number;
115
+ fps: number;
116
+ /** Frame the entrance starts. */
117
+ at: number;
118
+ /** Frame the exit starts. Omit to stay on screen. */
119
+ until?: number;
120
+ dur?: Dur;
121
+ from?: Place;
122
+ exitTo?: Place;
123
+ /** Pixels of travel; clamped to LIMITS.maxTravelPx. */
124
+ distance?: number;
125
+ /** Clamped to >= LIMITS.minScaleFrom. */
126
+ scaleFrom?: number;
127
+ /** Pixels; clamped to LIMITS.maxBlurPx. */
128
+ blur?: number;
129
+ };
130
+
131
+ export type PresenceValues = {x: number; y: number; scale: number; opacity: number; blur: number};
132
+
133
+ /**
134
+ * Standard entrance + exit values for any element, following the motion laws:
135
+ * ease-out in, ease-in out, exits faster than entrances, opacity leads the
136
+ * transform, never opacity alone, bounded scale and blur.
137
+ * Prefer the <Presence> component, which adds velocity motion blur.
138
+ */
139
+ export const presenceValues = ({
140
+ frame,
141
+ fps,
142
+ at,
143
+ until,
144
+ dur = 'base',
145
+ from = 'below',
146
+ exitTo = 'above',
147
+ distance = LIMITS.defaultTravelPx,
148
+ scaleFrom = 1,
149
+ blur = 0,
150
+ }: PresenceOptions): PresenceValues => {
151
+ const travel = Math.min(Math.abs(distance), LIMITS.maxTravelPx);
152
+ const s0 = Math.min(1, Math.max(LIMITS.minScaleFrom, scaleFrom));
153
+ const b0 = Math.min(LIMITS.maxBlurPx, Math.max(0, blur));
154
+
155
+ const inDur = durFrames(dur, fps);
156
+ const t = frame - at;
157
+ const p = interpolate(t, [0, inDur], [0, 1], {...clamp, easing: EASE.out});
158
+ let opacity = interpolate(t, [0, Math.max(1, Math.round(inDur * OPACITY_LEAD))], [0, 1], {
159
+ ...clamp,
160
+ easing: EASE.outSoft,
161
+ });
162
+ const blurPx = interpolate(t, [0, Math.max(1, Math.round(inDur * 0.8))], [b0, 0], {
163
+ ...clamp,
164
+ easing: EASE.out,
165
+ });
166
+
167
+ const [fx, fy] = placeOffset(from, travel);
168
+ let x = fx * (1 - p);
169
+ let y = fy * (1 - p);
170
+ const scale = s0 + (1 - s0) * p;
171
+
172
+ if (until !== undefined) {
173
+ const outDur = exitFrames(dur, fps);
174
+ const u = frame - until;
175
+ const q = interpolate(u, [0, outDur], [0, 1], {...clamp, easing: EASE.in});
176
+ const fade = interpolate(u, [Math.round(outDur * 0.4), outDur], [1, 0], {...clamp, easing: EASE.in});
177
+ const [ex, ey] = placeOffset(exitTo, travel);
178
+ x += ex * q;
179
+ y += ey * q;
180
+ opacity *= fade;
181
+ }
182
+
183
+ return {x, y, scale, opacity, blur: blurPx};
184
+ };
185
+
186
+ /** Style for presence values (no motion blur). */
187
+ export const presenceStyle = (v: PresenceValues): CSSProperties => ({
188
+ opacity: v.opacity,
189
+ transform: `translate3d(${v.x.toFixed(3)}px, ${v.y.toFixed(3)}px, 0) scale(${v.scale.toFixed(4)})`,
190
+ filter: v.blur > 0.01 ? `blur(${v.blur.toFixed(2)}px)` : undefined,
191
+ });
192
+
193
+ /** Presence as a plain style, for elements that barely move. Prefer <Presence>. */
194
+ export const presence = (options: PresenceOptions): CSSProperties => presenceStyle(presenceValues(options));
@@ -0,0 +1,58 @@
1
+ import type {CSSProperties} from 'react';
2
+ import {interpolate} from 'remotion';
3
+ import {EASE, type Dur} from '../design/motion';
4
+ import {durFrames} from './animate';
5
+
6
+ /**
7
+ * Virtual camera over one large canvas. Scenes are regions of the canvas and the
8
+ * camera glides between them, so the viewer's eye is carried instead of cut.
9
+ * A shot is the canvas point at the centre of the frame, plus zoom.
10
+ */
11
+
12
+ export type Shot = {x: number; y: number; zoom?: number};
13
+ export type CameraMove = {at: number; to: Shot; dur?: Dur};
14
+
15
+ const clamp = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
16
+
17
+ const moveFrames = (move: CameraMove, fps: number) => durFrames(move.dur ?? 'scene', fps);
18
+
19
+ const sorted = (moves: CameraMove[], fps: number): CameraMove[] => {
20
+ const list = [...moves].sort((a, b) => a.at - b.at);
21
+ list.forEach((move, i) => {
22
+ const prev = list[i - 1];
23
+ if (prev && move.at < prev.at + moveFrames(prev, fps)) {
24
+ throw new Error(`Camera move at frame ${move.at} starts before the previous move finishes.`);
25
+ }
26
+ });
27
+ return list;
28
+ };
29
+
30
+ export const cameraShot = (frame: number, fps: number, start: Shot, moves: CameraMove[]): Required<Shot> => {
31
+ let shot = {x: start.x, y: start.y, zoom: start.zoom ?? 1};
32
+ for (const move of sorted(moves, fps)) {
33
+ if (frame <= move.at) break;
34
+ const p = interpolate(frame - move.at, [0, moveFrames(move, fps)], [0, 1], {...clamp, easing: EASE.inOut});
35
+ const to = {x: move.to.x, y: move.to.y, zoom: move.to.zoom ?? 1};
36
+ shot = {
37
+ x: shot.x + (to.x - shot.x) * p,
38
+ y: shot.y + (to.y - shot.y) * p,
39
+ // Geometric zoom interpolation so zoom speed feels even.
40
+ zoom: Math.exp(Math.log(shot.zoom) + (Math.log(to.zoom) - Math.log(shot.zoom)) * p),
41
+ };
42
+ if (p < 1) break;
43
+ }
44
+ return shot;
45
+ };
46
+
47
+ /** Apply to the canvas element (positioned at 0,0). */
48
+ export const cameraTransform = (shot: Required<Shot>, width: number, height: number): CSSProperties => ({
49
+ position: 'absolute',
50
+ left: 0,
51
+ top: 0,
52
+ transformOrigin: '0 0',
53
+ transform: `translate3d(${width / 2}px, ${height / 2}px, 0) scale(${shot.zoom.toFixed(5)}) translate3d(${(-shot.x).toFixed(3)}px, ${(-shot.y).toFixed(3)}px, 0)`,
54
+ });
55
+
56
+ /** True while any move is in progress (plus a frame of padding) — use to enable motion blur. */
57
+ export const cameraMoving = (frame: number, fps: number, moves: CameraMove[], pad = 1): boolean =>
58
+ moves.some((move) => frame >= move.at - pad && frame <= move.at + moveFrames(move, fps) + pad);
@@ -0,0 +1,86 @@
1
+ import {interpolate} from 'remotion';
2
+ import {EASE, TRANSITION, type Dur} from '../design/motion';
3
+ import {durFrames, msToFrames, type Place} from './animate';
4
+
5
+ /**
6
+ * Continuity helpers — what keeps beats feeling connected instead of cut together.
7
+ */
8
+
9
+ const clamp = {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'} as const;
10
+
11
+ /** How many frames consecutive beats overlap. */
12
+ export const overlapFrames = (fps: number): number => msToFrames(TRANSITION.idealOverlapMs, fps);
13
+
14
+ /** Start of the next beat: before the previous one has settled, never after. */
15
+ export const nextBeatAt = (prevSettledAt: number, fps: number): number => prevSettledAt - overlapFrames(fps);
16
+
17
+ /**
18
+ * Velocity handoff: where the next entrance should come from so it continues
19
+ * the direction of the previous exit. Exit upward -> next enters from below.
20
+ */
21
+ export const handoff = (exitTo: Place): Place =>
22
+ ({above: 'below', below: 'above', left: 'right', right: 'left', none: 'none'} as const)[exitTo];
23
+
24
+ export type Keyframe<K extends string> = {at: number; to: Record<K, number>; dur?: Dur};
25
+
26
+ const checkOrder = <K extends string>(keys: Keyframe<K>[], fps: number): Keyframe<K>[] => {
27
+ const list = [...keys].sort((a, b) => a.at - b.at);
28
+ list.forEach((k, i) => {
29
+ const prev = list[i - 1];
30
+ if (prev && k.at < prev.at + durFrames(prev.dur ?? 'scene', fps)) {
31
+ throw new Error(`Keyframe at frame ${k.at} starts before the previous move finishes.`);
32
+ }
33
+ });
34
+ return list;
35
+ };
36
+
37
+ /**
38
+ * A value that moves through a series of states over time (e.g. a video that goes
39
+ * full screen -> card -> full screen). Each move eases in-out from wherever the
40
+ * previous move ended, so the element is never reset or re-entered.
41
+ */
42
+ export const sequence = <K extends string>(
43
+ frame: number,
44
+ fps: number,
45
+ start: Record<K, number>,
46
+ keys: Keyframe<K>[],
47
+ ): Record<K, number> => {
48
+ let current = start;
49
+ for (const k of checkOrder(keys, fps)) {
50
+ if (frame <= k.at) break;
51
+ current = morph(frame, fps, k.at, current, k.to, k.dur);
52
+ if (frame < k.at + durFrames(k.dur ?? 'scene', fps)) break;
53
+ }
54
+ return current;
55
+ };
56
+
57
+ /** Where a sequence is heading: its destination if a move is in progress, else its current state. */
58
+ export const sequenceTarget = <K extends string>(
59
+ frame: number,
60
+ fps: number,
61
+ start: Record<K, number>,
62
+ keys: Keyframe<K>[],
63
+ ): Record<K, number> => {
64
+ const moving = checkOrder(keys, fps).find((k) => frame >= k.at && frame < k.at + durFrames(k.dur ?? 'scene', fps));
65
+ return moving ? moving.to : sequence(frame, fps, start, keys);
66
+ };
67
+
68
+ /**
69
+ * Shared-element morph: interpolate any numeric properties (x, y, width, radius...)
70
+ * between two layouts. The element never exits and re-enters.
71
+ */
72
+ export const morph = <K extends string>(
73
+ frame: number,
74
+ fps: number,
75
+ at: number,
76
+ from: Record<K, number>,
77
+ to: Record<K, number>,
78
+ dur: Dur = 'scene',
79
+ ): Record<K, number> => {
80
+ const p = interpolate(frame - at, [0, durFrames(dur, fps)], [0, 1], {...clamp, easing: EASE.inOut});
81
+ const out = {} as Record<K, number>;
82
+ for (const key of Object.keys(from) as K[]) {
83
+ out[key] = from[key] + (to[key] - from[key]) * p;
84
+ }
85
+ return out;
86
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Every video keeps its own files in assets/projects/<name>/ (footage, screenshots, processed,
3
+ * cutouts, audio). Use the helper so a video can never pick up another video's assets:
4
+ *
5
+ * const asset = projectAssets('my-first-video');
6
+ * staticFile(asset('footage/talk-original.mp4'))
7
+ *
8
+ * Shared brand files (fonts, logos) stay at the top of assets/ and are referenced directly.
9
+ */
10
+ export const projectAssets =
11
+ (project: string) =>
12
+ (path: string): string =>
13
+ `projects/${project}/${path}`;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Script-timed words: every animation start comes from when a word is spoken.
3
+ * Words come from scripts/align_script.py (script text + WhisperX timing).
4
+ */
5
+
6
+ export type SpokenWord = {text: string; start: number; end: number; paragraph: number; matched: boolean};
7
+
8
+ const key = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
9
+
10
+ export const createSpine = (words: SpokenWord[], fps: number) => {
11
+ const find = (phrase: string, occurrence = 1): {first: number; last: number} => {
12
+ const target = phrase.split(/\s+/).map(key).filter(Boolean);
13
+ let seen = 0;
14
+ for (let i = 0; i + target.length <= words.length; i++) {
15
+ if (target.every((k, j) => key(words[i + j].text) === k) && ++seen === occurrence) {
16
+ return {first: i, last: i + target.length - 1};
17
+ }
18
+ }
19
+ throw new Error(`Phrase not found in script: "${phrase}" (occurrence ${occurrence})`);
20
+ };
21
+
22
+ return {
23
+ words,
24
+ find,
25
+ /** Frame the phrase starts being spoken. */
26
+ at: (phrase: string, occurrence = 1) => Math.round(words[find(phrase, occurrence).first].start * fps),
27
+ /** Frame the phrase finishes. */
28
+ until: (phrase: string, occurrence = 1) => Math.round(words[find(phrase, occurrence).last].end * fps),
29
+ /** Word indexes covered by a phrase, e.g. to hide them from captions. */
30
+ indexes: (phrase: string, occurrence = 1): number[] => {
31
+ const {first, last} = find(phrase, occurrence);
32
+ return Array.from({length: last - first + 1}, (_, i) => first + i);
33
+ },
34
+ };
35
+ };
36
+
37
+ export type Spine = ReturnType<typeof createSpine>;
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, useVideoConfig} from 'remotion';
3
+ import {HighlightMark} from '../../components/HighlightMark';
4
+ import {RollingNumber} from '../../components/RollingNumber';
5
+ import {durFrames} from '../../lib/animate';
6
+ import {COLOR, FONT, LAYOUT, TYPE} from '../../design/tokens';
7
+
8
+ /**
9
+ * Your first video. It exists to prove the studio works end to end and to show the
10
+ * shape of every video you will make: timing comes from token helpers, never from
11
+ * hand-typed frame numbers, and everything visual comes from design/tokens.ts.
12
+ *
13
+ * Render it with: npm run render Sample out/sample.mp4
14
+ * Then delete it and make your own: npm run new-video -- my-first-reel
15
+ */
16
+
17
+ export const Sample: React.FC = () => {
18
+ const {fps} = useVideoConfig();
19
+
20
+ // Timing comes from the motion tokens, so it stays right at 30fps or 60fps.
21
+ const headlineAt = durFrames('base', fps);
22
+ const numberAt = headlineAt + durFrames('scene', fps);
23
+
24
+ return (
25
+ <AbsoluteFill style={{backgroundColor: COLOR.surfaceAlt, justifyContent: 'center', padding: LAYOUT.margin}}>
26
+ <span
27
+ style={{
28
+ fontFamily: FONT.sans,
29
+ fontSize: TYPE.h1.size,
30
+ fontWeight: TYPE.h1.weight,
31
+ lineHeight: TYPE.h1.lineHeight,
32
+ letterSpacing: TYPE.h1.tracking,
33
+ color: COLOR.fg,
34
+ }}
35
+ >
36
+ Your studio <HighlightMark at={headlineAt}>works</HighlightMark>
37
+ </span>
38
+
39
+ <div style={{marginTop: 60, display: 'flex', alignItems: 'baseline', gap: 20}}>
40
+ {/* No `from` — digits roll up from zero on their own. from="0" against value="100"
41
+ throws, because RollingNumber requires both to have the same shape. */}
42
+ <RollingNumber value="100" at={numberAt} sizePx={TYPE.stat.size} />
43
+ <span
44
+ style={{
45
+ fontFamily: FONT.sans,
46
+ fontSize: TYPE.bodyLg.size,
47
+ fontWeight: TYPE.bodyLg.weight,
48
+ color: COLOR.fgSecondary,
49
+ }}
50
+ >
51
+ percent yours
52
+ </span>
53
+ </div>
54
+ </AbsoluteFill>
55
+ );
56
+ };
57
+
58
+ export const SAMPLE_FRAMES = 150;