reelkit-cli 0.6.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 +2 -2
- package/package.json +5 -1
- package/skill/SKILL.md +20 -9
- 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 +25 -19
- package/skill/reference/captions.md +11 -5
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +139 -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 +18 -7
- package/src/commands/assets.ts +310 -30
- package/src/commands/build.ts +148 -35
- package/src/commands/init.ts +1 -1
- 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 +4 -2
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +44 -10
- 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/manifest.ts +59 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +4 -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/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 +132 -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/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/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +12 -0
- package/src/testing/fake-api.ts +4 -4
- package/src/testing/fixtures.ts +3 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Measures how a finished film sounds against what it shows: how often something hits, how often a swell rises, and how many of the big
|
|
2
|
+
// changes in the picture have a hit beside them. Everything but the two decoders is a pure function over arrays.
|
|
3
|
+
import { loudnessLufs, meanAbsDiffs, probeVideo, tool } from "./refmeasure";
|
|
4
|
+
|
|
5
|
+
export const AUDIO_RATE = 22050;
|
|
6
|
+
export const PICTURE_FPS = 30;
|
|
7
|
+
export const PICTURE_WIDTH = 64;
|
|
8
|
+
const WINDOW_SEC = 0.046, HOP_SEC = 0.012;
|
|
9
|
+
// A hit this close to a picture change belongs to it.
|
|
10
|
+
export const HIT_NEAR_SEC = 0.1;
|
|
11
|
+
// Nothing quieter than about -50 dBFS counts as sound, so a track that is only dither does not start the film.
|
|
12
|
+
const AUDIBLE_RMS = 0.003;
|
|
13
|
+
// A swell lives in the 0.5 to 10 kHz band, the air of a whoosh or a riser. Measured on the library's own sounds, whooshes keep their energy between
|
|
14
|
+
// 0.2 and 5 kHz (the Cinematic and Quantum Motion ones put 40 to 90 percent of it between 0.5 and 5 kHz, risers 40 to 95 percent between 2 and 10 kHz)
|
|
15
|
+
// and only a riser has a real share above 5 kHz, so a 5 to 10 kHz band could not hear a whoosh at all. Bass and kicks stay below it.
|
|
16
|
+
export const SWELL_BAND: [number, number] = [500, 10000];
|
|
17
|
+
// The band must be at least this loud (about -50 dBFS) to count as a swell, so that silence and dither are not counted.
|
|
18
|
+
const BAND_FLOOR = 0.003;
|
|
19
|
+
// A swell is a rise of the band above its own local level: the level is the median of the band over about 1.5 s around it, the rise must reach
|
|
20
|
+
// SWELL_RISE_DB above it, stay above SWELL_EDGE_DB for at least SWELL_MIN_SEC, and take at least SWELL_RAMP_SEC to build (a click or a hit does not).
|
|
21
|
+
const FLOOR_SEC = 1.5, SWELL_EDGE_DB = 2, SWELL_RISE_DB = 4, SWELL_MIN_SEC = 0.12, SWELL_RAMP_SEC = 0.08;
|
|
22
|
+
|
|
23
|
+
const mean = (a: ArrayLike<number>) => { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]!; return a.length ? s / a.length : 0; };
|
|
24
|
+
const sd = (a: ArrayLike<number>, m = mean(a)) => { let s = 0; for (let i = 0; i < a.length; i++) s += (a[i]! - m) ** 2; return a.length ? Math.sqrt(s / a.length) : 0; };
|
|
25
|
+
|
|
26
|
+
// The root-mean-square level of each ~46 ms window, every ~12 ms.
|
|
27
|
+
export function envelope(samples: ArrayLike<number>, rate: number): { values: Float64Array; hopSec: number } {
|
|
28
|
+
const win = Math.max(1, Math.round(WINDOW_SEC * rate)), hop = Math.max(1, Math.round(HOP_SEC * rate));
|
|
29
|
+
const n = samples.length >= win ? Math.floor((samples.length - win) / hop) + 1 : 0;
|
|
30
|
+
const values = new Float64Array(n);
|
|
31
|
+
for (let f = 0; f < n; f++) {
|
|
32
|
+
let s = 0;
|
|
33
|
+
for (let i = f * hop, end = i + win; i < end; i++) s += samples[i]! * samples[i]!;
|
|
34
|
+
values[f] = Math.sqrt(s / win);
|
|
35
|
+
}
|
|
36
|
+
return { values, hopSec: hop / rate };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Peaks of a series that rise above `threshold`, at least `gap` entries apart (the higher of two close ones is kept).
|
|
40
|
+
function pickPeaks(series: ArrayLike<number>, threshold: number, gap: number): number[] {
|
|
41
|
+
const peaks: number[] = [];
|
|
42
|
+
for (let i = 0; i < series.length; i++) {
|
|
43
|
+
const v = series[i]!;
|
|
44
|
+
if (!(v > threshold) || v < (series[i - 1] ?? -Infinity) || v < (series[i + 1] ?? -Infinity)) continue;
|
|
45
|
+
const last = peaks[peaks.length - 1];
|
|
46
|
+
if (last !== undefined && i - last < gap) { if (v > series[last]!) peaks[peaks.length - 1] = i; } else peaks.push(i);
|
|
47
|
+
}
|
|
48
|
+
return peaks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The times in seconds of the hits: sudden rises of the full-band level. A hit is where the level of an ~46 ms window jumps by at least HIT_JUMP_DB
|
|
52
|
+
// over what it was 48 ms before and ends up audible, at least 90 ms apart. The jump is relative to the sound just before it, so a quiet tap is a hit
|
|
53
|
+
// whatever else the film holds: a loud drop does not raise the bar for the rest (a threshold taken from the whole film's statistics did).
|
|
54
|
+
const HIT_JUMP_DB = 8, HIT_LOOKBACK = 4, HIT_FLOOR = 0.001;
|
|
55
|
+
export function hitTimes(samples: ArrayLike<number>, rate: number): number[] {
|
|
56
|
+
const { values, hopSec } = envelope(samples, rate);
|
|
57
|
+
const jump = new Float64Array(values.length);
|
|
58
|
+
for (let i = HIT_LOOKBACK; i < values.length; i++) {
|
|
59
|
+
if (values[i]! < HIT_FLOOR) continue;
|
|
60
|
+
jump[i] = 20 * Math.log10(values[i]! / Math.max(values[i - HIT_LOOKBACK]!, 1e-5));
|
|
61
|
+
}
|
|
62
|
+
return pickPeaks(jump, HIT_JUMP_DB, Math.max(1, Math.round(0.09 / hopSec))).map((i) => (i * hopSec) + WINDOW_SEC / 2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// One second-order section (RBJ cookbook). Two of them in a row make each edge of the band steeper.
|
|
66
|
+
function biquad(x: ArrayLike<number>, type: "high" | "low", freq: number, rate: number): Float64Array {
|
|
67
|
+
const w0 = (2 * Math.PI * freq) / rate, cos = Math.cos(w0), alpha = Math.sin(w0) / (2 * Math.SQRT1_2);
|
|
68
|
+
const [b0, b1, b2] = type === "high" ? [(1 + cos) / 2, -(1 + cos), (1 + cos) / 2] : [(1 - cos) / 2, 1 - cos, (1 - cos) / 2];
|
|
69
|
+
const a0 = 1 + alpha, a1 = -2 * cos, a2 = 1 - alpha;
|
|
70
|
+
const y = new Float64Array(x.length);
|
|
71
|
+
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
|
|
72
|
+
for (let i = 0; i < x.length; i++) {
|
|
73
|
+
const v = (b0 * x[i]! + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0;
|
|
74
|
+
x2 = x1; x1 = x[i]!; y2 = y1; y1 = v; y[i] = v;
|
|
75
|
+
}
|
|
76
|
+
return y;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Swell = { start: number; peak: number; length: number };
|
|
80
|
+
|
|
81
|
+
// The swells of a sound: where the 0.5 to 10 kHz band rises ramping up above its own local level (see the constants above). Each is a start (where it
|
|
82
|
+
// leaves the local level), a peak (its loudest moment) and a length, all in seconds. The level is local, so one loud riser does not hide the quiet
|
|
83
|
+
// whooshes around it, and a steady bed of music, which is its own level everywhere, gives none.
|
|
84
|
+
export function swells(samples: ArrayLike<number>, rate: number): Swell[] {
|
|
85
|
+
if (samples.length < rate / 10) return [];
|
|
86
|
+
const [lo, hi] = [SWELL_BAND[0], Math.min(SWELL_BAND[1], rate * 0.45)];
|
|
87
|
+
const band = biquad(biquad(biquad(biquad(samples, "high", lo, rate), "high", lo, rate), "low", hi, rate), "low", hi, rate);
|
|
88
|
+
const fine = envelope(band, rate);
|
|
89
|
+
// One value every four hops (about 48 ms) is enough resolution and keeps the median cheap.
|
|
90
|
+
const step = 4, hopSec = fine.hopSec * step, n = Math.floor(fine.values.length / step);
|
|
91
|
+
const level = new Float64Array(n), db = new Float64Array(n);
|
|
92
|
+
for (let i = 0; i < n; i++) { level[i] = fine.values[i * step]!; db[i] = 20 * Math.log10(Math.max(level[i]!, 1e-6)); }
|
|
93
|
+
const half = Math.max(1, Math.round(FLOOR_SEC / 2 / hopSec));
|
|
94
|
+
const excess = new Float64Array(n);
|
|
95
|
+
for (let i = 0; i < n; i++) {
|
|
96
|
+
const win = Array.from(db.subarray(Math.max(0, i - half), Math.min(n, i + half + 1))).sort((x, y) => x - y);
|
|
97
|
+
excess[i] = db[i]! - win[win.length >> 1]!;
|
|
98
|
+
}
|
|
99
|
+
const out: Swell[] = [];
|
|
100
|
+
for (let i = 0; i < n;) {
|
|
101
|
+
if (!(excess[i]! > SWELL_EDGE_DB)) { i++; continue; }
|
|
102
|
+
let j = i;
|
|
103
|
+
while (j < n && excess[j]! > SWELL_EDGE_DB) j++;
|
|
104
|
+
let top = i, loudest = i;
|
|
105
|
+
for (let k = i; k < j; k++) { if (excess[k]! > excess[top]!) top = k; if (level[k]! > level[loudest]!) loudest = k; }
|
|
106
|
+
// How long the rise took: from leaving the local level to coming within 3 dB of the run's own top.
|
|
107
|
+
let near = i;
|
|
108
|
+
while (near < top && excess[near]! < excess[top]! - 3) near++;
|
|
109
|
+
const loud = level[loudest]! >= BAND_FLOOR;
|
|
110
|
+
if (loud && excess[top]! >= SWELL_RISE_DB && (j - i) * hopSec >= SWELL_MIN_SEC && (near - i + 1) * hopSec >= SWELL_RAMP_SEC) {
|
|
111
|
+
out.push({ start: i * hopSec, peak: loudest * hopSec, length: (j - i) * hopSec });
|
|
112
|
+
}
|
|
113
|
+
i = j;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The start times in seconds of the swells.
|
|
119
|
+
export const swellTimes = (samples: ArrayLike<number>, rate: number): number[] => swells(samples, rate).map((w) => w.start);
|
|
120
|
+
|
|
121
|
+
// A big change of the picture. A transition is a span of frames with a high difference, not a single frame: `start` and `end` (seconds) bound the
|
|
122
|
+
// run of frames above the change threshold around the peak, `time` and `frame` are the peak itself, and `strength` is the peak's mean difference
|
|
123
|
+
// between neighbouring frames (0 to 255, on a small grey picture).
|
|
124
|
+
export type PictureChange = { time: number; frame: number; strength: number; start: number; end: number };
|
|
125
|
+
|
|
126
|
+
// The big picture changes: peaks of the mean absolute difference between neighbouring frames above its mean plus 1.5 standard deviations, at least
|
|
127
|
+
// 0.2 s apart, each with the contiguous run of frames above that same threshold around it. `diffs[i]` is the change from frame i to frame i + 1.
|
|
128
|
+
export function pictureChanges(diffs: ArrayLike<number>, fps: number): PictureChange[] {
|
|
129
|
+
const m = mean(diffs);
|
|
130
|
+
const threshold = Math.max(0.5, m + 1.5 * sd(diffs, m));
|
|
131
|
+
return pickPeaks(diffs, threshold, Math.max(1, Math.round(0.2 * fps))).map((i) => {
|
|
132
|
+
let a = i, b = i;
|
|
133
|
+
while (a > 0 && diffs[a - 1]! > threshold) a--;
|
|
134
|
+
while (b < diffs.length - 1 && diffs[b + 1]! > threshold) b++;
|
|
135
|
+
return { time: (i + 1) / fps, frame: i + 1, strength: Math.round(diffs[i]! * 10) / 10, start: (a + 1) / fps, end: (b + 1) / fps };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const pictureChangeTimes = (diffs: ArrayLike<number>, fps: number): number[] => pictureChanges(diffs, fps).map((c) => c.time);
|
|
140
|
+
|
|
141
|
+
// The hit nearest to a change, measured to the nearest point of the change's own span: 0 when the hit lies inside it. `side` says which way it is.
|
|
142
|
+
export type HitMatch = { hit: number; distance: number; side: "inside" | "before" | "after" };
|
|
143
|
+
export function nearestHit(change: PictureChange | number, hits: readonly number[]): HitMatch | undefined {
|
|
144
|
+
const [a, b] = typeof change === "number" ? [change, change] : [change.start, change.end];
|
|
145
|
+
let best: HitMatch | undefined;
|
|
146
|
+
for (const h of hits) {
|
|
147
|
+
const d = h < a ? a - h : h > b ? h - b : 0;
|
|
148
|
+
if (!best || d < best.distance) best = { hit: h, distance: d, side: h < a ? "before" : h > b ? "after" : "inside" };
|
|
149
|
+
}
|
|
150
|
+
return best;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// How many changes have a hit within `near` seconds of their span.
|
|
154
|
+
export const countWithHit = (changes: readonly (PictureChange | number)[], hits: readonly number[], near = HIT_NEAR_SEC): number =>
|
|
155
|
+
changes.filter((c) => (nearestHit(c, hits)?.distance ?? Infinity) <= near + 1e-9).length;
|
|
156
|
+
|
|
157
|
+
// The first moment the full-band level reaches audible, or null for a film that is silent.
|
|
158
|
+
export function firstSoundSec(samples: ArrayLike<number>, rate: number): number | null {
|
|
159
|
+
const { values, hopSec } = envelope(samples, rate);
|
|
160
|
+
const i = values.findIndex((v) => v > AUDIBLE_RMS);
|
|
161
|
+
return i < 0 ? null : Math.round(i * hopSec * 100) / 100;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type SoundDetail = {
|
|
165
|
+
changes: (PictureChange & { hit: number | null; distance: number | null; side: HitMatch["side"] | null; matched: boolean })[];
|
|
166
|
+
swells: Swell[];
|
|
167
|
+
hits: number[];
|
|
168
|
+
firstSoundSec: number | null;
|
|
169
|
+
};
|
|
170
|
+
export type SoundReport = { hitsPerSecond: number; swellsPerSecond: number; pictureChanges: number; changesWithHit: number; firstSoundSec: number | null; loudnessLufs?: number; detail?: SoundDetail; voice?: VoiceMusic };
|
|
171
|
+
const round = (n: number, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
|
|
172
|
+
|
|
173
|
+
// The whole measurement from decoded audio and the picture's frame differences. `detail` lists every event the counts were made from.
|
|
174
|
+
export function soundReport(samples: ArrayLike<number>, rate: number, pictureDiffs: ArrayLike<number>, fps: number): SoundReport {
|
|
175
|
+
const seconds = samples.length / rate;
|
|
176
|
+
const hits = hitTimes(samples, rate), found = swells(samples, rate), changes = pictureChanges(pictureDiffs, fps), first = firstSoundSec(samples, rate);
|
|
177
|
+
return {
|
|
178
|
+
hitsPerSecond: seconds > 0 ? round(hits.length / seconds) : 0, swellsPerSecond: seconds > 0 ? round(found.length / seconds, 3) : 0,
|
|
179
|
+
pictureChanges: changes.length, changesWithHit: countWithHit(changes, hits), firstSoundSec: first,
|
|
180
|
+
detail: {
|
|
181
|
+
changes: changes.map((c) => { const m = nearestHit(c, hits); return { ...c, hit: m ? round(m.hit, 3) : null, distance: m ? round(m.distance, 3) : null, side: m?.side ?? null, matched: (m?.distance ?? Infinity) <= HIT_NEAR_SEC + 1e-9 }; }),
|
|
182
|
+
swells: found.map((w) => ({ start: round(w.start, 2), peak: round(w.peak, 2), length: round(w.length, 2) })),
|
|
183
|
+
hits: hits.map((h) => round(h, 3)),
|
|
184
|
+
firstSoundSec: first,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The same report as lines of text, one per event, for `reelkit sound --detail`.
|
|
190
|
+
export function detailLines(d: SoundDetail): string[] {
|
|
191
|
+
const t = (n: number) => `${n.toFixed(2)}s`;
|
|
192
|
+
const lines = ["Picture changes (the time and frame of the peak, how strong, and the nearest hit measured to the changing span):"];
|
|
193
|
+
for (const c of d.changes) {
|
|
194
|
+
const span = c.end > c.start ? ` (changing ${t(c.start)} to ${t(c.end)})` : "";
|
|
195
|
+
const hit = c.hit === null ? "no hit at all" : c.side === "inside" ? `hit at ${t(c.hit)}, inside the change` : `nearest hit at ${t(c.hit)}, ${t(c.distance!)} ${c.side} it`;
|
|
196
|
+
lines.push(` ${t(c.time)} frame ${c.frame} strength ${c.strength}${span}: ${hit}${c.matched ? "" : " <- no sound within 0.1s"}`);
|
|
197
|
+
}
|
|
198
|
+
if (!d.changes.length) lines.push(" none found");
|
|
199
|
+
lines.push("Swells (start, peak, length):");
|
|
200
|
+
for (const w of d.swells) lines.push(` ${t(w.start)} peak ${t(w.peak)} length ${t(w.length)}`);
|
|
201
|
+
if (!d.swells.length) lines.push(" none found");
|
|
202
|
+
lines.push(`Hits: ${d.hits.length}${d.hits.length ? ` (${d.hits.slice(0, 40).map((h) => h.toFixed(2)).join(", ")}${d.hits.length > 40 ? ", ..." : ""})` : ""}`);
|
|
203
|
+
lines.push(`First sound: ${d.firstSoundSec === null ? "never" : t(d.firstSoundSec)}`);
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Plain-word advice for a film with no voice, against the targets in reference/launch-film.md. It is a measurement and never a verdict.
|
|
208
|
+
export function soundAdvice(r: SoundReport): string {
|
|
209
|
+
const hits = `${r.hitsPerSecond.toFixed(1)} hits a second`;
|
|
210
|
+
const every = r.swellsPerSecond > 0 ? 1 / r.swellsPerSecond : undefined;
|
|
211
|
+
const swell = every === undefined ? "no swell at all" : `a swell every ${every < 10 ? every.toFixed(1) : Math.round(every)} seconds`;
|
|
212
|
+
const swellFix = r.swellsPerSecond < 0.5 ? " (films like this have one every 1 to 2 seconds: add a whoosh to each move)" : r.swellsPerSecond > 1.3 ? " (films like this have one every 1 to 2 seconds: that is busy, so leave some moves silent)" : "";
|
|
213
|
+
const aim = Math.max(1, Math.ceil(r.pictureChanges * 0.6));
|
|
214
|
+
const changes = r.pictureChanges ? `${r.changesWithHit} of ${r.pictureChanges} picture changes have a sound within a tenth of a second (aim for ${aim} or more)` : "no clear picture change was found to compare with";
|
|
215
|
+
const start = r.firstSoundSec === null ? " The film is silent." : r.firstSoundSec > 0.1 ? ` The first sound is at ${r.firstSoundSec.toFixed(2)}s; the films started within 0.05s.` : "";
|
|
216
|
+
return `Sound: ${hits}; ${swell}${swellFix}; ${changes}.${start} This is a measurement of the file, not a judgement of how it sounds: listen to it too.`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- The voice against the music, for a narrated film ----------------------------------------------------------------------------------------
|
|
220
|
+
// The mix is one channel, so the music cannot be measured alone. What can be measured is the mix while the voice speaks, the mix in the pauses
|
|
221
|
+
// (where only the music and effects are heard), and the mix in the short pauses between words, where the music is still held down: that last level
|
|
222
|
+
// is the estimate of "the music under the voice". The numbers say "estimated" for that reason.
|
|
223
|
+
|
|
224
|
+
// A word's time in the film, in seconds. `sentenceEnd` is true for the last word of a sentence (it ends with . ? ! or ends its scene).
|
|
225
|
+
export type SpokenWord = { start: number; end: number; sentenceEnd?: boolean };
|
|
226
|
+
export type VoiceMusic = {
|
|
227
|
+
// The mix's level while the voice speaks, dBFS (RMS), and in pauses of at least 0.5 s where it does not.
|
|
228
|
+
speechDb: number;
|
|
229
|
+
pauseDb?: number;
|
|
230
|
+
// speechDb minus pauseDb: how far the voice stands above what is heard in a real pause.
|
|
231
|
+
voiceOverPauseDb?: number;
|
|
232
|
+
// The music's level under the voice, from the short pauses between words, and how far below the voice it is (estimated).
|
|
233
|
+
underVoiceDb?: number;
|
|
234
|
+
musicBelowVoiceDb?: number;
|
|
235
|
+
// How many times the level between two sentences rose more than 6 dB above the level under the voice, and how many such gaps were looked at.
|
|
236
|
+
pumps?: number;
|
|
237
|
+
pumpsPer30s?: number;
|
|
238
|
+
sentenceGaps?: number;
|
|
239
|
+
estimated: true;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const PUMP_RISE_DB = 6;
|
|
243
|
+
// The music under the voice should sit this far below it (dB): closer masks the voice, farther is not heard.
|
|
244
|
+
export const MUSIC_BELOW_VOICE_DB: [number, number] = [10, 22];
|
|
245
|
+
export const MAX_PUMPS_PER_30S = 2;
|
|
246
|
+
const SPEECH_EDGE = 0.02, PAUSE_MIN = 0.5, PAUSE_EDGE = 0.1, SHORT_PAUSE_MIN = 0.12, SHORT_PAUSE_START = 0.05, SHORT_PAUSE_END = 0.03;
|
|
247
|
+
const SENTENCE_GAP_MIN = 0.2, SENTENCE_GAP_START = 0.1, SENTENCE_GAP_END = 0.05, PUMP_WINDOW = 0.1, PUMP_STEP = 0.05;
|
|
248
|
+
|
|
249
|
+
const toDb = (power: number) => (power > 1e-12 ? 10 * Math.log10(power) : -120);
|
|
250
|
+
const r1 = (n: number) => Math.round(n * 10) / 10;
|
|
251
|
+
|
|
252
|
+
// The measurement itself, from the decoded mix and the words' times. Returns undefined for a film with no words.
|
|
253
|
+
export function voiceAgainstMusic(samples: ArrayLike<number>, rate: number, words: SpokenWord[]): VoiceMusic | undefined {
|
|
254
|
+
const ws = words.filter((w) => w.end > w.start).sort((a, b) => a.start - b.start);
|
|
255
|
+
if (!ws.length || samples.length < rate) return undefined;
|
|
256
|
+
const seconds = samples.length / rate;
|
|
257
|
+
// Prefix sums of the squares, so the power of any stretch is two lookups.
|
|
258
|
+
const acc = new Float64Array(samples.length + 1);
|
|
259
|
+
for (let i = 0; i < samples.length; i++) acc[i + 1] = acc[i]! + samples[i]! * samples[i]!;
|
|
260
|
+
const sumOf = (a: number, b: number): { sum: number; n: number } => {
|
|
261
|
+
const i = Math.max(0, Math.min(samples.length, Math.round(a * rate))), j = Math.max(0, Math.min(samples.length, Math.round(b * rate)));
|
|
262
|
+
return j > i ? { sum: acc[j]! - acc[i]!, n: j - i } : { sum: 0, n: 0 };
|
|
263
|
+
};
|
|
264
|
+
const powerOver = (spans: [number, number][]): number | undefined => {
|
|
265
|
+
let sum = 0, n = 0;
|
|
266
|
+
for (const [a, b] of spans) { const r = sumOf(a, b); sum += r.sum; n += r.n; }
|
|
267
|
+
return n > 0 ? sum / n : undefined;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const speech = powerOver(ws.map((w): [number, number] => [w.start + SPEECH_EDGE, w.end - SPEECH_EDGE]));
|
|
271
|
+
if (speech === undefined) return undefined;
|
|
272
|
+
|
|
273
|
+
// The gaps between consecutive words, with the start and the end of the film counted as gaps too (the last second is a fade-out, so it is left out).
|
|
274
|
+
type Gap = { from: number; to: number; after?: SpokenWord };
|
|
275
|
+
const gaps: Gap[] = [{ from: 0, to: ws[0]!.start }];
|
|
276
|
+
for (let i = 0; i + 1 < ws.length; i++) gaps.push({ from: ws[i]!.end, to: ws[i + 1]!.start, after: ws[i] });
|
|
277
|
+
gaps.push({ from: ws[ws.length - 1]!.end, to: Math.max(ws[ws.length - 1]!.end, seconds - 1) });
|
|
278
|
+
|
|
279
|
+
const pauses = gaps.filter((g) => g.to - g.from >= PAUSE_MIN).map((g): [number, number] => [g.from + PAUSE_EDGE, g.to - PAUSE_EDGE]).filter(([a, b]) => b - a >= 0.2);
|
|
280
|
+
const pause = powerOver(pauses);
|
|
281
|
+
// The music held down under the voice: the quiet moments between words (and, with tight gaps, between sentences), the voice's own tail trimmed off.
|
|
282
|
+
// Word times are rounded, so a short pause may still hold the end of a word: take the quietest quarter of its 50 ms blocks, which is the music (its median is what the gaps between sentences are compared with).
|
|
283
|
+
const blocks: number[] = [];
|
|
284
|
+
for (const g of gaps) {
|
|
285
|
+
if (!g.after || g.to - g.from < SHORT_PAUSE_MIN || g.to - g.from >= PAUSE_MIN) continue;
|
|
286
|
+
for (let t = g.from + SHORT_PAUSE_START; t + 0.05 <= g.to - SHORT_PAUSE_END + 1e-9; t += 0.05) { const r = sumOf(t, t + 0.05); if (r.n) blocks.push(r.sum / r.n); }
|
|
287
|
+
}
|
|
288
|
+
blocks.sort((a, b) => a - b);
|
|
289
|
+
const short = blocks.length ? blocks[Math.floor((blocks.length - 1) * 0.25)] : undefined;
|
|
290
|
+
// Between two sentences the music is compared with the typical level of those pauses.
|
|
291
|
+
const typical = blocks.length ? blocks[Math.floor((blocks.length - 1) * 0.5)] : undefined;
|
|
292
|
+
|
|
293
|
+
const out: VoiceMusic = { speechDb: r1(toDb(speech)), estimated: true };
|
|
294
|
+
if (pause !== undefined) { out.pauseDb = r1(toDb(pause)); out.voiceOverPauseDb = r1(toDb(speech) - toDb(pause)); }
|
|
295
|
+
if (short !== undefined) {
|
|
296
|
+
out.underVoiceDb = r1(toDb(short));
|
|
297
|
+
// The voice alone is the speech power less the music's: a level only a few dB above the music leaves little voice, so the floor is a tenth of it.
|
|
298
|
+
out.musicBelowVoiceDb = r1(toDb(Math.max(speech - short, speech * 0.1)) - toDb(short));
|
|
299
|
+
// Between two sentences: the loudest 0.1 s of the gap, apart from the voice's tail at its start and the next word's breath at its end.
|
|
300
|
+
let pumps = 0, looked = 0;
|
|
301
|
+
for (const g of gaps) {
|
|
302
|
+
if (!g.after?.sentenceEnd || g.to - g.from < SENTENCE_GAP_MIN) continue;
|
|
303
|
+
const a = g.from + SENTENCE_GAP_START, b = g.to - SENTENCE_GAP_END;
|
|
304
|
+
if (b - a < PUMP_WINDOW) continue;
|
|
305
|
+
looked++;
|
|
306
|
+
let loudest = -Infinity;
|
|
307
|
+
for (let t = a; t + PUMP_WINDOW <= b + 1e-9; t += PUMP_STEP) { const r = sumOf(t, t + PUMP_WINDOW); if (r.n) loudest = Math.max(loudest, toDb(r.sum / r.n)); }
|
|
308
|
+
if (loudest - toDb(typical ?? short) > PUMP_RISE_DB) pumps++;
|
|
309
|
+
}
|
|
310
|
+
out.pumps = pumps; out.sentenceGaps = looked; out.pumpsPer30s = r1((pumps * 30) / seconds);
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The words of a narrated film as times in the film, from its manifest: a word ends a sentence when it ends with . ? ! … or is a scene's last.
|
|
316
|
+
export function spokenWords(manifest: { fps: number; scenes: { startFrame: number; words: { word: string; startSec: number; endSec: number }[] }[] }): SpokenWord[] {
|
|
317
|
+
return manifest.scenes.flatMap((s) => s.words.map((w, i) => ({ start: s.startFrame / manifest.fps + w.startSec, end: s.startFrame / manifest.fps + w.endSec, sentenceEnd: i === s.words.length - 1 || /[.!?…؟׃。!?]["'”’»)\]]*$/.test(w.word.trim()) })));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Plain-word lines about the voice and the music, ending with advice when the music is too near the voice or too far from it, or pumps.
|
|
321
|
+
export function voiceAdvice(v: VoiceMusic): string {
|
|
322
|
+
const parts: string[] = [];
|
|
323
|
+
if (v.voiceOverPauseDb !== undefined) parts.push(`the voice speaks ${v.voiceOverPauseDb.toFixed(1)} dB above the level of the pauses (${v.speechDb.toFixed(1)} against ${v.pauseDb!.toFixed(1)} dBFS)`);
|
|
324
|
+
else parts.push(`the voice speaks at ${v.speechDb.toFixed(1)} dBFS and there is no pause of 0.5 s to compare it with`);
|
|
325
|
+
if (v.musicBelowVoiceDb !== undefined) parts.push(`the music under the voice is about ${v.musicBelowVoiceDb.toFixed(0)} dB below it`);
|
|
326
|
+
if (v.pumps !== undefined) parts.push(`${v.pumps} pump${v.pumps === 1 ? "" : "s"} (${v.pumpsPer30s!.toFixed(1)} per 30 s) in ${v.sentenceGaps} gap${v.sentenceGaps === 1 ? "" : "s"} between sentences`);
|
|
327
|
+
const advice: string[] = [];
|
|
328
|
+
if (v.musicBelowVoiceDb !== undefined && v.musicBelowVoiceDb < MUSIC_BELOW_VOICE_DB[0]) advice.push(`The music is too close to the voice (under ${MUSIC_BELOW_VOICE_DB[0]} dB below it) and will compete with it: lower duckTo on <Music>.`);
|
|
329
|
+
if (v.musicBelowVoiceDb !== undefined && v.musicBelowVoiceDb > MUSIC_BELOW_VOICE_DB[1]) advice.push(`The music is hardly there under the voice (over ${MUSIC_BELOW_VOICE_DB[1]} dB below it): raise duckTo on <Music>.`);
|
|
330
|
+
if (v.pumpsPer30s !== undefined && v.pumpsPer30s > MAX_PUMPS_PER_30S) advice.push(`The music pumps: its level rises between sentences more than ${MAX_PUMPS_PER_30S} times in 30 s. Keep the gaps tight (plan gap), leave Music's defaults, and give no volume per scene.`);
|
|
331
|
+
return `Voice against music (estimated from the mix, which has the music and the voice in one signal): ${parts.join("; ")}. ${advice.length ? advice.join(" ") : "Both are inside the targets (the music 10 to 22 dB below the voice, at most 2 pumps in 30 s)."}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Decodes the audio and the picture of a finished film and measures them. The loudness is passed in when mastering already measured it.
|
|
335
|
+
export async function measureSound(path: string, loudness?: number, words?: SpokenWord[]): Promise<SoundReport> {
|
|
336
|
+
const audio = await tool("ffmpeg", ["-v", "error", "-i", path, "-vn", "-ac", "1", "-ar", String(AUDIO_RATE), "-f", "f32le", "-"]);
|
|
337
|
+
const samples = new Float32Array(audio.stdout.buffer.slice(audio.stdout.byteOffset, audio.stdout.byteOffset + Math.floor(audio.stdout.length / 4) * 4));
|
|
338
|
+
const info = await probeVideo(path, path);
|
|
339
|
+
const w = PICTURE_WIDTH, h = Math.max(2, Math.round((w * info.height) / info.width / 2) * 2);
|
|
340
|
+
const frames = await tool("ffmpeg", ["-v", "error", "-i", path, "-an", "-vf", `fps=${PICTURE_FPS},scale=${w}:${h}:flags=area,format=gray`, "-f", "rawvideo", "-pix_fmt", "gray", "-"]);
|
|
341
|
+
const diffs = meanAbsDiffs(new Uint8Array(frames.stdout.buffer, frames.stdout.byteOffset, frames.stdout.length), w * h);
|
|
342
|
+
const base = soundReport(samples, AUDIO_RATE, diffs, PICTURE_FPS);
|
|
343
|
+
const voice = words?.length ? voiceAgainstMusic(samples, AUDIO_RATE, words) : undefined;
|
|
344
|
+
const report: SoundReport = voice ? { ...base, voice } : base;
|
|
345
|
+
const lufs = loudness ?? (await loudnessLufs(path));
|
|
346
|
+
return lufs === undefined ? report : { ...report, loudnessLufs: lufs };
|
|
347
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What a downloaded vector graphic must be before it is saved into a project. The server returns a cleaned SVG, but a file that will be drawn in the
|
|
2
|
+
// renderer's browser is checked here as well, so a mistake or a changed server cannot put a script or an outside link into a film.
|
|
3
|
+
// Returns one plain line saying what is wrong, or undefined when the file is fine.
|
|
4
|
+
export function svgProblem(text: string): string | undefined {
|
|
5
|
+
const body = text.replace(/^/, "").replace(/<\?xml[\s\S]*?\?>/g, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<!DOCTYPE[^>]*>/gi, "").trim();
|
|
6
|
+
if (!/^<svg[\s>]/i.test(body) || !/<\/svg\s*>\s*$/i.test(body) && !/^<svg[^>]*\/>\s*$/i.test(body)) return "The file is not an SVG: it does not start with an <svg> element. Nothing was saved; run the command again.";
|
|
7
|
+
if (/<script/i.test(body)) return "The SVG contains a <script>. Nothing was saved; run the command again.";
|
|
8
|
+
if (/<foreignObject/i.test(body)) return "The SVG contains a <foreignObject>. Nothing was saved; run the command again.";
|
|
9
|
+
if (/\son[a-z]+\s*=/i.test(body)) return "The SVG contains an event attribute (such as onclick). Nothing was saved; run the command again.";
|
|
10
|
+
for (const m of body.matchAll(/\s(?:xlink:)?(?:href|src)\s*=\s*("([^"]*)"|'([^']*)')/gi)) {
|
|
11
|
+
const value = (m[2] ?? m[3] ?? "").trim();
|
|
12
|
+
if (!(value.startsWith("#") || /^data:image\//i.test(value))) return `The SVG points outside the file (${value.slice(0, 40)}). Nothing was saved; run the command again.`;
|
|
13
|
+
}
|
|
14
|
+
// A style may also reach out: url(...) other than a #fragment or a data: image, and @import.
|
|
15
|
+
for (const m of body.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi)) {
|
|
16
|
+
const value = m[2]!.trim();
|
|
17
|
+
if (!(value.startsWith("#") || /^data:image\//i.test(value))) return `The SVG points outside the file (${value.slice(0, 40)}). Nothing was saved; run the command again.`;
|
|
18
|
+
}
|
|
19
|
+
if (/@import/i.test(body)) return "The SVG imports a style from outside the file. Nothing was saved; run the command again.";
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { Color, Object3D, type InstancedMesh } from "three";
|
|
4
|
+
import { CameraKeysContext } from "./scene3d-context";
|
|
5
|
+
import { hash01 } from "./seeded";
|
|
6
|
+
import { textSlots } from "./sample-text";
|
|
7
|
+
import { assembleRanks, centred, MAX_PIECES, pieceAt, shapeSlots, type AssembleOrder, type AssembleShape } from "./three-fx-math";
|
|
8
|
+
import { nearestVisible, type Vec3 } from "./three-math";
|
|
9
|
+
|
|
10
|
+
// Instanced blocks that fly from a seeded scattered cloud into an arrangement, one after another, each on an arc with a turn that unwinds, then rest: the build-up
|
|
11
|
+
// beat, up to 4000 pieces in one draw. `shape` is "grid" (a slab), "ring" (a band), "sphere", "wall" (bricks) or "text" (the blocks fall inside the letters of `text`, a
|
|
12
|
+
// short Latin word in the kit's typeface), or give `targets` (any [x, y, z] list; it is centred and fitted). `count` (400 unless set, at most 4000) is how many pieces;
|
|
13
|
+
// `order` is who goes first: "y" bottom to top (default), "x", "radial" from the middle out or "random"; `from` is the frame it starts (default 0) and `frames` how long
|
|
14
|
+
// the whole build takes (default 60: every piece is home at from + frames); `colors` are two or three colours, jittered a little per piece; `fit` is how much of the
|
|
15
|
+
// frame's width the arrangement fills at the closest the Scene3D camera comes (0.8), or give `size` in scene units. `spread` is how far the cloud reaches, in arrangements
|
|
16
|
+
// (1.6). Everything is seeded (`seed`), so a frame is the same every time. assembleProgress, assembledCount and assembleEnd (exported) give a piece counter and the frame of the lock sound.
|
|
17
|
+
export const Assemble3D: React.FC<{
|
|
18
|
+
shape?: AssembleShape;
|
|
19
|
+
text?: string;
|
|
20
|
+
targets?: Vec3[];
|
|
21
|
+
count?: number;
|
|
22
|
+
order?: AssembleOrder;
|
|
23
|
+
from?: number;
|
|
24
|
+
frames?: number;
|
|
25
|
+
colors?: string[];
|
|
26
|
+
fit?: number;
|
|
27
|
+
size?: number;
|
|
28
|
+
spread?: number;
|
|
29
|
+
seed?: number;
|
|
30
|
+
position?: Vec3;
|
|
31
|
+
}> = ({ shape = "grid", text = "Relay", targets, count = 400, order = "y", from = 0, frames = 60, colors = ["#7C3AED", "#22D3EE"], fit = 0.8, size, spread = 1.6, seed = 0, position = [0, 0, 0] }) => {
|
|
32
|
+
const frame = useCurrentFrame();
|
|
33
|
+
const { width, height } = useVideoConfig();
|
|
34
|
+
const keys = React.useContext(CameraKeysContext);
|
|
35
|
+
const n0 = Math.max(1, Math.min(MAX_PIECES, Math.round(count)));
|
|
36
|
+
const slots = React.useMemo(() => (targets?.length ? centred(targets.slice(0, MAX_PIECES)) : shape === "text" ? textSlots(text, n0) : shapeSlots(shape, n0)), [shape, text, targets, n0]);
|
|
37
|
+
const n = slots.length;
|
|
38
|
+
const ranks = React.useMemo(() => assembleRanks(slots, order, seed), [slots, order, seed]);
|
|
39
|
+
// The arrangement's extent, so that it fits the frame, and the size of a piece from how close its neighbours are.
|
|
40
|
+
const geo = React.useMemo(() => {
|
|
41
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
42
|
+
for (const p of slots) { x0 = Math.min(x0, p[0]); x1 = Math.max(x1, p[0]); y0 = Math.min(y0, p[1]); y1 = Math.max(y1, p[1]); }
|
|
43
|
+
const probe = Math.min(n, 48), gaps: number[] = [];
|
|
44
|
+
for (let a = 0; a < probe; a++) {
|
|
45
|
+
const i = Math.floor((a / probe) * n);
|
|
46
|
+
let best = Infinity;
|
|
47
|
+
for (let j = 0; j < n; j++) if (j !== i) best = Math.min(best, Math.hypot(slots[i]![0] - slots[j]![0], slots[i]![1] - slots[j]![1], slots[i]![2] - slots[j]![2]));
|
|
48
|
+
if (Number.isFinite(best)) gaps.push(best);
|
|
49
|
+
}
|
|
50
|
+
gaps.sort((a, b) => a - b);
|
|
51
|
+
return { w: Math.max(1e-3, x1 - x0), h: Math.max(1e-3, y1 - y0), gap: gaps[Math.floor(gaps.length / 2)] ?? 0.1 };
|
|
52
|
+
}, [slots, n]);
|
|
53
|
+
const view = nearestVisible(keys, width / height, position[2]);
|
|
54
|
+
const scale = size ? size / geo.w : Math.min((fit * view.width) / geo.w, (fit * view.height) / geo.h);
|
|
55
|
+
const piece = geo.gap * 0.84;
|
|
56
|
+
const mesh = React.useRef<InstancedMesh>(null);
|
|
57
|
+
const dummy = React.useMemo(() => new Object3D(), []);
|
|
58
|
+
const palette = React.useMemo(() => colors.map((c) => new Color(c)), [colors]);
|
|
59
|
+
React.useLayoutEffect(() => {
|
|
60
|
+
const m = mesh.current;
|
|
61
|
+
if (!m) return;
|
|
62
|
+
const c = new Color();
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
const t = hash01(i, seed + 61) * (palette.length - 1), k = Math.min(palette.length - 2, Math.floor(t));
|
|
65
|
+
c.copy(palette[Math.max(0, k)]!).lerp(palette[Math.min(palette.length - 1, k + 1)]!, palette.length > 1 ? t - k : 0);
|
|
66
|
+
c.offsetHSL(0, 0, (hash01(i, seed + 62) - 0.5) * 0.16);
|
|
67
|
+
m.setColorAt(i, c);
|
|
68
|
+
}
|
|
69
|
+
if (m.instanceColor) m.instanceColor.needsUpdate = true;
|
|
70
|
+
}, [palette, n, seed]);
|
|
71
|
+
React.useLayoutEffect(() => {
|
|
72
|
+
const m = mesh.current;
|
|
73
|
+
if (!m) return;
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
const s = pieceAt(frame, i, slots[i]!, { ranks, from, frames, seed, spread });
|
|
76
|
+
dummy.position.set(s.position[0] * scale, s.position[1] * scale, s.position[2] * scale);
|
|
77
|
+
dummy.rotation.set(s.rotation[0], s.rotation[1], s.rotation[2]);
|
|
78
|
+
dummy.scale.setScalar(piece * scale * s.scale);
|
|
79
|
+
dummy.updateMatrix();
|
|
80
|
+
m.setMatrixAt(i, dummy.matrix);
|
|
81
|
+
}
|
|
82
|
+
m.instanceMatrix.needsUpdate = true;
|
|
83
|
+
}, [frame, n, slots, ranks, from, frames, seed, spread, scale, piece, dummy]);
|
|
84
|
+
return (
|
|
85
|
+
<group position={position}>
|
|
86
|
+
<instancedMesh ref={mesh} args={[undefined, undefined, n]} frustumCulled={false}>
|
|
87
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
88
|
+
<meshStandardMaterial roughness={0.42} metalness={0.12} />
|
|
89
|
+
</instancedMesh>
|
|
90
|
+
</group>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { continueRender, delayRender, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { Place } from "./Place";
|
|
4
|
+
import { useMedia } from "./media";
|
|
5
|
+
import { fonts } from "./theme";
|
|
6
|
+
import { DEFAULT_HERO, rgba, uiColors, type UiTheme } from "./ui-theme";
|
|
7
|
+
import { PANEL_SPRING, inOutCubic, browserFrames } from "./ui-math";
|
|
8
|
+
|
|
9
|
+
// The natural size of an image, read once. The frame waits for it, so every frame knows how far the picture can scroll.
|
|
10
|
+
function useImageSize(url: string): { w: number; h: number } | null {
|
|
11
|
+
const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
const handle = delayRender(`size of ${url.slice(0, 40)}`);
|
|
14
|
+
const img = new Image();
|
|
15
|
+
img.onload = () => { setSize({ w: img.naturalWidth, h: img.naturalHeight }); continueRender(handle); };
|
|
16
|
+
img.onerror = () => continueRender(handle);
|
|
17
|
+
img.src = url;
|
|
18
|
+
return () => { continueRender(handle); };
|
|
19
|
+
}, [url]);
|
|
20
|
+
return size;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A browser window (or, with device="phone", a phone) in perspective, carrying your own screenshot `src` (a project path or urls[...]). The picture starts at
|
|
24
|
+
// its top and scrolls down by `scroll`, a fraction of the window's height (default 0.7), over `frames` (default 90) frames from `delay`; it never scrolls past
|
|
25
|
+
// the end of the picture. A soft shadow lies under it and a sheen sweeps across once as it lands. `tilt` is the turn about the vertical axis in degrees
|
|
26
|
+
// (default -12 for a browser, -16 for a phone; 0 faces the camera), `url` the text of the address bar. CSS 3D, no three.js. Times are frames from the
|
|
27
|
+
// component's own mount; browserFrames({ delay }) gives the frame it settles and the sheen's span. `width` is a fraction of the frame's width (0.86 for a
|
|
28
|
+
// browser, 0.5 for a phone); `theme` "dark" (default) or "light" colours the frame.
|
|
29
|
+
export const BrowserFrame: React.FC<{
|
|
30
|
+
src: string;
|
|
31
|
+
device?: "browser" | "phone";
|
|
32
|
+
scroll?: number;
|
|
33
|
+
frames?: number;
|
|
34
|
+
delay?: number;
|
|
35
|
+
tilt?: number;
|
|
36
|
+
url?: string;
|
|
37
|
+
theme?: UiTheme;
|
|
38
|
+
hero?: string;
|
|
39
|
+
font?: string;
|
|
40
|
+
width?: number;
|
|
41
|
+
viewAspect?: number;
|
|
42
|
+
y?: number;
|
|
43
|
+
}> = ({ src, device = "browser", scroll = 0.7, frames = 90, delay = 0, tilt, url = "relay.app", theme = "dark", hero = DEFAULT_HERO, font = fonts.body, width: given, viewAspect, y = 0.5 }) => {
|
|
44
|
+
const frame = useCurrentFrame();
|
|
45
|
+
const { fps, width: W, height: H } = useVideoConfig();
|
|
46
|
+
const link = useMedia(src);
|
|
47
|
+
const size = useImageSize(link);
|
|
48
|
+
const c = uiColors(theme, hero);
|
|
49
|
+
const phone = device === "phone";
|
|
50
|
+
const P = W * (given ?? (phone ? 0.5 : 0.86));
|
|
51
|
+
const turn = tilt ?? (phone ? -16 : -12);
|
|
52
|
+
const p = spring({ frame: frame - delay, fps, config: PANEL_SPRING });
|
|
53
|
+
const bar = phone ? 0 : P * 0.085, bezel = phone ? P * 0.035 : 0;
|
|
54
|
+
const viewW = P - bezel * 2, viewH = viewW * (viewAspect ?? (phone ? 2.05 : 0.78));
|
|
55
|
+
const imgH = size ? (viewW * size.h) / size.w : viewH;
|
|
56
|
+
const travel = Math.max(0, Math.min(scroll * viewH, imgH - viewH));
|
|
57
|
+
const ev = browserFrames({ delay, fps });
|
|
58
|
+
const sc = inOutCubic((frame - delay - 10) / Math.max(1, frames)) * travel;
|
|
59
|
+
const sheen = interpolate(frame, [ev.sheenStart, ev.sheenEnd], [-0.4, 1.4], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
60
|
+
const totalH = bar + viewH + bezel * 2;
|
|
61
|
+
return (
|
|
62
|
+
<Place y={y}>
|
|
63
|
+
<div style={{ position: "relative", width: P, height: totalH, perspective: P * 2.4, opacity: Math.min(1, p * 1.5) }}>
|
|
64
|
+
<div style={{ position: "absolute", left: "6%", right: "6%", bottom: -P * 0.07, height: P * 0.1, background: "radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0.55), rgba(0,0,0,0) 70%)", filter: `blur(${P * 0.03}px)`, transform: `translateY(${(1 - p) * H * 0.05}px)` }} />
|
|
65
|
+
<div style={{ position: "absolute", inset: 0, transform: `translateY(${(1 - p) * H * 0.06}px) rotateY(${turn * p}deg) rotateX(${Math.abs(turn) * 0.38 * p}deg)`, transformStyle: "preserve-3d" }}>
|
|
66
|
+
<div style={{ position: "absolute", inset: 0, borderRadius: phone ? P * 0.12 : P * 0.035, background: phone ? "#0b0b10" : c.panel, border: `${Math.max(2, P * 0.004)}px solid ${phone ? "rgba(255,255,255,0.28)" : c.stroke}`, boxShadow: `0 ${P * 0.06}px ${P * 0.14}px ${c.shadow}, 0 0 ${P * 0.18}px ${rgba(hero, 0.2)}`, overflow: "hidden" }}>
|
|
67
|
+
{!phone ? (
|
|
68
|
+
<div style={{ height: bar, display: "flex", alignItems: "center", gap: P * 0.015, padding: `0 ${P * 0.035}px`, background: c.field, borderBottom: `1px solid ${c.stroke}` }}>
|
|
69
|
+
{["#ff5f57", "#febc2e", "#28c840"].map((d) => <div key={d} style={{ width: P * 0.022, height: P * 0.022, borderRadius: 999, background: d }} />)}
|
|
70
|
+
<div style={{ flex: 1, margin: `0 ${P * 0.03}px 0 ${P * 0.03}px`, background: c.chip, borderRadius: 999, padding: `${P * 0.008}px ${P * 0.03}px`, fontFamily: font, fontSize: P * 0.032, color: c.dim, textAlign: "center", fontWeight: 500 }}>{url}</div>
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
<div style={{ position: "absolute", left: bezel, right: bezel, top: bar + bezel, height: viewH, overflow: "hidden", borderRadius: phone ? P * 0.09 : 0, background: "#ffffff" }}>
|
|
74
|
+
<img src={link} style={{ display: "block", width: "100%", height: "auto", transform: `translateY(${-sc}px)` }} />
|
|
75
|
+
{phone ? <div style={{ position: "absolute", left: "50%", top: viewW * 0.03, width: viewW * 0.3, height: viewW * 0.085, borderRadius: 999, background: "#000", transform: "translateX(-50%)" }} /> : null}
|
|
76
|
+
</div>
|
|
77
|
+
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", background: `linear-gradient(105deg, rgba(255,255,255,0) ${sheen * 100 - 14}%, rgba(255,255,255,0.22) ${sheen * 100}%, rgba(255,255,255,0) ${sheen * 100 + 14}%)` }} />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</Place>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
-
import { cameraAt, cameraTransform, type CameraKey } from "./motion-math";
|
|
3
|
+
import { applyPunches, cameraAt, cameraTransform, type CameraKey, type Punch } from "./motion-math";
|
|
4
4
|
|
|
5
5
|
// Moves the whole picture. Wrap all the scenes in it for one camera that never cuts, or wrap one scene's content.
|
|
6
6
|
// Each key gives the point of the content, as fractions of the frame, that sits at the middle of the screen (x, y), the zoom (1 shows
|
|
7
7
|
// the content as it fits) and a turn in degrees; what a key leaves out stays as it was. The move toward a key starts `lead` frames
|
|
8
8
|
// before its frame (12 unless set) on a spring and lands on the key's frame. drift is a very slow push on top, as a fraction of the
|
|
9
|
-
// zoom per second (0.02 is two percent a second), so a held shot is never perfectly still.
|
|
9
|
+
// zoom per second (0.02 is two percent a second), so a held shot is never perfectly still. punches are quick emphasis pushes of 8 to 15 percent onto a
|
|
10
|
+
// point, { frame, x, y, amount?: 0.1, frames?: 5, hold?: 6 }, on top of the keys. To push into an element until it fills the frame, put zoomTo(frame, box) in keys.
|
|
10
11
|
export const Camera: React.FC<{
|
|
11
12
|
keys: CameraKey[];
|
|
12
13
|
drift?: number;
|
|
14
|
+
punches?: Punch[];
|
|
13
15
|
lead?: number;
|
|
14
16
|
stiffness?: number;
|
|
15
17
|
damping?: number;
|
|
16
18
|
children: React.ReactNode;
|
|
17
|
-
}> = ({ keys, drift, lead, stiffness, damping, children }) => {
|
|
19
|
+
}> = ({ keys, drift, punches, lead, stiffness, damping, children }) => {
|
|
18
20
|
const frame = useCurrentFrame();
|
|
19
21
|
const { fps, width, height } = useVideoConfig();
|
|
20
|
-
const { origin, transform } = cameraTransform(cameraAt(keys, frame, fps, { lead, stiffness, damping, drift }), width, height);
|
|
22
|
+
const { origin, transform } = cameraTransform(applyPunches(cameraAt(keys, frame, fps, { lead, stiffness, damping, drift }), punches ?? [], frame), width, height);
|
|
21
23
|
return <AbsoluteFill style={{ transformOrigin: origin, transform }}>{children}</AbsoluteFill>;
|
|
22
24
|
};
|