reelkit-cli 0.6.0 → 0.10.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.
Files changed (144) hide show
  1. package/README.md +5 -3
  2. package/package.json +52 -9
  3. package/skill/SKILL.md +40 -19
  4. package/skill/THIRD_PARTY.md +104 -2
  5. package/skill/commands/launch-film.md +7 -0
  6. package/skill/reference/art-styles.md +70 -0
  7. package/skill/reference/asset-reuse.md +13 -2
  8. package/skill/reference/backgrounds.md +63 -0
  9. package/skill/reference/beat-sync.md +25 -19
  10. package/skill/reference/brand-motion.md +62 -0
  11. package/skill/reference/captions.md +11 -5
  12. package/skill/reference/clips.md +3 -3
  13. package/skill/reference/component-authoring.md +1 -1
  14. package/skill/reference/continuity.md +26 -7
  15. package/skill/reference/delivery-review.md +40 -0
  16. package/skill/reference/hebrew-rtl.md +3 -4
  17. package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
  18. package/skill/reference/kit.md +144 -12
  19. package/skill/reference/launch-film.md +194 -0
  20. package/skill/reference/motion-design.md +18 -16
  21. package/skill/reference/scene-treatments.md +20 -0
  22. package/skill/reference/scriptwriting.md +4 -1
  23. package/skill/reference/sound-design.md +34 -12
  24. package/skill/reference/studio-editing.md +55 -0
  25. package/skill/reference/styles.md +9 -6
  26. package/skill/reference/three-d.md +135 -0
  27. package/skill/reference/voice-sync.md +108 -0
  28. package/src/agents.ts +23 -12
  29. package/src/api/client.ts +4 -1
  30. package/src/cli.ts +18 -7
  31. package/src/commands/assets.ts +314 -30
  32. package/src/commands/build.ts +148 -35
  33. package/src/commands/init.ts +1 -1
  34. package/src/commands/install.ts +1 -1
  35. package/src/commands/plan.ts +8 -5
  36. package/src/commands/ref.ts +5 -2
  37. package/src/contract/index.ts +5 -3
  38. package/src/hyperframes/Root.tsx +1 -0
  39. package/src/hyperframes/fonts.ts +54 -0
  40. package/src/hyperframes/frame.tsx +46 -0
  41. package/src/hyperframes/host.tsx +38 -0
  42. package/src/hyperframes/kit/Assemble3D.tsx +92 -0
  43. package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
  44. package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
  45. package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
  46. package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
  47. package/src/hyperframes/kit/Card3D.tsx +211 -0
  48. package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
  49. package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
  50. package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
  51. package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
  52. package/src/hyperframes/kit/CounterRoll.tsx +75 -0
  53. package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
  54. package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
  55. package/src/hyperframes/kit/GlassPanel.tsx +43 -0
  56. package/src/hyperframes/kit/Grounds.tsx +177 -0
  57. package/src/hyperframes/kit/Headline.tsx +97 -0
  58. package/src/hyperframes/kit/Hero3D.tsx +197 -0
  59. package/src/hyperframes/kit/HudOverlay.tsx +52 -0
  60. package/src/hyperframes/kit/ImageLayers.tsx +48 -0
  61. package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
  62. package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
  63. package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
  64. package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
  65. package/src/hyperframes/kit/Music.tsx +19 -0
  66. package/src/hyperframes/kit/NamedCursor.tsx +54 -0
  67. package/src/hyperframes/kit/Orbit3D.tsx +49 -0
  68. package/src/hyperframes/kit/Particles3D.tsx +74 -0
  69. package/src/hyperframes/kit/Place.tsx +12 -0
  70. package/src/hyperframes/kit/PromptBox.tsx +84 -0
  71. package/src/hyperframes/kit/Scene3D.tsx +70 -0
  72. package/src/hyperframes/kit/SceneFrame.tsx +96 -0
  73. package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
  74. package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
  75. package/src/hyperframes/kit/SoundCues.tsx +22 -0
  76. package/src/hyperframes/kit/TerminalLog.tsx +98 -0
  77. package/src/hyperframes/kit/Text3D.tsx +78 -0
  78. package/src/hyperframes/kit/TextOnImage.tsx +41 -0
  79. package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
  80. package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
  81. package/src/hyperframes/kit/Warp3D.tsx +59 -0
  82. package/src/hyperframes/kit/bg-math.ts +179 -0
  83. package/src/hyperframes/kit/brand-transform.ts +25 -0
  84. package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
  85. package/src/hyperframes/kit/caption-style.ts +45 -0
  86. package/src/hyperframes/kit/docs.ts +249 -0
  87. package/src/hyperframes/kit/image-layers-math.ts +115 -0
  88. package/src/hyperframes/kit/index.ts +74 -0
  89. package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
  90. package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
  91. package/src/hyperframes/kit/music-math.ts +59 -0
  92. package/src/hyperframes/kit/quiet-three.ts +11 -0
  93. package/src/hyperframes/kit/sample-text.ts +55 -0
  94. package/src/hyperframes/kit/scene3d-context.ts +5 -0
  95. package/src/hyperframes/kit/seeded.ts +13 -0
  96. package/src/hyperframes/kit/sound-cues.ts +89 -0
  97. package/src/hyperframes/kit/sound-kinds.ts +135 -0
  98. package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
  99. package/src/hyperframes/kit/three-fx-math.ts +192 -0
  100. package/src/hyperframes/kit/three-math.ts +145 -0
  101. package/src/hyperframes/kit/transition-math.ts +116 -0
  102. package/src/hyperframes/kit/ui-math.ts +145 -0
  103. package/src/hyperframes/kit/ui-theme.ts +25 -0
  104. package/src/hyperframes/kit/word-anchor.ts +107 -0
  105. package/src/hyperframes/math.ts +62 -0
  106. package/src/hyperframes/three.tsx +10 -0
  107. package/src/pipeline/beatsnap.ts +72 -0
  108. package/src/pipeline/review.ts +44 -10
  109. package/src/pipeline/schema.ts +51 -4
  110. package/src/pipeline/timing.ts +27 -1
  111. package/src/project/background.ts +33 -0
  112. package/src/project/chromakey.ts +1 -1
  113. package/src/project/layers.ts +60 -0
  114. package/src/project/manifest.ts +59 -14
  115. package/src/project/music.ts +19 -5
  116. package/src/project/project.ts +4 -1
  117. package/src/project/serve.ts +2 -2
  118. package/src/project/soundreport.ts +347 -0
  119. package/src/project/svgcheck.ts +21 -0
  120. package/src/render/component-preview.ts +11 -55
  121. package/src/render/contact-sheet.ts +39 -0
  122. package/src/render/continuity.ts +14 -4
  123. package/src/render/deps.ts +15 -3
  124. package/src/render/render.ts +62 -57
  125. package/src/render/serve.ts +31 -0
  126. package/src/render/sound-notes.ts +106 -0
  127. package/src/render/static-check.ts +15 -4
  128. package/src/render/validate.ts +4 -4
  129. package/src/render/word-check.ts +181 -0
  130. package/src/render/worker.ts +71 -0
  131. package/src/testing/conformance.ts +12 -0
  132. package/src/testing/fake-api.ts +4 -4
  133. package/src/testing/fixtures.ts +4 -1
  134. package/src/remotion/Root.tsx +0 -31
  135. package/src/remotion/kit/Music.tsx +0 -19
  136. package/src/remotion/kit/SceneFrame.tsx +0 -19
  137. package/src/remotion/kit/docs.ts +0 -124
  138. package/src/remotion/kit/index.ts +0 -29
  139. package/src/remotion/kit/music-math.ts +0 -42
  140. /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
  141. /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
  142. /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
  143. /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
  144. /package/src/{remotion → hyperframes}/types.ts +0 -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
