reelme 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/audio/PROVENANCE.md +18 -11
- package/assets/audio/generate-sfx.mjs +128 -0
- package/assets/audio/light-steps.mp3 +0 -0
- package/assets/audio/lofi-dusk.mp3 +0 -0
- package/assets/audio/manifest.json +56 -63
- package/assets/audio/neon-pulse.mp3 +0 -0
- package/assets/audio/night-drive.mp3 +0 -0
- package/assets/audio/sfx/SOURCES.md +16 -0
- package/assets/audio/sfx/pop.mp3 +0 -0
- package/assets/audio/sfx/rise.mp3 +0 -0
- package/assets/audio/sfx/whoosh.mp3 +0 -0
- package/assets/audio/sunny-bounce.mp3 +0 -0
- package/assets/audio/warm-memories.mp3 +0 -0
- package/package.json +1 -1
- package/src/cache.mjs +92 -2
- package/src/render.mjs +7 -0
- package/template/src/Root.tsx +75 -7
- package/template/src/brief.ts +26 -3
- package/template/src/cinematic/Atmosphere.tsx +176 -30
- package/template/src/cinematic/Camera.tsx +21 -9
- package/template/src/cinematic/look.ts +28 -4
- package/template/src/cinematic/transitions.tsx +4 -2
- package/template/src/components/primitives/Arrow.tsx +8 -5
- package/template/src/components/primitives/CodeBlock.tsx +21 -7
- package/template/src/components/primitives/RevealText.tsx +8 -2
- package/template/src/components/primitives/Terminal.tsx +24 -7
- package/template/src/components/scenes/BrowserFrame.tsx +12 -5
- package/template/src/components/scenes/CTA.tsx +4 -2
- package/template/src/components/scenes/Clip.tsx +27 -14
- package/template/src/components/scenes/CodeReveal.tsx +8 -10
- package/template/src/components/scenes/DataFlow.tsx +46 -18
- package/template/src/components/scenes/FileTree.tsx +22 -6
- package/template/src/components/scenes/Hook.tsx +4 -3
- package/template/src/components/scenes/MobileScreen.tsx +10 -18
- package/template/src/components/scenes/OSWindowScene.tsx +1 -1
- package/template/src/components/scenes/SplitComparison.tsx +41 -14
- package/template/src/custom-scenes.ts +19 -0
- package/template/src/duration.ts +43 -4
- package/template/src/index.ts +9 -5
- package/template/src/theme.ts +10 -1
- package/assets/audio/bright-sparks.mp3 +0 -0
- package/assets/audio/calm-keys.mp3 +0 -0
- package/assets/audio/circuit-pulse.mp3 +0 -0
- package/assets/audio/clean-horizon.mp3 +0 -0
- package/assets/audio/generate-tracks.mjs +0 -218
- package/assets/audio/midnight-protocol.mp3 +0 -0
- package/assets/audio/pixel-bounce.mp3 +0 -0
- package/assets/audio/steady-launch.mp3 +0 -0
- package/assets/audio/sunny-loop.mp3 +0 -0
- package/assets/audio/vector-grid.mp3 +0 -0
package/template/src/brief.ts
CHANGED
|
@@ -18,11 +18,21 @@ export interface ProjectMeta {
|
|
|
18
18
|
installCommand: string;
|
|
19
19
|
repoUrl: string;
|
|
20
20
|
primaryColor: string;
|
|
21
|
+
/**
|
|
22
|
+
* Second brand hue for backdrops and gradients (taken from the project's
|
|
23
|
+
* logo or site). Without it one is derived from primaryColor — a real brand
|
|
24
|
+
* pair reads noticeably less generic than a derived one.
|
|
25
|
+
*/
|
|
26
|
+
secondaryColor?: string;
|
|
21
27
|
tone: "professional" | "playful" | "technical";
|
|
22
28
|
/** Publishing targets; required, at least one. Presets derive dimensions. */
|
|
23
29
|
platforms: PlatformId[];
|
|
24
|
-
/**
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Bundled CC0 track selection; false disables audio (music, SFX, and beat
|
|
32
|
+
* quantization together). `bpm` is injected by the CLI from the audio
|
|
33
|
+
* manifest at stage time — briefs don't set it by hand.
|
|
34
|
+
*/
|
|
35
|
+
audio?: { track: string; volume?: number; bpm?: number } | false;
|
|
26
36
|
/** "made with reelme" credit in the CTA footer; default true. */
|
|
27
37
|
watermark?: boolean;
|
|
28
38
|
mode?: "intro" | "announcement";
|
|
@@ -198,6 +208,18 @@ export interface HotkeyScene {
|
|
|
198
208
|
caption?: string;
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
export interface CustomScene {
|
|
212
|
+
type: "custom";
|
|
213
|
+
/**
|
|
214
|
+
* Repo-relative path to a bespoke scene component (a .tsx default export),
|
|
215
|
+
* usually authored by the skill for THIS project's one differentiating
|
|
216
|
+
* moment. The CLI stages the file and registers it (custom-scenes.ts).
|
|
217
|
+
*/
|
|
218
|
+
component: string;
|
|
219
|
+
durationInFrames: number;
|
|
220
|
+
caption?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
export interface HookScene {
|
|
202
224
|
type: "hook";
|
|
203
225
|
text: string;
|
|
@@ -245,7 +267,8 @@ export type Scene =
|
|
|
245
267
|
| HotkeyScene
|
|
246
268
|
| HookScene
|
|
247
269
|
| BenchmarkScene
|
|
248
|
-
| ClipScene
|
|
270
|
+
| ClipScene
|
|
271
|
+
| CustomScene;
|
|
249
272
|
|
|
250
273
|
export interface Cuts {
|
|
251
274
|
/** The full narrative arc. Always required. */
|
|
@@ -19,50 +19,31 @@ function drift(frame: number, period: number, phase: number) {
|
|
|
19
19
|
/**
|
|
20
20
|
* The continuous stage the reel is shot on. Rendered once, behind every scene,
|
|
21
21
|
* so the background never resets between cuts — that single fact is what turns
|
|
22
|
-
* a deck of slides into one film.
|
|
22
|
+
* a deck of slides into one film.
|
|
23
|
+
*
|
|
24
|
+
* Each look stages the film on a distinct backdrop SYSTEM (look.backdrop)
|
|
25
|
+
* instead of one shared orb recipe. The system covers the frame with intent —
|
|
26
|
+
* a full gradient field, a structural grid, an aimed light — so no region is
|
|
27
|
+
* ever a flat void with a detached glow floating in it.
|
|
23
28
|
*/
|
|
24
29
|
export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
|
|
25
30
|
const liveFrame = useCurrentFrame();
|
|
26
31
|
const lite = quality === "lite";
|
|
27
|
-
// Gif output freezes the
|
|
32
|
+
// Gif output freezes the motion: a frame that changes every tick defeats gif
|
|
28
33
|
// compression and balloons file size. The graded look stays; only motion goes.
|
|
29
34
|
const frame = lite ? 0 : liveFrame;
|
|
30
35
|
|
|
31
|
-
const accent = chroma(theme.accent);
|
|
32
|
-
const cool = accent.set("hsl.h", "+150").desaturate(0.8).hex();
|
|
33
|
-
|
|
34
|
-
// Primary key light drifts across the upper third.
|
|
35
|
-
const keyX = 50 + drift(frame, 520, 0) * 14;
|
|
36
|
-
const keyY = 32 + drift(frame, 680, 1.3) * 8;
|
|
37
|
-
// Secondary fill light (only on two-tone looks) sweeps the opposite corner.
|
|
38
|
-
const fillX = 24 + drift(frame, 600, 2.1) * 12;
|
|
39
|
-
const fillY = 74 + drift(frame, 740, 0.6) * 10;
|
|
40
|
-
|
|
41
|
-
const glowAlpha = look.glow * (lite ? 0.6 : 1);
|
|
42
36
|
// Patterned overlays (1px grid/scanlines) are GIF-compression poison — every
|
|
43
37
|
// frame becomes high-frequency detail. Drop them on the lite path.
|
|
44
38
|
const showOverlay = !lite;
|
|
45
|
-
// Gif compresses flat areas; smooth wide gradients (worst with bright accents)
|
|
46
|
-
// explode its 256-color palette. On lite, the key light is a smaller, tighter
|
|
47
|
-
// pool and the fill light is dropped, leaving most of the frame the flat base.
|
|
48
|
-
const keyGradient = lite
|
|
49
|
-
? `radial-gradient(42% 38% at ${keyX}% ${keyY}%, ${accent.alpha(0.34 * glowAlpha).css()} 0%, transparent 62%)`
|
|
50
|
-
: `radial-gradient(60% 55% at ${keyX}% ${keyY}%, ${accent.alpha(0.55 * glowAlpha).css()} 0%, ${accent.alpha(0.12 * glowAlpha).css()} 38%, transparent 70%)`;
|
|
51
39
|
|
|
52
40
|
return (
|
|
53
41
|
<AbsoluteFill style={{ background: theme.bg }}>
|
|
54
|
-
{
|
|
55
|
-
<AbsoluteFill style={{ background: keyGradient }} />
|
|
56
|
-
{/* Fill light */}
|
|
57
|
-
{look.twoTone && !lite && (
|
|
58
|
-
<AbsoluteFill
|
|
59
|
-
style={{
|
|
60
|
-
background: `radial-gradient(55% 50% at ${fillX}% ${fillY}%, ${chroma(cool).alpha(0.4 * glowAlpha).css()} 0%, transparent 68%)`,
|
|
61
|
-
}}
|
|
62
|
-
/>
|
|
63
|
-
)}
|
|
42
|
+
<Backdrop theme={theme} look={look} frame={frame} lite={lite} />
|
|
64
43
|
|
|
65
|
-
{showOverlay && look.overlay === "grid" &&
|
|
44
|
+
{showOverlay && look.overlay === "grid" && look.backdrop !== "grid" && (
|
|
45
|
+
<GridOverlay color={theme.border} lite={lite} />
|
|
46
|
+
)}
|
|
66
47
|
{showOverlay && look.overlay === "scanlines" && <Scanlines lite={lite} />}
|
|
67
48
|
|
|
68
49
|
{/* Grade pass: a subtle full-frame tint that unifies the palette. */}
|
|
@@ -85,6 +66,171 @@ export const Atmosphere: React.FC<Props> = ({ theme, look, quality }) => {
|
|
|
85
66
|
);
|
|
86
67
|
};
|
|
87
68
|
|
|
69
|
+
/** Dispatch to the look's backdrop system. */
|
|
70
|
+
const Backdrop: React.FC<{ theme: Theme; look: CinematicLook; frame: number; lite: boolean }> = ({
|
|
71
|
+
theme,
|
|
72
|
+
look,
|
|
73
|
+
frame,
|
|
74
|
+
lite,
|
|
75
|
+
}) => {
|
|
76
|
+
const accent = chroma(theme.accent);
|
|
77
|
+
const accent2 = chroma(theme.accent2);
|
|
78
|
+
// On light stages the color has to stay pastel or text contrast dies.
|
|
79
|
+
const lightBg = chroma(theme.bg).luminance() > 0.5;
|
|
80
|
+
// Lite keeps most of the backdrop's identity: at half strength the gif's
|
|
81
|
+
// stage read as a flat dark void, which is exactly the look this system
|
|
82
|
+
// replaced. Slightly larger gif files are the accepted price.
|
|
83
|
+
const k = look.glow * (lite ? 0.75 : 1) * (lightBg ? 0.5 : 1);
|
|
84
|
+
|
|
85
|
+
switch (look.backdrop) {
|
|
86
|
+
case "mesh": {
|
|
87
|
+
// Full-bleed gradient field: three large soft color poles (brand hue,
|
|
88
|
+
// second hue, and a deep counter-tone) drifting very slowly. The whole
|
|
89
|
+
// frame carries color variation — no dead flat region, no orb smudge.
|
|
90
|
+
const ax = 22 + drift(frame, 620, 0) * 10;
|
|
91
|
+
const ay = 24 + drift(frame, 760, 1.1) * 8;
|
|
92
|
+
const bx = 80 + drift(frame, 680, 2.3) * 10;
|
|
93
|
+
const by = 30 + drift(frame, 820, 0.5) * 10;
|
|
94
|
+
const cx = 55 + drift(frame, 720, 3.4) * 12;
|
|
95
|
+
const cy = 92 + drift(frame, 900, 1.9) * 6;
|
|
96
|
+
const deep = accent.set("hsl.h", "-30").darken(1.2);
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<AbsoluteFill
|
|
100
|
+
style={{
|
|
101
|
+
background: `radial-gradient(70% 65% at ${ax}% ${ay}%, ${accent.alpha(0.5 * k).css()} 0%, transparent 72%)`,
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
<AbsoluteFill
|
|
105
|
+
style={{
|
|
106
|
+
background: `radial-gradient(65% 60% at ${bx}% ${by}%, ${accent2.alpha(0.38 * k).css()} 0%, transparent 70%)`,
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
<AbsoluteFill
|
|
110
|
+
style={{
|
|
111
|
+
background: `radial-gradient(80% 55% at ${cx}% ${cy}%, ${deep.alpha(0.4 * k).css()} 0%, transparent 75%)`,
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "spotlight": {
|
|
119
|
+
// One aimed light: an elliptical pool centered on the content zone
|
|
120
|
+
// (slightly above frame center), like a subject lit on a dark stage.
|
|
121
|
+
// The light falls ON the content instead of drifting in empty space.
|
|
122
|
+
const sway = drift(frame, 780, 0.4) * 3;
|
|
123
|
+
const warm = accent.brighten(0.4);
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<AbsoluteFill
|
|
127
|
+
style={{
|
|
128
|
+
background: `radial-gradient(52% 44% at ${50 + sway}% 42%, ${warm.alpha(0.34 * k).css()} 0%, ${warm.alpha(0.08 * k).css()} 55%, transparent 74%)`,
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
{/* Faint floor bounce so the lower frame isn't pure void. */}
|
|
132
|
+
<AbsoluteFill
|
|
133
|
+
style={{
|
|
134
|
+
background: `radial-gradient(90% 30% at 50% 104%, ${accent2.alpha(0.14 * k).css()} 0%, transparent 70%)`,
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "sweep": {
|
|
142
|
+
// Saturated full-frame diagonal wash between the two brand hues, with a
|
|
143
|
+
// slow-moving highlight band. Vibrant edge to edge — arcade energy comes
|
|
144
|
+
// from color, not from floating lights.
|
|
145
|
+
const shiftA = 12 + drift(frame, 560, 0.8) * 8;
|
|
146
|
+
const shiftB = 88 + drift(frame, 640, 2.6) * 8;
|
|
147
|
+
const bandY = 50 + drift(frame, 700, 1.5) * 16;
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<AbsoluteFill
|
|
151
|
+
style={{
|
|
152
|
+
background: `linear-gradient(128deg, ${accent.alpha(0.42 * k).css()} 0%, transparent ${shiftA + 34}%), linear-gradient(308deg, ${accent2.alpha(0.36 * k).css()} 0%, transparent ${100 - shiftB + 42}%)`,
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
<AbsoluteFill
|
|
156
|
+
style={{
|
|
157
|
+
background: `linear-gradient(180deg, transparent ${bandY - 24}%, ${accent.brighten(1).alpha(0.1 * k).css()} ${bandY}%, transparent ${bandY + 24}%)`,
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
case "grid": {
|
|
165
|
+
// The engineering grid IS the backdrop: structural lines with a soft
|
|
166
|
+
// cool wash rising from the baseline. Reads as a drafting table, not a
|
|
167
|
+
// dark room with a lamp.
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<AbsoluteFill
|
|
171
|
+
style={{
|
|
172
|
+
background: `linear-gradient(180deg, transparent 55%, ${accent.alpha(0.16 * k).css()} 100%)`,
|
|
173
|
+
}}
|
|
174
|
+
/>
|
|
175
|
+
<StructuralGrid color={theme.border} accent={theme.accent} frame={frame} lite={lite} />
|
|
176
|
+
</>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "field":
|
|
181
|
+
default: {
|
|
182
|
+
// Editorial poster field: a rich duotone wash — second hue at the top,
|
|
183
|
+
// brand hue rising from below — over the deep base. Near-solid, carried
|
|
184
|
+
// by grain and grade rather than by lights.
|
|
185
|
+
return (
|
|
186
|
+
<AbsoluteFill
|
|
187
|
+
style={{
|
|
188
|
+
background: `linear-gradient(180deg, ${accent2.alpha(0.2 * k).css()} 0%, transparent 40%, transparent 62%, ${accent.alpha(0.3 * k).css()} 100%)`,
|
|
189
|
+
}}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Blueprint's structural grid: minor + major lines and a center crosshair
|
|
198
|
+
* tick, strong enough to read as the actual set design instead of a texture.
|
|
199
|
+
*/
|
|
200
|
+
const StructuralGrid: React.FC<{ color: string; accent: string; frame: number; lite: boolean }> = ({
|
|
201
|
+
color,
|
|
202
|
+
accent,
|
|
203
|
+
frame,
|
|
204
|
+
lite,
|
|
205
|
+
}) => {
|
|
206
|
+
const shift = lite ? 0 : (frame * 0.12) % 72;
|
|
207
|
+
const minor = chroma(color).alpha(lite ? 0.08 : 0.13).css();
|
|
208
|
+
const major = chroma(color).alpha(lite ? 0.14 : 0.22).css();
|
|
209
|
+
const tick = chroma(accent).alpha(0.4).css();
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
<AbsoluteFill
|
|
213
|
+
style={{
|
|
214
|
+
backgroundImage: `linear-gradient(${minor} 1px, transparent 1px), linear-gradient(90deg, ${minor} 1px, transparent 1px), linear-gradient(${major} 1px, transparent 1px), linear-gradient(90deg, ${major} 1px, transparent 1px)`,
|
|
215
|
+
backgroundSize: "72px 72px, 72px 72px, 360px 360px, 360px 360px",
|
|
216
|
+
backgroundPosition: `${shift}px ${shift}px`,
|
|
217
|
+
maskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
|
|
218
|
+
WebkitMaskImage: "radial-gradient(130% 110% at 50% 50%, black 35%, transparent 85%)",
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
{/* Center crosshair tick: the drafting-table registration mark. */}
|
|
222
|
+
{!lite && (
|
|
223
|
+
<AbsoluteFill style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
224
|
+
<div style={{ position: "relative", width: 28, height: 28, opacity: 0.5 }}>
|
|
225
|
+
<div style={{ position: "absolute", left: "50%", top: 0, bottom: 0, width: 1, background: tick }} />
|
|
226
|
+
<div style={{ position: "absolute", top: "50%", left: 0, right: 0, height: 1, background: tick }} />
|
|
227
|
+
</div>
|
|
228
|
+
</AbsoluteFill>
|
|
229
|
+
)}
|
|
230
|
+
</>
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
88
234
|
const GridOverlay: React.FC<{ color: string; lite: boolean }> = ({ color, lite }) => {
|
|
89
235
|
const frame = useCurrentFrame();
|
|
90
236
|
const shift = lite ? 0 : (frame * 0.15) % 64;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";
|
|
3
3
|
import { CinematicLook } from "./look";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
@@ -12,34 +12,46 @@ interface Props {
|
|
|
12
12
|
children: React.ReactNode;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// The arrival window: how long the camera keeps settling after the cut before
|
|
16
|
+
// coming to a complete rest, as a fraction of the scene (capped in frames).
|
|
17
|
+
const SETTLE_FRACTION = 0.55;
|
|
18
|
+
const SETTLE_MAX_FRAMES = 90;
|
|
19
|
+
|
|
15
20
|
/**
|
|
16
|
-
* A virtual camera over a scene's content.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* A virtual camera over a scene's content. Each shot ARRIVES with a slow zoom
|
|
22
|
+
* that eases to a complete rest, then holds perfectly still. A zoom that runs
|
|
23
|
+
* for the whole scene re-rasterizes crisp text at a new sub-pixel scale every
|
|
24
|
+
* frame, which reads as a constant vibration on bold type — so all movement is
|
|
25
|
+
* front-loaded into the arrival while the eye is still adjusting to the cut,
|
|
26
|
+
* and the hold (where the viewer actually reads) stays rock solid. Zoom only,
|
|
27
|
+
* anchored to center, so the composition never slides sideways.
|
|
19
28
|
*/
|
|
20
29
|
export const Camera: React.FC<Props> = ({ look, durationInFrames, seed, disabled, children }) => {
|
|
21
30
|
const frame = useCurrentFrame();
|
|
22
31
|
if (disabled) return <AbsoluteFill>{children}</AbsoluteFill>;
|
|
32
|
+
void seed;
|
|
23
33
|
|
|
24
|
-
const
|
|
34
|
+
const settleFrames = Math.min(Math.round(durationInFrames * SETTLE_FRACTION), SETTLE_MAX_FRAMES);
|
|
35
|
+
const p = interpolate(frame, [0, settleFrames], [0, 1], {
|
|
25
36
|
extrapolateLeft: "clamp",
|
|
26
37
|
extrapolateRight: "clamp",
|
|
38
|
+
easing: Easing.out(Easing.cubic),
|
|
27
39
|
});
|
|
28
40
|
const k = look.cameraIntensity;
|
|
29
41
|
|
|
30
42
|
let scale = 1;
|
|
31
43
|
switch (look.camera) {
|
|
32
44
|
case "push":
|
|
33
|
-
scale = interpolate(p, [0, 1], [1
|
|
45
|
+
scale = interpolate(p, [0, 1], [1 - 0.045 * k, 1.0]); // slow zoom in to rest
|
|
34
46
|
break;
|
|
35
47
|
case "drift":
|
|
36
|
-
scale = interpolate(p, [0, 1], [1
|
|
48
|
+
scale = interpolate(p, [0, 1], [1 - 0.03 * k, 1.0]); // gentle zoom in to rest
|
|
37
49
|
break;
|
|
38
50
|
case "pan":
|
|
39
|
-
scale = interpolate(p, [0, 1], [1 + 0.
|
|
51
|
+
scale = interpolate(p, [0, 1], [1 + 0.03 * k, 1.0]); // slow zoom out to rest
|
|
40
52
|
break;
|
|
41
53
|
case "float":
|
|
42
|
-
scale = 1
|
|
54
|
+
scale = interpolate(p, [0, 1], [1 - 0.02 * k, 1.0]); // soft zoom in to rest
|
|
43
55
|
break;
|
|
44
56
|
case "still":
|
|
45
57
|
default:
|
|
@@ -15,6 +15,21 @@ export type CameraMove = "push" | "drift" | "pan" | "float" | "still";
|
|
|
15
15
|
|
|
16
16
|
export type AtmosphereOverlay = "none" | "grid" | "scanlines" | "dust";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The backdrop SYSTEM a look is staged on. This is the look's silhouette: two
|
|
20
|
+
* looks with different backdrops read as different films even before grading.
|
|
21
|
+
* The old single recipe (dark field + drifting radial orbs) was the template's
|
|
22
|
+
* fingerprint — every reel shared it, and an orb in empty space reads as a
|
|
23
|
+
* smudge, not a light. Each system covers the frame with intent instead:
|
|
24
|
+
*
|
|
25
|
+
* - mesh: full-bleed multi-hue gradient field (accent + accent2), slow drift
|
|
26
|
+
* - spotlight: one elliptical light pool aimed where the content sits
|
|
27
|
+
* - sweep: saturated full-frame diagonal gradient between the brand hues
|
|
28
|
+
* - grid: structural engineering grid — the graphic IS the backdrop
|
|
29
|
+
* - field: rich near-solid editorial color field with a soft top wash
|
|
30
|
+
*/
|
|
31
|
+
export type BackdropSystem = "mesh" | "spotlight" | "sweep" | "grid" | "field";
|
|
32
|
+
|
|
18
33
|
export interface Grade {
|
|
19
34
|
/** Tint color layered over the whole frame. */
|
|
20
35
|
color: string;
|
|
@@ -24,7 +39,9 @@ export interface Grade {
|
|
|
24
39
|
|
|
25
40
|
export interface CinematicLook {
|
|
26
41
|
id: LookId;
|
|
27
|
-
/**
|
|
42
|
+
/** Which backdrop system stages the whole reel. */
|
|
43
|
+
backdrop: BackdropSystem;
|
|
44
|
+
/** Backdrop light/color intensity (0..1). */
|
|
28
45
|
glow: number;
|
|
29
46
|
/** Whether a second, cooler light source is added for depth. */
|
|
30
47
|
twoTone: boolean;
|
|
@@ -33,7 +50,7 @@ export interface CinematicLook {
|
|
|
33
50
|
/** Film-grain opacity on the final frame (0..1); skipped for gif output. */
|
|
34
51
|
grain: number;
|
|
35
52
|
overlay: AtmosphereOverlay;
|
|
36
|
-
/**
|
|
53
|
+
/** Arrival move for every scene's content: a zoom that eases to a rest. */
|
|
37
54
|
camera: CameraMove;
|
|
38
55
|
/** Camera amplitude multiplier (1 = default). */
|
|
39
56
|
cameraIntensity: number;
|
|
@@ -53,6 +70,7 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
|
53
70
|
// Clean keynote stage — soft key light, gentle push, long dissolves.
|
|
54
71
|
// Motion: settled, no bounce — elements arrive and hold.
|
|
55
72
|
keynote: {
|
|
73
|
+
backdrop: "mesh",
|
|
56
74
|
glow: 0.5, twoTone: false, vignette: 0.35, grain: 0.04, overlay: "none",
|
|
57
75
|
camera: "push", cameraIntensity: 1, grade: { color: "#ffffff", blend: "soft-light", alpha: 0.05 },
|
|
58
76
|
energy: 0.4,
|
|
@@ -61,22 +79,27 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
|
61
79
|
// Low-key cinema — deep vignette, cool grade, slow drift, dips to black.
|
|
62
80
|
// Motion: heavy and deliberate — weighty mass, slow to settle.
|
|
63
81
|
noir: {
|
|
82
|
+
backdrop: "spotlight",
|
|
64
83
|
glow: 0.42, twoTone: true, vignette: 0.62, grain: 0.09, overlay: "dust",
|
|
65
84
|
camera: "drift", cameraIntensity: 1.1, grade: { color: "#2a3550", blend: "multiply", alpha: 0.22 },
|
|
66
85
|
energy: 0.35,
|
|
67
86
|
motion: { damping: 28, stiffness: 78, mass: 1.4 },
|
|
68
87
|
},
|
|
69
88
|
// Saturated arcade — dual lights, scanlines, snappy whips and zooms.
|
|
70
|
-
// Motion: snappy
|
|
89
|
+
// Motion: snappy but settled — light mass, high stiffness, near-critical
|
|
90
|
+
// damping. Energy comes from speed, not from overshoot: a bouncing element
|
|
91
|
+
// reads as a toy, not a cut (gallery feedback).
|
|
71
92
|
arcade: {
|
|
93
|
+
backdrop: "sweep",
|
|
72
94
|
glow: 0.72, twoTone: true, vignette: 0.3, grain: 0.05, overlay: "scanlines",
|
|
73
95
|
camera: "float", cameraIntensity: 1.25, grade: { color: "#ff3da6", blend: "screen", alpha: 0.06 },
|
|
74
96
|
energy: 0.85,
|
|
75
|
-
motion: { damping:
|
|
97
|
+
motion: { damping: 20, stiffness: 155, mass: 0.7 },
|
|
76
98
|
},
|
|
77
99
|
// Engineering blueprint — cool tint, faint grid, measured pans, mixed cuts.
|
|
78
100
|
// Motion: stiff and precise — quick, minimal bounce, machined.
|
|
79
101
|
blueprint: {
|
|
102
|
+
backdrop: "grid",
|
|
80
103
|
glow: 0.45, twoTone: false, vignette: 0.42, grain: 0.06, overlay: "grid",
|
|
81
104
|
camera: "pan", cameraIntensity: 1, grade: { color: "#1e3a5f", blend: "soft-light", alpha: 0.12 },
|
|
82
105
|
energy: 0.6,
|
|
@@ -85,6 +108,7 @@ const LOOKS: Record<LookId, Omit<CinematicLook, "id">> = {
|
|
|
85
108
|
// Premium brand film — warm grade, big soft vignette, very slow elegant push.
|
|
86
109
|
// Motion: the smoothest — elegant glide, no perceptible bounce.
|
|
87
110
|
editorial: {
|
|
111
|
+
backdrop: "field",
|
|
88
112
|
glow: 0.55, twoTone: false, vignette: 0.5, grain: 0.07, overlay: "none",
|
|
89
113
|
camera: "push", cameraIntensity: 0.7, grade: { color: "#ffb27a", blend: "overlay", alpha: 0.08 },
|
|
90
114
|
energy: 0.3,
|
|
@@ -8,7 +8,9 @@ import { CinematicLook, TransitionStyle } from "./look";
|
|
|
8
8
|
const RHYTHM: Record<string, TransitionStyle[]> = {
|
|
9
9
|
keynote: ["fade", "rise", "fade", "rise"],
|
|
10
10
|
noir: ["dip", "fade", "dip", "rise"],
|
|
11
|
-
|
|
11
|
+
// No flip: a hinged swing reads as a gimmick next to the rest of the edit
|
|
12
|
+
// (gallery feedback) — arcade's energy now comes from fast zooms and whips.
|
|
13
|
+
arcade: ["zoom", "whip", "zoom", "fade"],
|
|
12
14
|
blueprint: ["cut", "wipe", "cut", "whip"],
|
|
13
15
|
editorial: ["fade", "wipe", "rise", "fade"],
|
|
14
16
|
};
|
|
@@ -66,7 +68,7 @@ export const Enter: React.FC<EnterProps> = ({ style, look, fromBlack, seed, chil
|
|
|
66
68
|
break;
|
|
67
69
|
case "punch":
|
|
68
70
|
opacity = interpolate(frame, [0, win * 0.6], [0, 1], ease);
|
|
69
|
-
scale = String(interpolate(p, [0, 1], [1.
|
|
71
|
+
scale = String(interpolate(p, [0, 1], [1.07, 1]));
|
|
70
72
|
break;
|
|
71
73
|
case "zoom":
|
|
72
74
|
opacity = p;
|
|
@@ -10,11 +10,14 @@ interface ArrowProps {
|
|
|
10
10
|
label?: string;
|
|
11
11
|
theme: Theme;
|
|
12
12
|
startFrame?: number;
|
|
13
|
+
/** Line weight; supporting graphics need real presence at video size. */
|
|
14
|
+
strokeWidth?: number;
|
|
15
|
+
labelSize?: number;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
const ARROW_HEAD_SIZE =
|
|
18
|
+
const ARROW_HEAD_SIZE = 14;
|
|
16
19
|
|
|
17
|
-
export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0 }) => {
|
|
20
|
+
export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, startFrame = 0, strokeWidth = 3.5, labelSize = 22 }) => {
|
|
18
21
|
const frame = useCurrentFrame();
|
|
19
22
|
const { fps } = useVideoConfig();
|
|
20
23
|
const elapsed = frame - startFrame;
|
|
@@ -50,7 +53,7 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
|
|
|
50
53
|
x2={x2short}
|
|
51
54
|
y2={y2short}
|
|
52
55
|
stroke={theme.accent}
|
|
53
|
-
strokeWidth={
|
|
56
|
+
strokeWidth={strokeWidth}
|
|
54
57
|
strokeDasharray={len}
|
|
55
58
|
strokeDashoffset={dashOffset}
|
|
56
59
|
strokeLinecap="round"
|
|
@@ -68,9 +71,9 @@ export const Arrow: React.FC<ArrowProps> = ({ x1, y1, x2, y2, label, theme, star
|
|
|
68
71
|
{label && progress > 0.5 && (
|
|
69
72
|
<text
|
|
70
73
|
x={midX}
|
|
71
|
-
y={midY -
|
|
74
|
+
y={midY - 14}
|
|
72
75
|
fill={theme.textMuted}
|
|
73
|
-
fontSize={
|
|
76
|
+
fontSize={labelSize}
|
|
74
77
|
textAnchor="middle"
|
|
75
78
|
fontFamily={theme.fontSans}
|
|
76
79
|
opacity={interpolate(progress, [0.5, 1], [0, 1])}
|
|
@@ -61,19 +61,30 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
61
61
|
framesPerLine = 9,
|
|
62
62
|
}) => {
|
|
63
63
|
const frame = useCurrentFrame();
|
|
64
|
-
const { fps } = useVideoConfig();
|
|
64
|
+
const { fps, width: frameWidth } = useVideoConfig();
|
|
65
65
|
const elapsed = frame - startFrame;
|
|
66
66
|
const lines = code.split("\n");
|
|
67
|
+
// Scale type with the frame so the editor window commands wide canvases
|
|
68
|
+
// (ch-based width sizing inherits this automatically).
|
|
69
|
+
const fontSize = Math.max(22, Math.round(frameWidth * 0.0135));
|
|
67
70
|
const visibleCount = Math.max(0, Math.min(lines.length, Math.floor(elapsed / framesPerLine) + 1));
|
|
68
71
|
|
|
72
|
+
// Size the window to the longest line of the FULL snippet (not the partial
|
|
73
|
+
// reveal) so it arrives at its final width and never stretches to fill the
|
|
74
|
+
// frame — an editor window several times wider than its code reads as empty
|
|
75
|
+
// chrome (gallery feedback). 83px covers gutter, line numbers, and padding.
|
|
76
|
+
const cols = Math.min(Math.max(lines.reduce((m, l) => Math.max(m, l.length), 0), 24), 88);
|
|
77
|
+
|
|
69
78
|
return (
|
|
70
|
-
<div style={{ fontFamily: theme.fontMono, fontSize
|
|
79
|
+
<div style={{ fontFamily: theme.fontMono, fontSize, lineHeight: 1.7, width: `calc(${cols}ch + 83px)`, maxWidth: "100%" }}>
|
|
71
80
|
<div
|
|
72
81
|
style={{
|
|
73
82
|
background: "#0d1117",
|
|
74
|
-
borderRadius:
|
|
83
|
+
borderRadius: 12,
|
|
75
84
|
overflow: "hidden",
|
|
76
|
-
|
|
85
|
+
// Contact shadow + brand-light halo so the window sits in the stage.
|
|
86
|
+
boxShadow: `0 30px 90px -18px ${theme.accent}59, 0 24px 60px rgba(0,0,0,0.55)`,
|
|
87
|
+
outline: "1px solid rgba(255,255,255,0.09)",
|
|
77
88
|
}}
|
|
78
89
|
>
|
|
79
90
|
{/* Title bar */}
|
|
@@ -95,9 +106,11 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
95
106
|
</span>
|
|
96
107
|
</div>
|
|
97
108
|
|
|
98
|
-
{/* Code lines
|
|
109
|
+
{/* Code lines. All rows are laid out from frame 0 (unrevealed ones
|
|
110
|
+
invisible) so the window opens at its final height — the reveal
|
|
111
|
+
happens inside stable chrome instead of inflating it. */}
|
|
99
112
|
<div style={{ padding: "20px 0" }}>
|
|
100
|
-
{lines.
|
|
113
|
+
{lines.map((line, i) => {
|
|
101
114
|
const lineNum = i + 1;
|
|
102
115
|
const isHighlight = highlightLine !== undefined && lineNum === highlightLine;
|
|
103
116
|
const highlightOpacity =
|
|
@@ -115,6 +128,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
115
128
|
style={{
|
|
116
129
|
display: "flex",
|
|
117
130
|
padding: "0 24px",
|
|
131
|
+
visibility: i < visibleCount ? "visible" : "hidden",
|
|
118
132
|
background: isHighlight
|
|
119
133
|
? `rgba(${hexToRgb(theme.accent)}, ${0.15 * highlightOpacity})`
|
|
120
134
|
: "transparent",
|
|
@@ -128,7 +142,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({
|
|
|
128
142
|
color: "#484f58",
|
|
129
143
|
minWidth: 32,
|
|
130
144
|
userSelect: "none",
|
|
131
|
-
fontSize:
|
|
145
|
+
fontSize: Math.round(fontSize * 0.8),
|
|
132
146
|
paddingTop: 1,
|
|
133
147
|
}}
|
|
134
148
|
>
|
|
@@ -79,7 +79,6 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
79
79
|
lineHeight,
|
|
80
80
|
letterSpacing,
|
|
81
81
|
maxWidth,
|
|
82
|
-
textShadow: glow ? "0 6px 40px rgba(0,0,0,0.45)" : undefined,
|
|
83
82
|
}}
|
|
84
83
|
>
|
|
85
84
|
{words.map((word, i) => {
|
|
@@ -92,12 +91,18 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
92
91
|
extrapolateRight: "clamp",
|
|
93
92
|
});
|
|
94
93
|
const isEmph = emphIndices.has(i);
|
|
94
|
+
// The clip mask exists only for the rise. Once the word is at rest the
|
|
95
|
+
// mask is released and the glow fades in — a soft 40px shadow inside an
|
|
96
|
+
// overflow-hidden span gets sheared into visible rectangles, so the two
|
|
97
|
+
// must never coexist (gallery feedback: "shadow looks cut off").
|
|
98
|
+
const settled = y < 1;
|
|
99
|
+
const shadowAlpha = glow && settled ? interpolate(p, [0.991, 1], [0, 0.45], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0;
|
|
95
100
|
return (
|
|
96
101
|
<React.Fragment key={i}>
|
|
97
102
|
<span
|
|
98
103
|
style={{
|
|
99
104
|
display: "inline-block",
|
|
100
|
-
overflow: "hidden",
|
|
105
|
+
overflow: settled ? "visible" : "hidden",
|
|
101
106
|
paddingBottom: "0.14em",
|
|
102
107
|
marginBottom: "-0.14em",
|
|
103
108
|
verticalAlign: "bottom",
|
|
@@ -109,6 +114,7 @@ export const RevealText: React.FC<RevealTextProps> = ({
|
|
|
109
114
|
translate: `0 ${y}%`,
|
|
110
115
|
opacity,
|
|
111
116
|
color: isEmph ? theme.accent : undefined,
|
|
117
|
+
textShadow: shadowAlpha > 0 ? `0 6px 40px rgba(0,0,0,${shadowAlpha.toFixed(3)})` : undefined,
|
|
112
118
|
}}
|
|
113
119
|
>
|
|
114
120
|
{word}
|