rvlib-shipkit 2.2.0 → 2.3.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 (107) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/cli/cmd_Commands.ts +5 -0
  12. package/src/cli/cmd_Review.ts +948 -0
  13. package/src/cli/cmd_Time.ts +112 -0
  14. package/src/cli/cmd_Trailer.ts +259 -6
  15. package/src/cli/kit/verb.ts +14 -4
  16. package/src/cli.ts +6 -4
  17. package/src/collections/collection-types.ts +6 -1
  18. package/src/collections/providers.ts +3 -0
  19. package/src/collections/review.ts +70 -0
  20. package/src/core/layout.ts +2 -0
  21. package/src/reflect/prompt-corpus.ts +15 -119
  22. package/src/reflect/transcript.ts +254 -0
  23. package/src/review/anim.ts +228 -0
  24. package/src/review/auto.ts +141 -0
  25. package/src/review/format.ts +150 -0
  26. package/src/review/http.ts +23 -0
  27. package/src/review/marks.ts +43 -0
  28. package/src/review/media.ts +45 -0
  29. package/src/review/model.ts +854 -0
  30. package/src/review/project.ts +110 -0
  31. package/src/review/role.ts +48 -0
  32. package/src/review/routes.ts +228 -0
  33. package/src/review/store.ts +1002 -0
  34. package/src/review/threads.ts +237 -0
  35. package/src/review/trailer-desk.ts +385 -0
  36. package/src/review/trailer-model.ts +183 -0
  37. package/src/review/trailer-routes.ts +136 -0
  38. package/src/review/unit.ts +165 -0
  39. package/src/review/watchers.ts +54 -0
  40. package/src/serve/embed.ts +35 -0
  41. package/src/serve/hub-app/activity-view.tsx +1 -1
  42. package/src/serve/hub-app/activity.css +0 -4
  43. package/src/serve/hub-app/dock-view.tsx +5 -4
  44. package/src/serve/hub-app/hash-route.ts +28 -1
  45. package/src/serve/hub-app/header-bar.tsx +3 -1
  46. package/src/serve/hub-app/home-st.ts +10 -2
  47. package/src/serve/hub-app/home-strip.tsx +3 -2
  48. package/src/serve/hub-app/home.html +1 -0
  49. package/src/serve/hub-app/hub-app.tsx +80 -11
  50. package/src/serve/hub-app/hub.css +45 -13
  51. package/src/serve/hub-app/icons.tsx +9 -0
  52. package/src/serve/hub-app/index.html +1 -0
  53. package/src/serve/hub-app/insights-view.tsx +4 -3
  54. package/src/serve/hub-app/keys.ts +14 -10
  55. package/src/serve/hub-app/layout-st.ts +63 -2
  56. package/src/serve/hub-app/planned-view.tsx +3 -2
  57. package/src/serve/hub-app/planned.css +0 -1
  58. package/src/serve/hub-app/project-list.css +22 -4
  59. package/src/serve/hub-app/project-list.tsx +41 -8
  60. package/src/serve/hub-app/projects-svc.ts +4 -0
  61. package/src/serve/hub-app/repo-sections.ts +15 -3
  62. package/src/serve/hub-app/repo-st.ts +45 -3
  63. package/src/serve/hub-app/review/clip-st.ts +208 -0
  64. package/src/serve/hub-app/review/kind-view.tsx +171 -0
  65. package/src/serve/hub-app/review/kit.tsx +113 -0
  66. package/src/serve/hub-app/review/review-answer.tsx +239 -0
  67. package/src/serve/hub-app/review/review-board.tsx +46 -0
  68. package/src/serve/hub-app/review/review-list.tsx +156 -0
  69. package/src/serve/hub-app/review/review-page.tsx +101 -0
  70. package/src/serve/hub-app/review/review-st.ts +779 -0
  71. package/src/serve/hub-app/review/review-views.tsx +503 -0
  72. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  73. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  74. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  75. package/src/serve/hub-app/review.css +329 -0
  76. package/src/serve/hub-app/theme-st.ts +56 -0
  77. package/src/serve/hub-app/tree-build.ts +4 -0
  78. package/src/serve/hub-app/tree-row.tsx +1 -1
  79. package/src/serve/hub-app/tree-svc.ts +4 -0
  80. package/src/serve/hub-runtime.ts +5 -3
  81. package/src/serve/same-origin.ts +5 -1
  82. package/src/serve/serve-hub.ts +8 -0
  83. package/src/shipkit-files.ts +9 -5
  84. package/src/trailer/adapter.ts +58 -3
  85. package/src/trailer/build.ts +300 -41
  86. package/src/trailer/ffmpeg.ts +22 -0
  87. package/src/trailer/footage.ts +109 -40
  88. package/src/trailer/grammar.ts +444 -69
  89. package/src/trailer/graph.ts +199 -14
  90. package/src/trailer/holes.ts +72 -0
  91. package/src/trailer/load-unit.ts +20 -3
  92. package/src/trailer/look.ts +17 -3
  93. package/src/trailer/passage.ts +129 -0
  94. package/src/trailer/plan.ts +80 -21
  95. package/src/trailer/prune.ts +193 -0
  96. package/src/trailer/score/compose.ts +240 -0
  97. package/src/trailer/score/dsp.ts +272 -0
  98. package/src/trailer/score/palette.ts +188 -0
  99. package/src/trailer/score/pattern.ts +91 -0
  100. package/src/trailer/score/score.ts +214 -0
  101. package/src/trailer/score/sound.ts +112 -0
  102. package/src/trailer/score/synth.ts +361 -0
  103. package/src/trailer/states.ts +76 -0
  104. package/src/trailer/stitch.ts +92 -9
  105. package/src/trailer/titles.ts +123 -18
  106. package/src/trailer/vocab.ts +23 -4
  107. package/src/trailer/words.ts +254 -0
