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,141 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {CAPTION, FONT} from '../design/tokens';
4
+ import {CAPTION_PRESETS, type CaptionVariant} from '../design/presets';
5
+ import {exitFrames, msToFrames} from '../lib/animate';
6
+ import type {SpokenWord} from '../lib/spine';
7
+ import {Presence} from './Presence';
8
+
9
+ export type CaptionPlacement = {x: number; y: number; width: number; align: 'left' | 'center'; /** Font size (defaults to CAPTION.size). */ size?: number};
10
+
11
+ type Chunk = {words: Array<{text: string; at: number}>; start: number; end: number};
12
+
13
+ const MAX_WORDS = 6;
14
+ const HOLD_MS = 350;
15
+ const MAX_GAP_SEC = 0.6;
16
+
17
+ /**
18
+ * Split the script into short caption chunks: at sentence ends, clauses, pauses, paragraph
19
+ * changes, forced breaks (e.g. layout moves), and around hidden words.
20
+ */
21
+ export const buildChunks = (words: SpokenWord[], fps: number, hidden: Set<number>, breaks: number[]): Chunk[] => {
22
+ const chunks: Chunk[] = [];
23
+ let current: Chunk | null = null;
24
+ let prev: SpokenWord | null = null;
25
+
26
+ const close = () => {
27
+ if (current && current.words.length) chunks.push(current);
28
+ current = null;
29
+ };
30
+
31
+ words.forEach((w, i) => {
32
+ const at = Math.round(w.start * fps);
33
+ if (hidden.has(i)) {
34
+ close();
35
+ prev = w;
36
+ return;
37
+ }
38
+ if (current && prev) {
39
+ const first = current.words[0].at;
40
+ const shouldBreak =
41
+ current.words.length >= MAX_WORDS ||
42
+ /[.?!]$/.test(prev.text) ||
43
+ (/[,;:]$/.test(prev.text) && current.words.length >= 3) ||
44
+ w.start - prev.end > MAX_GAP_SEC ||
45
+ w.paragraph !== prev.paragraph ||
46
+ breaks.some((b) => b > first && b <= at);
47
+ if (shouldBreak) close();
48
+ }
49
+ if (!current) current = {words: [], start: at, end: at};
50
+ current.words.push({text: w.text, at});
51
+ current.end = Math.round(w.end * fps);
52
+ prev = w;
53
+ });
54
+ close();
55
+
56
+ return chunks.map((c, i) => {
57
+ const next = chunks[i + 1];
58
+ const natural = c.end + msToFrames(HOLD_MS, fps);
59
+ // A layout change (break) just after the last word ends the chunk there, so a caption never
60
+ // lingers into the next scene.
61
+ const nextBreak = breaks.find((b) => b > c.start && b < natural);
62
+ const end = Math.min(natural, nextBreak !== undefined ? nextBreak - 1 : natural, next ? next.start - 1 : natural);
63
+ return {...c, end: Math.max(c.start + 1, end)};
64
+ });
65
+ };
66
+
67
+ /**
68
+ * Word-by-word captions: each word sharpens into place as it is spoken, in short chunks.
69
+ * `placement` is evaluated once per chunk (at its start), so text never reflows mid-chunk.
70
+ */
71
+ export const Captions: React.FC<{
72
+ words: SpokenWord[];
73
+ hidden?: number[];
74
+ breaks?: number[];
75
+ placement: (frame: number) => CaptionPlacement | null;
76
+ /** Named caption look from src/design/presets.ts. Defaults to the house style. */
77
+ variant?: CaptionVariant;
78
+ }> = ({words, hidden = [], breaks = [], placement, variant = 'clean'}) => {
79
+ const frame = useCurrentFrame();
80
+ const {fps} = useVideoConfig();
81
+ const chunks = React.useMemo(() => buildChunks(words, fps, new Set(hidden), breaks), [words, fps, hidden, breaks]);
82
+
83
+ const preset = CAPTION_PRESETS[variant];
84
+ const chunk = chunks.find((c) => frame >= c.start - 1 && frame <= c.end);
85
+ if (!chunk) return null;
86
+ const place = placement(chunk.start);
87
+ if (!place) return null;
88
+
89
+ return (
90
+ <div
91
+ style={{
92
+ position: 'absolute',
93
+ left: place.x,
94
+ top: place.y,
95
+ width: place.width,
96
+ display: 'flex',
97
+ flexWrap: 'wrap',
98
+ justifyContent: place.align === 'center' ? 'center' : 'flex-start',
99
+ fontFamily: FONT.sans,
100
+ fontSize: place.size ?? CAPTION.size,
101
+ fontWeight: CAPTION.weight,
102
+ lineHeight: CAPTION.lineHeight,
103
+ letterSpacing: CAPTION.tracking,
104
+ color: CAPTION.color,
105
+ textShadow: CAPTION.shadow,
106
+ }}
107
+ >
108
+ {chunk.words.map((w, i) => {
109
+ // "Being said now" runs from this word until the next one starts.
110
+ const next = chunk.words[i + 1];
111
+ const speaking = frame >= w.at && (!next || frame < next.at);
112
+ return (
113
+ <Presence
114
+ key={`${chunk.start}-${i}`}
115
+ as="span"
116
+ at={w.at - 1}
117
+ until={chunk.end - exitFrames('fast', fps)}
118
+ dur="fast"
119
+ distance={14}
120
+ blur={8}
121
+ scaleFrom={preset.scaleFrom}
122
+ style={{display: 'inline-block', marginRight: '0.26em'}}
123
+ >
124
+ <span
125
+ style={{
126
+ display: 'inline-block',
127
+ color: speaking && preset.activeColor ? preset.activeColor : undefined,
128
+ backgroundColor: preset.background,
129
+ padding: preset.padding,
130
+ borderRadius: preset.radius,
131
+ textShadow: preset.background ? 'none' : undefined,
132
+ }}
133
+ >
134
+ {w.text}
135
+ </span>
136
+ </Presence>
137
+ );
138
+ })}
139
+ </div>
140
+ );
141
+ };
@@ -0,0 +1,93 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {COLOR, RADIUS} from '../design/tokens';
5
+ import {durFrames, perFrame, progress, travelFrames} from '../lib/animate';
6
+ import {Presence} from './Presence';
7
+ import {VelocityBlur} from './VelocityBlur';
8
+
9
+ /** A point the cursor ARRIVES at by frame `at` (the tip of the arrow). */
10
+ export type CursorStop = {at: number; x: number; y: number; click?: boolean};
11
+
12
+ const CLICK_FRAMES = 6;
13
+ /** Pointer size and where its tip sits inside the 64px artwork. */
14
+ const POINTER = 68;
15
+ const TIP = 6 * (POINTER / 64);
16
+
17
+ /**
18
+ * An arrow cursor that guides the eye: travels between stops on gentle arcs,
19
+ * arriving exactly on time, and clicks with a press + ripple.
20
+ */
21
+ export const Cursor: React.FC<{stops: CursorStop[]; hideAt: number}> = ({stops, hideAt}) => {
22
+ const frame = useCurrentFrame();
23
+ const {fps} = useVideoConfig();
24
+
25
+ const positionAt = (f: number) => {
26
+ let x = stops[0].x;
27
+ let y = stops[0].y;
28
+ for (let i = 1; i < stops.length; i++) {
29
+ const from = stops[i - 1];
30
+ const to = stops[i];
31
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
32
+ const travel = Math.min(travelFrames(distance, fps), Math.max(1, to.at - from.at));
33
+ const depart = to.at - travel;
34
+ if (f <= depart) break;
35
+ const t = progress(f, depart, travel, EASE.inOut);
36
+ // Arc: bulge perpendicular to the path, peaking mid-way.
37
+ const bulge = Math.sin(Math.PI * t) * distance * 0.1;
38
+ const nx = distance ? -(to.y - from.y) / distance : 0;
39
+ const ny = distance ? (to.x - from.x) / distance : 0;
40
+ x = from.x + (to.x - from.x) * t + nx * bulge;
41
+ y = from.y + (to.y - from.y) * t + ny * bulge;
42
+ }
43
+ return {x, y};
44
+ };
45
+
46
+ const {x, y} = positionAt(frame);
47
+ const click = stops.find((s) => s.click && frame >= s.at && frame < s.at + durFrames('slow', fps));
48
+ const pressT = click ? progress(frame, click.at, CLICK_FRAMES, EASE.inOut) : 0;
49
+ const press = 1 - 0.14 * Math.sin(Math.PI * pressT);
50
+ const ripple = click ? progress(frame, click.at, durFrames('slow', fps)) : 0;
51
+
52
+ return (
53
+ <Presence at={stops[0].at} until={hideAt} dur="fast" from="none" style={{position: 'absolute', inset: 0}}>
54
+ {click ? (
55
+ <div
56
+ style={{
57
+ position: 'absolute',
58
+ left: click.x - 70 * ripple,
59
+ top: click.y - 70 * ripple,
60
+ width: 140 * ripple,
61
+ height: 140 * ripple,
62
+ borderRadius: RADIUS.pill,
63
+ border: `4px solid ${COLOR.accent}`,
64
+ opacity: 1 - ripple,
65
+ }}
66
+ />
67
+ ) : null}
68
+ <VelocityBlur
69
+ vx={perFrame((f) => positionAt(f).x, frame)}
70
+ vy={perFrame((f) => positionAt(f).y, frame)}
71
+ style={{
72
+ position: 'absolute',
73
+ left: x - TIP,
74
+ top: y - TIP,
75
+ transformOrigin: `${TIP}px ${TIP}px`,
76
+ transform: `scale(${press.toFixed(4)})`,
77
+ filter: `drop-shadow(0 8px 14px ${COLOR.fg}66)`,
78
+ }}
79
+ >
80
+ {/* Solid pointer (as in the reference): tip top-left, notched tail. */}
81
+ <svg width={POINTER} height={POINTER} viewBox="0 0 64 64">
82
+ <path
83
+ d="M6 6 L58 26 Q60 27 58 28 L35 35 L28 58 Q27 60 26 58 Z"
84
+ fill={COLOR.accent}
85
+ stroke={COLOR.fg}
86
+ strokeWidth={3}
87
+ strokeLinejoin="round"
88
+ />
89
+ </svg>
90
+ </VelocityBlur>
91
+ </Presence>
92
+ );
93
+ };
@@ -0,0 +1,55 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {COLOR} from '../design/tokens';
5
+ import {durFrames, progress} from '../lib/animate';
6
+
7
+ const SEGMENTS = 72;
8
+
9
+ /**
10
+ * A hand-drawn loop that draws itself around a rectangle (the emphasis stroke from the
11
+ * references). Slightly irregular and overshoots its start, like a real pen stroke.
12
+ * Coordinates are in the parent's space.
13
+ */
14
+ export const DrawnCircle: React.FC<{
15
+ rect: {x: number; y: number; w: number; h: number};
16
+ at: number;
17
+ pad?: number;
18
+ strokeWidth?: number;
19
+ color?: string;
20
+ }> = ({rect, at, pad = 14, strokeWidth = 5, color = COLOR.fg}) => {
21
+ const frame = useCurrentFrame();
22
+ const {fps} = useVideoConfig();
23
+ const draw = progress(frame, at, durFrames('slow', fps), EASE.inOut);
24
+ if (draw <= 0) return null;
25
+
26
+ const cx = rect.x + rect.w / 2;
27
+ const cy = rect.y + rect.h / 2;
28
+ const rx = rect.w / 2 + pad;
29
+ const ry = rect.h / 2 + pad;
30
+ const startAngle = -0.62 * Math.PI;
31
+ const sweep = 2.1 * Math.PI;
32
+ const points = Array.from({length: SEGMENTS + 1}, (_, i) => {
33
+ const t = i / SEGMENTS;
34
+ const a = startAngle + sweep * t;
35
+ // Gentle wobble + a slow spiral outward, so the end overshoots the start.
36
+ const wobble = 1 + 0.03 * Math.sin(2 * a + 1) + 0.015 * Math.sin(5 * a) + 0.05 * t;
37
+ return `${(cx + Math.cos(a) * rx * wobble).toFixed(2)},${(cy + Math.sin(a) * ry * wobble).toFixed(2)}`;
38
+ });
39
+
40
+ return (
41
+ <svg style={{position: 'absolute', left: 0, top: 0, width: 1, height: 1, overflow: 'visible'}}>
42
+ <polyline
43
+ points={points.join(' ')}
44
+ pathLength={1}
45
+ fill="none"
46
+ stroke={color}
47
+ strokeWidth={strokeWidth}
48
+ strokeLinecap="round"
49
+ strokeLinejoin="round"
50
+ strokeDasharray={1}
51
+ strokeDashoffset={1 - draw}
52
+ />
53
+ </svg>
54
+ );
55
+ };
@@ -0,0 +1,139 @@
1
+ import React from 'react';
2
+ import {AbsoluteFill, useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {
4
+ FILM_PRESETS,
5
+ GRAIN,
6
+ HALATION,
7
+ LEAK,
8
+ LETTERBOX,
9
+ WEAVE,
10
+ strengthScale,
11
+ type FilmLook,
12
+ } from '../design/overlays';
13
+
14
+ /**
15
+ * Film texture over any scene, driven by tokens.
16
+ *
17
+ * <FilmOverlay look="film">{<Footage />}</FilmOverlay>
18
+ *
19
+ * Layers, outside in: gate weave moves the whole frame, halation blooms the highlights,
20
+ * a light leak washes across, grain sits on top, letterbox bars crop the result.
21
+ * Every layer is off by default at 'none' and every one of them is frame-driven — no CSS
22
+ * animation, no Math.random — so a re-render is identical to the last one.
23
+ *
24
+ * Individual layers can be overridden per shot when a look is nearly right:
25
+ * <FilmOverlay look="film" grain="strong" />
26
+ */
27
+ export const FilmOverlay: React.FC<{
28
+ look?: FilmLook;
29
+ grain?: (typeof FILM_PRESETS)[FilmLook]['grain'];
30
+ halation?: (typeof FILM_PRESETS)[FilmLook]['halation'];
31
+ leak?: (typeof FILM_PRESETS)[FilmLook]['leak'];
32
+ weave?: (typeof FILM_PRESETS)[FilmLook]['weave'];
33
+ letterbox?: (typeof FILM_PRESETS)[FilmLook]['letterbox'];
34
+ /** Shifts the leak and weave phase, so two shots with the same look do not drift in step. */
35
+ seed?: number;
36
+ children: React.ReactNode;
37
+ }> = ({look = 'film', seed = 0, children, ...override}) => {
38
+ const frame = useCurrentFrame();
39
+ const {fps, width, height} = useVideoConfig();
40
+ const preset = FILM_PRESETS[look];
41
+
42
+ const grain = strengthScale(override.grain ?? preset.grain);
43
+ const halation = strengthScale(override.halation ?? preset.halation);
44
+ const leak = strengthScale(override.leak ?? preset.leak);
45
+ const weave = strengthScale(override.weave ?? preset.weave);
46
+ const letterbox = override.letterbox ?? preset.letterbox;
47
+
48
+ const seconds = frame / fps;
49
+ /** Sum of two slow sines: smooth, continuous, and never on an obvious loop. */
50
+ const wobble = (hz: number, phase: number) => Math.sin(2 * Math.PI * hz * seconds + phase + seed);
51
+
52
+ /* ------------------------------------------------------------------ gate weave */
53
+
54
+ const weaveX = weave * WEAVE.translatePx * wobble(WEAVE.hz[0], 0);
55
+ const weaveY = weave * WEAVE.translatePx * wobble(WEAVE.hz[1], 1.7);
56
+ const weaveR = weave * WEAVE.rotateDeg * wobble(WEAVE.hz[0] * 0.7, 3.1);
57
+ const weaveStyle: React.CSSProperties = weave
58
+ ? {transform: `translate(${weaveX.toFixed(3)}px, ${weaveY.toFixed(3)}px) rotate(${weaveR.toFixed(4)}deg) scale(1.01)`}
59
+ : {};
60
+
61
+ /* ------------------------------------------------------------------ halation */
62
+
63
+ // Crush to a highlight-only plate, warm it, blur it, then screen it back on. Screening
64
+ // with black is a no-op, so everything that is not a highlight comes through untouched.
65
+ // motion-ok: a static blur radius on a bloom plate, not motion blur — the 12px cap is about moving edges.
66
+ const bloomFilter = [
67
+ `contrast(${HALATION.contrast})`,
68
+ `brightness(${HALATION.brightness})`,
69
+ `sepia(${HALATION.sepia})`,
70
+ `saturate(${HALATION.saturate})`,
71
+ `blur(${HALATION.blurPx.toFixed(1)}px)`,
72
+ ].join(' ');
73
+
74
+ /* ------------------------------------------------------------------ light leak */
75
+
76
+ const leakX = 50 + 32 * wobble(1 / LEAK.cycleSeconds, 0.4);
77
+ const leakY = 12 + 14 * wobble(1 / (LEAK.cycleSeconds * 1.6), 2.2);
78
+ const leakStyle: React.CSSProperties = {
79
+ background: `radial-gradient(45% 34% at ${leakX.toFixed(1)}% ${leakY.toFixed(1)}%, ${LEAK.warm} 0%, ${LEAK.cool} 26%, transparent 66%)`,
80
+ mixBlendMode: 'screen',
81
+ opacity: LEAK.opacity * leak,
82
+ };
83
+
84
+ /* ------------------------------------------------------------------ grain */
85
+
86
+ // A small noise plate scaled up: full-frame turbulence every frame is far too slow to render.
87
+ const plateW = Math.round(width * GRAIN.renderScale);
88
+ const plateH = Math.round(height * GRAIN.renderScale);
89
+ const grainSeed = Math.floor(frame / GRAIN.framesPerSeed);
90
+
91
+ /* ------------------------------------------------------------------ letterbox */
92
+
93
+ const barHeight = letterbox === 'off' ? 0 : Math.round(height * LETTERBOX.ratio[letterbox]);
94
+
95
+ return (
96
+ <AbsoluteFill>
97
+ <AbsoluteFill style={weaveStyle}>
98
+ <AbsoluteFill>{children}</AbsoluteFill>
99
+
100
+ {halation > 0 ? (
101
+ <AbsoluteFill style={{filter: bloomFilter, mixBlendMode: 'screen', opacity: HALATION.opacity * halation}}>
102
+ {children}
103
+ </AbsoluteFill>
104
+ ) : null}
105
+
106
+ {leak > 0 ? <AbsoluteFill style={leakStyle} /> : null}
107
+
108
+ {grain > 0 ? (
109
+ <AbsoluteFill style={{opacity: GRAIN.opacity * grain}}>
110
+ <svg width="100%" height="100%" viewBox={`0 0 ${plateW} ${plateH}`} preserveAspectRatio="none">
111
+ <filter id={`grain-${grainSeed}`} x="0" y="0" width="100%" height="100%">
112
+ <feTurbulence
113
+ type="fractalNoise"
114
+ baseFrequency={GRAIN.frequency}
115
+ numOctaves={1}
116
+ seed={grainSeed}
117
+ stitchTiles="stitch"
118
+ />
119
+ <feColorMatrix type="saturate" values="0" />
120
+ {/* Turbulence varies alpha as well as colour; flatten it so only brightness grains. */}
121
+ <feComponentTransfer>
122
+ <feFuncA type="linear" slope="0" intercept="1" />
123
+ </feComponentTransfer>
124
+ </filter>
125
+ <rect width={plateW} height={plateH} filter={`url(#grain-${grainSeed})`} />
126
+ </svg>
127
+ </AbsoluteFill>
128
+ ) : null}
129
+ </AbsoluteFill>
130
+
131
+ {barHeight > 0 ? (
132
+ <>
133
+ <AbsoluteFill style={{height: barHeight, top: 0, backgroundColor: LETTERBOX.color}} />
134
+ <AbsoluteFill style={{height: barHeight, top: height - barHeight, backgroundColor: LETTERBOX.color}} />
135
+ </>
136
+ ) : null}
137
+ </AbsoluteFill>
138
+ );
139
+ };
@@ -0,0 +1,114 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {heroGlow} from '../design/glow';
5
+ import {CAPTION, FONT, HERO, TYPE, WEIGHT} from '../design/tokens';
6
+ import {durFrames, perFrame, progress, wipeMask, type Place} from '../lib/animate';
7
+ import {Presence} from './Presence';
8
+
9
+ const labelStyle: React.CSSProperties = {
10
+ fontWeight: WEIGHT.bold,
11
+ letterSpacing: TYPE.eyebrow.tracking,
12
+ textTransform: 'uppercase',
13
+ color: CAPTION.color,
14
+ textShadow: CAPTION.shadow,
15
+ };
16
+
17
+ /**
18
+ * Big glowing accent headline, centred horizontally. Place it between the stage video and the
19
+ * stage cutout to sit behind the subject. `children` is the headline (HeroWords, a RollingNumber...).
20
+ * The block itself only moves on exit — its words bring their own entrance.
21
+ */
22
+ export const HeroBlock: React.FC<{
23
+ at: number;
24
+ until?: number;
25
+ top: number;
26
+ exitTo?: Place;
27
+ size?: number;
28
+ /** Font size of the eyebrow / subline labels. */
29
+ labelSize?: number;
30
+ /** Small white label above the headline. */
31
+ eyebrow?: {text: string; at: number};
32
+ /** White line below the headline. */
33
+ subline?: {text: string; at: number};
34
+ children: React.ReactNode;
35
+ }> = ({at, until, top, exitTo = 'above', size = HERO.size, labelSize, eyebrow, subline, children}) => (
36
+ <Presence
37
+ at={at}
38
+ until={until}
39
+ from="none"
40
+ exitTo={exitTo}
41
+ dur="base"
42
+ distance={40}
43
+ style={{position: 'absolute', left: 0, right: 0, top, textAlign: 'center', fontFamily: FONT.sans}}
44
+ >
45
+ {eyebrow ? (
46
+ <Presence at={eyebrow.at} dur="base" blur={8} style={{...labelStyle, fontSize: labelSize ?? TYPE.eyebrow.size + 8, marginBottom: 10}}>
47
+ {eyebrow.text}
48
+ </Presence>
49
+ ) : null}
50
+ <div
51
+ style={{
52
+ fontSize: size,
53
+ fontWeight: TYPE.display.weight,
54
+ letterSpacing: TYPE.display.tracking,
55
+ lineHeight: 1,
56
+ color: HERO.color,
57
+ textShadow: heroGlow(),
58
+ whiteSpace: 'nowrap',
59
+ }}
60
+ >
61
+ {children}
62
+ </div>
63
+ {subline ? (
64
+ <Presence at={subline.at} dur="base" blur={8} style={{...labelStyle, fontSize: labelSize ?? TYPE.h3.size, marginTop: 18}}>
65
+ {subline.text}
66
+ </Presence>
67
+ ) : null}
68
+ </Presence>
69
+ );
70
+
71
+ /** Words of a hero headline, each rising out of a blur on its spoken frame. */
72
+ export const HeroWords: React.FC<{words: Array<{text: string; at: number}>; from?: Place}> = ({words, from = 'below'}) => (
73
+ <>
74
+ {words.map((w, i) => (
75
+ <Presence
76
+ key={`${w.text}-${i}`}
77
+ as="span"
78
+ at={w.at}
79
+ from={from}
80
+ dur="slow"
81
+ distance={40}
82
+ blur={12}
83
+ style={{display: 'inline-block', marginRight: i < words.length - 1 ? '0.24em' : 0}}
84
+ >
85
+ {w.text}
86
+ </Presence>
87
+ ))}
88
+ </>
89
+ );
90
+
91
+ /** A line drawn through a headline, left to right with a motion-blurred leading edge. */
92
+ export const Strike: React.FC<{at: number; color: string; children: React.ReactNode}> = ({at, color, children}) => {
93
+ const frame = useCurrentFrame();
94
+ const {fps} = useVideoConfig();
95
+ const drawAt = (f: number) => progress(f, at, durFrames('slow', fps), EASE.emphasis);
96
+ return (
97
+ <span style={{position: 'relative', display: 'inline-block'}}>
98
+ {children}
99
+ <span
100
+ style={{
101
+ position: 'absolute',
102
+ left: '-0.04em',
103
+ right: '-0.04em',
104
+ top: '50%',
105
+ height: '0.09em',
106
+ borderRadius: '0.05em',
107
+ backgroundColor: color,
108
+ boxShadow: `0 0 0.12em ${color}`,
109
+ ...wipeMask(drawAt(frame), perFrame(drawAt, frame)),
110
+ }}
111
+ />
112
+ </span>
113
+ );
114
+ };
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import {interpolateColors, useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {withAlpha} from '../design/color';
4
+ import {heroGlow} from '../design/glow';
5
+ import {COLOR, FONT, HERO, RADIUS, TYPE} from '../design/tokens';
6
+ import {perFrame} from '../lib/animate';
7
+ import {morph} from '../lib/continuity';
8
+ import {Presence} from './Presence';
9
+ import {VelocityBlur} from './VelocityBlur';
10
+
11
+ /**
12
+ * A big glowing accent headline that later shrinks into a small corner pill and stays on screen
13
+ * as a section label (the element is never removed and re-added — it becomes the tag).
14
+ * Place it between the stage video and the stage cutout to sit behind the subject.
15
+ */
16
+ export const HeroTag: React.FC<{
17
+ words: string[];
18
+ /** Frame each word appears (spoken time). */
19
+ wordAt: number[];
20
+ shrinkAt: number;
21
+ big: {centerX: number; top: number; size?: number};
22
+ pill: {x: number; y: number; scale: number};
23
+ until?: number;
24
+ }> = ({words, wordAt, shrinkAt, big, pill, until}) => {
25
+ const frame = useCurrentFrame();
26
+ const {fps} = useVideoConfig();
27
+ const stateAt = (f: number) =>
28
+ morph(
29
+ f,
30
+ fps,
31
+ shrinkAt,
32
+ {left: big.centerX, top: big.top, shift: -50, scale: 1, chrome: 0},
33
+ {left: pill.x, top: pill.y, shift: 0, scale: pill.scale, chrome: 1},
34
+ );
35
+ const s = stateAt(frame);
36
+ const border = 2 / pill.scale;
37
+
38
+ return (
39
+ <Presence at={wordAt[0]} until={until} dur="fast" from="none" style={{position: 'absolute', inset: 0}}>
40
+ <VelocityBlur
41
+ vx={perFrame((f) => stateAt(f).left, frame)}
42
+ vy={perFrame((f) => stateAt(f).top, frame)}
43
+ style={{
44
+ position: 'absolute',
45
+ left: s.left,
46
+ top: s.top,
47
+ transformOrigin: '0 0',
48
+ transform: `translateX(${s.shift.toFixed(3)}%) scale(${s.scale.toFixed(5)})`,
49
+ display: 'inline-flex',
50
+ alignItems: 'center',
51
+ whiteSpace: 'nowrap',
52
+ padding: `${(0.22 * s.chrome).toFixed(4)}em ${(0.55 * s.chrome).toFixed(4)}em`,
53
+ borderRadius: RADIUS.pill,
54
+ border: `${border}px solid ${withAlpha(COLOR.borderSubtle, s.chrome)}`,
55
+ backgroundColor: withAlpha(COLOR.paper, s.chrome),
56
+ fontFamily: FONT.sans,
57
+ fontSize: big.size ?? HERO.size,
58
+ fontWeight: TYPE.display.weight,
59
+ letterSpacing: TYPE.display.tracking,
60
+ lineHeight: 1,
61
+ color: interpolateColors(s.chrome, [0, 1], [HERO.color, COLOR.fg]),
62
+ textShadow: heroGlow(1 - s.chrome),
63
+ }}
64
+ >
65
+ <span
66
+ style={{
67
+ display: 'inline-block',
68
+ width: `${(0.3 * s.chrome).toFixed(4)}em`,
69
+ height: `${(0.3 * s.chrome).toFixed(4)}em`,
70
+ marginRight: `${(0.3 * s.chrome).toFixed(4)}em`,
71
+ borderRadius: RADIUS.pill,
72
+ backgroundColor: COLOR.fg,
73
+ boxShadow: `0 0 0 ${(0.08 * s.chrome).toFixed(4)}em ${COLOR.accent}`,
74
+ }}
75
+ />
76
+ {words.map((word, i) => (
77
+ <Presence
78
+ key={word}
79
+ as="span"
80
+ at={wordAt[i]}
81
+ dur="slow"
82
+ distance={40}
83
+ blur={12}
84
+ style={{display: 'inline-block', marginRight: i < words.length - 1 ? '0.24em' : 0}}
85
+ >
86
+ {word}
87
+ </Presence>
88
+ ))}
89
+ </VelocityBlur>
90
+ </Presence>
91
+ );
92
+ };
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame, useVideoConfig} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {HIGHLIGHT} from '../design/tokens';
5
+ import {durFrames, perFrame, progress, wipeMask} from '../lib/animate';
6
+
7
+ /**
8
+ * Brand signature emphasis: a flat accent marker that wipes in left to right
9
+ * behind a phrase, after the phrase has landed. The wipe edge is motion-blurred
10
+ * by its speed. `at` is in the frame space of the enclosing Sequence.
11
+ */
12
+ export const HighlightMark: React.FC<{at: number; children: React.ReactNode}> = ({at, children}) => {
13
+ const frame = useCurrentFrame();
14
+ const {fps} = useVideoConfig();
15
+ const wipeAt = (f: number) => progress(f, at, durFrames('slow', fps), EASE.emphasis);
16
+
17
+ return (
18
+ <span style={{position: 'relative', display: 'inline-block', padding: `0 ${HIGHLIGHT.padX}`}}>
19
+ <span
20
+ style={{
21
+ position: 'absolute',
22
+ left: 0,
23
+ right: 0,
24
+ top: HIGHLIGHT.top,
25
+ bottom: HIGHLIGHT.bottom,
26
+ backgroundColor: HIGHLIGHT.color,
27
+ borderRadius: HIGHLIGHT.radius,
28
+ ...wipeMask(wipeAt(frame), perFrame(wipeAt, frame)),
29
+ }}
30
+ />
31
+ <span style={{position: 'relative'}}>{children}</span>
32
+ </span>
33
+ );
34
+ };