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.
- package/README.md +15 -0
- package/bin/init.mjs +72 -0
- package/package.json +18 -0
- package/template/CLAUDE.md +341 -0
- package/template/STYLE.md +165 -0
- package/template/package.json +45 -0
- package/template/remotion.config.ts +7 -0
- package/template/requirements.txt +17 -0
- package/template/scripts/align_script.py +106 -0
- package/template/scripts/assemble.mjs +364 -0
- package/template/scripts/beats.mjs +273 -0
- package/template/scripts/blur_regions.py +40 -0
- package/template/scripts/check_pair.py +90 -0
- package/template/scripts/doctor.mjs +141 -0
- package/template/scripts/find_cuts.py +45 -0
- package/template/scripts/grade.py +202 -0
- package/template/scripts/lib/media.mjs +228 -0
- package/template/scripts/lib/media.py +99 -0
- package/template/scripts/make_cutout.py +68 -0
- package/template/scripts/motion-check.mjs +268 -0
- package/template/scripts/motion-lint.mjs +122 -0
- package/template/scripts/new-video.mjs +49 -0
- package/template/scripts/prep.mjs +287 -0
- package/template/scripts/ramp.mjs +149 -0
- package/template/scripts/refs.mjs +72 -0
- package/template/scripts/refstyle.mjs +177 -0
- package/template/scripts/sounddesign.mjs +332 -0
- package/template/scripts/stills.mjs +64 -0
- package/template/scripts/sync-audio.mjs +134 -0
- package/template/scripts/transcribe.py +103 -0
- package/template/scripts/trim.mjs +374 -0
- package/template/src/Root.tsx +42 -0
- package/template/src/components/BrandBackground.tsx +22 -0
- package/template/src/components/Captions.tsx +141 -0
- package/template/src/components/Cursor.tsx +93 -0
- package/template/src/components/DrawnCircle.tsx +55 -0
- package/template/src/components/FilmOverlay.tsx +139 -0
- package/template/src/components/Hero.tsx +114 -0
- package/template/src/components/HeroTag.tsx +92 -0
- package/template/src/components/HighlightMark.tsx +34 -0
- package/template/src/components/LogoIcon.tsx +45 -0
- package/template/src/components/MacWindow.tsx +94 -0
- package/template/src/components/MaskReveal.tsx +34 -0
- package/template/src/components/MotionBlur.tsx +34 -0
- package/template/src/components/PlatformTile.tsx +153 -0
- package/template/src/components/Presence.tsx +27 -0
- package/template/src/components/RollingNumber.tsx +89 -0
- package/template/src/components/ScreenView.tsx +52 -0
- package/template/src/components/Stage.tsx +103 -0
- package/template/src/components/Transition.tsx +100 -0
- package/template/src/components/VelocityBlur.tsx +53 -0
- package/template/src/compositions/FilmTest.tsx +67 -0
- package/template/src/compositions/PresetTest.tsx +52 -0
- package/template/src/design/color.ts +6 -0
- package/template/src/design/glow.ts +11 -0
- package/template/src/design/logos.json +18 -0
- package/template/src/design/motion.ts +90 -0
- package/template/src/design/overlays.ts +121 -0
- package/template/src/design/presets.ts +65 -0
- package/template/src/design/tokens.ts +200 -0
- package/template/src/index.ts +4 -0
- package/template/src/lib/animate.ts +194 -0
- package/template/src/lib/camera.ts +58 -0
- package/template/src/lib/continuity.ts +86 -0
- package/template/src/lib/project.ts +13 -0
- package/template/src/lib/spine.ts +37 -0
- package/template/src/videos/sample/Sample.tsx +58 -0
- package/template/templates/BRIEF.md +88 -0
- 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
|
+
};
|