@vosjs/render-core 0.1.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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/index.d.ts +394 -0
- package/dist/index.js +938 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
// src/chunkPlanner.ts
|
|
2
|
+
var DEFAULT_MIN_FRAMES_PER_CHUNK = 24;
|
|
3
|
+
function planChunks(totalFrames, fps, policy) {
|
|
4
|
+
if (!Number.isInteger(totalFrames) || totalFrames <= 0) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
`planChunks: totalFrames must be a positive integer, got ${totalFrames}`
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
if (!(fps > 0)) {
|
|
10
|
+
throw new Error(`planChunks: fps must be positive, got ${fps}`);
|
|
11
|
+
}
|
|
12
|
+
const minFrames = policy.minFramesPerChunk ?? DEFAULT_MIN_FRAMES_PER_CHUNK;
|
|
13
|
+
const chunkCount = Math.max(
|
|
14
|
+
1,
|
|
15
|
+
Math.min(
|
|
16
|
+
Math.floor(policy.maxParallel),
|
|
17
|
+
Math.floor(totalFrames / minFrames)
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
const base = Math.floor(totalFrames / chunkCount);
|
|
21
|
+
const remainder = totalFrames % chunkCount;
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let startFrame = 0;
|
|
24
|
+
for (let index = 0; index < chunkCount; index++) {
|
|
25
|
+
const frameCount = base + (index < remainder ? 1 : 0);
|
|
26
|
+
const endFrame = startFrame + frameCount;
|
|
27
|
+
chunks.push({
|
|
28
|
+
index,
|
|
29
|
+
startFrame,
|
|
30
|
+
endFrame,
|
|
31
|
+
frameCount,
|
|
32
|
+
startTime: startFrame / fps,
|
|
33
|
+
duration: frameCount / fps
|
|
34
|
+
});
|
|
35
|
+
startFrame = endFrame;
|
|
36
|
+
}
|
|
37
|
+
return chunks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/concat.ts
|
|
41
|
+
import {
|
|
42
|
+
ALL_FORMATS,
|
|
43
|
+
BufferSource,
|
|
44
|
+
BufferTarget,
|
|
45
|
+
EncodedAudioPacketSource,
|
|
46
|
+
EncodedPacketSink,
|
|
47
|
+
EncodedVideoPacketSource,
|
|
48
|
+
Input,
|
|
49
|
+
Mp4OutputFormat,
|
|
50
|
+
Output,
|
|
51
|
+
WebMOutputFormat
|
|
52
|
+
} from "mediabunny";
|
|
53
|
+
async function openVideoTrack(data, chunkIndex) {
|
|
54
|
+
const input = new Input({
|
|
55
|
+
formats: ALL_FORMATS,
|
|
56
|
+
source: new BufferSource(data)
|
|
57
|
+
});
|
|
58
|
+
const track = await input.getPrimaryVideoTrack();
|
|
59
|
+
if (!track) throw new Error(`Chunk ${chunkIndex} has no video track`);
|
|
60
|
+
return { input, track };
|
|
61
|
+
}
|
|
62
|
+
async function muxEncodedExport(options) {
|
|
63
|
+
const output = new Output({
|
|
64
|
+
format: options.format === "mp4" ? new Mp4OutputFormat() : new WebMOutputFormat(),
|
|
65
|
+
target: new BufferTarget()
|
|
66
|
+
});
|
|
67
|
+
let audioTrack = null;
|
|
68
|
+
if (options.audio) {
|
|
69
|
+
const input = new Input({
|
|
70
|
+
formats: ALL_FORMATS,
|
|
71
|
+
source: new BufferSource(options.audio)
|
|
72
|
+
});
|
|
73
|
+
const track = await input.getPrimaryAudioTrack();
|
|
74
|
+
if (!track || !track.codec) {
|
|
75
|
+
throw new Error("Audio part has no readable audio track");
|
|
76
|
+
}
|
|
77
|
+
const decoderConfig2 = await track.getDecoderConfig();
|
|
78
|
+
if (!decoderConfig2) throw new Error("Audio part has no decoder config");
|
|
79
|
+
const source = new EncodedAudioPacketSource(track.codec);
|
|
80
|
+
output.addAudioTrack(source);
|
|
81
|
+
audioTrack = { source, sink: new EncodedPacketSink(track), decoderConfig: decoderConfig2 };
|
|
82
|
+
}
|
|
83
|
+
let videoSource = null;
|
|
84
|
+
let codec = null;
|
|
85
|
+
let decoderConfig = null;
|
|
86
|
+
let started = false;
|
|
87
|
+
let offset = 0;
|
|
88
|
+
let packetCount = 0;
|
|
89
|
+
let index = 0;
|
|
90
|
+
for await (const chunk of options.video) {
|
|
91
|
+
const i = index++;
|
|
92
|
+
const { track } = await openVideoTrack(chunk.data, i);
|
|
93
|
+
if (i === 0) {
|
|
94
|
+
codec = track.codec;
|
|
95
|
+
if (!codec) throw new Error("Chunk 0 video codec could not be determined");
|
|
96
|
+
decoderConfig = await track.getDecoderConfig();
|
|
97
|
+
if (!decoderConfig) throw new Error("Chunk 0 has no decoder config");
|
|
98
|
+
videoSource = new EncodedVideoPacketSource(codec);
|
|
99
|
+
output.addVideoTrack(
|
|
100
|
+
videoSource,
|
|
101
|
+
options.frameRate ? { frameRate: options.frameRate } : void 0
|
|
102
|
+
);
|
|
103
|
+
await output.start();
|
|
104
|
+
started = true;
|
|
105
|
+
if (audioTrack) {
|
|
106
|
+
let firstAudio = true;
|
|
107
|
+
for await (const packet of audioTrack.sink.packets()) {
|
|
108
|
+
await audioTrack.source.add(
|
|
109
|
+
packet,
|
|
110
|
+
firstAudio ? { decoderConfig: audioTrack.decoderConfig } : void 0
|
|
111
|
+
);
|
|
112
|
+
firstAudio = false;
|
|
113
|
+
}
|
|
114
|
+
audioTrack.source.close();
|
|
115
|
+
}
|
|
116
|
+
} else if (track.codec !== codec) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Chunk ${i} codec ${String(track.codec)} != chunk 0 codec ${String(codec)} \u2014 chunks must share one encoder config`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const sink = new EncodedPacketSink(track);
|
|
122
|
+
let firstOfChunk = true;
|
|
123
|
+
for await (const packet of sink.packets()) {
|
|
124
|
+
if (firstOfChunk && packet.type !== "key") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Chunk ${i} does not start on a keyframe \u2014 was it encoded as an independent chunk?`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const shifted = packet.clone({
|
|
130
|
+
timestamp: packet.timestamp + offset
|
|
131
|
+
});
|
|
132
|
+
await videoSource.add(
|
|
133
|
+
shifted,
|
|
134
|
+
firstOfChunk && i === 0 && decoderConfig ? { decoderConfig } : void 0
|
|
135
|
+
);
|
|
136
|
+
firstOfChunk = false;
|
|
137
|
+
packetCount++;
|
|
138
|
+
}
|
|
139
|
+
offset += chunk.duration;
|
|
140
|
+
}
|
|
141
|
+
if (!started || !videoSource || !codec) {
|
|
142
|
+
throw new Error("muxEncodedExport: no video parts");
|
|
143
|
+
}
|
|
144
|
+
await output.finalize();
|
|
145
|
+
const bytes = output.target.buffer;
|
|
146
|
+
if (!bytes) throw new Error("Concat produced no output buffer");
|
|
147
|
+
return { bytes: new Uint8Array(bytes), packetCount, codec };
|
|
148
|
+
}
|
|
149
|
+
async function concatEncodedVideo(chunks, options) {
|
|
150
|
+
if (chunks.length === 0) throw new Error("concatEncodedVideo: no chunks");
|
|
151
|
+
return muxEncodedExport({ ...options, video: chunks });
|
|
152
|
+
}
|
|
153
|
+
async function countVideoPackets(data) {
|
|
154
|
+
const { track } = await openVideoTrack(data, 0);
|
|
155
|
+
const sink = new EncodedPacketSink(track);
|
|
156
|
+
let count = 0;
|
|
157
|
+
for await (const packet of sink.packets()) {
|
|
158
|
+
void packet;
|
|
159
|
+
count++;
|
|
160
|
+
}
|
|
161
|
+
return count;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/finalizePage.ts
|
|
165
|
+
var MEDIABUNNY_URL = "https://esm.sh/mediabunny@1.27.3?target=es2022";
|
|
166
|
+
function buildFinalizeConcatPage(options) {
|
|
167
|
+
if (options.parts.length === 0) {
|
|
168
|
+
throw new Error("buildFinalizeConcatPage: no parts");
|
|
169
|
+
}
|
|
170
|
+
if (options.audio && options.audioPart) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"buildFinalizeConcatPage: audio and audioPart are mutually exclusive"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const contentType = options.format === "mp4" ? "video/mp4" : "video/webm";
|
|
176
|
+
const config = JSON.stringify({
|
|
177
|
+
parts: options.parts,
|
|
178
|
+
format: options.format,
|
|
179
|
+
frameRate: options.frameRate,
|
|
180
|
+
uploadUrl: options.uploadUrl,
|
|
181
|
+
contentType,
|
|
182
|
+
audioData: options.audio ? options.audio.data : null,
|
|
183
|
+
audioDuration: options.audio ? options.audio.duration : 0,
|
|
184
|
+
audioPartUrl: options.audioPart ? options.audioPart.url : null
|
|
185
|
+
});
|
|
186
|
+
const audioProducerBlock = options.audio ? options.audio.producerCode : "";
|
|
187
|
+
return `<!doctype html>
|
|
188
|
+
<html>
|
|
189
|
+
<head><meta charset="utf-8"><title>vos finalize</title></head>
|
|
190
|
+
<body>
|
|
191
|
+
<script type="module">
|
|
192
|
+
const CONFIG = ${config};
|
|
193
|
+
${audioProducerBlock}
|
|
194
|
+
|
|
195
|
+
// Stage marker: the supervising worker polls this every tick and reports the
|
|
196
|
+
// last stage seen when the page dies without a result \u2014 the only way to
|
|
197
|
+
// localize a page death on a fleet with no devtools (a diagnosis aid).
|
|
198
|
+
// Each stage carries the V8 heap sample when available, so a death
|
|
199
|
+
// names its memory peak too. Heap only covers JS objects (ArrayBuffers are
|
|
200
|
+
// external), so treat it as a floor, not the whole bill. The stage NAME must
|
|
201
|
+
// stay the first space-delimited token \u2014 renderPolicy's finalizeDeathStage
|
|
202
|
+
// parses it out of the error message.
|
|
203
|
+
const stage = (s) => {
|
|
204
|
+
const m = performance.memory;
|
|
205
|
+
window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;
|
|
206
|
+
};
|
|
207
|
+
stage('boot');
|
|
208
|
+
|
|
209
|
+
;(async () => {
|
|
210
|
+
stage('import-mediabunny');
|
|
211
|
+
const MB = await import(${JSON.stringify(MEDIABUNNY_URL)});
|
|
212
|
+
|
|
213
|
+
// Audio first: a producer failure aborts before any concat work.
|
|
214
|
+
let audioBuffer = null;
|
|
215
|
+
if (window.__vosAudioProducer__ && CONFIG.audioData != null) {
|
|
216
|
+
stage('audio-produce');
|
|
217
|
+
audioBuffer = await window.__vosAudioProducer__({
|
|
218
|
+
data: CONFIG.audioData,
|
|
219
|
+
duration: CONFIG.audioDuration,
|
|
220
|
+
sampleRate: 48000,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Pre-encoded audio part: stream-copy, no production memory bill.
|
|
225
|
+
let audioPartTrack = null;
|
|
226
|
+
let audioPartConfig = null;
|
|
227
|
+
if (CONFIG.audioPartUrl) {
|
|
228
|
+
stage('audio-part-open');
|
|
229
|
+
const abytes = await (await fetch(CONFIG.audioPartUrl)).arrayBuffer();
|
|
230
|
+
const ainput = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(abytes) });
|
|
231
|
+
audioPartTrack = await ainput.getPrimaryAudioTrack();
|
|
232
|
+
if (!audioPartTrack || !audioPartTrack.codec) {
|
|
233
|
+
throw new Error('Audio part has no readable audio track');
|
|
234
|
+
}
|
|
235
|
+
audioPartConfig = await audioPartTrack.getDecoderConfig();
|
|
236
|
+
if (!audioPartConfig) throw new Error('Audio part has no decoder config');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const openTrack = async (bytes, index) => {
|
|
240
|
+
const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.BufferSource(bytes) });
|
|
241
|
+
const track = await input.getPrimaryVideoTrack();
|
|
242
|
+
if (!track) throw new Error('Chunk ' + index + ' has no video track');
|
|
243
|
+
return track;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
stage('fetch-part-0');
|
|
247
|
+
const first = await (await fetch(CONFIG.parts[0].url)).arrayBuffer();
|
|
248
|
+
stage('open-part-0');
|
|
249
|
+
const firstTrack = await openTrack(first, 0);
|
|
250
|
+
const codec = firstTrack.codec;
|
|
251
|
+
if (!codec) throw new Error('Chunk 0 video codec could not be determined');
|
|
252
|
+
const decoderConfig = await firstTrack.getDecoderConfig();
|
|
253
|
+
if (!decoderConfig) throw new Error('Chunk 0 has no decoder config');
|
|
254
|
+
|
|
255
|
+
const output = new MB.Output({
|
|
256
|
+
format: CONFIG.format === 'mp4' ? new MB.Mp4OutputFormat() : new MB.WebMOutputFormat(),
|
|
257
|
+
target: new MB.BufferTarget(),
|
|
258
|
+
});
|
|
259
|
+
const videoSource = new MB.EncodedVideoPacketSource(codec);
|
|
260
|
+
output.addVideoTrack(videoSource, { frameRate: CONFIG.frameRate });
|
|
261
|
+
|
|
262
|
+
let audioSource = null;
|
|
263
|
+
let audioPacketSource = null;
|
|
264
|
+
if (audioPartTrack) {
|
|
265
|
+
audioPacketSource = new MB.EncodedAudioPacketSource(audioPartTrack.codec);
|
|
266
|
+
output.addAudioTrack(audioPacketSource);
|
|
267
|
+
} else if (audioBuffer) {
|
|
268
|
+
// AAC first for mp4 (compatibility), Opus fallback \u2014 AAC encode is
|
|
269
|
+
// unavailable on some fleets (probe: aacEncode false on Linux).
|
|
270
|
+
const preferred = CONFIG.format === 'mp4' ? 'aac' : 'opus';
|
|
271
|
+
const audioCodec =
|
|
272
|
+
preferred === 'aac' && !(await MB.canEncodeAudio('aac')) ? 'opus' : preferred;
|
|
273
|
+
audioSource = new MB.AudioBufferSource({ codec: audioCodec, bitrate: MB.QUALITY_HIGH });
|
|
274
|
+
output.addAudioTrack(audioSource);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await output.start();
|
|
278
|
+
|
|
279
|
+
if (audioPacketSource && audioPartTrack) {
|
|
280
|
+
stage('audio-part-copy');
|
|
281
|
+
const asink = new MB.EncodedPacketSink(audioPartTrack);
|
|
282
|
+
let firstAudio = true;
|
|
283
|
+
for await (const packet of asink.packets()) {
|
|
284
|
+
await audioPacketSource.add(
|
|
285
|
+
packet,
|
|
286
|
+
firstAudio ? { decoderConfig: audioPartConfig } : undefined,
|
|
287
|
+
);
|
|
288
|
+
firstAudio = false;
|
|
289
|
+
}
|
|
290
|
+
audioPacketSource.close();
|
|
291
|
+
} else if (audioSource && audioBuffer) {
|
|
292
|
+
await audioSource.add(audioBuffer);
|
|
293
|
+
audioSource.close();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Mirrors render-core concat.ts: plan-derived offsets, keyframe-per-chunk
|
|
297
|
+
// guard, decode-order packet walk.
|
|
298
|
+
let offset = 0;
|
|
299
|
+
let packetCount = 0;
|
|
300
|
+
for (let i = 0; i < CONFIG.parts.length; i++) {
|
|
301
|
+
stage('concat-part-' + i);
|
|
302
|
+
const bytes = i === 0 ? first : await (await fetch(CONFIG.parts[i].url)).arrayBuffer();
|
|
303
|
+
const track = i === 0 ? firstTrack : await openTrack(bytes, i);
|
|
304
|
+
if (track.codec !== codec) {
|
|
305
|
+
throw new Error('Chunk ' + i + ' codec ' + track.codec + ' != chunk 0 codec ' + codec);
|
|
306
|
+
}
|
|
307
|
+
const sink = new MB.EncodedPacketSink(track);
|
|
308
|
+
let firstOfChunk = true;
|
|
309
|
+
for await (const packet of sink.packets()) {
|
|
310
|
+
if (firstOfChunk && packet.type !== 'key') {
|
|
311
|
+
throw new Error('Chunk ' + i + ' does not start on a keyframe');
|
|
312
|
+
}
|
|
313
|
+
const shifted = packet.clone({ timestamp: packet.timestamp + offset });
|
|
314
|
+
await videoSource.add(shifted, firstOfChunk && i === 0 ? { decoderConfig } : undefined);
|
|
315
|
+
firstOfChunk = false;
|
|
316
|
+
packetCount++;
|
|
317
|
+
}
|
|
318
|
+
offset += CONFIG.parts[i].duration;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stage('finalize-output');
|
|
322
|
+
await output.finalize();
|
|
323
|
+
const buffer = output.target.buffer;
|
|
324
|
+
if (!buffer) throw new Error('Concat produced no output buffer');
|
|
325
|
+
|
|
326
|
+
stage('upload');
|
|
327
|
+
const res = await fetch(CONFIG.uploadUrl, {
|
|
328
|
+
method: 'PUT',
|
|
329
|
+
headers: { 'Content-Type': CONFIG.contentType },
|
|
330
|
+
body: buffer,
|
|
331
|
+
});
|
|
332
|
+
if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);
|
|
333
|
+
|
|
334
|
+
window.__renderComplete = {
|
|
335
|
+
success: true,
|
|
336
|
+
uploaded: true,
|
|
337
|
+
size: buffer.byteLength,
|
|
338
|
+
packetCount,
|
|
339
|
+
};
|
|
340
|
+
})().catch((e) => {
|
|
341
|
+
window.__renderComplete = { success: false, error: String((e && e.stack) || e) };
|
|
342
|
+
});
|
|
343
|
+
</script>
|
|
344
|
+
</body>
|
|
345
|
+
</html>`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/audioProducer.ts
|
|
349
|
+
var CORE_AUDIO_CDN_URL = "https://esm.sh/@vosjs/core@0.23.1/audio?target=es2022";
|
|
350
|
+
var STUDIO_ENTRY_ID = "vosso.studio";
|
|
351
|
+
function studioEntryData(stack) {
|
|
352
|
+
if (stack == null || typeof stack !== "object") return null;
|
|
353
|
+
const asData = (value) => value && typeof value === "object" ? value : null;
|
|
354
|
+
if (Array.isArray(stack)) {
|
|
355
|
+
for (const entry of stack) {
|
|
356
|
+
if (entry && typeof entry === "object" && entry.id === STUDIO_ENTRY_ID) {
|
|
357
|
+
return asData(entry.data);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return asData(stack[STUDIO_ENTRY_ID]);
|
|
363
|
+
}
|
|
364
|
+
function audioProducerCode(options = {}) {
|
|
365
|
+
const coreAudioUrl = options.coreAudioUrl ?? CORE_AUDIO_CDN_URL;
|
|
366
|
+
const bakedPlan = options.plan ? `window.__vosAudioPlan__ = ${JSON.stringify(options.plan)};` : "";
|
|
367
|
+
return `
|
|
368
|
+
${bakedPlan}
|
|
369
|
+
window.__vosAudioProducer__ = async ({ data, plan, duration, sampleRate }) => {
|
|
370
|
+
const rate = sampleRate || 48000;
|
|
371
|
+
const audioPlan = plan === undefined ? window.__vosAudioPlan__ : plan;
|
|
372
|
+
const CORE_AUDIO_URL = ${JSON.stringify(coreAudioUrl)};
|
|
373
|
+
|
|
374
|
+
const decodeAudio = async (url) => {
|
|
375
|
+
try {
|
|
376
|
+
const buf = await (await fetch(url)).arrayBuffer();
|
|
377
|
+
const ac = new AudioContext();
|
|
378
|
+
try {
|
|
379
|
+
return await ac.decodeAudioData(buf);
|
|
380
|
+
} finally {
|
|
381
|
+
void ac.close();
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.warn('[audio-producer] source not decodable:', e);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Cut mic audio to the kept source segments; rated segments resample
|
|
390
|
+
// piecewise with linear interpolation (tape-style pitch shift).
|
|
391
|
+
const spliceAudio = (buf, segments) => {
|
|
392
|
+
const sr = buf.sampleRate;
|
|
393
|
+
const pieces = segments.map((s) => {
|
|
394
|
+
const start = Math.max(0, Math.min(Math.round(s.in * sr), buf.length));
|
|
395
|
+
const end = Math.max(start, Math.min(Math.round(s.out * sr), buf.length));
|
|
396
|
+
const r = s.rate !== undefined && s.rate > 0 ? s.rate : 1;
|
|
397
|
+
return { start, end, rate: r, outLen: Math.round((end - start) / r) };
|
|
398
|
+
});
|
|
399
|
+
const total = pieces.reduce((sum, p) => sum + p.outLen, 0);
|
|
400
|
+
if (total <= 0) return buf;
|
|
401
|
+
if (pieces.length === 1 && pieces[0].start === 0 && pieces[0].rate === 1 && total === buf.length) {
|
|
402
|
+
return buf;
|
|
403
|
+
}
|
|
404
|
+
const out = new AudioBuffer({ length: total, numberOfChannels: buf.numberOfChannels, sampleRate: sr });
|
|
405
|
+
for (let ch = 0; ch < buf.numberOfChannels; ch++) {
|
|
406
|
+
const src = buf.getChannelData(ch);
|
|
407
|
+
const dst = out.getChannelData(ch);
|
|
408
|
+
let offset = 0;
|
|
409
|
+
for (const p of pieces) {
|
|
410
|
+
if (p.rate === 1) {
|
|
411
|
+
dst.set(src.subarray(p.start, p.end), offset);
|
|
412
|
+
} else {
|
|
413
|
+
const last = buf.length - 1;
|
|
414
|
+
for (let i = 0; i < p.outLen; i++) {
|
|
415
|
+
const pos = p.start + i * p.rate;
|
|
416
|
+
const j = Math.min(Math.floor(pos), last);
|
|
417
|
+
const a = src[j];
|
|
418
|
+
const b = src[Math.min(j + 1, last)];
|
|
419
|
+
dst[offset + i] = a + (b - a) * (pos - j);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
offset += p.outLen;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const videoSrc = typeof data?.videoSrc === 'string' ? data.videoSrc : null;
|
|
429
|
+
const segments = Array.isArray(data?.segments) ? data.segments : [];
|
|
430
|
+
const tracks = audioPlan && Array.isArray(audioPlan.tracks) ? audioPlan.tracks : [];
|
|
431
|
+
const micGain = typeof data?.micGain === 'number' ? data.micGain : 1;
|
|
432
|
+
const sysGain = typeof data?.sysGain === 'number' ? data.sysGain : 1;
|
|
433
|
+
const micSrc = typeof data?.micSrc === 'string' ? data.micSrc : null;
|
|
434
|
+
// Voice: the mic sidecar (AT split), else the legacy track on the recording.
|
|
435
|
+
// System: the recording's own track, only when the take is split.
|
|
436
|
+
const voiceSrc = micSrc ?? (data?.hasAudio && videoSrc ? videoSrc : null);
|
|
437
|
+
const sysSrc = micSrc && data?.hasAudio && videoSrc ? videoSrc : null;
|
|
438
|
+
|
|
439
|
+
// Recording tracks load through a pluggable seam: a host page may
|
|
440
|
+
// install window.__vosStreamSplice__(url, segments, maxSeconds) \u2014 the
|
|
441
|
+
// audio mix page does, backed by mediabunny streaming decode of ONLY the
|
|
442
|
+
// needed source spans, capped at the output duration \u2014 and the producer
|
|
443
|
+
// prefers it, falling back to the whole-file decodeAudioData path on any
|
|
444
|
+
// failure. Standalone pages (single-flight capture) have no seam and
|
|
445
|
+
// behave exactly as before.
|
|
446
|
+
const loadRecordingTrack = async (url) => {
|
|
447
|
+
const streamSplice = window.__vosStreamSplice__;
|
|
448
|
+
if (streamSplice) {
|
|
449
|
+
try {
|
|
450
|
+
const buf = await streamSplice(url, segments, duration);
|
|
451
|
+
if (buf) return buf;
|
|
452
|
+
} catch (e) {
|
|
453
|
+
console.warn('[audio-producer] stream splice failed, falling back:', e);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const raw = await decodeAudio(url);
|
|
457
|
+
return raw && segments.length ? spliceAudio(raw, segments) : raw;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
let mic = voiceSrc ? await loadRecordingTrack(voiceSrc) : null;
|
|
461
|
+
let sys = sysSrc ? await loadRecordingTrack(sysSrc) : null;
|
|
462
|
+
|
|
463
|
+
// The clips: decode each distinct plan source to plain PCM, then render
|
|
464
|
+
// the plan with the engine's mixer (one buffer, every clip at its
|
|
465
|
+
// interpolated position and gain, the duck curve already folded in).
|
|
466
|
+
const clipPcm = new Map();
|
|
467
|
+
for (const src of new Set(tracks.map((t) => t.src))) {
|
|
468
|
+
const buf = await decodeAudio(src);
|
|
469
|
+
if (!buf) continue;
|
|
470
|
+
const channels = [];
|
|
471
|
+
for (let c = 0; c < buf.numberOfChannels; c++) channels.push(buf.getChannelData(c));
|
|
472
|
+
clipPcm.set(src, { sampleRate: buf.sampleRate, length: buf.length, channels });
|
|
473
|
+
}
|
|
474
|
+
if (!mic && !sys && clipPcm.size === 0) return null;
|
|
475
|
+
// Voice-only at unity gain: skip the offline pass \u2014 but ONLY when the
|
|
476
|
+
// buffer fits the requested output duration. A spliced take can run longer
|
|
477
|
+
// than a duration-capped render asks for (Render API maxDuration), and
|
|
478
|
+
// returning it whole used to mux extra seconds of audio onto the video
|
|
479
|
+
// Overlong falls through to the offline render, whose
|
|
480
|
+
// length IS the duration by construction.
|
|
481
|
+
if (
|
|
482
|
+
clipPcm.size === 0 && mic && !sys && micGain === 1 &&
|
|
483
|
+
mic.length <= Math.ceil(duration * mic.sampleRate)
|
|
484
|
+
) return mic;
|
|
485
|
+
|
|
486
|
+
const off = new OfflineAudioContext(2, Math.max(1, Math.ceil(duration * rate)), rate);
|
|
487
|
+
if (mic) {
|
|
488
|
+
const src = off.createBufferSource();
|
|
489
|
+
src.buffer = mic;
|
|
490
|
+
const gain = off.createGain();
|
|
491
|
+
gain.gain.value = micGain;
|
|
492
|
+
src.connect(gain);
|
|
493
|
+
gain.connect(off.destination);
|
|
494
|
+
src.start(0);
|
|
495
|
+
}
|
|
496
|
+
if (sys) {
|
|
497
|
+
const src = off.createBufferSource();
|
|
498
|
+
src.buffer = sys;
|
|
499
|
+
const gain = off.createGain();
|
|
500
|
+
gain.gain.value = sysGain;
|
|
501
|
+
src.connect(gain);
|
|
502
|
+
gain.connect(off.destination);
|
|
503
|
+
src.start(0);
|
|
504
|
+
}
|
|
505
|
+
if (clipPcm.size > 0) {
|
|
506
|
+
const { mixAudio } = await import(CORE_AUDIO_URL);
|
|
507
|
+
const pcm = mixAudio(audioPlan, clipPcm, { sampleRate: rate, channels: 2 });
|
|
508
|
+
const buf = off.createBuffer(pcm.channels.length, pcm.length, pcm.sampleRate);
|
|
509
|
+
for (let c = 0; c < pcm.channels.length; c++) buf.copyToChannel(pcm.channels[c], c);
|
|
510
|
+
const src = off.createBufferSource();
|
|
511
|
+
src.buffer = buf;
|
|
512
|
+
src.connect(off.destination);
|
|
513
|
+
src.start(0);
|
|
514
|
+
}
|
|
515
|
+
return off.startRendering();
|
|
516
|
+
};
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
function dataHasAudio(data, stack, plan) {
|
|
520
|
+
const d = data != null && typeof data === "object" ? data : null;
|
|
521
|
+
const hasVoice = !!d && (typeof d.micSrc === "string" || !!d.hasAudio && typeof d.videoSrc === "string");
|
|
522
|
+
const entryAudio = studioEntryData(stack)?.audio;
|
|
523
|
+
const hasClips = !!plan && plan.tracks.length > 0 || Array.isArray(entryAudio) && entryAudio.length > 0;
|
|
524
|
+
return hasVoice || hasClips;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/audioMixPage.ts
|
|
528
|
+
var MEDIABUNNY_URL2 = "https://esm.sh/mediabunny@1.27.3?target=es2022";
|
|
529
|
+
function buildAudioMixPage(options) {
|
|
530
|
+
const config = JSON.stringify({
|
|
531
|
+
data: options.data,
|
|
532
|
+
plan: options.plan ?? null,
|
|
533
|
+
duration: options.duration,
|
|
534
|
+
uploadUrl: options.uploadUrl
|
|
535
|
+
});
|
|
536
|
+
return `<!doctype html>
|
|
537
|
+
<html>
|
|
538
|
+
<head><meta charset="utf-8"><title>vos audio mix</title></head>
|
|
539
|
+
<body>
|
|
540
|
+
<script type="module">
|
|
541
|
+
const CONFIG = ${config};
|
|
542
|
+
|
|
543
|
+
// Same stage contract as the finalize page: worker polls __finalizeStage,
|
|
544
|
+
// reports the last stage on a silent death. Stage NAME stays the first
|
|
545
|
+
// space-delimited token (renderPolicy.finalizeDeathStage parses it).
|
|
546
|
+
const stage = (s) => {
|
|
547
|
+
const m = performance.memory;
|
|
548
|
+
window.__finalizeStage = m ? s + ' heap=' + Math.round(m.usedJSHeapSize / 1048576) + 'MB' : s;
|
|
549
|
+
};
|
|
550
|
+
stage('boot');
|
|
551
|
+
|
|
552
|
+
;(async () => {
|
|
553
|
+
stage('import-mediabunny');
|
|
554
|
+
const MB = await import(${JSON.stringify(MEDIABUNNY_URL2)});
|
|
555
|
+
|
|
556
|
+
// Streaming splice seam consumed by the audio producer below: decode ONLY
|
|
557
|
+
// the source spans the segments keep, capped at maxSeconds of output.
|
|
558
|
+
// Mirrors spliceAudio's piece math (copy at rate 1, linear resample
|
|
559
|
+
// otherwise); the producer's whole-file path remains the authoritative
|
|
560
|
+
// fallback, so a divergence here degrades to slower, never to wrong.
|
|
561
|
+
window.__vosStreamSplice__ = async (url, segments, maxSeconds) => {
|
|
562
|
+
const input = new MB.Input({ formats: MB.ALL_FORMATS, source: new MB.UrlSource(url) });
|
|
563
|
+
try {
|
|
564
|
+
const track = await input.getPrimaryAudioTrack();
|
|
565
|
+
if (!track) return null;
|
|
566
|
+
if (!(await track.canDecode())) return null;
|
|
567
|
+
const sr = track.sampleRate;
|
|
568
|
+
const ch = Math.min(2, Math.max(1, track.numberOfChannels || 2));
|
|
569
|
+
const srcDur = await input.computeDuration();
|
|
570
|
+
|
|
571
|
+
const spans = Array.isArray(segments) && segments.length
|
|
572
|
+
? segments
|
|
573
|
+
: [{ in: 0, out: srcDur }];
|
|
574
|
+
const pieces = [];
|
|
575
|
+
let outTotal = 0;
|
|
576
|
+
for (const s of spans) {
|
|
577
|
+
const start = Math.max(0, Math.min(s.in, srcDur));
|
|
578
|
+
const end = Math.max(start, Math.min(s.out, srcDur));
|
|
579
|
+
const rate = s.rate !== undefined && s.rate > 0 ? s.rate : 1;
|
|
580
|
+
let outLen = (end - start) / rate;
|
|
581
|
+
if (outTotal + outLen > maxSeconds) outLen = maxSeconds - outTotal;
|
|
582
|
+
if (outLen <= 0) break;
|
|
583
|
+
pieces.push({ in: start, out: start + outLen * rate, rate, outLen });
|
|
584
|
+
outTotal += outLen;
|
|
585
|
+
if (outTotal >= maxSeconds) break;
|
|
586
|
+
}
|
|
587
|
+
if (pieces.length === 0) return null;
|
|
588
|
+
|
|
589
|
+
const totalFrames = Math.max(1, Math.round(outTotal * sr));
|
|
590
|
+
const out = new AudioBuffer({ length: totalFrames, numberOfChannels: ch, sampleRate: sr });
|
|
591
|
+
const sink = new MB.AudioBufferSink(track);
|
|
592
|
+
let outOffset = 0;
|
|
593
|
+
for (let pi = 0; pi < pieces.length; pi++) {
|
|
594
|
+
const p = pieces[pi];
|
|
595
|
+
stage('voice-piece-' + pi);
|
|
596
|
+
// Materialize ONE piece's source span (bounded), then splice from it.
|
|
597
|
+
const pieceFrames = Math.max(1, Math.round((p.out - p.in) * sr));
|
|
598
|
+
const temp = [];
|
|
599
|
+
for (let c = 0; c < ch; c++) temp.push(new Float32Array(pieceFrames));
|
|
600
|
+
for await (const wrapped of sink.buffers(p.in, p.out)) {
|
|
601
|
+
const b = wrapped.buffer;
|
|
602
|
+
const at = Math.round((wrapped.timestamp - p.in) * sr);
|
|
603
|
+
for (let c = 0; c < ch; c++) {
|
|
604
|
+
const src = b.getChannelData(Math.min(c, b.numberOfChannels - 1));
|
|
605
|
+
let from = 0, to = at, n = src.length;
|
|
606
|
+
if (to < 0) { from = -to; to = 0; n -= from; }
|
|
607
|
+
n = Math.min(n, pieceFrames - to);
|
|
608
|
+
if (n > 0) temp[c].set(src.subarray(from, from + n), to);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const outFrames = Math.min(Math.round(p.outLen * sr), totalFrames - outOffset);
|
|
612
|
+
for (let c = 0; c < ch; c++) {
|
|
613
|
+
const dst = out.getChannelData(c);
|
|
614
|
+
const src = temp[c];
|
|
615
|
+
if (p.rate === 1) {
|
|
616
|
+
dst.set(src.subarray(0, Math.min(outFrames, src.length)), outOffset);
|
|
617
|
+
} else {
|
|
618
|
+
const last = src.length - 1;
|
|
619
|
+
for (let i = 0; i < outFrames; i++) {
|
|
620
|
+
const pos = i * p.rate;
|
|
621
|
+
const j = Math.min(Math.floor(pos), last);
|
|
622
|
+
const a = src[j];
|
|
623
|
+
const b2 = src[Math.min(j + 1, last)];
|
|
624
|
+
dst[outOffset + i] = a + (b2 - a) * (pos - j);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
outOffset += outFrames;
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
} finally {
|
|
632
|
+
input.dispose();
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
${audioProducerCode()}
|
|
637
|
+
|
|
638
|
+
stage('mix');
|
|
639
|
+
let mixed = await window.__vosAudioProducer__({
|
|
640
|
+
data: CONFIG.data,
|
|
641
|
+
plan: CONFIG.plan,
|
|
642
|
+
duration: CONFIG.duration,
|
|
643
|
+
sampleRate: 48000,
|
|
644
|
+
});
|
|
645
|
+
if (!mixed) {
|
|
646
|
+
// Every declared source failed to decode (fetch denied, undecodable
|
|
647
|
+
// bytes). Reporting "no buffer" here used to route finalize onto the
|
|
648
|
+
// browser concat fallback \u2014 whose in-page producer faces the SAME
|
|
649
|
+
// failures and whose page-memory concat is the known OOM death (job
|
|
650
|
+
// cdf6e026 died twice at concat-part-10 after this very branch). Land a
|
|
651
|
+
// duration-true SILENT track instead so the worker mux still runs: the
|
|
652
|
+
// audible outcome matches the client exporter's documented fail-open
|
|
653
|
+
// (a source that won't decode loses its sound, never the export).
|
|
654
|
+
console.warn('[audio-mix] no source decoded; landing a silent track');
|
|
655
|
+
mixed = new AudioBuffer({
|
|
656
|
+
length: Math.max(1, Math.ceil(CONFIG.duration * 48000)),
|
|
657
|
+
numberOfChannels: 2,
|
|
658
|
+
sampleRate: 48000,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
stage('encode');
|
|
663
|
+
// Opus always: the fleet is Linux Chrome, which has no AAC encoder, and
|
|
664
|
+
// the worker mux stream-copies whatever codec arrives here.
|
|
665
|
+
const output = new MB.Output({ format: new MB.WebMOutputFormat(), target: new MB.BufferTarget() });
|
|
666
|
+
const source = new MB.AudioBufferSource({ codec: 'opus', bitrate: MB.QUALITY_HIGH });
|
|
667
|
+
output.addAudioTrack(source);
|
|
668
|
+
await output.start();
|
|
669
|
+
await source.add(mixed);
|
|
670
|
+
source.close();
|
|
671
|
+
await output.finalize();
|
|
672
|
+
const buffer = output.target.buffer;
|
|
673
|
+
if (!buffer) throw new Error('Audio encode produced no output buffer');
|
|
674
|
+
|
|
675
|
+
stage('upload');
|
|
676
|
+
const res = await fetch(CONFIG.uploadUrl, {
|
|
677
|
+
method: 'PUT',
|
|
678
|
+
headers: { 'Content-Type': 'audio/webm' },
|
|
679
|
+
body: buffer,
|
|
680
|
+
});
|
|
681
|
+
if (!res.ok) throw new Error('Upload failed: HTTP ' + res.status);
|
|
682
|
+
|
|
683
|
+
window.__renderComplete = { success: true, uploaded: true, size: buffer.byteLength };
|
|
684
|
+
})().catch((e) => {
|
|
685
|
+
window.__renderComplete = { success: false, error: String((e && e.stack) || e) };
|
|
686
|
+
});
|
|
687
|
+
</script>
|
|
688
|
+
</body>
|
|
689
|
+
</html>`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/imageDiffPage.ts
|
|
693
|
+
function buildImageDiffPage(options) {
|
|
694
|
+
const config = JSON.stringify({
|
|
695
|
+
candidate: options.candidateUrl,
|
|
696
|
+
golden: options.goldenUrl
|
|
697
|
+
}).replace(/</g, "\\u003c");
|
|
698
|
+
return `<!doctype html>
|
|
699
|
+
<html>
|
|
700
|
+
<head><meta charset="utf-8"><title>vos golden diff</title></head>
|
|
701
|
+
<body>
|
|
702
|
+
<script type="module">
|
|
703
|
+
const CONFIG = ${config};
|
|
704
|
+
|
|
705
|
+
const load = (src) =>
|
|
706
|
+
new Promise((resolve, reject) => {
|
|
707
|
+
const img = new Image();
|
|
708
|
+
img.onload = () => resolve(img);
|
|
709
|
+
img.onerror = () => reject(new Error('image failed to load'));
|
|
710
|
+
img.src = src;
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
;(async () => {
|
|
714
|
+
const [candidate, golden] = await Promise.all([
|
|
715
|
+
load(CONFIG.candidate),
|
|
716
|
+
load(CONFIG.golden),
|
|
717
|
+
]);
|
|
718
|
+
if (candidate.width !== golden.width || candidate.height !== golden.height) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
'size mismatch: candidate ' + candidate.width + 'x' + candidate.height +
|
|
721
|
+
' vs golden ' + golden.width + 'x' + golden.height,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const draw = (img) => {
|
|
725
|
+
const canvas = document.createElement('canvas');
|
|
726
|
+
canvas.width = img.width;
|
|
727
|
+
canvas.height = img.height;
|
|
728
|
+
const ctx = canvas.getContext('2d');
|
|
729
|
+
ctx.drawImage(img, 0, 0);
|
|
730
|
+
return ctx.getImageData(0, 0, img.width, img.height).data;
|
|
731
|
+
};
|
|
732
|
+
const a = draw(candidate);
|
|
733
|
+
const b = draw(golden);
|
|
734
|
+
let sum = 0;
|
|
735
|
+
let n = 0;
|
|
736
|
+
for (let i = 0; i < a.length; i += 4) {
|
|
737
|
+
for (let c = 0; c < 3; c++) {
|
|
738
|
+
const d = a[i + c] - b[i + c];
|
|
739
|
+
sum += d * d;
|
|
740
|
+
n++;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const rms = Math.sqrt(sum / n);
|
|
744
|
+
window.__renderComplete = {
|
|
745
|
+
success: true,
|
|
746
|
+
rms,
|
|
747
|
+
width: candidate.width,
|
|
748
|
+
height: candidate.height,
|
|
749
|
+
};
|
|
750
|
+
})().catch((e) => {
|
|
751
|
+
window.__renderComplete = { success: false, error: String((e && e.message) || e) };
|
|
752
|
+
});
|
|
753
|
+
</script>
|
|
754
|
+
</body>
|
|
755
|
+
</html>`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/digestPage.ts
|
|
759
|
+
function buildDigestPage(options) {
|
|
760
|
+
const config = JSON.stringify(options).replace(/</g, "\\u003c");
|
|
761
|
+
return `<!doctype html>
|
|
762
|
+
<html>
|
|
763
|
+
<head><meta charset="utf-8"><title>vos digest</title><link rel="icon" href="data:,">
|
|
764
|
+
<style>html,body{margin:0;background:#000}video{position:absolute;left:-99999px}</style>
|
|
765
|
+
</head>
|
|
766
|
+
<body>
|
|
767
|
+
<script type="module">
|
|
768
|
+
const cfg = ${config}
|
|
769
|
+
const raf = () => new Promise((r) => requestAnimationFrame(r))
|
|
770
|
+
const done = (v) => { window.__renderComplete = v }
|
|
771
|
+
try {
|
|
772
|
+
// One page per job: the whole recording comes down once, then every seek
|
|
773
|
+
// is local (a network-backed paused <video> gets suspended within seconds).
|
|
774
|
+
const blob = await (await fetch(cfg.videoUrl)).blob()
|
|
775
|
+
const v = document.createElement('video')
|
|
776
|
+
v.muted = true
|
|
777
|
+
v.preload = 'auto'
|
|
778
|
+
v.src = URL.createObjectURL(blob)
|
|
779
|
+
document.body.appendChild(v)
|
|
780
|
+
await new Promise((res, rej) => {
|
|
781
|
+
v.addEventListener('loadeddata', () => res(), { once: true })
|
|
782
|
+
v.addEventListener('error', () => rej(new Error('video failed to load')), { once: true })
|
|
783
|
+
})
|
|
784
|
+
const settle = async () => {
|
|
785
|
+
const t0 = performance.now()
|
|
786
|
+
let last = ''
|
|
787
|
+
while (performance.now() - t0 < 4000) {
|
|
788
|
+
await raf()
|
|
789
|
+
if (!v.seeking && v.readyState >= 2) {
|
|
790
|
+
const now = v.currentTime.toFixed(3)
|
|
791
|
+
if (now === last) return
|
|
792
|
+
last = now
|
|
793
|
+
} else last = ''
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const seek = async (t) => {
|
|
797
|
+
const target = Math.max(0, Math.min(t, Math.max(0, (v.duration || t) - 0.02)))
|
|
798
|
+
if (Math.abs(v.currentTime - target) > 0.0005 || v.readyState < 2) {
|
|
799
|
+
await new Promise((res) => {
|
|
800
|
+
const on = () => { v.removeEventListener('seeked', on); res() }
|
|
801
|
+
v.addEventListener('seeked', on)
|
|
802
|
+
v.currentTime = target
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
await settle()
|
|
806
|
+
}
|
|
807
|
+
const W = v.videoWidth
|
|
808
|
+
const H = v.videoHeight
|
|
809
|
+
const region = {
|
|
810
|
+
x: Math.max(0, Math.min(cfg.region.x, W)),
|
|
811
|
+
y: Math.max(0, Math.min(cfg.region.y, H)),
|
|
812
|
+
w: Math.max(1, Math.min(cfg.region.w, W - cfg.region.x)),
|
|
813
|
+
h: Math.max(1, Math.min(cfg.region.h, H - cfg.region.y)),
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Pass 1: motion bins, one per source second.
|
|
817
|
+
const bw = 64, bh = 36
|
|
818
|
+
const bc = document.createElement('canvas')
|
|
819
|
+
bc.width = bw; bc.height = bh
|
|
820
|
+
const bctx = bc.getContext('2d', { willReadFrequently: true })
|
|
821
|
+
let prev = null
|
|
822
|
+
const bins = []
|
|
823
|
+
const n = Math.max(1, Math.ceil(cfg.durationS))
|
|
824
|
+
for (let i = 0; i < n; i++) {
|
|
825
|
+
await seek(Math.min(cfg.durationS, i + 0.5))
|
|
826
|
+
bctx.drawImage(v, 0, 0, bw, bh)
|
|
827
|
+
const d = bctx.getImageData(0, 0, bw, bh).data
|
|
828
|
+
const luma = new Uint8ClampedArray(bw * bh)
|
|
829
|
+
for (let p = 0; p < luma.length; p++) {
|
|
830
|
+
const o = p * 4
|
|
831
|
+
luma[p] = (d[o] * 299 + d[o + 1] * 587 + d[o + 2] * 114) / 1000
|
|
832
|
+
}
|
|
833
|
+
if (!prev) bins.push(0)
|
|
834
|
+
else {
|
|
835
|
+
let changed = 0
|
|
836
|
+
for (let p = 0; p < luma.length; p++) if (Math.abs(luma[p] - prev[p]) > cfg.motionDelta) changed++
|
|
837
|
+
bins.push(Math.round((changed / luma.length) * 1000) / 1000)
|
|
838
|
+
}
|
|
839
|
+
prev = luma
|
|
840
|
+
window.__renderProgress = 0.4 * ((i + 1) / n)
|
|
841
|
+
}
|
|
842
|
+
const scenes = []
|
|
843
|
+
for (let i = 1; i < bins.length; i++) {
|
|
844
|
+
if (bins[i] >= cfg.scene.motion && bins[i - 1] <= cfg.scene.quiet) scenes.push(i)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Pass 2: frames, crops, the sheet \u2014 each PUT as its own part.
|
|
848
|
+
const sizes = {}
|
|
849
|
+
const toPng = (c) => new Promise((res, rej) => c.toBlob((b) => (b ? res(b) : rej(new Error('toBlob failed'))), 'image/png'))
|
|
850
|
+
const put = async (name, body, type) => {
|
|
851
|
+
const r = await fetch(cfg.uploadUrl + '&part=digest-' + name, { method: 'PUT', headers: { 'content-type': type }, body })
|
|
852
|
+
if (!r.ok) throw new Error('upload of ' + name + ' failed: ' + r.status)
|
|
853
|
+
}
|
|
854
|
+
const save = async (name, c) => {
|
|
855
|
+
await put(name, await toPng(c), 'image/png')
|
|
856
|
+
sizes[name] = { width: c.width, height: c.height }
|
|
857
|
+
}
|
|
858
|
+
const fit = (w, h, max) => {
|
|
859
|
+
const s = Math.min(1, max / Math.max(w, h))
|
|
860
|
+
return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) }
|
|
861
|
+
}
|
|
862
|
+
const draw = (src, sx, sy, sw, sh, max) => {
|
|
863
|
+
const c = document.createElement('canvas')
|
|
864
|
+
const f = fit(sw, sh, max)
|
|
865
|
+
c.width = f.w; c.height = f.h
|
|
866
|
+
c.getContext('2d').drawImage(src, sx, sy, sw, sh, 0, 0, f.w, f.h)
|
|
867
|
+
return c
|
|
868
|
+
}
|
|
869
|
+
const tiles = []
|
|
870
|
+
const shots = cfg.shots.map((s) => ({ ...s, scene: false }))
|
|
871
|
+
for (const s of scenes) shots.push({ name: 'scene-' + s, t: Math.min(cfg.durationS, s + 0.04), box: null, label: 'scene ' + s + 's', scene: true })
|
|
872
|
+
shots.sort((a, b) => a.t - b.t)
|
|
873
|
+
for (let i = 0; i < shots.length; i++) {
|
|
874
|
+
const s = shots[i]
|
|
875
|
+
await seek(s.t)
|
|
876
|
+
const full = draw(v, region.x, region.y, region.w, region.h, cfg.fullMax)
|
|
877
|
+
await save(s.name + '.full.png', full)
|
|
878
|
+
let tileSrc = full
|
|
879
|
+
if (s.box) {
|
|
880
|
+
const b = s.box
|
|
881
|
+
const crop = draw(v, b.x, b.y, b.w, b.h, cfg.cropMax)
|
|
882
|
+
await save(s.name + '.crop.png', crop)
|
|
883
|
+
tileSrc = crop
|
|
884
|
+
}
|
|
885
|
+
const tw = 240
|
|
886
|
+
const th = Math.max(1, Math.round((tw * tileSrc.height) / tileSrc.width))
|
|
887
|
+
const tile = document.createElement('canvas')
|
|
888
|
+
tile.width = tw; tile.height = th
|
|
889
|
+
tile.getContext('2d').drawImage(tileSrc, 0, 0, tw, th)
|
|
890
|
+
tiles.push({ label: s.label, c: tile })
|
|
891
|
+
window.__renderProgress = 0.4 + 0.55 * ((i + 1) / shots.length)
|
|
892
|
+
}
|
|
893
|
+
if (tiles.length) {
|
|
894
|
+
const cols = Math.min(6, tiles.length)
|
|
895
|
+
const tw = 240
|
|
896
|
+
const th = Math.max(...tiles.map((t) => t.c.height))
|
|
897
|
+
const rows = Math.ceil(tiles.length / cols)
|
|
898
|
+
const sheet = document.createElement('canvas')
|
|
899
|
+
sheet.width = cols * (tw + 8) + 8
|
|
900
|
+
sheet.height = rows * (th + 26) + 8
|
|
901
|
+
const ctx = sheet.getContext('2d')
|
|
902
|
+
ctx.fillStyle = '#111'
|
|
903
|
+
ctx.fillRect(0, 0, sheet.width, sheet.height)
|
|
904
|
+
ctx.font = '12px ui-monospace, Menlo, monospace'
|
|
905
|
+
tiles.forEach((t, i) => {
|
|
906
|
+
const x = 8 + (i % cols) * (tw + 8)
|
|
907
|
+
const y = 8 + Math.floor(i / cols) * (th + 26)
|
|
908
|
+
ctx.drawImage(t.c, x, y)
|
|
909
|
+
ctx.fillStyle = '#eee'
|
|
910
|
+
ctx.fillText(t.label, x, y + th + 16)
|
|
911
|
+
})
|
|
912
|
+
await save('sheet.png', sheet)
|
|
913
|
+
}
|
|
914
|
+
await put('manifest.json', new Blob([JSON.stringify({ width: W, height: H, bins, scenes, sizes })], { type: 'application/json' }), 'application/json')
|
|
915
|
+
done({ success: true, uploaded: true, bins: bins.length, shots: shots.length })
|
|
916
|
+
} catch (e) {
|
|
917
|
+
done({ success: false, error: String((e && e.stack) || e) })
|
|
918
|
+
}
|
|
919
|
+
</script>
|
|
920
|
+
</body>
|
|
921
|
+
</html>`;
|
|
922
|
+
}
|
|
923
|
+
export {
|
|
924
|
+
CORE_AUDIO_CDN_URL,
|
|
925
|
+
DEFAULT_MIN_FRAMES_PER_CHUNK,
|
|
926
|
+
audioProducerCode,
|
|
927
|
+
buildAudioMixPage,
|
|
928
|
+
buildDigestPage,
|
|
929
|
+
buildFinalizeConcatPage,
|
|
930
|
+
buildImageDiffPage,
|
|
931
|
+
concatEncodedVideo,
|
|
932
|
+
countVideoPackets,
|
|
933
|
+
dataHasAudio,
|
|
934
|
+
muxEncodedExport,
|
|
935
|
+
planChunks,
|
|
936
|
+
studioEntryData
|
|
937
|
+
};
|
|
938
|
+
//# sourceMappingURL=index.js.map
|