+ }
@@ -1,65 +1,21 @@
1
- import { execFile } from "node:child_process";
2
- import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
2
  import { tmpdir } from "node:os";
4
3
  import { dirname, join } from "node:path";
5
- import { promisify } from "node:util";
6
- import { renderMedia, selectComposition } from "@remotion/renderer";
7
- import { bundleProject, disposeBundle } from "./render";
8
-
9
- const run = promisify(execFile);
10
- const NAME = /^[A-Z][A-Za-z0-9]*$/;
11
-
4
+ import { bundleProject, disposeBundle, renderVideo } from "./render";
5
+ import { migrateFrameImports } from "./static-check";
12
6
  export const PREVIEW = { width: 960, height: 600, fps: 30, frames: 105 } as const;
13
-
14
- // The generated entry: the kit's default dark background with the example filling the frame. Importing the kit also loads
15
- // its fonts, which is what makes text render in the kit fonts. `name` is checked against NAME and `example` starts with `<name`,
16
- // because both are interpolated into this code; the example is trusted seed text from the owner's own repository.
17
- const entrySource = (name: string, example: string) => `import React from "react";
18
- import { AbsoluteFill, Composition, registerRoot } from "remotion";
19
- import { palettes } from "reelkit/kit";
20
- import { ${name} } from "./${name}";
21
-
22
- void ${name};
23
- const Preview: React.FC = () => (
24
- <AbsoluteFill style={{ background: palettes.darkTech.bg }}>
25
- ${example}
26
- </AbsoluteFill>
27
- );
28
-
29
- registerRoot(() => (
30
- <Composition id="Preview" component={Preview} durationInFrames={${PREVIEW.frames}} fps={${PREVIEW.fps}} width={${PREVIEW.width}} height={${PREVIEW.height}} />
31
- ));
32
- `;
33
-
34
- // Renders one component with its example to a short silent MP4 (960x600, 30 fps, 3.5 s, H.264, yuv420p, CRF 28).
35
- // Everything it creates besides outPath (the temp folder and the bundle) is removed again, also when it fails.
7
+ const NAME = /^[A-Z][A-Za-z0-9]*$/;
36
8
  export async function renderComponentPreview(input: { name: string; source: string; example: string }, outPath: string): Promise<void> {
37
9
  const { name, source, example } = input;
38
10
  if (!NAME.test(name)) throw new Error(`Component name "${name}" must be PascalCase letters and digits.`);
39
11
  if (!example.trimStart().startsWith(`<${name}`)) throw new Error(`The example for ${name} must start with <${name}.`);
40
-
41
- const work = await mkdtemp(join(tmpdir(), "rk-cmp-preview-"));
42
- let serveUrl: string | undefined;
12
+ const work = await mkdtemp(join(tmpdir(), "rk-cmp-preview-")); let bundle: string | undefined;
13
+ const props = { manifest: { width: PREVIEW.width, height: PREVIEW.height, fps: PREVIEW.fps, totalFrames: PREVIEW.frames, scenes: [] }, urls: {} };
43
14
  try {
44
- await writeFile(join(work, `${name}.tsx`), source);
15
+ await writeFile(join(work, `${name}.tsx`), migrateFrameImports(source));
45
16
  const entry = join(work, "index.tsx");
46
- await writeFile(entry, entrySource(name, example.trim()));
47
- serveUrl = await bundleProject(entry);
48
- const composition = await selectComposition({ serveUrl, id: "Preview" });
49
- const rendered = join(work, "rendered.mp4");
50
- await renderMedia({
51
- composition, serveUrl, codec: "h264", outputLocation: rendered, crf: 28, pixelFormat: "yuv420p", colorSpace: "bt709", muted: true, logLevel: "error",
52
- });
53
- // The last pass guarantees no audio stream and the index at the front, whatever Remotion wrote; the video is copied, not re-encoded.
54
- const finished = join(work, "final.mp4");
55
- await run("ffmpeg", ["-y", "-v", "error", "-i", rendered, "-an", "-movflags", "+faststart", "-c:v", "copy", finished]);
56
- await mkdir(dirname(outPath), { recursive: true });
57
- await rename(finished, outPath).catch(async () => {
58
- // A different file system than the temp folder: copy instead.
59
- await run("ffmpeg", ["-y", "-v", "error", "-i", finished, "-c", "copy", "-movflags", "+faststart", outPath]);
60
- });
61
- } finally {
62
- if (serveUrl) await disposeBundle(serveUrl).catch(() => {});
63
- await rm(work, { recursive: true, force: true });
64
- }
17
+ await writeFile(entry, `import React from "react";\nimport { AbsoluteFill } from "reelkit/frame";\nimport { mountVideo } from "reelkit/host";\nimport { palettes } from "reelkit/kit";\nimport { ${name} } from "./${name}";\nmountVideo(() => <AbsoluteFill style={{background: palettes.darkTech.bg}}>${example.trim()}</AbsoluteFill>);\n`);
18
+ bundle = await bundleProject(entry); await mkdir(dirname(outPath), { recursive: true });
19
+ await renderVideo(bundle, props, outPath, { silent: true, crf: 28 });
20
+ } finally { if (bundle) await disposeBundle(bundle).catch(() => {}); await rm(work, { recursive: true, force: true }); }
65
21
  }