@@ -0,0 +1,361 @@
1
+ // the score rendered offline, in Bun: every note an oscillator (or noise) through its envelope, filter and pan into the
2
+ // music bus, a send into the room (a convolution reverb), then the master chain (gain, compressor, limiter, soft
3
+ // ceiling). The same graph and settings as the browser synth the language comes from, computed sample by sample, so a
4
+ // score sounds alike in either. No browser, no audio device
5
+ import { applyEnvelope, biquad, compress, convolveInto, envPoints, softClip } from './dsp.ts'
6
+ import { type InstrumentDef, midiToHz } from './pattern.ts'
7
+ import type { CompiledScore, ScheduledNote } from './score.ts'
8
+
9
+ export const RATE = 48000
10
+ /** the master's gain into the compressor */
11
+ export const MASTER_GAIN = 0.45
12
+ /** what a render keeps past its end: the last notes' release and the room */
13
+ export const TAIL_S = 1.5
14
+ /** the fade that closes a render at its end */
15
+ const CLOSE_FADE_S = 1.2
16
+ /** a dead stop: the score and its room cut over this long */
17
+ const STOP_FADE_S = 0.01
18
+
19
+ export interface Rendered {
20
+ rate: number
21
+ left: Float32Array
22
+ right: Float32Array
23
+ }
24
+
25
+ const TABLE = 2048
26
+ const HARMONICS = 64
27
+ /** one softened wave serves every note within a minor third */
28
+ const BAND_SEMIS = 3
29
+ const bandOf = (hz: number): number => Math.round((12 * Math.log2(Math.max(20, hz) / 440)) / BAND_SEMIS)
30
+ const bandHz = (band: number): number => 440 * 2 ** ((band * BAND_SEMIS) / 12)
31
+ /** the frequency above which a pulse, a saw or a wave loses its harmonics, unless the instrument sets its own */
32
+ const TONE_HZ = 3000
33
+ /** the pitch metal noise loops at, and the one drums and noise notes are relative to */
34
+ const NOISE_REF_HZ = midiToHz(60)
35
+
36
+ type Coefficients = { real: Float64Array; imag: Float64Array }
37
+
38
+ function pulseCoefficients(duty: number): Coefficients {
39
+ const d = Math.max(0.05, Math.min(0.95, duty))
40
+ const real = new Float64Array(HARMONICS + 1)
41
+ const imag = new Float64Array(HARMONICS + 1)
42
+ for (let k = 1; k <= HARMONICS; k++) {
43
+ const c = (2 / (k * Math.PI)) * Math.sin(Math.PI * k * d)
44
+ real[k] = c * Math.cos(Math.PI * k * d)
45
+ imag[k] = c * Math.sin(Math.PI * k * d)
46
+ }
47
+ return { real, imag }
48
+ }
49
+
50
+ function waveCoefficients(samples: readonly number[]): Coefficients {
51
+ const n = samples.length
52
+ const h = Math.max(1, Math.min(32, Math.floor(n / 2)))
53
+ const real = new Float64Array(h + 1)
54
+ const imag = new Float64Array(h + 1)
55
+ for (let k = 1; k <= h; k++) {
56
+ let re = 0
57
+ let im = 0
58
+ for (let i = 0; i < n; i++) {
59
+ const ph = (2 * Math.PI * k * i) / n
60
+ re += (samples[i] ?? 0) * Math.cos(ph)
61
+ im += (samples[i] ?? 0) * Math.sin(ph)
62
+ }
63
+ real[k] = (2 * re) / n
64
+ imag[k] = (2 * im) / n
65
+ }
66
+ return { real, imag }
67
+ }
68
+
69
+ function sawCoefficients(): Coefficients {
70
+ const real = new Float64Array(HARMONICS + 1)
71
+ const imag = new Float64Array(HARMONICS + 1)
72
+ for (let k = 1; k <= HARMONICS; k++) imag[k] = (2 / (k * Math.PI)) * (k % 2 === 1 ? 1 : -1)
73
+ return { real, imag }
74
+ }
75
+
76
+ function triangleCoefficients(): Coefficients {
77
+ const real = new Float64Array(HARMONICS + 1)
78
+ const imag = new Float64Array(HARMONICS + 1)
79
+ for (let k = 1; k <= HARMONICS; k += 2) imag[k] = (8 / (Math.PI * Math.PI * k * k)) * (k % 4 === 1 ? 1 : -1)
80
+ return { real, imag }
81
+ }
82
+
83
+ /** one cycle, its harmonics past `top` Hz dropped (no alias), softened past `tone`, its peak brought to 1 as a browser
84
+ * periodic wave is */
85
+ function tableOf(c: Coefficients, hz: number, tone: number | null, rate: number): Float32Array {
86
+ const top = hz * 2 ** (2 / 12)
87
+ const out = new Float32Array(TABLE + 1)
88
+ for (let k = 1; k < c.real.length; k++) {
89
+ if (k * top >= rate / 2) break
90
+ const g = tone === null ? 1 : 1 / (1 + ((k * hz) / tone) ** 2)
91
+ const a = c.real[k]! * g
92
+ const b = c.imag[k]! * g
93
+ if (a === 0 && b === 0) continue
94
+ for (let i = 0; i < TABLE; i++) {
95
+ const ph = (2 * Math.PI * k * i) / TABLE
96
+ out[i] = out[i]! + a * Math.cos(ph) + b * Math.sin(ph)
97
+ }
98
+ }
99
+ let peak = 0
100
+ for (let i = 0; i < TABLE; i++) peak = Math.max(peak, Math.abs(out[i]!))
101
+ if (peak > 0) for (let i = 0; i < TABLE; i++) out[i] = out[i]! / peak
102
+ out[TABLE] = out[0]!
103
+ return out
104
+ }
105
+
106
+ /** white: one second of hiss; metal: a short 7 bit lfsr cycle that rings with a pitch, as the old sound chips' noise */
107
+ function noiseBuffer(rate: number, metal: boolean): Float32Array {
108
+ if (!metal) {
109
+ const d = new Float32Array(rate)
110
+ let s = 0x1234
111
+ for (let i = 0; i < d.length; i++) {
112
+ s = (s * 1103515245 + 12345) & 0x7fffffff
113
+ d[i] = s / 0x3fffffff - 1
114
+ }
115
+ return d
116
+ }
117
+ const period = 127
118
+ const perStep = rate / (NOISE_REF_HZ * period)
119
+ const len = Math.max(1, Math.round(period * perStep))
120
+ const d = new Float32Array(len)
121
+ let reg = 0x7f
122
+ const bits: number[] = []
123
+ for (let i = 0; i < period; i++) {
124
+ const bit = (reg ^ (reg >> 1)) & 1
125
+ reg = (reg >> 1) | (bit << 6)
126
+ bits.push(reg & 1 ? 1 : -1)
127
+ }
128
+ for (let i = 0; i < len; i++) d[i] = bits[Math.floor(i / perStep) % period] ?? 0
129
+ return d
130
+ }
131
+
132
+ /** the room: a stereo burst of noise decaying over `seconds` */
133
+ function impulse(seconds: number, rate: number): [Float32Array, Float32Array] {
134
+ const key = Math.round(seconds * 10) / 10
135
+ const len = Math.max(1, Math.floor(rate * Math.max(0.1, key)))
136
+ const out: [Float32Array, Float32Array] = [new Float32Array(len), new Float32Array(len)]
137
+ out.forEach((d, ch) => {
138
+ let s = 777 + ch * 31
139
+ for (let i = 0; i < len; i++) {
140
+ s = (s * 1103515245 + 12345) & 0x7fffffff
141
+ const decay = (1 - i / len) ** 2.5
142
+ const early = i < rate * 0.012 ? 0.4 : 1
143
+ d[i] = (s / 0x3fffffff - 1) * decay * early
144
+ }
145
+ })
146
+ return out
147
+ }
148
+
149
+ /** a browser convolver's own normalization of its impulse, so the room's level matches it */
150
+ function impulseScale(ir: readonly Float32Array[], rate: number): number {
151
+ let power = 0
152
+ for (const d of ir) for (let i = 0; i < d.length; i++) power += d[i]! * d[i]!
153
+ power = Math.sqrt(power / (ir.length * (ir[0]?.length ?? 1)))
154
+ if (!Number.isFinite(power) || power < 0.000125) power = 0.000125
155
+ return (1 / power) * 0.00125 * (44100 / rate)
156
+ }
157
+
158
+ /** the waves a render reads, made once each */
159
+ class Waves {
160
+ private tables = new Map<string, Float32Array>()
161
+ private waveIds = new Map<readonly number[], number>()
162
+ readonly white: Float32Array
163
+ readonly metal: Float32Array
164
+ constructor(readonly rate: number) {
165
+ this.white = noiseBuffer(rate, false)
166
+ this.metal = noiseBuffer(rate, true)
167
+ }
168
+ table(inst: InstrumentDef, hz: number): Float32Array {
169
+ const band = bandOf(hz)
170
+ const osc = inst.osc === 'wave' && !(inst.wave && inst.wave.length > 1) ? 'triangle' : inst.osc
171
+ const soft = osc === 'pulse' || osc === 'saw' || osc === 'wave'
172
+ const tone = soft ? (inst.tone ?? TONE_HZ) : null
173
+ let id = ''
174
+ if (osc === 'pulse') id = `p${Math.round((inst.duty ?? 0.5) * 100) / 100}`
175
+ else if (osc === 'wave') {
176
+ let w = this.waveIds.get(inst.wave!)
177
+ if (w === undefined) this.waveIds.set(inst.wave!, (w = this.waveIds.size))
178
+ id = `w${w}`
179
+ } else id = osc
180
+ const key = `${id}:${band}:${tone}`
181
+ let t = this.tables.get(key)
182
+ if (!t) {
183
+ const c =
184
+ osc === 'pulse' ? pulseCoefficients(Math.round((inst.duty ?? 0.5) * 100) / 100)
185
+ : osc === 'saw' ? sawCoefficients()
186
+ : osc === 'wave' ? waveCoefficients(inst.wave!)
187
+ : osc === 'triangle' ? triangleCoefficients()
188
+ : { real: new Float64Array(2), imag: Float64Array.of(0, 1) }
189
+ t = tableOf(c, bandHz(band), tone, this.rate)
190
+ this.tables.set(key, t)
191
+ }
192
+ return t
193
+ }
194
+ }
195
+
196
+ /** how a note's pitch moves from its start: held, swept (kicks, toms) or glided from the note before */
197
+ interface Pitch {
198
+ from: number
199
+ to: number
200
+ /** seconds to go from `from` to `to`, exponentially; 0: held at `to` */
201
+ over: number
202
+ }
203
+
204
+ const pitchAt = (p: Pitch, t: number): number => (p.over <= 0 || t >= p.over ? p.to : p.from * (p.to / p.from) ** (Math.max(0, t) / p.over))
205
+
206
+ /** one oscillator voice added into `buf`: a table read at a moving pitch, its detune and vibrato in cents */
207
+ function oscillatorRun(buf: Float32Array, n: number, t0: number, rate: number, table: Float32Array, p: Pitch, cents: number, vib: InstrumentDef['vibrato'] | null, gain: number): void {
208
+ const LN = Math.LN2 / 1200
209
+ const steady = p.over <= 0 && !vib
210
+ const fixed = (p.to * Math.exp(cents * LN)) / rate
211
+ let phase = Math.max(0, t0) * (p.over <= 0 ? p.to : p.from) * Math.exp(cents * LN)
212
+ phase -= Math.floor(phase)
213
+ for (let i = 0; i < n; i++) {
214
+ const x = phase * TABLE
215
+ const j = x | 0
216
+ const v = table[j]!
217
+ buf[i] = buf[i]! + gain * (v + (table[j + 1]! - v) * (x - j))
218
+ let inc = fixed
219
+ if (!steady) {
220
+ const t = t0 + i / rate
221
+ let c = cents
222
+ if (vib) {
223
+ const on = Math.max(0, vib.delay)
224
+ const depth = t <= on ? 0 : vib.depth * 100 * Math.min(1, (t - on) / 0.12)
225
+ c += depth * Math.sin(2 * Math.PI * vib.rate * t)
226
+ }
227
+ inc = (pitchAt(p, t) * Math.exp(c * LN)) / rate
228
+ }
229
+ phase += inc
230
+ if (phase >= 1) phase -= Math.floor(phase)
231
+ }
232
+ }
233
+
234
+ /** a noise buffer looped at a moving playback rate, added into `buf` */
235
+ function noiseRun(buf: Float32Array, n: number, t0: number, rate: number, src: Float32Array, p: Pitch): void {
236
+ let pos = 0
237
+ const len = src.length
238
+ for (let i = 0; i < n; i++) {
239
+ const j = pos | 0
240
+ const v = src[j % len]!
241
+ buf[i] = buf[i]! + v + (src[(j + 1) % len]! - v) * (pos - j)
242
+ pos += pitchAt(p, t0 + i / rate)
243
+ if (pos >= len) pos -= len
244
+ }
245
+ }
246
+
247
+ /** the music bus a render writes: the dry mix and the room's send, both stereo */
248
+ interface Bus {
249
+ l: Float32Array
250
+ r: Float32Array
251
+ sendL: Float32Array | null
252
+ sendR: Float32Array | null
253
+ }
254
+
255
+ const scratch = { buf: new Float32Array(0) }
256
+
257
+ /** one note into the bus: its source (oscillators or noise), its envelope, filter and pan, its send to the room */
258
+ function playNote(bus: Bus, waves: Waves, inst: InstrumentDef, note: ScheduledNote): void {
259
+ const rate = waves.rate
260
+ const midi = note.midi ?? inst.pitch ?? 60
261
+ const hz = midiToHz(midi)
262
+ const peak = inst.vol * note.vol
263
+ if (peak <= 0) return
264
+ const pts = envPoints(inst.env, note.dur, peak)
265
+ const length = (pts[pts.length - 1]?.[0] ?? note.dur) + 0.02
266
+ const i0 = Math.max(0, Math.ceil(note.time * rate))
267
+ const i1 = Math.min(bus.l.length, Math.ceil((note.time + length) * rate))
268
+ const n = i1 - i0
269
+ if (n <= 0) return
270
+ if (scratch.buf.length < n) scratch.buf = new Float32Array(n)
271
+ const buf = scratch.buf
272
+ buf.fill(0, 0, n)
273
+ const t0 = i0 / rate - note.time
274
+ if (inst.osc === 'noise') {
275
+ const r0 = Math.max(0.05, Math.min(8, hz / NOISE_REF_HZ))
276
+ const pitch: Pitch = inst.sweep ? { from: r0, to: Math.max(0.02, r0 * 2 ** (inst.sweep.semis / 12)), over: Math.max(1e-4, inst.sweep.time) } : { from: r0, to: r0, over: 0 }
277
+ noiseRun(buf, n, t0, rate, inst.noise === 'metal' ? waves.metal : waves.white, pitch)
278
+ } else {
279
+ const voices = Math.max(1, Math.min(4, inst.unison ?? (inst.detune ? 2 : 1)))
280
+ const spread = inst.detune ?? 0
281
+ const pitch: Pitch = inst.sweep
282
+ ? { from: hz, to: Math.max(10, hz * 2 ** (inst.sweep.semis / 12)), over: Math.max(0.005, inst.sweep.time) }
283
+ : inst.glide && note.from !== null && note.from !== note.midi
284
+ ? { from: midiToHz(note.from), to: hz, over: inst.glide }
285
+ : { from: hz, to: hz, over: 0 }
286
+ const vib = inst.vibrato && inst.vibrato.depth > 0 ? inst.vibrato : null
287
+ const table = waves.table(inst, hz)
288
+ for (let v = 0; v < voices; v++) {
289
+ const cents = voices === 1 ? spread : spread * ((2 * v) / (voices - 1) - 1)
290
+ oscillatorRun(buf, n, t0, rate, table, pitch, cents, vib, 1 / Math.sqrt(voices))
291
+ }
292
+ }
293
+ applyEnvelope(buf, n, t0, rate, pts)
294
+ if (inst.filter) biquad(buf, n, rate, inst.filter.type, inst.filter.freq, inst.filter.q ?? 0.8)
295
+ const pan = inst.pan !== undefined && inst.pan !== 0 ? (Math.max(-1, Math.min(1, inst.pan)) + 1) / 2 : null
296
+ const gl = pan === null ? 1 : Math.cos((pan * Math.PI) / 2)
297
+ const gr = pan === null ? 1 : Math.sin((pan * Math.PI) / 2)
298
+ const send = bus.sendL && inst.reverb && inst.reverb > 0 ? inst.reverb : 0
299
+ mixRun(buf, n, i0, bus.l, bus.r, gl, gr)
300
+ if (send > 0) mixRun(buf, n, i0, bus.sendL!, bus.sendR!, gl * send, gr * send)
301
+ }
302
+
303
+ function mixRun(buf: Float32Array, n: number, at: number, l: Float32Array, r: Float32Array, gl: number, gr: number): void {
304
+ for (let i = 0; i < n; i++) {
305
+ const x = buf[i]!
306
+ l[at + i] = l[at + i]! + x * gl
307
+ r[at + i] = r[at + i]! + x * gr
308
+ }
309
+ }
310
+
311
+ /** the gain over the render: whole, then a dead stop or the closing fade at the end */
312
+ function closeRun(l: Float32Array, r: Float32Array, rate: number, end: number, fade: number): void {
313
+ const a = Math.max(0, Math.floor(end * rate))
314
+ const b = Math.ceil((end + fade) * rate)
315
+ for (let i = a; i < l.length; i++) {
316
+ const g = i >= b ? 0 : Math.cos(((i - a) / (b - a)) * (Math.PI / 2))
317
+ l[i] = l[i]! * g
318
+ r[i] = r[i]! * g
319
+ }
320
+ }
321
+
322
+ /** `seconds` of the score from its start, rendered with a tail of TAIL_S: the notes starting before its end (and before
323
+ * its stop, if any) played, closed with a fade at the end, or cut dead at its stop */
324
+ export function renderScore(score: CompiledScore, seconds: number, rate = RATE): Rendered {
325
+ const n = Math.ceil(rate * (seconds + TAIL_S))
326
+ const room = score.reverb && score.reverb.mix > 0 ? score.reverb : null
327
+ const bus: Bus = { l: new Float32Array(n), r: new Float32Array(n), sendL: room ? new Float32Array(n) : null, sendR: room ? new Float32Array(n) : null }
328
+ const waves = new Waves(rate)
329
+ const end = Math.min(seconds, score.stop ?? Number.POSITIVE_INFINITY)
330
+ for (const note of score.notes) {
331
+ if (note.time >= end) break
332
+ const inst = score.instruments[note.instrument]
333
+ if (inst) playNote(bus, waves, inst, note)
334
+ }
335
+ if (room) {
336
+ const ir = impulse(room.seconds, rate)
337
+ convolveInto(bus.sendL!, bus.sendR!, ir[0], ir[1], bus.l, bus.r, impulseScale(ir, rate) * Math.min(1, room.mix * 1.6))
338
+ }
339
+ if (score.stop !== null && score.stop < seconds) closeRun(bus.l, bus.r, rate, score.stop, STOP_FADE_S)
340
+ else closeRun(bus.l, bus.r, rate, seconds, CLOSE_FADE_S)
341
+ master(bus.l, bus.r, rate)
342
+ return { rate, left: bus.l, right: bus.r }
343
+ }
344
+
345
+ /** the master chain: its gain, a gentle compressor, a fast limiter, then the soft ceiling */
346
+ function master(l: Float32Array, r: Float32Array, rate: number): void {
347
+ for (let i = 0; i < l.length; i++) {
348
+ l[i] = l[i]! * MASTER_GAIN
349
+ r[i] = r[i]! * MASTER_GAIN
350
+ }
351
+ compress(l, r, rate, { threshold: -10, knee: 30, ratio: 4, attack: 0.003, release: 0.25 })
352
+ compress(l, r, rate, { threshold: -3, knee: 0, ratio: 20, attack: 0.001, release: 0.05 })
353
+ // a browser wave shaper clamps its input to [-1, 1] before its curve: the ceiling is softClip(1), never CEILING
354
+ for (let i = 0; i < l.length; i++) {
355
+ l[i] = softClip(Math.max(-1, Math.min(1, l[i]!)))
356
+ r[i] = softClip(Math.max(-1, Math.min(1, r[i]!)))
357
+ }
358
+ }
359
+
360
+ /** a sample this close to the ceiling is the soft clip working: distortion when it lasts */
361
+ export const HOT = softClip(1) - 0.01
@@ -0,0 +1,76 @@
1
+ // what each frame of a take holds that a cut must not land on (wiki build/trailer.md, Film lines): a film command may
2
+ // print `states <rle>`, each run of one state and its frames (`dialog*12,-*30`, `-` a plain frame, parts joined by `+`).
3
+ // The stamp keeps it; the timeline names a shot whose edges cut into a change. Pure: no page, no file
4
+
5
+ /** a frame with nothing on it a cut must mind */
6
+ export const FREE = '-'
7
+
8
+ /** `dialog*12,-*30`: each run of one state and its length */
9
+ export function encodeStates(states: readonly string[]): string {
10
+ const runs: string[] = []
11
+ let i = 0
12
+ while (i < states.length) {
13
+ let j = i
14
+ while (j < states.length && states[j] === states[i]) j++
15
+ runs.push(`${states[i]}*${j - i}`)
16
+ i = j
17
+ }
18
+ return runs.join(',')
19
+ }
20
+
21
+ export function decodeStates(text: string): string[] {
22
+ const out: string[] = []
23
+ for (const run of text.split(',').filter(Boolean)) {
24
+ const at = run.lastIndexOf('*')
25
+ const n = Number(run.slice(at + 1))
26
+ if (at <= 0 || !Number.isInteger(n) || n < 1) throw new Error(`states: ${run} is no <state>*<frames>`)
27
+ for (let k = 0; k < n; k++) out.push(run.slice(0, at))
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** the states a film's output says, its `states <rle>` word (on its own line or after ` · `); null when it says none */
33
+ export function statesOf(said: string): string | null {
34
+ return /(?:^|\s·\s|\n)states (\S+)/.exec(said)?.[1] ?? null
35
+ }
36
+
37
+ /** frames at each edge of a shot where a change shows: a box rising or a change still playing reads as a flash */
38
+ export const EDGE_FRAMES = 6
39
+
40
+ export interface StatesRule {
41
+ /** parts that open on their own way in, never a change a cut minds */
42
+ ignore?: readonly string[]
43
+ /** parts that are a change under way: a shot never opens or ends on one */
44
+ underWay?: readonly string[]
45
+ }
46
+
47
+ /** what changes within EDGE_FRAMES of the shot's first or last frame (`first`, `last`: frames of the take), and a change
48
+ * under way the shot opens or ends on: one line each, none when the edges are settled */
49
+ export function edgeChanges(states: readonly string[], first: number, last: number, rule: StatesRule = {}): string[] {
50
+ const ignore = new Set(rule.ignore ?? [])
51
+ const parts = (s: string): string[] => (s === FREE ? [] : s.split('+').filter((x) => !ignore.has(x)))
52
+ const change = (from: string, to: string): string => {
53
+ const a = parts(from)
54
+ const b = parts(to)
55
+ return [...b.filter((x) => !a.includes(x)).map((x) => `${x} in`), ...a.filter((x) => !b.includes(x)).map((x) => `${x} out`)].join(', ')
56
+ }
57
+ const end = Math.min(last, states.length - 1)
58
+ if (first > end || first < 0) return []
59
+ const out: string[] = []
60
+ const seen = new Set<number>()
61
+ const at = (i: number): void => {
62
+ if (seen.has(i)) return
63
+ seen.add(i)
64
+ const after = i - first
65
+ const before = end - i + 1
66
+ out.push(`${change(states[i - 1]!, states[i]!)} ${after <= before ? `${after} frame${after === 1 ? '' : 's'} after its start` : `${before} frame${before === 1 ? '' : 's'} before its end`}`)
67
+ }
68
+ const moved = (i: number): boolean => change(states[i - 1]!, states[i]!) !== ''
69
+ for (let i = first + 1; i <= Math.min(end, first + EDGE_FRAMES); i++) if (moved(i)) at(i)
70
+ for (let i = Math.max(first + 1, end - EDGE_FRAMES + 1); i <= end; i++) if (moved(i)) at(i)
71
+ for (const u of rule.underWay ?? []) {
72
+ if (parts(states[first]!).includes(u)) out.unshift(`opens on a ${u}`)
73
+ if (end > first && parts(states[end]!).includes(u)) out.push(`ends on a ${u}`)
74
+ }
75
+ return out
76
+ }
@@ -5,8 +5,9 @@ import { existsSync, mkdirSync, renameSync, rmSync, statSync, utimesSync, writeF
5
5
  import { availableParallelism } from 'node:os'
6
6
  import { dirname, join } from 'node:path'
7
7
  import sharp from 'sharp'
8
- import { ffmpeg } from './ffmpeg.ts'
9
- import { type Encoder, mixArgs, pieceArgs, type StitchInput, THUMB_W } from './graph.ts'
8
+ import { ffmpeg, loudnessKept } from './ffmpeg.ts'
9
+ import { type Encoder, GRID_RADIUS, gridLayout, MIX_TRUE_PEAK, mixArgs, pieceArgs, type SplitMasks, splitPane, type StitchInput, THUMB_W } from './graph.ts'
10
+ import type { TrailerLook } from './look.ts'
10
11
  import { type Piece, pieces } from './plan.ts'
11
12
 
12
13
  /** bump when the stitch changes what the same arguments make */
@@ -50,6 +51,67 @@ export async function cachedAs(dir: string, stem: string, key: string, exts: str
50
51
  return { files, made: true }
51
52
  }
52
53
 
54
+ const n3 = (x: number): string => (+x.toFixed(4)).toString()
55
+
56
+ /** a drawing's own temporary file beside `file`: two builds drawing one piece at once each rename their own */
57
+ const partOf = (file: string): string => `${file}.${process.pid}-${Math.random().toString(36).slice(2, 8)}.part.png`
58
+
59
+ async function drawSvg(file: string, svg: string): Promise<void> {
60
+ mkdirSync(dirname(file), { recursive: true })
61
+ const part = partOf(file)
62
+ await sharp(Buffer.from(svg)).png().toFile(part)
63
+ renameSync(part, file)
64
+ }
65
+
66
+ const colourKey = (look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): string => new Bun.CryptoHasher('sha1').update(JSON.stringify([look.ground, look.light, look.edge])).digest('hex').slice(0, 8)
67
+
68
+ /** a grid's frame for `n` cells: the look's ground with a hole per cell, its corners rounded and rimmed in its light;
69
+ * laid over the cells. Drawn once per layout and look and kept, so its file never changes a piece's key */
70
+ export async function gridMask(dir: string, n: number, size: readonly [number, number], look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): Promise<string> {
71
+ const [W, H] = size
72
+ const file = join(dir, 'grid', `mask-${STITCH_VERSION}-${n}-${W}x${H}-${colourKey(look)}.png`)
73
+ if (existsSync(file)) return file
74
+ const r = H * GRID_RADIUS
75
+ const rim = Math.max(1.5, H * 0.0028)
76
+ const cells = gridLayout(n, W, H)
77
+ const holes = cells.map((c) => `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="${n3(r)}" fill="black"/>`).join('')
78
+ const rims = cells.map((c) => `<rect x="${c.x + rim / 2}" y="${c.y + rim / 2}" width="${c.w - rim}" height="${c.h - rim}" rx="${n3(r - rim / 2)}" fill="none" stroke="${look.light}" stroke-opacity="0.85" stroke-width="${n3(rim)}"/>`).join('')
79
+ await drawSvg(
80
+ file,
81
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
82
+ <defs><radialGradient id="g" cx="50%" cy="45%" r="75%"><stop offset="0" stop-color="${look.ground[0]}"/><stop offset="1" stop-color="${look.ground[1]}"/></radialGradient>
83
+ <mask id="m"><rect width="${W}" height="${H}" fill="white"/>${holes}</mask></defs>
84
+ <rect width="${W}" height="${H}" fill="url(#g)" mask="url(#m)"/>${rims}</svg>`,
85
+ )
86
+ return file
87
+ }
88
+
89
+ /** a split's panes and seams for `n` panes at `angle`: each pane's shape, and the seams, a line of the look's light over
90
+ * a soft shadow of its edge, its width a 150th of the frame's height. Drawn once and kept like the grid's frame */
91
+ export async function splitMasks(dir: string, n: number, angle: number, size: readonly [number, number], look: Pick<TrailerLook, 'ground' | 'light' | 'edge'>): Promise<SplitMasks> {
92
+ const [W, H] = size
93
+ const stem = join(dir, 'split', `${STITCH_VERSION}-${n}-${n3(angle)}-${W}x${H}`)
94
+ const panes = Array.from({ length: n }, (_, j) => `${stem}-pane${j}.png`)
95
+ const seams = `${stem}-seams-${colourKey(look)}.png`
96
+ if ([...panes, seams].every((f) => existsSync(f))) return { panes, seams }
97
+ const poly = (pts: [number, number][]): string => pts.map(([x, y]) => `${n3(x)},${n3(y)}`).join(' ')
98
+ for (let j = 0; j < n; j++)
99
+ if (!existsSync(panes[j]!))
100
+ await drawSvg(panes[j]!, `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="black"/><polygon points="${poly(splitPane(n, j, angle, W, H).points)}" fill="white"/></svg>`)
101
+ const t = Math.tan((angle * Math.PI) / 180)
102
+ // each seam drawn from well above the frame to well below it, so it crosses it whole
103
+ const lines = Array.from({ length: n - 1 }, (_, i) => (W * (i + 1)) / n)
104
+ const wLine = Math.max(2, H / 150)
105
+ const seg = (stroke: string, width: number): string =>
106
+ lines.map((x) => `<line x1="${n3(x + 1.5 * H * t)}" y1="${-H}" x2="${n3(x - 1.5 * H * t)}" y2="${2 * H}" stroke="${stroke}" stroke-width="${n3(width)}"/>`).join('')
107
+ await drawSvg(
108
+ seams,
109
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><defs><filter id="s" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="${n3(H / 90)}"/></filter></defs>` +
110
+ `<g filter="url(#s)" opacity="0.55">${seg(look.edge, wLine * 4)}</g>${seg(look.light, wLine)}</svg>`,
111
+ )
112
+ return { panes, seams }
113
+ }
114
+
53
115
  /** runs the jobs, at most `n` at once */
54
116
  export async function pool<T>(jobs: (() => Promise<T>)[], n: number): Promise<T[]> {
55
117
  const out: T[] = []
@@ -95,19 +157,31 @@ async function renderPieces(a: StitchRun, ps: Piece[]): Promise<Stitched['pieces
95
157
  )
96
158
  }
97
159
 
98
- async function renderMix(a: StitchRun): Promise<{ file: string; made: boolean }> {
99
- const { files, made } = await cached(join(a.cache, 'mix'), `mix-${a.t.name}`, ['m4a'], ([out]) => mixArgs(a, out!))
100
- return { file: files[0]!, made }
160
+ /** the mix, its true peak held under MIX_TRUE_PEAK after the aac encode: the encoder's band limit rings over a limited
161
+ * harsh sound (a square wave measured +2.1 dB), so a mix that overshoots is made again that much lower */
162
+ export async function renderMix(a: Pick<StitchRun, 't' | 'music' | 'sfx' | 'voices' | 'cache'>): Promise<{ file: string; made: boolean }> {
163
+ let trim = 0
164
+ let made = false
165
+ for (let pass = 0; ; pass++) {
166
+ const r = await cached(join(a.cache, 'mix'), `mix-${a.t.name}`, ['m4a'], ([out]) => mixArgs(a, out!, trim))
167
+ made ||= r.made
168
+ const { truePeak } = await loudnessKept(r.files[0]!)
169
+ if (truePeak <= MIX_TRUE_PEAK || pass === 2) return { file: r.files[0]!, made }
170
+ trim = +(trim - (truePeak - MIX_TRUE_PEAK) - 0.1).toFixed(2)
171
+ }
101
172
  }
102
173
 
103
- /** the pieces joined as they are, no encode, with the sound beside them */
104
- async function joinPieces(files: string[], sound: string | null, out: string): Promise<void> {
174
+ /** the pieces joined as they are, no encode, with the sound beside them: the whole mix copied, or a window of it (a
175
+ * section alone) cut on its samples, so encoded again */
176
+ async function joinPieces(files: string[], sound: { file: string; start?: number; seconds?: number } | null, out: string): Promise<void> {
105
177
  mkdirSync(dirname(out), { recursive: true })
106
178
  const list = `${out}.list.txt`
107
179
  writeFileSync(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n'))
108
180
  const part = out.replace(/(\.\w+)$/, '.part$1')
181
+ const audio = sound ? [...(sound.start !== undefined ? ['-ss', n3(sound.start), '-t', n3(sound.seconds ?? 0)] : []), '-i', sound.file] : []
182
+ const acodec = sound?.start !== undefined ? ['-c:a', 'aac', '-b:a', '192k'] : ['-c:a', 'copy']
109
183
  try {
110
- await ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, ...(sound ? ['-i', sound] : []), '-map', '0:v', ...(sound ? ['-map', '1:a', '-c:a', 'copy'] : []), '-c:v', 'copy', '-movflags', '+faststart', part], { nice: 10 })
184
+ await ffmpeg(['-f', 'concat', '-safe', '0', '-i', list, ...audio, '-map', '0:v', ...(sound ? ['-map', '1:a', ...acodec] : []), '-c:v', 'copy', '-movflags', '+faststart', part], { nice: 10 })
111
185
  renameSync(part, out)
112
186
  } finally {
113
187
  rmSync(list, { force: true })
@@ -118,7 +192,16 @@ async function joinPieces(files: string[], sound: string | null, out: string): P
118
192
  /** the whole cut: every piece (rendered where the cache misses it), joined, with its sound */
119
193
  export async function stitch(a: StitchRun): Promise<Stitched> {
120
194
  const [ps, mix] = await Promise.all([renderPieces(a, pieces(a.t)), a.silent ? null : renderMix(a)])
121
- await joinPieces(ps.map((p) => p.file), mix?.file ?? null, a.out)
195
+ await joinPieces(ps.map((p) => p.file), mix ? { file: mix.file } : null, a.out)
196
+ return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
197
+ }
198
+
199
+ /** one piece alone (a section, `start` the shots before the first), with its window of the sound */
200
+ export async function stitchPiece(a: StitchRun, name: string): Promise<Stitched> {
201
+ const p = pieces(a.t).find((x) => x.name === name)
202
+ if (!p) throw new Error(`no section ${name}: ${pieces(a.t).map((x) => x.name).join(', ')}`)
203
+ const [ps, mix] = await Promise.all([renderPieces(a, [p]), a.silent ? null : renderMix(a)])
204
+ await joinPieces(ps.map((x) => x.file), mix && { file: mix.file, start: p.from / a.t.fps, seconds: (p.to - p.from) / a.t.fps }, a.out)
122
205
  return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
123
206
  }
124
207