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,45 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import logos from '../design/logos.json';
5
+ import {durFrames, progress, staggerFrames} from '../lib/animate';
6
+
7
+ type IconSet = {width: number; height: number; icons: Record<string, {body: string; width?: number; height?: number}>};
8
+ const SET = logos as IconSet;
9
+
10
+ /**
11
+ * A full-colour brand logo (SVG Logos via Iconify, CC0) that assembles itself: each shape of the
12
+ * logo pops in slightly after the previous one, so the icon feels animated rather than pasted in.
13
+ */
14
+ export const LogoIcon: React.FC<{name: keyof typeof logos.icons; size: number; at: number}> = ({name, size, at}) => {
15
+ const frame = useCurrentFrame();
16
+ const {fps} = useVideoConfig();
17
+ const icon = SET.icons[name];
18
+ const width = icon.width ?? SET.width;
19
+ const height = icon.height ?? SET.height;
20
+
21
+ const defs = icon.body.match(/<defs>[\s\S]*?<\/defs>/)?.[0] ?? '';
22
+ const rest = icon.body.replace(defs, '');
23
+ const parts = rest.match(/<(path|g|circle|rect|polygon|ellipse)\b[\s\S]*?(?:\/>|<\/\1>)/g) ?? [rest];
24
+
25
+ return (
26
+ <svg width={size} height={size} viewBox={`0 0 ${width} ${height}`} style={{overflow: 'visible'}}>
27
+ {defs ? <g dangerouslySetInnerHTML={{__html: defs}} /> : null}
28
+ {parts.map((part, i) => {
29
+ const p = progress(frame, at + staggerFrames(i, fps, 'tight'), durFrames('base', fps), EASE.out);
30
+ return (
31
+ <g
32
+ key={i}
33
+ style={{
34
+ opacity: Math.min(1, p * 1.6),
35
+ transformBox: 'fill-box',
36
+ transformOrigin: 'center',
37
+ transform: `scale(${(0.9 + 0.1 * p).toFixed(4)})`,
38
+ }}
39
+ dangerouslySetInnerHTML={{__html: part}}
40
+ />
41
+ );
42
+ })}
43
+ </svg>
44
+ );
45
+ };
@@ -0,0 +1,94 @@
1
+ import React from 'react';
2
+ import {COLOR, FONT, MOCKUP, RADIUS, SHADOW} from '../design/tokens';
3
+
4
+ const Light: React.FC<{color: string}> = ({color}) => (
5
+ <span style={{width: 16, height: 16, borderRadius: RADIUS.pill, backgroundColor: color}} />
6
+ );
7
+
8
+ const NavButton: React.FC<{flip?: boolean}> = ({flip}) => (
9
+ <span
10
+ style={{
11
+ width: 36,
12
+ height: 36,
13
+ borderRadius: RADIUS.pill,
14
+ backgroundColor: MOCKUP.navButton,
15
+ display: 'flex',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ transform: flip ? 'scaleX(-1)' : undefined,
19
+ }}
20
+ >
21
+ <svg width={18} height={18} viewBox="0 0 18 18">
22
+ <path d="M14 9 H4 M8.5 4.5 L4 9 L8.5 13.5" fill="none" stroke={MOCKUP.navIcon} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
23
+ </svg>
24
+ </span>
25
+ );
26
+
27
+ /**
28
+ * Light Mac-style browser window (matches the user's reference): traffic lights, back/forward,
29
+ * URL field with search icon, overflow menu. Fills its parent; content goes in `children`.
30
+ */
31
+ export const MacWindow: React.FC<{url: string; children?: React.ReactNode}> = ({url, children}) => (
32
+ <div
33
+ style={{
34
+ position: 'absolute',
35
+ inset: 0,
36
+ borderRadius: MOCKUP.radius,
37
+ overflow: 'hidden',
38
+ backgroundColor: MOCKUP.content,
39
+ boxShadow: SHADOW.pop,
40
+ display: 'flex',
41
+ flexDirection: 'column',
42
+ }}
43
+ >
44
+ <div
45
+ style={{
46
+ height: MOCKUP.barHeight,
47
+ flexShrink: 0,
48
+ backgroundColor: MOCKUP.bar,
49
+ borderBottom: `1px solid ${MOCKUP.barDivider}`,
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ padding: '0 26px',
53
+ gap: 10,
54
+ }}
55
+ >
56
+ <Light color={MOCKUP.close} />
57
+ <Light color={MOCKUP.minimize} />
58
+ <Light color={MOCKUP.maximize} />
59
+ <span style={{width: 18}} />
60
+ <NavButton />
61
+ <NavButton flip />
62
+ <div
63
+ style={{
64
+ flex: 1,
65
+ height: 38,
66
+ marginLeft: 12,
67
+ marginRight: 12,
68
+ borderRadius: RADIUS.pill,
69
+ backgroundColor: MOCKUP.urlField,
70
+ display: 'flex',
71
+ alignItems: 'center',
72
+ padding: '0 18px',
73
+ fontFamily: FONT.sans,
74
+ fontSize: 18,
75
+ color: MOCKUP.urlText,
76
+ whiteSpace: 'nowrap',
77
+ overflow: 'hidden',
78
+ }}
79
+ >
80
+ <span style={{flex: 1}}>{url}</span>
81
+ <svg width={18} height={18} viewBox="0 0 18 18">
82
+ <circle cx={7.5} cy={7.5} r={5} fill="none" stroke={MOCKUP.navIcon} strokeWidth={2} />
83
+ <path d="M11.5 11.5 L16 16" stroke={MOCKUP.navIcon} strokeWidth={2} strokeLinecap="round" />
84
+ </svg>
85
+ </div>
86
+ <span style={{display: 'flex', flexDirection: 'column', gap: 4, padding: '0 4px'}}>
87
+ {[0, 1, 2].map((i) => (
88
+ <span key={i} style={{width: 4, height: 4, borderRadius: RADIUS.pill, backgroundColor: COLOR.surface}} />
89
+ ))}
90
+ </span>
91
+ </div>
92
+ <div style={{position: 'relative', flex: 1, overflow: 'hidden'}}>{children}</div>
93
+ </div>
94
+ );
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import type {Dur} from '../design/motion';
4
+ import {maskRevealOffset, perFrame} from '../lib/animate';
5
+ import {VelocityBlur} from './VelocityBlur';
6
+
7
+ /**
8
+ * Text that rises out of a mask (and exits upward through it), with velocity
9
+ * motion blur. `lineHeightPx` = fontSize x lineHeight of the text inside.
10
+ */
11
+ export const MaskReveal: React.FC<{
12
+ at: number;
13
+ until?: number;
14
+ dur?: Dur;
15
+ lineHeightPx: number;
16
+ children: React.ReactNode;
17
+ }> = ({at, until, dur, lineHeightPx, children}) => {
18
+ const frame = useCurrentFrame();
19
+ const {fps} = useVideoConfig();
20
+ const offsetAt = (f: number) => maskRevealOffset({frame: f, fps, at, until, dur}) * lineHeightPx;
21
+
22
+ return (
23
+ // Padding gives descenders and the highlight room inside the mask.
24
+ <span style={{display: 'inline-block', overflow: 'hidden', padding: '0.04em 0 0.16em'}}>
25
+ <VelocityBlur
26
+ as="span"
27
+ vy={perFrame(offsetAt, frame)}
28
+ style={{display: 'inline-block', transform: `translate3d(0, ${offsetAt(frame).toFixed(3)}px, 0)`}}
29
+ >
30
+ {children}
31
+ </VelocityBlur>
32
+ </span>
33
+ );
34
+ };
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, Freeze, useCurrentFrame} from 'remotion';
3
+ import {BLUR} from '../design/motion';
4
+
5
+ /**
6
+ * Film-style motion blur, sampled symmetrically around the current frame.
7
+ * Unlike @remotion/motion-blur's CameraMotionBlur (which samples ahead of the frame),
8
+ * blurred and unblurred frames share the same timing, so switching blur on only
9
+ * while things move never causes a hitch. Costs `samples`x render time while active.
10
+ * Keep static backgrounds outside this component.
11
+ */
12
+ export const MotionBlur: React.FC<{
13
+ children: React.ReactNode;
14
+ active: (frame: number) => boolean;
15
+ shutterAngle?: number;
16
+ samples?: number;
17
+ }> = ({children, active, shutterAngle = BLUR.shutterAngle, samples = BLUR.samples}) => {
18
+ const frame = useCurrentFrame();
19
+
20
+ if (!active(frame) || samples < 2 || shutterAngle <= 0) {
21
+ return <AbsoluteFill>{children}</AbsoluteFill>;
22
+ }
23
+
24
+ const shutter = shutterAngle / 360;
25
+ return (
26
+ <AbsoluteFill style={{isolation: 'isolate'}}>
27
+ {Array.from({length: samples}, (_, i) => (
28
+ <AbsoluteFill key={i} style={{mixBlendMode: 'plus-lighter', filter: `opacity(${1 / samples})`}}>
29
+ <Freeze frame={frame + (i / (samples - 1) - 0.5) * shutter}>{children}</Freeze>
30
+ </AbsoluteFill>
31
+ ))}
32
+ </AbsoluteFill>
33
+ );
34
+ };
@@ -0,0 +1,153 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {COLOR, FONT, RADIUS, SHADOW, TYPE, WEIGHT} from '../design/tokens';
4
+ import {durFrames, progress, staggerFrames} from '../lib/animate';
5
+ import {Presence} from './Presence';
6
+
7
+ export type TileBox = {x: number; y: number; w: number; h: number; radius: number};
8
+
9
+ /**
10
+ * A rounded tile whose border draws itself on, then its icon and label arrive.
11
+ * `box` can be animated by the caller (e.g. the tile growing into a window);
12
+ * `contentOpacity` fades the tile's own content out while `children` fade in.
13
+ */
14
+ export const PlatformTile: React.FC<{
15
+ box: TileBox;
16
+ enterAt: number;
17
+ icon: React.ReactNode;
18
+ label: string;
19
+ /** 0 = normal, 1 = fully dimmed (not the focus). */
20
+ dim?: number;
21
+ checkAt?: number;
22
+ exitAt?: number;
23
+ contentOpacity?: number;
24
+ /** Label under the tile (default) or beside it on the right (for vertical stacks). */
25
+ labelSide?: 'below' | 'right';
26
+ labelSize?: number;
27
+ children?: React.ReactNode;
28
+ }> = ({box, enterAt, icon, label, dim = 0, checkAt, exitAt, contentOpacity = 1, labelSide = 'below', labelSize, children}) => {
29
+ const frame = useCurrentFrame();
30
+ const {fps} = useVideoConfig();
31
+ const draw = progress(frame, enterAt, durFrames('slow', fps));
32
+
33
+ return (
34
+ <Presence
35
+ at={enterAt}
36
+ until={exitAt}
37
+ exitTo="below"
38
+ distance={24}
39
+ style={{position: 'absolute', left: box.x, top: box.y, width: box.w, height: box.h}}
40
+ >
41
+ <div style={{position: 'absolute', inset: 0, opacity: 1 - 0.65 * dim}}>
42
+ <div
43
+ style={{
44
+ position: 'absolute',
45
+ inset: 0,
46
+ borderRadius: box.radius,
47
+ backgroundColor: COLOR.surface,
48
+ boxShadow: SHADOW.card,
49
+ }}
50
+ />
51
+ <svg
52
+ width={box.w}
53
+ height={box.h}
54
+ style={{position: 'absolute', inset: 0, overflow: 'visible', opacity: contentOpacity}}
55
+ >
56
+ <rect
57
+ x={1.5}
58
+ y={1.5}
59
+ width={Math.max(0, box.w - 3)}
60
+ height={Math.max(0, box.h - 3)}
61
+ rx={box.radius}
62
+ pathLength={1}
63
+ fill="none"
64
+ stroke={COLOR.borderStrong}
65
+ strokeWidth={3}
66
+ strokeDasharray={1}
67
+ strokeDashoffset={1 - draw}
68
+ />
69
+ </svg>
70
+ <Presence
71
+ at={enterAt + staggerFrames(1, fps)}
72
+ blur={8}
73
+ style={{
74
+ position: 'absolute',
75
+ inset: 0,
76
+ display: 'flex',
77
+ alignItems: 'center',
78
+ justifyContent: 'center',
79
+ }}
80
+ >
81
+ <div style={{opacity: contentOpacity}}>{icon}</div>
82
+ </Presence>
83
+ <Presence
84
+ at={enterAt + staggerFrames(2, fps)}
85
+ style={
86
+ labelSide === 'right'
87
+ ? {
88
+ position: 'absolute',
89
+ left: box.w + 36,
90
+ top: 0,
91
+ height: box.h,
92
+ display: 'flex',
93
+ alignItems: 'center',
94
+ whiteSpace: 'nowrap',
95
+ fontFamily: FONT.sans,
96
+ fontSize: labelSize ?? TYPE.h3.size,
97
+ fontWeight: WEIGHT.bold,
98
+ color: COLOR.fg,
99
+ }
100
+ : {
101
+ position: 'absolute',
102
+ left: -40,
103
+ right: -40,
104
+ top: box.h + 22,
105
+ textAlign: 'center',
106
+ fontFamily: FONT.sans,
107
+ fontSize: labelSize ?? TYPE.caption.size,
108
+ fontWeight: TYPE.caption.weight,
109
+ color: COLOR.fgSecondary,
110
+ }
111
+ }
112
+ >
113
+ <div style={{opacity: contentOpacity}}>{label}</div>
114
+ </Presence>
115
+ {checkAt !== undefined ? (
116
+ <div style={{opacity: contentOpacity}}>
117
+ <Presence
118
+ at={checkAt}
119
+ scaleFrom={0.9}
120
+ distance={10}
121
+ style={{
122
+ position: 'absolute',
123
+ right: -14,
124
+ top: -14,
125
+ width: 46,
126
+ height: 46,
127
+ borderRadius: RADIUS.pill,
128
+ backgroundColor: COLOR.accent,
129
+ border: `3px solid ${COLOR.surface}`,
130
+ display: 'flex',
131
+ alignItems: 'center',
132
+ justifyContent: 'center',
133
+ opacity: contentOpacity,
134
+ }}
135
+ >
136
+ <svg width={22} height={22} viewBox="0 0 22 22">
137
+ <polyline
138
+ points="4,11.5 9,16 18,6"
139
+ fill="none"
140
+ stroke={COLOR.onAccent}
141
+ strokeWidth={3}
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ />
145
+ </svg>
146
+ </Presence>
147
+ </div>
148
+ ) : null}
149
+ {children}
150
+ </div>
151
+ </Presence>
152
+ );
153
+ };
@@ -0,0 +1,27 @@
1
+ import React, {type CSSProperties} from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {perFrame, presenceStyle, presenceValues, type PresenceOptions} from '../lib/animate';
4
+ import {VelocityBlur} from './VelocityBlur';
5
+
6
+ /**
7
+ * Standard entrance/exit (see presenceValues) with velocity motion blur.
8
+ * Renders the element itself — put its layout styles in `style`.
9
+ */
10
+ export const Presence: React.FC<
11
+ Omit<PresenceOptions, 'frame' | 'fps'> & {as?: 'div' | 'span'; style?: CSSProperties; children?: React.ReactNode}
12
+ > = ({as, style, children, ...options}) => {
13
+ const frame = useCurrentFrame();
14
+ const {fps} = useVideoConfig();
15
+ const valuesAt = (f: number) => presenceValues({...options, frame: f, fps});
16
+
17
+ return (
18
+ <VelocityBlur
19
+ as={as}
20
+ vx={perFrame((f) => valuesAt(f).x, frame)}
21
+ vy={perFrame((f) => valuesAt(f).y, frame)}
22
+ style={{...style, ...presenceStyle(valuesAt(frame))}}
23
+ >
24
+ {children}
25
+ </VelocityBlur>
26
+ );
27
+ };
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE, type Dur} from '../design/motion';
4
+ import {durFrames, perFrame, progress, staggerFrames} from '../lib/animate';
5
+ import {VelocityBlur} from './VelocityBlur';
6
+
7
+ /**
8
+ * Stat reveal where each digit rolls to its final value, rightmost first.
9
+ * With `from` (same format, e.g. "$26.50" -> "$48.94"), each digit rolls from its old value
10
+ * to its new one; otherwise digits roll up from 0.
11
+ * Motion is continuous every frame (a plain counter "ticks" as it slows down), digits are
12
+ * motion-blurred by speed, and every slot keeps its final glyph width so nothing shifts.
13
+ * Set font styles on the parent; `sizePx` must equal its font size.
14
+ * `at` is in the enclosing Sequence's frames.
15
+ */
16
+ export const RollingNumber: React.FC<{value: string; from?: string; at: number; sizePx: number; dur?: Dur}> = ({
17
+ value,
18
+ from,
19
+ at,
20
+ sizePx,
21
+ dur = 'scene',
22
+ }) => {
23
+ const frame = useCurrentFrame();
24
+ const {fps} = useVideoConfig();
25
+ const chars = [...value];
26
+ const fromChars = from ? [...from] : null;
27
+ const sameShape =
28
+ fromChars !== null &&
29
+ fromChars.length === chars.length &&
30
+ chars.every((c, i) => /\d/.test(c) === /\d/.test(fromChars[i]) && (/\d/.test(c) || c === fromChars[i]));
31
+ if (from && !sameShape) {
32
+ throw new Error(`RollingNumber: "${from}" and "${value}" must have the same format.`);
33
+ }
34
+ const digitPositions = chars.flatMap((c, i) => (/\d/.test(c) ? [i] : []));
35
+
36
+ return (
37
+ <span style={{display: 'inline-flex', alignItems: 'flex-start', lineHeight: 1, whiteSpace: 'pre'}}>
38
+ {chars.map((char, i) => {
39
+ if (!/\d/.test(char)) {
40
+ return (
41
+ <span key={i} style={{height: '1em'}}>
42
+ {char}
43
+ </span>
44
+ );
45
+ }
46
+ const target = Number(char);
47
+ const origin = fromChars ? Number(fromChars[i]) : 0;
48
+ const order = digitPositions.length - 1 - digitPositions.indexOf(i);
49
+ const offsetPx = (f: number) => {
50
+ const p = progress(f, at + staggerFrames(order, fps, 'tight'), durFrames(dur, fps), EASE.out);
51
+ return -(origin + (target - origin) * p) * sizePx;
52
+ };
53
+ return (
54
+ <span
55
+ key={i}
56
+ style={{
57
+ position: 'relative',
58
+ display: 'inline-block',
59
+ height: '1em',
60
+ // Clip vertically only, so wide digits can pass through narrow slots.
61
+ clipPath: 'inset(0 -50% 0 -50%)',
62
+ }}
63
+ >
64
+ <span style={{visibility: 'hidden'}}>{char}</span>
65
+ <VelocityBlur
66
+ as="span"
67
+ vy={perFrame(offsetPx, frame)}
68
+ style={{
69
+ position: 'absolute',
70
+ left: '50%',
71
+ top: 0,
72
+ display: 'flex',
73
+ flexDirection: 'column',
74
+ alignItems: 'center',
75
+ transform: `translate3d(-50%, ${offsetPx(frame).toFixed(3)}px, 0)`,
76
+ }}
77
+ >
78
+ {Array.from({length: Math.max(target, origin) + 1}, (_, digit) => (
79
+ <span key={digit} style={{height: '1em'}}>
80
+ {digit}
81
+ </span>
82
+ ))}
83
+ </VelocityBlur>
84
+ </span>
85
+ );
86
+ })}
87
+ </span>
88
+ );
89
+ };
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import {Img, staticFile, useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {perFrame} from '../lib/animate';
4
+ import {sequence, type Keyframe} from '../lib/continuity';
5
+ import {VelocityBlur} from './VelocityBlur';
6
+
7
+ /** A camera on a screenshot: top-left image pixel (x, y) and content px per image px. */
8
+ export type ViewKey = 'x' | 'y' | 'scale';
9
+ export type View = Record<ViewKey, number>;
10
+
11
+ /**
12
+ * A screenshot inside a frame (e.g. a browser window) that pans and zooms between views.
13
+ * Children are overlays positioned in the screenshot's own pixels, so annotations stay glued
14
+ * to the content while it moves.
15
+ */
16
+ export const ScreenView: React.FC<{
17
+ src: string;
18
+ width: number;
19
+ height: number;
20
+ start: View;
21
+ keys?: Keyframe<ViewKey>[];
22
+ children?: React.ReactNode;
23
+ }> = ({src, width, height, start, keys = [], children}) => {
24
+ const frame = useCurrentFrame();
25
+ const {fps} = useVideoConfig();
26
+ const viewAt = (f: number) => sequence(f, fps, start, keys);
27
+ const v = viewAt(frame);
28
+
29
+ return (
30
+ <VelocityBlur
31
+ bleed={0}
32
+ vx={perFrame((f) => -viewAt(f).x * viewAt(f).scale, frame)}
33
+ vy={perFrame((f) => -viewAt(f).y * viewAt(f).scale, frame)}
34
+ style={{position: 'absolute', inset: 0, overflow: 'hidden'}}
35
+ >
36
+ <div
37
+ style={{
38
+ position: 'absolute',
39
+ left: 0,
40
+ top: 0,
41
+ width,
42
+ height,
43
+ transformOrigin: '0 0',
44
+ transform: `translate3d(${(-v.x * v.scale).toFixed(3)}px, ${(-v.y * v.scale).toFixed(3)}px, 0) scale(${v.scale.toFixed(5)})`,
45
+ }}
46
+ >
47
+ <Img src={staticFile(src)} style={{display: 'block', width, height}} />
48
+ {children}
49
+ </div>
50
+ </VelocityBlur>
51
+ );
52
+ };
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import {Img, OffthreadVideo, staticFile, useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {SCRIM, SHADOW} from '../design/tokens';
5
+ import {durFrames, perFrame} from '../lib/animate';
6
+ import {sequence, type Keyframe} from '../lib/continuity';
7
+ import {VelocityBlur} from './VelocityBlur';
8
+
9
+ export type StageKey = 'x' | 'y' | 'w' | 'h' | 'radius' | 'focusX' | 'focusY' | 'zoom' | 'shadow' | 'scrim';
10
+ export type StageState = Record<StageKey, number>;
11
+
12
+ export type StageSource = {
13
+ /** Path inside assets/. */
14
+ video: string;
15
+ width: number;
16
+ height: number;
17
+ /** Frame-aligned RGBA cutout frames (assets/<dir>/f<frame>.png) for the given frame ranges. */
18
+ cutout?: {dir: string; ranges: Array<[number, number]>};
19
+ };
20
+
21
+ /** Cover the stage box with the video (times `zoom`), keeping the focus point centred where possible. */
22
+ const mediaRect = (s: StageState, src: StageSource) => {
23
+ const scale = Math.max(s.w / src.width, s.h / src.height) * s.zoom;
24
+ const width = src.width * scale;
25
+ const height = src.height * scale;
26
+ const left = Math.min(0, Math.max(s.w - width, s.w / 2 - s.focusX * scale));
27
+ const top = Math.min(0, Math.max(s.h - height, s.h / 2 - s.focusY * scale));
28
+ return {left, top, width, height};
29
+ };
30
+
31
+ /**
32
+ * 0..1 zoom envelope around a jump cut (d = frames from the cut): ease-in-out push that is fastest at
33
+ * the cut, then a slow ease-out back to rest.
34
+ */
35
+ const cutZoomPulse = (d: number, fps: number): number => {
36
+ const push = durFrames('fast', fps); // frames on each side of the cut
37
+ const relax = durFrames('drift', fps) / 2; // settle back afterwards
38
+ if (d < -push || d > push + relax) return 0;
39
+ if (d <= push) return EASE.inOut((d + push) / (2 * push));
40
+ return 1 - EASE.inOut((d - push) / relax);
41
+ };
42
+
43
+ /**
44
+ * The talking-head video, moving between layouts (full frame, card, push-in...) without ever cutting.
45
+ * Render it twice with the same keys: layer="video" at the bottom, layer="cutout" above anything
46
+ * that should sit behind the subject. Both layers share one geometry, so they always line up.
47
+ */
48
+ export const Stage: React.FC<{
49
+ source: StageSource;
50
+ start: StageState;
51
+ keys: Keyframe<StageKey>[];
52
+ layer: 'video' | 'cutout';
53
+ /**
54
+ * Jump cuts in the footage (first frame after each cut). The framing alternates between normal
55
+ * and `zoom` at every cut, so a cut reads as a deliberate camera change instead of a skip.
56
+ */
57
+ punchIns?: {frames: number[]; zoom: number};
58
+ /**
59
+ * Zoom THROUGH each jump cut instead of snapping: the frame pushes in, moving fastest at the cut
60
+ * (so the eye reads motion, not a jump), then eases back out. `amount` is the extra zoom at the peak.
61
+ */
62
+ cutZoom?: {frames: number[]; amount: number};
63
+ }> = ({source, start, keys, layer, punchIns, cutZoom}) => {
64
+ const frame = useCurrentFrame();
65
+ const {fps} = useVideoConfig();
66
+ const stateAt = (f: number) => sequence(f, fps, start, keys);
67
+ const s = stateAt(frame);
68
+
69
+ const sourceFrame = Math.round(frame);
70
+ if (layer === 'cutout' && !source.cutout?.ranges.some(([a, b]) => sourceFrame >= a && sourceFrame <= b)) {
71
+ return null;
72
+ }
73
+
74
+ const cutsSoFar = punchIns ? punchIns.frames.filter((cut) => sourceFrame >= cut).length : 0;
75
+ const punch = punchIns && cutsSoFar % 2 === 1 ? punchIns.zoom : 1;
76
+ const through = cutZoom ? 1 + cutZoom.amount * Math.max(0, ...cutZoom.frames.map((cut) => cutZoomPulse(frame - cut, fps))) : 1;
77
+ const rect = mediaRect({...s, zoom: s.zoom * punch * through}, source);
78
+ const media =
79
+ layer === 'video' ? (
80
+ <OffthreadVideo src={staticFile(source.video)} style={{position: 'absolute', ...rect}} />
81
+ ) : (
82
+ <Img src={staticFile(`${source.cutout!.dir}/f${sourceFrame}.png`)} style={{position: 'absolute', ...rect}} />
83
+ );
84
+
85
+ return (
86
+ <VelocityBlur
87
+ bleed={0.05}
88
+ vx={perFrame((f) => stateAt(f).x + stateAt(f).w / 2, frame)}
89
+ vy={perFrame((f) => stateAt(f).y + stateAt(f).h / 2, frame)}
90
+ style={{position: 'absolute', left: s.x, top: s.y, width: s.w, height: s.h}}
91
+ >
92
+ {layer === 'video' && s.shadow > 0.01 ? (
93
+ <div style={{position: 'absolute', inset: 0, borderRadius: s.radius, boxShadow: SHADOW.pop, opacity: s.shadow}} />
94
+ ) : null}
95
+ <div style={{position: 'absolute', inset: 0, borderRadius: s.radius, overflow: 'hidden'}}>
96
+ {media}
97
+ {layer === 'video' && s.scrim > 0.01 ? (
98
+ <div style={{position: 'absolute', inset: 0, backgroundImage: SCRIM, opacity: s.scrim}} />
99
+ ) : null}
100
+ </div>
101
+ </VelocityBlur>
102
+ );
103
+ };