@@ -0,0 +1,39 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const exec = promisify(execFile);
5
+
6
+ // The grid of a sheet of `count` tiles for a frame of `aspect` (width over height): 6 across for a tall frame, 4 for a wide one, so that the whole sheet
7
+ // is at most about `maxWidth` pixels across and a phone-shaped frame is still readable.
8
+ export function sheetGrid(count: number, aspect: number, maxWidth = 2400): { cols: number; rows: number; tileW: number; tileH: number } {
9
+ const cols = Math.max(1, Math.min(count, aspect < 1 ? 6 : aspect > 1.2 ? 4 : 5));
10
+ const tileW = Math.min(540, Math.floor(maxWidth / cols / 2) * 2);
11
+ const tileH = Math.max(2, Math.round(tileW / aspect / 2) * 2);
12
+ return { cols, rows: Math.ceil(count / cols), tileW, tileH };
13
+ }
14
+
15
+ // The drawn text is plain ASCII (the default font has no arrows): the arrow of a word label is drawn as >.
16
+ const clean = (s: string) => s.replace(/\u25B8/g, ">").replace(/[^A-Za-z0-9 _.>#-]/g, "");
17
+
18
+ // One picture of many: the files in order, each with its label in a box on its top left corner. Falls back to the same sheet without labels if this ffmpeg
19
+ // has no text drawing; the caller still lists the order. `newRowAt` starts a new row at that tile (the tiles before it are padded with empty ones), so a
20
+ // second kind of frame sits in rows of its own. Returns the grid, and whether the labels were drawn.
21
+ export async function contactSheet(files: string[], labels: string[], out: string, aspect: number, opts: { newRowAt?: number } = {}): Promise<{ cols: number; rows: number; width: number; height: number; labelled: boolean }> {
22
+ const base = sheetGrid(files.length, aspect);
23
+ const at = opts.newRowAt !== undefined && opts.newRowAt > 0 && opts.newRowAt < files.length ? opts.newRowAt : undefined;
24
+ const blanks = at === undefined ? 0 : (base.cols - (at % base.cols)) % base.cols;
25
+ const g = { ...base, rows: Math.ceil((files.length + blanks) / base.cols) };
26
+ const build = (labelled: boolean) => {
27
+ const tile = (i: number) => `[${i}:v]scale=${g.tileW}:${g.tileH},setsar=1${labelled ? `,drawtext=text='${clean(labels[i] ?? "")}':x=6:y=6:fontsize=${Math.max(12, Math.round(g.tileW / 22))}:fontcolor=white:box=1:boxcolor=black@0.65:boxborderw=4` : ""}[t${i}]`;
28
+ const parts = files.map((_, i) => tile(i));
29
+ // The empty tiles are extra inputs of plain colour, one frame each.
30
+ const blankInputs = Array.from({ length: blanks }, () => ["-f", "lavfi", "-i", `color=c=0x202020:s=${g.tileW}x${g.tileH}:r=25`]).flat();
31
+ const blankParts = Array.from({ length: blanks }, (_, k) => `[${files.length + k}:v]trim=end_frame=1,setpts=PTS-STARTPTS,format=yuvj420p,setsar=1[b${k}]`);
32
+ const order = [...files.map((_, i) => (i === at ? [...Array.from({ length: blanks }, (_, k) => `[b${k}]`), `[t${i}]`] : [`[t${i}]`])).flat()];
33
+ const filter = `${[...parts, ...blankParts].join(";")};${order.join("")}concat=n=${files.length + blanks}:v=1:a=0,tile=${g.cols}x${g.rows}:padding=2:color=0x202020[out]`;
34
+ return ["-y", "-loglevel", "error", ...files.flatMap((f) => ["-i", f]), ...blankInputs, "-filter_complex", filter, "-map", "[out]", "-frames:v", "1", "-q:v", "4", out];
35
+ };
36
+ let labelled = true;
37
+ try { await exec("ffmpeg", build(true)); } catch { labelled = false; await exec("ffmpeg", build(false)); }
38
+ return { cols: g.cols, rows: g.rows, width: g.cols * g.tileW + (g.cols - 1) * 2, height: g.rows * g.tileH + (g.rows - 1) * 2, labelled };
39
+ }
@@ -10,7 +10,8 @@ export const CARRIED_AT = 0.25;
10
10
  const MIN_EDGE_SHARE = 0.002;
11
11
 
12
12
  export type Gray = ArrayLike<number>;
13
- export type BoundaryKind = "carried" | "cut" | "empty";
13
+ // "cut (intended)" is a change the plan marked with cutIn: it is reported but never counts against the score.
14
+ export type BoundaryKind = "carried" | "cut" | "empty" | "cut (intended)";
14
15
 
15
16
  // A pixel is an edge where the brightness steps by more than the threshold to the pixel on its right or below it.
16
17
  export function edgeMap(gray: Gray, w: number, h: number, threshold = EDGE_THRESHOLD): Uint8Array {
@@ -57,6 +58,13 @@ export function dilate(map: Uint8Array, w: number, h: number, radius: number): U
57
58
  return out;
58
59
  }
59
60
 
61
+ // The later scene is compared at two moments, its first fully visible frame and one a few frames on while a move is still settling (a plate
62
+ // that becomes a card has not yet reached its edges at the first). The better of the two says whether something was carried.
63
+ export function compareAcross(earlier: Gray, laters: Gray[], w: number, h: number): ReturnType<typeof compareFrames> {
64
+ const all = laters.map((l) => compareFrames(earlier, l, w, h));
65
+ return all.reduce((best, r) => (r.survived > best.survived ? r : best));
66
+ }
67
+
60
68
  export function compareFrames(earlier: Gray, later: Gray, w: number, h: number): { survived: number; edges: number; kind: BoundaryKind } {
61
69
  const band = captionBand(w, h);
62
70
  const before = clearBottom(edgeMap(earlier, w, h), w, h, band);
@@ -72,15 +80,17 @@ export type Boundary = { from: string; to: string; survived: number; kind: Bound
72
80
 
73
81
  // The score is carried over carried plus cut: a change with nothing on screen to compare says nothing either way.
74
82
  export function summariseContinuity(boundaries: Boundary[]): { score: number | null; lines: string[] } {
83
+ const intended = boundaries.filter((b) => b.kind === "cut (intended)").length;
75
84
  const carried = boundaries.filter((b) => b.kind === "carried").length;
76
85
  const cut = boundaries.filter((b) => b.kind === "cut");
77
86
  const counted = carried + cut.length;
78
- const empty = boundaries.length - counted;
79
- if (!counted) return { score: null, lines: ["No scene change could be measured: the picture is nearly empty at the end of every scene."] };
87
+ const empty = boundaries.length - counted - intended;
88
+ const hard = intended ? ` ${intended} ${intended === 1 ? "is" : "are"} intended hard cut${intended === 1 ? "" : "s"}, not counted.` : "";
89
+ if (!counted) return { score: null, lines: [intended && !empty ? `Every scene change that could be measured is an intended hard cut.` : "No scene change could be measured: the picture is nearly empty at the end of every scene."] };
80
90
  return {
81
91
  score: carried / counted,
82
92
  lines: [
83
- `${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.`,
93
+ `${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.${hard}`,
84
94
  ...cut.map((b) => `${b.from} → ${b.to}: nothing carries over. Keep one element on screen through the change and move it into the next scene (see reference/continuity.md).`),
85
95
  ],
86
96
  };
@@ -1,8 +1,20 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { createRequire } from "node:module";
2
- import { dirname } from "node:path";
3
+ import { dirname, join } from "node:path";
3
4
 
4
5
  const require = createRequire(import.meta.url);
5
6
 
6
7
  // The directory of an installed dependency of this package, found through its package.json so it does not depend on
7
- // where the package keeps its main file or on how the package manager lays out node_modules.
8
- export const packageDir = (name: string): string => dirname(require.resolve(`${name}/package.json`));
8
+ // where the package keeps its main file or on how the package manager lays out node_modules. A package that does not export its
9
+ // package.json (three does not) is found from its main file instead, by walking up to the folder that holds its own package.json.
10
+ export const packageDir = (name: string): string => {
11
+ try {
12
+ return dirname(require.resolve(`${name}/package.json`));
13
+ } catch {
14
+ for (let dir = dirname(require.resolve(name)); dir !== dirname(dir); dir = dirname(dir)) {
15
+ const file = join(dir, "package.json");
16
+ if (existsSync(file) && (JSON.parse(readFileSync(file, "utf8")) as { name?: string }).name === name) return dir;
17
+ }
18
+ throw new Error(`Could not find the installed package ${name}.`);
19
+ }
20
+ };