reelkit-cli 0.5.0 → 0.8.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/README.md +3 -2
- package/package.json +7 -2
- package/skill/SKILL.md +22 -11
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +32 -17
- package/skill/reference/captions.md +11 -5
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +140 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +25 -8
- package/src/commands/assets.ts +334 -36
- package/src/commands/build.ts +172 -36
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +27 -5
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +67 -7
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +68 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +7 -2
- package/src/project/refmeasure.ts +1 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +133 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/media.ts +5 -3
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/master.ts +31 -0
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +3 -150
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +61 -1
- package/src/testing/fake-api.ts +11 -5
- package/src/testing/fixtures.ts +3 -0
|
@@ -42,7 +42,7 @@ const springOf = (o: Timing): SpringConfig => ({ stiffness: o.stiffness ?? sprin
|
|
|
42
42
|
// Where a value stands at `frame` among sorted keys. The move toward key N starts `lead` frames before key N's frame (or at key N-1's
|
|
43
43
|
// frame when they are closer than that) and ends on key N's frame; before and after a move the value holds. It returns the two keys
|
|
44
44
|
// the frame is between and how far along the move is (from === to when it is holding).
|
|
45
|
-
function locate<K extends { frame: number }>(sorted: K[], frame: number, fps: number, o: Timing): { from: K; to: K; p: number } {
|
|
45
|
+
export function locate<K extends { frame: number }>(sorted: K[], frame: number, fps: number, o: Timing): { from: K; to: K; p: number } {
|
|
46
46
|
const lead = Math.max(1, o.lead ?? DEFAULT_LEAD);
|
|
47
47
|
let from = sorted[0]!;
|
|
48
48
|
for (let j = 1; j < sorted.length; j++) {
|
|
@@ -55,7 +55,7 @@ function locate<K extends { frame: number }>(sorted: K[], frame: number, fps: nu
|
|
|
55
55
|
return { from, to: from, p: 0 };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const mix = (a: number, b: number, p: number) => a + (b - a) * p;
|
|
58
|
+
export const mix = (a: number, b: number, p: number) => a + (b - a) * p;
|
|
59
59
|
|
|
60
60
|
export type BoxKey = { frame: number; x: number; y: number; width: number; height: number; radius?: number; opacity?: number; rotate?: number };
|
|
61
61
|
export type Box = { x: number; y: number; width: number; height: number; radius: number; opacity: number; rotate: number };
|
|
@@ -111,3 +111,37 @@ export function cameraTransform(c: CameraView, w: number, h: number): { origin:
|
|
|
111
111
|
transform: `translate(${px((0.5 - c.x) * w)}, ${px((0.5 - c.y) * h)}) rotate(${Math.round(c.rotate * 1000) / 1000}deg) scale(${Math.round(c.zoom * 10000) / 10000})`,
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// A punch: a quick push of 8 to 15 percent onto a point, for emphasis on a word or a click. It rises over `frames` frames with a small overshoot,
|
|
116
|
+
// holds for `hold` frames and eases back over `release`. The zoom is about the point itself, so the point stays where it is on screen.
|
|
117
|
+
export type Punch = { frame: number; x: number; y: number; amount?: number; frames?: number; hold?: number; release?: number };
|
|
118
|
+
|
|
119
|
+
// 0 before the punch, 1 at its full push, with a little over 1 on the way in (an ease-out-back with a small constant).
|
|
120
|
+
export function punchEnvelope(p: Punch, frame: number): number {
|
|
121
|
+
const rise = Math.max(1, p.frames ?? 5), hold = Math.max(0, p.hold ?? 6), release = Math.max(1, p.release ?? 8);
|
|
122
|
+
const t = frame - p.frame;
|
|
123
|
+
if (t <= 0) return 0;
|
|
124
|
+
if (t < rise) { const u = t / rise - 1, c = 1.1; return 1 + (c + 1) * u ** 3 + c * u ** 2; }
|
|
125
|
+
if (t <= rise + hold) return 1;
|
|
126
|
+
const u = (t - rise - hold) / release;
|
|
127
|
+
return u >= 1 ? 0 : 1 - (3 * u * u - 2 * u * u * u);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The camera with every punch applied. A punch of strength `e` scales the view by 1 + amount * e about its point.
|
|
131
|
+
export function applyPunches(view: CameraView, punches: readonly Punch[], frame: number): CameraView {
|
|
132
|
+
let { x, y, zoom } = view;
|
|
133
|
+
for (const p of punches) {
|
|
134
|
+
const e = punchEnvelope(p, frame);
|
|
135
|
+
if (e === 0) continue;
|
|
136
|
+
const m = 1 + Math.min(0.5, Math.max(0, p.amount ?? 0.1)) * e;
|
|
137
|
+
// Zooming by m with the point P kept still on screen moves the centre to P - (P - C) / m.
|
|
138
|
+
x = p.x - (p.x - x) / m; y = p.y - (p.y - y) / m; zoom *= m;
|
|
139
|
+
}
|
|
140
|
+
return { ...view, x, y, zoom };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The camera key that pushes in until an element fills the frame: the element's box, as fractions of the frame (x and y its centre), so that
|
|
144
|
+
// its longer side just reaches the frame's edge. Put it in `keys`, then the next key pulls out or on to the next element.
|
|
145
|
+
export function zoomTo(frame: number, box: { x: number; y: number; width: number; height: number }): CameraKey {
|
|
146
|
+
return { frame, x: box.x, y: box.y, zoom: Math.max(1, Math.min(1 / Math.max(1e-3, box.width), 1 / Math.max(1e-3, box.height))) };
|
|
147
|
+
}
|
|
@@ -1,31 +1,43 @@
|
|
|
1
1
|
import type { WordTiming } from "../../pipeline/schema";
|
|
2
2
|
|
|
3
|
+
// The music is already down this long before a word starts, and stays down this long after it ends.
|
|
3
4
|
export const DUCK_PAD_SEC = 0.25;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export const
|
|
5
|
+
// The music falls over this long before a word (it is down 0.25 s before the word starts) and rises over this long after a pause.
|
|
6
|
+
export const DUCK_FALL_SEC = 0.25;
|
|
7
|
+
export const DUCK_RISE_SEC = 0.4;
|
|
8
|
+
// Only a gap of at least this long between words lets the music come up; a shorter one keeps it steady so it does not pump. With sentences 0.3 s
|
|
9
|
+
// apart the music holds under a whole narrated passage and lifts in real pauses only: before the first word and after the last.
|
|
10
|
+
export const DUCK_GAP_SEC = 1.2;
|
|
11
|
+
// What the music sits at under a voice, and the level it comes up to, as `volume` multipliers: the voice is 1.
|
|
12
|
+
export const DEFAULT_DUCK_TO = 0.18;
|
|
13
|
+
export const DEFAULT_MUSIC_VOLUME = 0.5;
|
|
7
14
|
export const FADE_OUT_SEC = 1;
|
|
15
|
+
// A dip eases in and out over this many frames just outside its range, so the track never jumps.
|
|
16
|
+
export const DIP_RAMP_FRAMES = 6;
|
|
17
|
+
|
|
18
|
+
export type MusicDip = { from: number; to: number; volume: number };
|
|
8
19
|
|
|
9
20
|
export type SpeechScene = { startFrame: number; words: WordTiming[] };
|
|
10
21
|
|
|
11
22
|
// The stretches of the video, in seconds, during which a voice is heard (with the padding around each word); words closer than
|
|
12
|
-
// DUCK_GAP_SEC are one stretch.
|
|
23
|
+
// DUCK_GAP_SEC (less than 1.2 s) apart are one stretch.
|
|
13
24
|
export function speechSpans(scenes: SpeechScene[], fps: number): [number, number][] {
|
|
14
25
|
const words = scenes.flatMap((s) => s.words.map((w) => [s.startFrame / fps + w.startSec, s.startFrame / fps + w.endSec] as const)).sort((a, b) => a[0] - b[0]);
|
|
15
26
|
const spans: [number, number][] = [];
|
|
16
27
|
for (const [start, end] of words) {
|
|
17
28
|
const last = spans[spans.length - 1];
|
|
18
|
-
if (last && start - (last[1] - DUCK_PAD_SEC)
|
|
29
|
+
if (last && start - (last[1] - DUCK_PAD_SEC) < DUCK_GAP_SEC) last[1] = Math.max(last[1], end + DUCK_PAD_SEC);
|
|
19
30
|
else spans.push([start - DUCK_PAD_SEC, end + DUCK_PAD_SEC]);
|
|
20
31
|
}
|
|
21
32
|
return spans;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
// How far down the music is at `t` seconds, 0 (not at all) to 1 (fully): full inside a stretch,
|
|
25
|
-
|
|
35
|
+
// How far down the music is at `t` seconds, 0 (not at all) to 1 (fully): full inside a stretch, falling over DUCK_FALL_SEC just before its start and
|
|
36
|
+
// rising over DUCK_RISE_SEC just after its end.
|
|
37
|
+
export function duckAmount(spans: [number, number][], t: number): number {
|
|
26
38
|
let amount = 0;
|
|
27
39
|
for (const [a, b] of spans) {
|
|
28
|
-
const edge = Math.min((t - a) /
|
|
40
|
+
const edge = Math.min((t - a) / DUCK_FALL_SEC + 1, (b - t) / DUCK_RISE_SEC + 1);
|
|
29
41
|
amount = Math.max(amount, Math.max(0, Math.min(1, edge)));
|
|
30
42
|
}
|
|
31
43
|
return amount;
|
|
@@ -33,10 +45,15 @@ function duckAmount(spans: [number, number][], t: number): number {
|
|
|
33
45
|
|
|
34
46
|
// The music's volume at a composition frame: `volume` in gaps, `duckTo` under a voice, and a fade to nothing over the last second.
|
|
35
47
|
// `gain` is the track's levelling (linear). Looping needs no handling here: the frame is the composition's, not the track's.
|
|
36
|
-
export function musicVolume(frame: number, o: { fps: number; totalFrames: number; volume: number; duckTo: number; spans: [number, number][]; gain?: number }): number {
|
|
48
|
+
export function musicVolume(frame: number, o: { fps: number; totalFrames: number; volume: number; duckTo: number; spans: [number, number][]; gain?: number; dips?: MusicDip[] }): number {
|
|
37
49
|
const t = frame / o.fps;
|
|
38
50
|
const duck = duckAmount(o.spans, t);
|
|
39
|
-
|
|
51
|
+
let base = o.volume + (Math.min(o.duckTo, o.volume) - o.volume) * duck;
|
|
52
|
+
// A dip is a rest that the author asked for: inside it the track is held at the dip's volume, with a short ramp at each edge.
|
|
53
|
+
for (const d of o.dips ?? []) {
|
|
54
|
+
const near = Math.max(0, Math.min(1, Math.min(frame - d.from, d.to - frame) / DIP_RAMP_FRAMES + 1));
|
|
55
|
+
if (near > 0) base = Math.min(base, base + (d.volume - base) * near);
|
|
56
|
+
}
|
|
40
57
|
const fade = Math.max(0, Math.min(1, (o.totalFrames - frame) / (FADE_OUT_SEC * o.fps)));
|
|
41
58
|
return base * fade * (o.gain ?? 1);
|
|
42
59
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// three.js 0.186 prints a deprecation notice for THREE.Clock from inside @react-three/fiber, once per canvas, to the browser console. Renders forward
|
|
2
|
+
// every console line, so that one notice would fill the output of render, preview and check. This drops that exact warning and nothing else.
|
|
3
|
+
const NOTICE = "THREE.Clock: This module has been deprecated";
|
|
4
|
+
const GUARD = "__reelkitQuietThree";
|
|
5
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
6
|
+
if (typeof console !== "undefined" && !g[GUARD]) {
|
|
7
|
+
g[GUARD] = true;
|
|
8
|
+
const warn = console.warn.bind(console);
|
|
9
|
+
console.warn = (...args: unknown[]) => { if (typeof args[0] === "string" && args[0].startsWith(NOTICE)) return; warn(...args); };
|
|
10
|
+
}
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { typeface } from "./Text3D";
|
|
2
|
+
import { centred, MAX_PIECES, MAX_POINTS } from "./three-fx-math";
|
|
3
|
+
import { hash01 } from "./seeded";
|
|
4
|
+
import type { Vec3 } from "./three-math";
|
|
5
|
+
|
|
6
|
+
type Poly = { x: number; y: number }[];
|
|
7
|
+
// The outline of every glyph of a word in the kit's typeface, outlines and holes together: a point is inside a letter when it is inside an odd number of them.
|
|
8
|
+
function outlines(text: string): { polys: Poly[]; box: { x0: number; y0: number; x1: number; y1: number } } {
|
|
9
|
+
const polys: Poly[] = [];
|
|
10
|
+
for (const shape of typeface().generateShapes(text, 1)) {
|
|
11
|
+
polys.push(shape.getPoints(10));
|
|
12
|
+
for (const hole of shape.holes) polys.push(hole.getPoints(10));
|
|
13
|
+
}
|
|
14
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
15
|
+
for (const poly of polys) for (const p of poly) { x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); }
|
|
16
|
+
return { polys, box: { x0, y0, x1, y1 } };
|
|
17
|
+
}
|
|
18
|
+
function inside(polys: Poly[], x: number, y: number): boolean {
|
|
19
|
+
let hit = false;
|
|
20
|
+
for (const poly of polys) for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
21
|
+
const a = poly[i]!, b = poly[j]!;
|
|
22
|
+
if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit;
|
|
23
|
+
}
|
|
24
|
+
return hit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The slots of a short word, as blocks: cells of a grid that fall inside the letters, about `count` of them (never over 4000), centred, the word's width 1.
|
|
28
|
+
export function textSlots(text: string, count: number): Vec3[] {
|
|
29
|
+
const { polys, box } = outlines(text);
|
|
30
|
+
if (!polys.length) return [[0, 0, 0]];
|
|
31
|
+
const w = box.x1 - box.x0, h = box.y1 - box.y0, want = Math.max(8, Math.min(MAX_PIECES, Math.round(count)));
|
|
32
|
+
// The share of the box the letters fill, estimated on a coarse sample, gives the cell size at which about `want` cells land inside.
|
|
33
|
+
let hits = 0;
|
|
34
|
+
const probe = 40;
|
|
35
|
+
for (let a = 0; a < probe; a++) for (let b = 0; b < probe; b++) if (inside(polys, box.x0 + ((a + 0.5) / probe) * w, box.y0 + ((b + 0.5) / probe) * h)) hits++;
|
|
36
|
+
const fill = Math.max(0.05, hits / (probe * probe)), cell = Math.sqrt((w * h * fill) / want);
|
|
37
|
+
const out: Vec3[] = [];
|
|
38
|
+
for (let y = box.y0 + cell / 2; y < box.y1; y += cell) for (let x = box.x0 + cell / 2; x < box.x1; x += cell) if (inside(polys, x, y)) out.push([x, y, 0]);
|
|
39
|
+
const stride = Math.max(1, Math.ceil(out.length / MAX_PIECES));
|
|
40
|
+
return centred(stride > 1 ? out.filter((_, i) => i % stride === 0) : out);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `count` seeded points inside the letters of a word, as a flat array, centred, the width 1, a little thickness in z.
|
|
44
|
+
export function textPoints(text: string, count: number, seed = 0): Float32Array {
|
|
45
|
+
const { polys, box } = outlines(text), n = Math.max(1, Math.min(MAX_POINTS, Math.round(count))), w = box.x1 - box.x0, h = box.y1 - box.y0;
|
|
46
|
+
const pts: Vec3[] = [];
|
|
47
|
+
for (let i = 0, k = 0; pts.length < n && k < n * 60; k++) {
|
|
48
|
+
const x = box.x0 + hash01(k, seed + 51) * w, y = box.y0 + hash01(k, seed + 52) * h;
|
|
49
|
+
if (inside(polys, x, y)) { pts.push([x, y, (hash01(i++, seed + 53) - 0.5) * 0.12 * w]); }
|
|
50
|
+
}
|
|
51
|
+
while (pts.length < n) pts.push(pts[pts.length % Math.max(1, pts.length)] ?? [0, 0, 0]);
|
|
52
|
+
const c = centred(pts), out = new Float32Array(n * 3);
|
|
53
|
+
c.forEach((p, i) => { out[i * 3] = p[0]; out[i * 3 + 1] = p[1]; out[i * 3 + 2] = p[2]; });
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import type { Camera3DKey } from "./three-math";
|
|
3
|
+
|
|
4
|
+
// The camera path of the Scene3D a 3D component sits in, so that Text3D and Orbit3D can size themselves to the closest the camera comes.
|
|
5
|
+
export const CameraKeysContext = createContext<readonly Camera3DKey[] | undefined>(undefined);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Seeded randomness for everything the kit draws or places: the same seed always gives the same numbers, so a frame can be drawn on its own. Never Math.random.
|
|
2
|
+
export function mulberry32(seed: number): () => number {
|
|
3
|
+
let a = seed >>> 0;
|
|
4
|
+
return () => {
|
|
5
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
6
|
+
let t = a;
|
|
7
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
8
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
9
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
// One number in [0, 1) from two integers (an index and a seed), with no state.
|
|
13
|
+
export const hash01 = (i: number, seed = 0): number => mulberry32((Math.imul(i | 0, 2654435761) ^ Math.imul(seed | 0, 40503) ^ 0x9e3779b9) >>> 0)();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The pure parts of SoundCues: nothing here needs React or Remotion, so it can be tested and used in a composition's own timing code.
|
|
2
|
+
import { travel } from "./motion-math";
|
|
3
|
+
import { PEAKS_ON_BOUNDARY, transitionSpan, type TransitionKind } from "./transition-math";
|
|
4
|
+
import { punchEnvelope, type Punch } from "./motion-math";
|
|
5
|
+
import { springs } from "./theme";
|
|
6
|
+
|
|
7
|
+
// One sound to play: `at` is an absolute frame of the video, `sound` a key into the sounds map, `volume` is this cue's own level.
|
|
8
|
+
export type SoundCue = { at: number; sound: string; volume?: number };
|
|
9
|
+
|
|
10
|
+
// A cue on every `every`th beat of the beats that fall from `from` to `to` (both included, in frames), starting with the first of them.
|
|
11
|
+
// `every` below 1 or not a whole number is treated as 1 so that a slip never silently drops all the cues.
|
|
12
|
+
export function cuesOnBeats(
|
|
13
|
+
beatFrames: number[],
|
|
14
|
+
opts: { from: number; to: number; every: number; sound: string; volume?: number },
|
|
15
|
+
): SoundCue[] {
|
|
16
|
+
const every = Number.isFinite(opts.every) && opts.every >= 1 ? Math.floor(opts.every) : 1;
|
|
17
|
+
return beatFrames
|
|
18
|
+
.filter((b) => b >= opts.from && b <= opts.to)
|
|
19
|
+
.filter((_, i) => i % every === 0)
|
|
20
|
+
.map((b) => ({ at: b, sound: opts.sound, ...(opts.volume !== undefined ? { volume: opts.volume } : {}) }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The frame at which a sound `leadFrames` long must start so that it ENDS on `frame`: a riser or a whoosh into a cut or a reveal.
|
|
24
|
+
// Never before the first frame of the video.
|
|
25
|
+
export const cueBefore = (frame: number, leadFrames: number): number => Math.max(0, Math.round(frame - leadFrames));
|
|
26
|
+
|
|
27
|
+
// Where the eye sees a scene end. A SceneFrame transition is complete exactly on the boundary (the next scene's start), but the picture changes most
|
|
28
|
+
// in the middle of it, which is half the exit's frames before the boundary (4 frames for the default 8, 3 for `transitionFrames={6}`, 3 for "rgb-whip", whose default is 6); a "cut" has no
|
|
29
|
+
// such span, and neither do "color-push", "flash", "ring" and "flip", which are fullest on the boundary itself. A hit placed on the boundary lands after the change, which is why it reads as late. `exit` and `transitionFrames` are SceneFrame's own.
|
|
30
|
+
export function changeFrame(scene: { startFrame: number; durationFrames: number; exit?: TransitionKind; transitionFrames?: number }): number {
|
|
31
|
+
const boundary = scene.startFrame + scene.durationFrames;
|
|
32
|
+
// A cut, and the transitions that are fullest on the frame the next scene starts (a colour field covering the frame, a flash, a closed ring, an inverted frame), peak on the boundary.
|
|
33
|
+
if (scene.exit && PEAKS_ON_BOUNDARY.includes(scene.exit)) return boundary;
|
|
34
|
+
const frames = Math.min(transitionSpan(scene.exit, scene.transitionFrames), Math.floor(scene.durationFrames / 2));
|
|
35
|
+
return Math.max(scene.startFrame, Math.round(boundary - frames / 2));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A cue for each scene change, where the eye sees it: an impact on the change's visual peak (changeFrame) and a whoosh that ENDS on that same frame.
|
|
39
|
+
// `scenes` are the manifest's scenes, each with its SceneFrame `exit` and `transitionFrames` if it has them, in order; the last scene has no change after
|
|
40
|
+
// it. `whoosh.frames` is the whoosh's length in frames (from the search line, seconds times 30). Leave out whichever of `impact` and `whoosh` you do not want.
|
|
41
|
+
// `skip` lists scene indices whose change gets no cue (a quiet one). Cues come out in order of frame.
|
|
42
|
+
export function cuesOnChanges(
|
|
43
|
+
scenes: { startFrame: number; durationFrames: number; exit?: TransitionKind; transitionFrames?: number }[],
|
|
44
|
+
opts: { impact?: { sound: string; volume?: number }; whoosh?: { sound: string; volume?: number; frames: number }; skip?: number[] },
|
|
45
|
+
): SoundCue[] {
|
|
46
|
+
const cues: SoundCue[] = [];
|
|
47
|
+
scenes.slice(0, -1).forEach((scene, i) => {
|
|
48
|
+
if (opts.skip?.includes(i)) return;
|
|
49
|
+
const at = changeFrame(scene);
|
|
50
|
+
if (opts.whoosh) cues.push({ at: cueBefore(at, opts.whoosh.frames), sound: opts.whoosh.sound, ...(opts.whoosh.volume !== undefined ? { volume: opts.whoosh.volume } : {}) });
|
|
51
|
+
if (opts.impact) cues.push({ at, sound: opts.impact.sound, ...(opts.impact.volume !== undefined ? { volume: opts.impact.volume } : {}) });
|
|
52
|
+
});
|
|
53
|
+
return cues.sort((a, b) => a.at - b.at);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Where the eye sees a camera move or a punch: the frame the picture changes fastest. A Camera key at `frame` is reached by a spring that starts
|
|
57
|
+
// `lead` frames earlier (12 unless set), so the fastest frame is about 9 frames before the key's own frame, not on it; a punch (`{ punch: true }`)
|
|
58
|
+
// rises over its first `frames` frames (5) and is fastest right after its frame. Place a hit there. `frame` is in the clock the Camera counts (inside a
|
|
59
|
+
// SceneFrame that is the scene's own frame: add the scene's startFrame for an absolute cue).
|
|
60
|
+
export function cueOnCamera(frame: number, o: { punch?: boolean; punchFrames?: number; lead?: number; stiffness?: number; damping?: number; fps?: number } = {}): number {
|
|
61
|
+
if (o.punch) {
|
|
62
|
+
const p: Punch = { frame, x: 0.5, y: 0.5, frames: o.punchFrames };
|
|
63
|
+
let best = 1, top = -1;
|
|
64
|
+
for (let k = 1; k <= (o.punchFrames ?? 5); k++) { const v = punchEnvelope(p, frame + k) - punchEnvelope(p, frame + k - 1); if (v > top) { top = v; best = k; } }
|
|
65
|
+
return frame + best;
|
|
66
|
+
}
|
|
67
|
+
const lead = Math.max(1, o.lead ?? 12), config = { stiffness: o.stiffness ?? springs.smooth.stiffness, damping: o.damping ?? springs.smooth.damping };
|
|
68
|
+
let best = 1, top = -1;
|
|
69
|
+
for (let k = 1; k <= lead; k++) { const v = travel(k / lead, o.fps ?? 30, config, lead) - travel((k - 1) / lead, o.fps ?? 30, config, lead); if (v > top) { top = v; best = k; } }
|
|
70
|
+
return frame - lead + best;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// What SoundCues plays: for each cue its start frame, its file and its final volume (cue volume times master times the file's levelling gain).
|
|
74
|
+
// A cue whose sound is not in `sounds` is a mistake in the composition and is reported with the names that exist.
|
|
75
|
+
export function resolveCues(args: {
|
|
76
|
+
cues: SoundCue[];
|
|
77
|
+
sounds: Record<string, string>;
|
|
78
|
+
volume?: number;
|
|
79
|
+
urls?: Record<string, string>;
|
|
80
|
+
gainFor: (src: string) => number;
|
|
81
|
+
}): { from: number; src: string; volume: number }[] {
|
|
82
|
+
const master = args.volume ?? 1;
|
|
83
|
+
return args.cues.map((c) => {
|
|
84
|
+
const src = args.sounds[c.sound];
|
|
85
|
+
if (src === undefined) throw new Error(`SoundCues: a cue uses the sound "${c.sound}", which is not in sounds. The sounds are: ${Object.keys(args.sounds).join(", ") || "none"}.`);
|
|
86
|
+
const file = args.urls?.[src] ?? src;
|
|
87
|
+
return { from: Math.max(0, Math.round(c.at)), src: file, volume: (c.volume ?? 0.35) * master * args.gainFor(src) };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Sound for interface events: turns the frames a component reports (promptFrames, lineFrames, clickFrames, settleFrame, ...) into SoundCues with a sensible
|
|
2
|
+
// sound role, level and density for the kind of event. Pure and deterministic: the same events always give the same cues. A cue's `sound` is a ROLE (like
|
|
3
|
+
// "type-tick"); the film's `sounds` map says which pulled file plays each role.
|
|
4
|
+
import { hash01 } from "./seeded";
|
|
5
|
+
import type { SoundCue } from "./sound-cues";
|
|
6
|
+
|
|
7
|
+
export const CUE_KINDS = ["type", "click", "snap", "pop", "count", "stream", "assemble", "resolve"] as const;
|
|
8
|
+
export type CueKind = (typeof CUE_KINDS)[number];
|
|
9
|
+
|
|
10
|
+
// `volume` is [without a narrator, under a narrator]: films with no voice use 0.35 to 0.6 for the main hits and less for small ticks; under a voice nothing is over 0.3.
|
|
11
|
+
export type CueRole = { search: string; seconds: number; volume: [number, number] };
|
|
12
|
+
export const CUE_TABLE: Record<CueKind, { about: string; per10s: string; roles: Record<string, CueRole> }> = {
|
|
13
|
+
type: {
|
|
14
|
+
about: "typed text: sparse key ticks, one every 2 to 3 characters, at most 12 a second, a little different in level each, and a thock on the return at the end",
|
|
15
|
+
per10s: "1 to 3 runs of 6 to 12 ticks",
|
|
16
|
+
roles: {
|
|
17
|
+
"type-tick": { search: "mouse click", seconds: 0.2, volume: [0.2, 0.1] },
|
|
18
|
+
"type-return": { search: "glass tap", seconds: 0.5, volume: [0.4, 0.2] },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
click: { about: "a cursor click or a button press", per10s: "1 to 3", roles: { click: { search: "mouse click", seconds: 0.2, volume: [0.4, 0.22] } } },
|
|
22
|
+
snap: { about: "a card or a panel settling", per10s: "2 to 5", roles: { snap: { search: "soft impact", seconds: 0.7, volume: [0.45, 0.25] } } },
|
|
23
|
+
pop: { about: "a badge, a chip, a bubble appearing", per10s: "3 to 6", roles: { pop: { search: "bubble pop", seconds: 0.5, volume: [0.4, 0.22] } } },
|
|
24
|
+
count: {
|
|
25
|
+
about: "a number counting: a train of ticks that speeds up, ending on a settle chime on settleFrame",
|
|
26
|
+
per10s: "0 to 2 trains",
|
|
27
|
+
roles: { "count-tick": { search: "counter ticks", seconds: 0.1, volume: [0.2, 0.1] }, "count-settle": { search: "success chime", seconds: 1, volume: [0.45, 0.25] } },
|
|
28
|
+
},
|
|
29
|
+
stream: { about: "terminal lines or streamed text: one soft tick per line, at most 6 a second", per10s: "5 to 15", roles: { stream: { search: "glass tap", seconds: 0.5, volume: [0.18, 0.1] } } },
|
|
30
|
+
assemble: {
|
|
31
|
+
about: "pieces building up: a rising run of small hits that gets louder, ending on a lock hit",
|
|
32
|
+
per10s: "0 to 1",
|
|
33
|
+
roles: { "assemble-run": { search: "counter ticks", seconds: 0.1, volume: [0.25, 0.12] }, "assemble-lock": { search: "soft impact", seconds: 0.7, volume: [0.55, 0.3] } },
|
|
34
|
+
},
|
|
35
|
+
resolve: {
|
|
36
|
+
about: "the logo or the end card: a riser that ends on a chime, with a clear second of nothing before the riser",
|
|
37
|
+
per10s: "1 per film, at the end",
|
|
38
|
+
roles: { "resolve-riser": { search: "whoosh buildup", seconds: 1.2, volume: [0.35, 0.2] }, "resolve-chime": { search: "sparkle shimmer", seconds: 1.5, volume: [0.55, 0.3] } },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The seconds a key tick may be spaced by at the closest: at most this many a second.
|
|
43
|
+
const MAX_TICKS_PER_SECOND = 12;
|
|
44
|
+
const MAX_STREAM_PER_SECOND = 6;
|
|
45
|
+
|
|
46
|
+
export type CueOptions = {
|
|
47
|
+
// The scene's startFrame (or any frame to add) when the events are in a scene's own clock; 0 for absolute frames.
|
|
48
|
+
offset?: number;
|
|
49
|
+
fps?: number;
|
|
50
|
+
// Under a narrator: the quieter levels.
|
|
51
|
+
voice?: boolean;
|
|
52
|
+
// A master level multiplied into every cue (default 1).
|
|
53
|
+
volume?: number;
|
|
54
|
+
seed?: number;
|
|
55
|
+
// "resolve" only: how long the riser is in frames (default 36: it ends on the chime).
|
|
56
|
+
riserFrames?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const level = (kind: CueKind, role: string, o: CueOptions) => CUE_TABLE[kind].roles[role]!.volume[o.voice ? 1 : 0] * (o.volume ?? 1);
|
|
60
|
+
const r3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
61
|
+
|
|
62
|
+
// Never two cues for one role on the same frame: the later is dropped. Frames are rounded, never below 0, and the cues come out in order.
|
|
63
|
+
function unique(cues: SoundCue[]): SoundCue[] {
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
return cues.filter((c) => { const k = `${c.sound}@${c.at}`; if (seen.has(k)) return false; seen.add(k); return true; }).sort((a, b) => a.at - b.at);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Drops events closer than `gap` frames to the one kept before.
|
|
69
|
+
const spaced = (frames: number[], gap: number): number[] => { const out: number[] = []; for (const f of frames) if (!out.length || f - out[out.length - 1]! >= gap) out.push(f); return out; };
|
|
70
|
+
|
|
71
|
+
// The cues for one kind of event. `events` are frames in the component's clock (add `offset` to make them absolute):
|
|
72
|
+
// "type": the frame of every typed character (promptFrames(...).type); ticks on every 2nd or 3rd, a return thock one frame after the last character is typed
|
|
73
|
+
// "click" | "snap" | "pop": the frames of the events (clickFrames, panelSettleFrame, chapterFrames(...).badges)
|
|
74
|
+
// "count": [startFrame, settleFrame] (rollStart and settleFrame of CounterRoll): a speeding tick train, then the chime ON settleFrame
|
|
75
|
+
// "stream": the frame of every line (terminalFrames(...).lines): one soft tick per line, at most 6 a second
|
|
76
|
+
// "assemble": [startFrame, lockFrame] (assembleProgress start and the frame the last piece lands): a rising run, then the lock ON lockFrame
|
|
77
|
+
// "resolve": [frame] the frame the logo lands: the riser ends on it and the chime is ON it (use clearBefore to keep the second before the riser silent)
|
|
78
|
+
export function cuesFor(events: readonly number[], kind: CueKind, o: CueOptions = {}): SoundCue[] {
|
|
79
|
+
if (!CUE_KINDS.includes(kind)) throw new Error(`cuesFor: unknown kind "${kind}". The kinds are: ${CUE_KINDS.join(", ")}.`);
|
|
80
|
+
const fps = o.fps ?? 30, off = o.offset ?? 0, seed = o.seed ?? 0;
|
|
81
|
+
const at = (f: number) => Math.max(0, Math.round(f + off));
|
|
82
|
+
const cue = (f: number, sound: string, volume: number): SoundCue => ({ at: at(f), sound, volume: r3(volume) });
|
|
83
|
+
const f = [...events].filter(Number.isFinite).sort((a, b) => a - b);
|
|
84
|
+
if (!f.length) return [];
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "type": {
|
|
87
|
+
// Every 2nd character, every 3rd if that would be over 12 a second, more if it is still too fast (the typing itself is faster than any key could be).
|
|
88
|
+
const span = Math.max(1, (f[f.length - 1]! - f[0]!) / fps), per = f.length / span;
|
|
89
|
+
let every = 2;
|
|
90
|
+
while (per / every > MAX_TICKS_PER_SECOND) every++;
|
|
91
|
+
const ticks = f.filter((_, i) => i % every === 0).map((fr, i) => cue(fr, "type-tick", level("type", "type-tick", o) * (0.8 + 0.4 * hash01(i, seed))));
|
|
92
|
+
return unique([...ticks, cue(f[f.length - 1]! + 1, "type-return", level("type", "type-return", o))]);
|
|
93
|
+
}
|
|
94
|
+
case "click": return unique(spaced(f, 3).map((fr) => cue(fr, "click", level("click", "click", o))));
|
|
95
|
+
case "snap": return unique(spaced(f, 3).map((fr) => cue(fr, "snap", level("snap", "snap", o))));
|
|
96
|
+
case "pop": return unique(spaced(f, 3).map((fr, i) => cue(fr, "pop", level("pop", "pop", o) * (0.9 + 0.2 * hash01(i, seed)))));
|
|
97
|
+
case "stream": return unique(spaced(f, Math.ceil(fps / MAX_STREAM_PER_SECOND)).map((fr) => cue(fr, "stream", level("stream", "stream", o))));
|
|
98
|
+
case "count": case "assemble": {
|
|
99
|
+
const role = kind === "count" ? ["count-tick", "count-settle"] : ["assemble-run", "assemble-lock"];
|
|
100
|
+
const start = f[0]!, end = f.length > 1 ? f[f.length - 1]! : f[0]! + fps;
|
|
101
|
+
const len = Math.max(1, end - start);
|
|
102
|
+
// The ticks bunch up toward the end (the roll eases out, the build gathers speed): positions (i/n)^0.6 of the way, no closer than the cap allows.
|
|
103
|
+
const n = Math.min(Math.floor((len / fps) * MAX_TICKS_PER_SECOND), 24);
|
|
104
|
+
const gap = Math.ceil(fps / MAX_TICKS_PER_SECOND);
|
|
105
|
+
const frames = spaced(Array.from({ length: n }, (_, i) => Math.round(start + len * ((i + 0.5) / n) ** (kind === "count" ? 0.6 : 1.4))), gap).filter((fr) => end - fr >= gap);
|
|
106
|
+
const base = level(kind, role[0]!, o);
|
|
107
|
+
const run = frames.map((fr, i) => cue(fr, role[0]!, kind === "assemble" ? base * (0.5 + 1.0 * (i / Math.max(1, frames.length - 1))) : base * (0.85 + 0.3 * hash01(i, seed))));
|
|
108
|
+
return unique([...run, cue(end, role[1]!, level(kind, role[1]!, o))]);
|
|
109
|
+
}
|
|
110
|
+
case "resolve": {
|
|
111
|
+
const end = f[0]!, rise = o.riserFrames ?? 36;
|
|
112
|
+
return unique([cue(Math.max(0, end - rise), "resolve-riser", level("resolve", "resolve-riser", o)), cue(end, "resolve-chime", level("resolve", "resolve-chime", o))]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Keeps the second of nothing before a resolve: removes every cue that starts in the `seconds` (default 1) before the riser does, and the riser's own span is kept.
|
|
118
|
+
// `frame` is the frame the logo lands (the chime); cues of the resolve itself are never removed.
|
|
119
|
+
export function clearBefore(cues: readonly SoundCue[], frame: number, o: { seconds?: number; fps?: number; riserFrames?: number } = {}): SoundCue[] {
|
|
120
|
+
const fps = o.fps ?? 30, riserStart = frame - (o.riserFrames ?? 36), from = riserStart - (o.seconds ?? 1) * fps;
|
|
121
|
+
return cues.filter((c) => c.sound.startsWith("resolve-") || c.at < from || c.at >= frame);
|
|
122
|
+
}
|
|
@@ -3,6 +3,7 @@ import { loadFont as loadDisplay } from "@remotion/google-fonts/Sora";
|
|
|
3
3
|
import { loadFont as loadArchivoBlack } from "@remotion/google-fonts/ArchivoBlack";
|
|
4
4
|
import { loadFont as loadBebasNeue } from "@remotion/google-fonts/BebasNeue";
|
|
5
5
|
import { loadFont as loadGreatVibes } from "@remotion/google-fonts/GreatVibes";
|
|
6
|
+
import { loadFont as loadJetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
|
|
6
7
|
import { loadFont as loadKanit } from "@remotion/google-fonts/Kanit";
|
|
7
8
|
import { loadFont as loadModak } from "@remotion/google-fonts/Modak";
|
|
8
9
|
import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat";
|
|
@@ -77,6 +78,7 @@ const loaders = {
|
|
|
77
78
|
// Genre faces for film-style titles (original designs, not imitations of any film's logo):
|
|
78
79
|
cinzel: () => loadCinzel("normal", { weights: ["700", "900"], ...latin }), // Roman inscription capitals: epic, historical
|
|
79
80
|
playfairDisplay: () => loadPlayfairDisplay("normal", { weights: ["700", "900"], ...latin }), // high-contrast serif: elegant, editorial
|
|
81
|
+
jetbrainsMono: () => loadJetBrainsMono("normal", { weights: ["400", "700"], ...latin }), // monospace: terminals, code, small labels
|
|
80
82
|
orbitron: () => loadOrbitron("normal", { weights: ["700", "900"], ...latin }), // geometric sci-fi
|
|
81
83
|
rye: () => loadRye("normal", { weights: ["400"], ...latin }), // western poster
|
|
82
84
|
creepster: () => loadCreepster("normal", { weights: ["400"], ...latin }), // horror
|