rvlib-shipkit 2.1.1 → 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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +7 -3
- package/src/assets/run-assets.ts +18 -4
- package/src/checks/SK004-gitignore-policy.check.ts +11 -6
- package/src/checks/SK013-one-way-in-policy.check.ts +13 -8
- package/src/cli/cmd_Calls.ts +53 -0
- package/src/cli/cmd_Commands.ts +163 -43
- package/src/cli/cmd_Review.ts +948 -0
- package/src/cli/cmd_Shot.ts +63 -0
- package/src/cli/cmd_Start.ts +8 -4
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +361 -0
- package/src/cli/kit/builtins.ts +40 -0
- package/src/cli/kit/calls.ts +177 -0
- package/src/cli/kit/families.ts +67 -0
- package/src/cli/kit/index.ts +8 -0
- package/src/cli/kit/pages-check.ts +36 -0
- package/src/cli/kit/project-cli.ts +73 -0
- package/src/cli/kit/verb.ts +167 -0
- package/src/cli.ts +15 -2
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/layout.ts +12 -2
- package/src/core/migrate-layout.ts +31 -8
- package/src/core/one-way-in-policy.ts +12 -4
- package/src/reflect/prompt-corpus.ts +15 -114
- package/src/reflect/transcript.ts +254 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +150 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +854 -0
- package/src/review/project.ts +110 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +228 -0
- package/src/review/store.ts +1002 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +165 -0
- package/src/review/watchers.ts +54 -0
- package/src/scaffold/cli-skeleton.ts +149 -0
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/files/tsconfig.json +3 -0
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +90 -12
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +28 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +10 -2
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +45 -13
- package/src/serve/hub-app/icons.tsx +9 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +22 -4
- package/src/serve/hub-app/project-list.tsx +41 -8
- package/src/serve/hub-app/projects-svc.ts +4 -0
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +45 -3
- package/src/serve/hub-app/review/clip-st.ts +208 -0
- package/src/serve/hub-app/review/kind-view.tsx +171 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +239 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +101 -0
- package/src/serve/hub-app/review/review-st.ts +779 -0
- package/src/serve/hub-app/review/review-views.tsx +503 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +329 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-runtime.ts +5 -3
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +8 -0
- package/src/shipkit-files.ts +12 -4
- package/src/shot/client.ts +67 -0
- package/src/shot/clip-encoder.ts +146 -0
- package/src/shot/dom-clip.ts +124 -0
- package/src/shot/serve-shot.ts +54 -0
- package/src/shot/shooter.ts +343 -0
- package/src/shot/shot-ask.ts +189 -0
- package/src/shot/shot.shipkit.ts +14 -0
- package/src/smoke/run-smoke.ts +2 -2
- package/src/start-repo.ts +29 -14
- package/src/suite/judge-suites.ts +56 -10
- package/src/suite/select.ts +2 -5
- package/src/suite/suite-cli.ts +3 -2
- package/src/trailer/adapter.ts +78 -0
- package/src/trailer/build.ts +603 -0
- package/src/trailer/ffmpeg.ts +72 -0
- package/src/trailer/footage.ts +173 -0
- package/src/trailer/grammar.ts +1290 -0
- package/src/trailer/graph.ts +508 -0
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +31 -0
- package/src/trailer/look.ts +124 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +192 -0
- package/src/trailer/prune.ts +193 -0
- package/src/trailer/score/compose.ts +240 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +91 -0
- package/src/trailer/score/score.ts +214 -0
- package/src/trailer/score/sound.ts +112 -0
- package/src/trailer/score/synth.ts +361 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +233 -0
- package/src/trailer/titles.ts +379 -0
- package/src/trailer/url.ts +108 -0
- package/src/trailer/vertical-draw.ts +286 -0
- package/src/trailer/vertical.ts +148 -0
- package/src/trailer/vocab.ts +59 -0
- 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
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// the stitch: the cut rendered piece by piece (a piece = a section), each cached on its ffmpeg arguments and the size
|
|
2
|
+
// and time of every file it reads, so an edit renders only the pieces it touches. The pieces join with no second
|
|
3
|
+
// encode, the sound is mixed once (cached the same way) and muxed beside them, and the strip lays out each piece's row
|
|
4
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { availableParallelism } from 'node:os'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import sharp from 'sharp'
|
|
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'
|
|
11
|
+
import { type Piece, pieces } from './plan.ts'
|
|
12
|
+
|
|
13
|
+
/** bump when the stitch changes what the same arguments make */
|
|
14
|
+
const STITCH_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** the files an ffmpeg command reads */
|
|
17
|
+
const inputsOf = (args: string[]): string[] => args.flatMap((x, i) => (args[i - 1] === '-i' && existsSync(x) ? [x] : []))
|
|
18
|
+
|
|
19
|
+
/** a render's key: its arguments (the outputs named alike) and every file it reads as it stands: a refilm rewrites a
|
|
20
|
+
* take in place, so its size and time count */
|
|
21
|
+
function keyOf(args: string[]): string {
|
|
22
|
+
const reads = inputsOf(args).map((f) => {
|
|
23
|
+
const s = statSync(f)
|
|
24
|
+
return [f, s.size, s.mtimeMs]
|
|
25
|
+
})
|
|
26
|
+
return new Bun.CryptoHasher('sha1').update(JSON.stringify([STITCH_VERSION, args, reads])).digest('hex').slice(0, 16)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** a render kept under its key, one file per extension (the first always made, the others when the render has them):
|
|
30
|
+
* made only when missing, written aside then moved in place, the first last */
|
|
31
|
+
async function cached(dir: string, stem: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
|
|
32
|
+
return cachedAs(dir, stem, keyOf(make(exts.map((e) => `out.${e}`))), exts, make)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** a render kept under a key its caller computed: for one whose inputs are rewritten by every build */
|
|
36
|
+
export async function cachedAs(dir: string, stem: string, key: string, exts: string[], make: (outs: string[]) => string[]): Promise<{ files: string[]; made: boolean }> {
|
|
37
|
+
const files = exts.map((e) => join(dir, `${stem}-${key}.${e}`))
|
|
38
|
+
if (existsSync(files[0]!)) {
|
|
39
|
+
const now = new Date()
|
|
40
|
+
for (const f of files) if (existsSync(f)) utimesSync(f, now, now)
|
|
41
|
+
return { files, made: false }
|
|
42
|
+
}
|
|
43
|
+
mkdirSync(dir, { recursive: true })
|
|
44
|
+
const parts = exts.map((e) => join(dir, `${stem}-${key}.part.${e}`))
|
|
45
|
+
try {
|
|
46
|
+
await ffmpeg(make(parts), { nice: 10 })
|
|
47
|
+
for (let i = parts.length - 1; i >= 0; i--) if (existsSync(parts[i]!)) renameSync(parts[i]!, files[i]!)
|
|
48
|
+
} finally {
|
|
49
|
+
for (const f of parts) rmSync(f, { force: true })
|
|
50
|
+
}
|
|
51
|
+
return { files, made: true }
|
|
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
|
+
|
|
115
|
+
/** runs the jobs, at most `n` at once */
|
|
116
|
+
export async function pool<T>(jobs: (() => Promise<T>)[], n: number): Promise<T[]> {
|
|
117
|
+
const out: T[] = []
|
|
118
|
+
let next = 0
|
|
119
|
+
const lane = async (): Promise<void> => {
|
|
120
|
+
while (next < jobs.length) {
|
|
121
|
+
const i = next++
|
|
122
|
+
out[i] = await jobs[i]!()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
await Promise.all(Array.from({ length: Math.min(n, jobs.length) }, lane))
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** how many pieces render at once: a graph runs mostly on one core */
|
|
130
|
+
const LANES = Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2)))
|
|
131
|
+
|
|
132
|
+
export interface Stitched {
|
|
133
|
+
/** the pieces, each with whether this run rendered it, its video and its row of the contact strip */
|
|
134
|
+
pieces: (Piece & { made: boolean; file: string; thumbs: string })[]
|
|
135
|
+
/** the sound was mixed again */
|
|
136
|
+
mixed: boolean
|
|
137
|
+
/** the mix file, null for a silent stitch */
|
|
138
|
+
mix: string | null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface StitchRun extends StitchInput {
|
|
142
|
+
out: string
|
|
143
|
+
/** where the rendered pieces and the mix are kept */
|
|
144
|
+
cache: string
|
|
145
|
+
encoder?: Encoder
|
|
146
|
+
/** no sound at all: a shot's preview */
|
|
147
|
+
silent?: boolean
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function renderPieces(a: StitchRun, ps: Piece[]): Promise<Stitched['pieces']> {
|
|
151
|
+
return pool(
|
|
152
|
+
ps.map((p) => async () => {
|
|
153
|
+
const { files, made } = await cached(a.cache, `${a.t.name}-${p.name}`, ['mp4', 'png'], ([video, row]) => pieceArgs(a, p, video!, a.encoder, row))
|
|
154
|
+
return { ...p, made, file: files[0]!, thumbs: files[1]! }
|
|
155
|
+
}),
|
|
156
|
+
LANES,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
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
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
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> {
|
|
177
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
178
|
+
const list = `${out}.list.txt`
|
|
179
|
+
writeFileSync(list, files.map((f) => `file '${f.replaceAll("'", "'\\''")}'`).join('\n'))
|
|
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']
|
|
183
|
+
try {
|
|
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 })
|
|
185
|
+
renameSync(part, out)
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(list, { force: true })
|
|
188
|
+
rmSync(part, { force: true })
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** the whole cut: every piece (rendered where the cache misses it), joined, with its sound */
|
|
193
|
+
export async function stitch(a: StitchRun): Promise<Stitched> {
|
|
194
|
+
const [ps, mix] = await Promise.all([renderPieces(a, pieces(a.t)), a.silent ? null : renderMix(a)])
|
|
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)
|
|
205
|
+
return { pieces: ps, mixed: mix?.made ?? false, mix: mix?.file ?? null }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** one frame from the middle of each shot, side by side, rows of six: the cut at a glance. Each piece drew its own
|
|
209
|
+
* row as it rendered, so the strip only lays the rows out */
|
|
210
|
+
export async function strip(ps: Pick<Stitched['pieces'][number], 'thumbs'>[], out: string): Promise<void> {
|
|
211
|
+
const rows = await Promise.all(ps.filter((p) => existsSync(p.thumbs)).map((p) => sharp(p.thumbs).removeAlpha().raw().toBuffer({ resolveWithObject: true })))
|
|
212
|
+
const h = rows[0]?.info.height ?? 0
|
|
213
|
+
const cells = rows.flatMap(({ data, info }) => Array.from({ length: Math.round(info.width / THUMB_W) }, (_, k) => ({ data, left: k * THUMB_W, width: info.width })))
|
|
214
|
+
if (cells.length === 0) return
|
|
215
|
+
const PAD = 6
|
|
216
|
+
const cols = Math.min(6, cells.length)
|
|
217
|
+
const W = cols * THUMB_W + (cols - 1) * PAD
|
|
218
|
+
const H = Math.ceil(cells.length / cols) * (h + PAD) - PAD
|
|
219
|
+
const grid = Buffer.alloc(W * H * 3)
|
|
220
|
+
cells.forEach((c, i) => {
|
|
221
|
+
const [x, y] = [(i % cols) * (THUMB_W + PAD), Math.floor(i / cols) * (h + PAD)]
|
|
222
|
+
for (let r = 0; r < h; r++) c.data.copy(grid, ((y + r) * W + x) * 3, (r * c.width + c.left) * 3, (r * c.width + c.left + THUMB_W) * 3)
|
|
223
|
+
})
|
|
224
|
+
await sharp(grid, { raw: { width: W, height: H, channels: 3 } }).png({ compressionLevel: 3 }).toFile(out)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** which pieces a stitch rendered, in one phrase; the others came from the cache */
|
|
228
|
+
export function rendered(d: Stitched): string {
|
|
229
|
+
const made = d.pieces.filter((p) => p.made).map((p) => p.name)
|
|
230
|
+
const all = d.pieces.length
|
|
231
|
+
const sound = d.mixed ? ', the sound mixed' : ''
|
|
232
|
+
return made.length === 0 ? `every section cached${sound}` : made.length === all ? `${all} section(s) rendered${sound}` : `rendered ${made.join(', ')}, ${all - made.length} of ${all} sections cached${sound}`
|
|
233
|
+
}
|