@pneuma-craft/video 0.4.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 +29 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/index.cjs +1339 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +172 -0
- package/dist/index.d.ts +172 -0
- package/dist/index.js +1310 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
createAudioScheduler: () => createAudioScheduler,
|
|
24
|
+
createCanvas2DCompositor: () => createCanvas2DCompositor,
|
|
25
|
+
createCompositor: () => createCompositor,
|
|
26
|
+
createExportEngine: () => createExportEngine,
|
|
27
|
+
createFrameRenderer: () => createFrameRenderer,
|
|
28
|
+
createGPUCompositor: () => createGPUCompositor,
|
|
29
|
+
createMasterClock: () => createMasterClock,
|
|
30
|
+
createMediaDecoder: () => createMediaDecoder,
|
|
31
|
+
createOfflineAudioRenderer: () => createOfflineAudioRenderer,
|
|
32
|
+
createPlaybackEngine: () => createPlaybackEngine
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/media-decoder.ts
|
|
37
|
+
var import_mediabunny = require("mediabunny");
|
|
38
|
+
async function decodeImageFitted(blob, width, height) {
|
|
39
|
+
const raw = await createImageBitmap(blob);
|
|
40
|
+
try {
|
|
41
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
42
|
+
const ctx = canvas.getContext("2d", { alpha: true });
|
|
43
|
+
if (!ctx) throw new Error("Failed to get 2d context for image resize");
|
|
44
|
+
const scale = Math.min(width / raw.width, height / raw.height);
|
|
45
|
+
const drawW = raw.width * scale;
|
|
46
|
+
const drawH = raw.height * scale;
|
|
47
|
+
const dx = (width - drawW) / 2;
|
|
48
|
+
const dy = (height - drawH) / 2;
|
|
49
|
+
ctx.drawImage(raw, 0, 0, raw.width, raw.height, dx, dy, drawW, drawH);
|
|
50
|
+
return await createImageBitmap(canvas);
|
|
51
|
+
} finally {
|
|
52
|
+
raw.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function createMediaDecoder(resolver, audioContext) {
|
|
56
|
+
const cache = /* @__PURE__ */ new Map();
|
|
57
|
+
const initPromises = /* @__PURE__ */ new Map();
|
|
58
|
+
async function getOrCreateAsset(assetId) {
|
|
59
|
+
const existing = cache.get(assetId);
|
|
60
|
+
if (existing) return existing;
|
|
61
|
+
const pending = initPromises.get(assetId);
|
|
62
|
+
if (pending) return pending;
|
|
63
|
+
const promise = (async () => {
|
|
64
|
+
const blob = await resolver.fetchBlob(assetId);
|
|
65
|
+
const input = new import_mediabunny.Input({ source: new import_mediabunny.BlobSource(blob), formats: import_mediabunny.ALL_FORMATS });
|
|
66
|
+
const asset = { input, blob, videoSink: null, imageBitmap: null, audioBuffer: null, mediaInfo: null };
|
|
67
|
+
cache.set(assetId, asset);
|
|
68
|
+
initPromises.delete(assetId);
|
|
69
|
+
return asset;
|
|
70
|
+
})();
|
|
71
|
+
promise.catch(() => {
|
|
72
|
+
initPromises.delete(assetId);
|
|
73
|
+
});
|
|
74
|
+
initPromises.set(assetId, promise);
|
|
75
|
+
return promise;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
async decodeVideoFrame(assetId, time, width, height) {
|
|
79
|
+
const asset = await getOrCreateAsset(assetId);
|
|
80
|
+
if (asset.imageBitmap) return asset.imageBitmap;
|
|
81
|
+
if (!asset.videoSink) {
|
|
82
|
+
let videoTrack = null;
|
|
83
|
+
try {
|
|
84
|
+
videoTrack = await asset.input.getPrimaryVideoTrack();
|
|
85
|
+
} catch {
|
|
86
|
+
}
|
|
87
|
+
if (!videoTrack) {
|
|
88
|
+
try {
|
|
89
|
+
asset.imageBitmap = await decodeImageFitted(asset.blob, width, height);
|
|
90
|
+
return asset.imageBitmap;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Asset ${assetId} has no video track and is not a decodable image: ${err.message}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
asset.videoSink = new import_mediabunny.CanvasSink(videoTrack, {
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
fit: "contain",
|
|
101
|
+
poolSize: 5,
|
|
102
|
+
// Preserve source alpha — required for transparent videos (e.g. WebM
|
|
103
|
+
// with VP8/VP9 alpha, HEVC/AV1 with alpha). With `alpha: false`
|
|
104
|
+
// (mediabunny default) CanvasSink flattens transparent pixels to a
|
|
105
|
+
// black background, breaking compositing of alpha videos.
|
|
106
|
+
alpha: true
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const result = await asset.videoSink.getCanvas(time);
|
|
110
|
+
if (!result) throw new Error(`Failed to decode frame at ${time}s for asset ${assetId}`);
|
|
111
|
+
return result.canvas;
|
|
112
|
+
},
|
|
113
|
+
async decodeAudio(assetId) {
|
|
114
|
+
const asset = await getOrCreateAsset(assetId);
|
|
115
|
+
if (asset.audioBuffer) return asset.audioBuffer;
|
|
116
|
+
try {
|
|
117
|
+
const arrayBuffer = await asset.blob.arrayBuffer();
|
|
118
|
+
const buffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
|
119
|
+
asset.audioBuffer = buffer;
|
|
120
|
+
return buffer;
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
const audioTrack = await asset.input.getPrimaryAudioTrack();
|
|
124
|
+
if (!audioTrack) throw new Error(`No audio track in asset ${assetId}`);
|
|
125
|
+
const sink = new import_mediabunny.AudioBufferSink(audioTrack);
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let totalFrames = 0;
|
|
128
|
+
let sampleRate = 0;
|
|
129
|
+
let numberOfChannels = 0;
|
|
130
|
+
for await (const wrapped of sink.buffers()) {
|
|
131
|
+
const buf = wrapped.buffer;
|
|
132
|
+
chunks.push(buf);
|
|
133
|
+
totalFrames += buf.length;
|
|
134
|
+
if (!sampleRate) sampleRate = buf.sampleRate;
|
|
135
|
+
if (!numberOfChannels) numberOfChannels = buf.numberOfChannels;
|
|
136
|
+
}
|
|
137
|
+
if (chunks.length === 0 || totalFrames === 0) {
|
|
138
|
+
throw new Error(`Failed to decode audio for asset ${assetId}`);
|
|
139
|
+
}
|
|
140
|
+
const merged = audioContext.createBuffer(numberOfChannels, totalFrames, sampleRate);
|
|
141
|
+
let offset = 0;
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
for (let ch = 0; ch < numberOfChannels; ch++) {
|
|
144
|
+
const src = chunk.getChannelData(Math.min(ch, chunk.numberOfChannels - 1));
|
|
145
|
+
merged.copyToChannel(src, ch, offset);
|
|
146
|
+
}
|
|
147
|
+
offset += chunk.length;
|
|
148
|
+
}
|
|
149
|
+
asset.audioBuffer = merged;
|
|
150
|
+
return merged;
|
|
151
|
+
},
|
|
152
|
+
async getMediaInfo(assetId) {
|
|
153
|
+
const asset = await getOrCreateAsset(assetId);
|
|
154
|
+
if (asset.mediaInfo) return asset.mediaInfo;
|
|
155
|
+
const videoTrack = await asset.input.getPrimaryVideoTrack();
|
|
156
|
+
const audioTrack = await asset.input.getPrimaryAudioTrack();
|
|
157
|
+
const duration = await asset.input.computeDuration();
|
|
158
|
+
let fps = 0;
|
|
159
|
+
if (videoTrack) {
|
|
160
|
+
const stats = await videoTrack.computePacketStats(100);
|
|
161
|
+
fps = stats.averagePacketRate;
|
|
162
|
+
}
|
|
163
|
+
const info = {
|
|
164
|
+
duration,
|
|
165
|
+
width: videoTrack?.displayWidth ?? 0,
|
|
166
|
+
height: videoTrack?.displayHeight ?? 0,
|
|
167
|
+
fps,
|
|
168
|
+
hasVideo: videoTrack !== null,
|
|
169
|
+
hasAudio: audioTrack !== null,
|
|
170
|
+
videoCodec: videoTrack?.codec ?? null,
|
|
171
|
+
audioCodec: audioTrack?.codec ?? null,
|
|
172
|
+
sampleRate: audioTrack?.sampleRate ?? 0,
|
|
173
|
+
channels: audioTrack?.numberOfChannels ?? 0
|
|
174
|
+
};
|
|
175
|
+
asset.mediaInfo = info;
|
|
176
|
+
return info;
|
|
177
|
+
},
|
|
178
|
+
destroy() {
|
|
179
|
+
for (const asset of cache.values()) {
|
|
180
|
+
asset.input.dispose();
|
|
181
|
+
asset.imageBitmap?.close();
|
|
182
|
+
}
|
|
183
|
+
cache.clear();
|
|
184
|
+
initPromises.clear();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/canvas2d-compositor.ts
|
|
190
|
+
function createCanvas2DCompositor(width, height) {
|
|
191
|
+
let canvas = new OffscreenCanvas(width, height);
|
|
192
|
+
let ctx = canvas.getContext("2d", { alpha: true });
|
|
193
|
+
let currentWidth = width;
|
|
194
|
+
let currentHeight = height;
|
|
195
|
+
return {
|
|
196
|
+
async composite(layers) {
|
|
197
|
+
ctx.clearRect(0, 0, currentWidth, currentHeight);
|
|
198
|
+
const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
|
199
|
+
for (const layer of sorted) {
|
|
200
|
+
if (layer.opacity <= 0) continue;
|
|
201
|
+
ctx.globalAlpha = layer.opacity;
|
|
202
|
+
ctx.drawImage(layer.source, 0, 0, currentWidth, currentHeight);
|
|
203
|
+
}
|
|
204
|
+
ctx.globalAlpha = 1;
|
|
205
|
+
return createImageBitmap(canvas);
|
|
206
|
+
},
|
|
207
|
+
resize(w, h) {
|
|
208
|
+
currentWidth = w;
|
|
209
|
+
currentHeight = h;
|
|
210
|
+
canvas = new OffscreenCanvas(w, h);
|
|
211
|
+
ctx = canvas.getContext("2d", { alpha: true });
|
|
212
|
+
},
|
|
213
|
+
destroy() {
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/gpu-compositor.ts
|
|
219
|
+
var VERTEX_SHADER = (
|
|
220
|
+
/* wgsl */
|
|
221
|
+
`
|
|
222
|
+
struct VertexOutput {
|
|
223
|
+
@builtin(position) position: vec4f,
|
|
224
|
+
@location(0) texCoord: vec2f,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
@vertex
|
|
228
|
+
fn main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
|
229
|
+
// Full-screen quad: 6 vertices, 2 triangles
|
|
230
|
+
var positions = array<vec2f, 6>(
|
|
231
|
+
vec2f(-1.0, -1.0),
|
|
232
|
+
vec2f( 1.0, -1.0),
|
|
233
|
+
vec2f(-1.0, 1.0),
|
|
234
|
+
vec2f(-1.0, 1.0),
|
|
235
|
+
vec2f( 1.0, -1.0),
|
|
236
|
+
vec2f( 1.0, 1.0),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
var texCoords = array<vec2f, 6>(
|
|
240
|
+
vec2f(0.0, 1.0),
|
|
241
|
+
vec2f(1.0, 1.0),
|
|
242
|
+
vec2f(0.0, 0.0),
|
|
243
|
+
vec2f(0.0, 0.0),
|
|
244
|
+
vec2f(1.0, 1.0),
|
|
245
|
+
vec2f(1.0, 0.0),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
var output: VertexOutput;
|
|
249
|
+
output.position = vec4f(positions[vertexIndex], 0.0, 1.0);
|
|
250
|
+
output.texCoord = texCoords[vertexIndex];
|
|
251
|
+
return output;
|
|
252
|
+
}
|
|
253
|
+
`
|
|
254
|
+
);
|
|
255
|
+
var FRAGMENT_SHADER = (
|
|
256
|
+
/* wgsl */
|
|
257
|
+
`
|
|
258
|
+
@group(0) @binding(0) var texSampler: sampler;
|
|
259
|
+
@group(0) @binding(1) var texSource: texture_2d<f32>;
|
|
260
|
+
@group(0) @binding(2) var<uniform> opacity: f32;
|
|
261
|
+
|
|
262
|
+
@fragment
|
|
263
|
+
fn main(@location(0) texCoord: vec2f) -> @location(0) vec4f {
|
|
264
|
+
var color = textureSample(texSource, texSampler, texCoord);
|
|
265
|
+
// Multiply alpha by uniform opacity
|
|
266
|
+
color.a = color.a * opacity;
|
|
267
|
+
// Premultiply RGB by the new alpha
|
|
268
|
+
color = vec4f(color.rgb * color.a, color.a);
|
|
269
|
+
return color;
|
|
270
|
+
}
|
|
271
|
+
`
|
|
272
|
+
);
|
|
273
|
+
async function createGPUCompositor(width, height) {
|
|
274
|
+
if (!navigator.gpu) {
|
|
275
|
+
throw new Error("WebGPU not available");
|
|
276
|
+
}
|
|
277
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
278
|
+
if (!adapter) {
|
|
279
|
+
throw new Error("Failed to get WebGPU adapter");
|
|
280
|
+
}
|
|
281
|
+
const device = await adapter.requestDevice();
|
|
282
|
+
const sampler = device.createSampler({
|
|
283
|
+
magFilter: "linear",
|
|
284
|
+
minFilter: "linear"
|
|
285
|
+
});
|
|
286
|
+
const opacityBuffer = device.createBuffer({
|
|
287
|
+
size: 16,
|
|
288
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
|
|
289
|
+
});
|
|
290
|
+
const bindGroupLayout = device.createBindGroupLayout({
|
|
291
|
+
entries: [
|
|
292
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
293
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
294
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }
|
|
295
|
+
]
|
|
296
|
+
});
|
|
297
|
+
const pipelineLayout = device.createPipelineLayout({
|
|
298
|
+
bindGroupLayouts: [bindGroupLayout]
|
|
299
|
+
});
|
|
300
|
+
const vertexModule = device.createShaderModule({ code: VERTEX_SHADER });
|
|
301
|
+
const fragmentModule = device.createShaderModule({ code: FRAGMENT_SHADER });
|
|
302
|
+
const pipeline = device.createRenderPipeline({
|
|
303
|
+
layout: pipelineLayout,
|
|
304
|
+
vertex: {
|
|
305
|
+
module: vertexModule,
|
|
306
|
+
entryPoint: "main"
|
|
307
|
+
},
|
|
308
|
+
fragment: {
|
|
309
|
+
module: fragmentModule,
|
|
310
|
+
entryPoint: "main",
|
|
311
|
+
targets: [{
|
|
312
|
+
format: "rgba8unorm",
|
|
313
|
+
blend: {
|
|
314
|
+
color: {
|
|
315
|
+
srcFactor: "one",
|
|
316
|
+
dstFactor: "one-minus-src-alpha",
|
|
317
|
+
operation: "add"
|
|
318
|
+
},
|
|
319
|
+
alpha: {
|
|
320
|
+
srcFactor: "one",
|
|
321
|
+
dstFactor: "one-minus-src-alpha",
|
|
322
|
+
operation: "add"
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}]
|
|
326
|
+
},
|
|
327
|
+
primitive: {
|
|
328
|
+
topology: "triangle-list"
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
let currentWidth = width;
|
|
332
|
+
let currentHeight = height;
|
|
333
|
+
let outputTexture = createOutputTexture(device, width, height);
|
|
334
|
+
function createOutputTexture(dev, w, h) {
|
|
335
|
+
return dev.createTexture({
|
|
336
|
+
size: { width: w, height: h },
|
|
337
|
+
format: "rgba8unorm",
|
|
338
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | GPUTextureUsage.TEXTURE_BINDING
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
async composite(layers) {
|
|
343
|
+
const sorted = [...layers].filter((l) => l.opacity > 0).sort((a, b) => a.zIndex - b.zIndex);
|
|
344
|
+
const encoder = device.createCommandEncoder();
|
|
345
|
+
const outputView = outputTexture.createView();
|
|
346
|
+
const layerTextures = [];
|
|
347
|
+
if (sorted.length === 0) {
|
|
348
|
+
const pass = encoder.beginRenderPass({
|
|
349
|
+
colorAttachments: [{
|
|
350
|
+
view: outputView,
|
|
351
|
+
loadOp: "clear",
|
|
352
|
+
storeOp: "store",
|
|
353
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 }
|
|
354
|
+
}]
|
|
355
|
+
});
|
|
356
|
+
pass.end();
|
|
357
|
+
} else {
|
|
358
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
359
|
+
const layer = sorted[i];
|
|
360
|
+
const layerTexture = device.createTexture({
|
|
361
|
+
size: { width: currentWidth, height: currentHeight },
|
|
362
|
+
format: "rgba8unorm",
|
|
363
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
|
|
364
|
+
});
|
|
365
|
+
layerTextures.push(layerTexture);
|
|
366
|
+
device.queue.copyExternalImageToTexture(
|
|
367
|
+
{ source: layer.source },
|
|
368
|
+
{ texture: layerTexture },
|
|
369
|
+
{ width: currentWidth, height: currentHeight }
|
|
370
|
+
);
|
|
371
|
+
const opacityData = new Float32Array([layer.opacity]);
|
|
372
|
+
device.queue.writeBuffer(opacityBuffer, 0, opacityData);
|
|
373
|
+
const bindGroup = device.createBindGroup({
|
|
374
|
+
layout: bindGroupLayout,
|
|
375
|
+
entries: [
|
|
376
|
+
{ binding: 0, resource: sampler },
|
|
377
|
+
{ binding: 1, resource: layerTexture.createView() },
|
|
378
|
+
{ binding: 2, resource: { buffer: opacityBuffer } }
|
|
379
|
+
]
|
|
380
|
+
});
|
|
381
|
+
const loadOp = i === 0 ? "clear" : "load";
|
|
382
|
+
const pass = encoder.beginRenderPass({
|
|
383
|
+
colorAttachments: [{
|
|
384
|
+
view: outputView,
|
|
385
|
+
loadOp,
|
|
386
|
+
storeOp: "store",
|
|
387
|
+
...loadOp === "clear" ? { clearValue: { r: 0, g: 0, b: 0, a: 0 } } : {}
|
|
388
|
+
}]
|
|
389
|
+
});
|
|
390
|
+
pass.setPipeline(pipeline);
|
|
391
|
+
pass.setBindGroup(0, bindGroup);
|
|
392
|
+
pass.draw(6);
|
|
393
|
+
pass.end();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const bytesPerRow = Math.ceil(currentWidth * 4 / 256) * 256;
|
|
397
|
+
const bufferSize = bytesPerRow * currentHeight;
|
|
398
|
+
const stagingBuffer = device.createBuffer({
|
|
399
|
+
size: bufferSize,
|
|
400
|
+
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
|
401
|
+
});
|
|
402
|
+
encoder.copyTextureToBuffer(
|
|
403
|
+
{ texture: outputTexture },
|
|
404
|
+
{ buffer: stagingBuffer, bytesPerRow },
|
|
405
|
+
{ width: currentWidth, height: currentHeight }
|
|
406
|
+
);
|
|
407
|
+
device.queue.submit([encoder.finish()]);
|
|
408
|
+
for (const tex of layerTextures) {
|
|
409
|
+
tex.destroy();
|
|
410
|
+
}
|
|
411
|
+
await stagingBuffer.mapAsync(GPUMapMode.READ);
|
|
412
|
+
const rawData = stagingBuffer.getMappedRange();
|
|
413
|
+
const pixelData = new Uint8ClampedArray(currentWidth * currentHeight * 4);
|
|
414
|
+
const src = new Uint8Array(rawData);
|
|
415
|
+
for (let row = 0; row < currentHeight; row++) {
|
|
416
|
+
const srcOffset = row * bytesPerRow;
|
|
417
|
+
const dstOffset = row * currentWidth * 4;
|
|
418
|
+
pixelData.set(src.subarray(srcOffset, srcOffset + currentWidth * 4), dstOffset);
|
|
419
|
+
}
|
|
420
|
+
stagingBuffer.unmap();
|
|
421
|
+
stagingBuffer.destroy();
|
|
422
|
+
for (let i = 0; i < pixelData.length; i += 4) {
|
|
423
|
+
const a = pixelData[i + 3];
|
|
424
|
+
if (a > 0 && a < 255) {
|
|
425
|
+
const inv = 255 / a;
|
|
426
|
+
pixelData[i] = Math.min(255, Math.round(pixelData[i] * inv));
|
|
427
|
+
pixelData[i + 1] = Math.min(255, Math.round(pixelData[i + 1] * inv));
|
|
428
|
+
pixelData[i + 2] = Math.min(255, Math.round(pixelData[i + 2] * inv));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const readbackCanvas = new OffscreenCanvas(currentWidth, currentHeight);
|
|
432
|
+
const ctx = readbackCanvas.getContext("2d", { alpha: true });
|
|
433
|
+
const imageData = new ImageData(pixelData, currentWidth, currentHeight);
|
|
434
|
+
ctx.putImageData(imageData, 0, 0);
|
|
435
|
+
return createImageBitmap(readbackCanvas);
|
|
436
|
+
},
|
|
437
|
+
resize(w, h) {
|
|
438
|
+
outputTexture.destroy();
|
|
439
|
+
currentWidth = w;
|
|
440
|
+
currentHeight = h;
|
|
441
|
+
outputTexture = createOutputTexture(device, w, h);
|
|
442
|
+
},
|
|
443
|
+
destroy() {
|
|
444
|
+
outputTexture.destroy();
|
|
445
|
+
opacityBuffer.destroy();
|
|
446
|
+
device.destroy();
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/compositor.ts
|
|
452
|
+
async function createCompositor(width, height, type = "auto") {
|
|
453
|
+
if (type === "canvas2d") {
|
|
454
|
+
return createCanvas2DCompositor(width, height);
|
|
455
|
+
}
|
|
456
|
+
if (type === "gpu" || type === "auto") {
|
|
457
|
+
if (typeof navigator !== "undefined" && navigator.gpu) {
|
|
458
|
+
try {
|
|
459
|
+
return await createGPUCompositor(width, height);
|
|
460
|
+
} catch {
|
|
461
|
+
if (type === "gpu") {
|
|
462
|
+
throw new Error("WebGPU compositor requested but initialization failed");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
} else if (type === "gpu") {
|
|
466
|
+
throw new Error("WebGPU not available");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return createCanvas2DCompositor(width, height);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/frame-renderer.ts
|
|
473
|
+
var import_timeline = require("@pneuma-craft/timeline");
|
|
474
|
+
function createFrameRenderer(decoder, compositor, width, height, subtitleRenderer) {
|
|
475
|
+
return {
|
|
476
|
+
async renderFrame(composition, time) {
|
|
477
|
+
const resolved = (0, import_timeline.resolveFrame)(composition, time);
|
|
478
|
+
const videoClips = resolved.clips.filter((rc) => rc.track.type === "video");
|
|
479
|
+
const subtitleClips = subtitleRenderer ? resolved.clips.filter((rc) => rc.track.type === "subtitle") : [];
|
|
480
|
+
const layers = [];
|
|
481
|
+
for (let i = 0; i < videoClips.length; i++) {
|
|
482
|
+
const rc = videoClips[i];
|
|
483
|
+
const source = await decoder.decodeVideoFrame(
|
|
484
|
+
rc.clip.assetId,
|
|
485
|
+
rc.localTime,
|
|
486
|
+
width,
|
|
487
|
+
height
|
|
488
|
+
);
|
|
489
|
+
layers.push({
|
|
490
|
+
source,
|
|
491
|
+
opacity: 1,
|
|
492
|
+
zIndex: i
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (subtitleRenderer && subtitleClips.length > 0) {
|
|
496
|
+
const baseZ = videoClips.length;
|
|
497
|
+
for (let i = 0; i < subtitleClips.length; i++) {
|
|
498
|
+
const rc = subtitleClips[i];
|
|
499
|
+
const rendered = await subtitleRenderer({
|
|
500
|
+
clip: rc.clip,
|
|
501
|
+
localTime: rc.localTime,
|
|
502
|
+
width,
|
|
503
|
+
height
|
|
504
|
+
});
|
|
505
|
+
if (rendered) {
|
|
506
|
+
layers.push({
|
|
507
|
+
source: rendered,
|
|
508
|
+
opacity: 1,
|
|
509
|
+
zIndex: baseZ + i
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const image = await compositor.composite(layers);
|
|
515
|
+
return { image, time, width, height };
|
|
516
|
+
},
|
|
517
|
+
destroy() {
|
|
518
|
+
decoder.destroy();
|
|
519
|
+
compositor.destroy();
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/master-clock.ts
|
|
525
|
+
function createMasterClock(options) {
|
|
526
|
+
const audioContext = options.audioContext;
|
|
527
|
+
let state = "stopped";
|
|
528
|
+
let _duration = options.duration ?? 0;
|
|
529
|
+
let _playbackRate = 1;
|
|
530
|
+
let _loop = null;
|
|
531
|
+
let startAudioContextTime = 0;
|
|
532
|
+
let startTimelineTime = 0;
|
|
533
|
+
let pausedAt = 0;
|
|
534
|
+
let _driftMs = 0;
|
|
535
|
+
const timeUpdateListeners = /* @__PURE__ */ new Set();
|
|
536
|
+
const stateChangeListeners = /* @__PURE__ */ new Set();
|
|
537
|
+
function computeCurrentTime() {
|
|
538
|
+
if (state !== "playing") return pausedAt;
|
|
539
|
+
const elapsed = (audioContext.currentTime - startAudioContextTime) * _playbackRate;
|
|
540
|
+
let time = startTimelineTime + elapsed;
|
|
541
|
+
if (_loop && _loop.end > _loop.start && time >= _loop.end) {
|
|
542
|
+
const loopDuration = _loop.end - _loop.start;
|
|
543
|
+
time = _loop.start + (time - _loop.start) % loopDuration;
|
|
544
|
+
}
|
|
545
|
+
return Math.max(0, Math.min(time, _duration || Infinity));
|
|
546
|
+
}
|
|
547
|
+
function notifyTimeUpdate(time) {
|
|
548
|
+
for (const cb of timeUpdateListeners) {
|
|
549
|
+
try {
|
|
550
|
+
cb(time);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
console.error("[MasterClock] listener error:", e);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function notifyStateChange(newState) {
|
|
557
|
+
for (const cb of stateChangeListeners) {
|
|
558
|
+
try {
|
|
559
|
+
cb(newState);
|
|
560
|
+
} catch (e) {
|
|
561
|
+
console.error("[MasterClock] listener error:", e);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function setState(newState) {
|
|
566
|
+
if (state === newState) return;
|
|
567
|
+
state = newState;
|
|
568
|
+
notifyStateChange(newState);
|
|
569
|
+
}
|
|
570
|
+
const clock = {
|
|
571
|
+
get currentTime() {
|
|
572
|
+
return computeCurrentTime();
|
|
573
|
+
},
|
|
574
|
+
get state() {
|
|
575
|
+
return state;
|
|
576
|
+
},
|
|
577
|
+
get driftMs() {
|
|
578
|
+
return _driftMs;
|
|
579
|
+
},
|
|
580
|
+
get playbackRate() {
|
|
581
|
+
return _playbackRate;
|
|
582
|
+
},
|
|
583
|
+
set playbackRate(rate) {
|
|
584
|
+
const clamped = Math.max(0.1, Math.min(rate, 16));
|
|
585
|
+
if (state === "playing") {
|
|
586
|
+
const current = computeCurrentTime();
|
|
587
|
+
startTimelineTime = current;
|
|
588
|
+
startAudioContextTime = audioContext.currentTime;
|
|
589
|
+
}
|
|
590
|
+
_playbackRate = clamped;
|
|
591
|
+
},
|
|
592
|
+
get duration() {
|
|
593
|
+
return _duration;
|
|
594
|
+
},
|
|
595
|
+
set duration(d) {
|
|
596
|
+
_duration = Math.max(0, d);
|
|
597
|
+
},
|
|
598
|
+
get loop() {
|
|
599
|
+
return _loop;
|
|
600
|
+
},
|
|
601
|
+
set loop(l) {
|
|
602
|
+
_loop = l;
|
|
603
|
+
},
|
|
604
|
+
play() {
|
|
605
|
+
if (state === "playing") return;
|
|
606
|
+
startTimelineTime = pausedAt;
|
|
607
|
+
startAudioContextTime = audioContext.currentTime;
|
|
608
|
+
setState("playing");
|
|
609
|
+
if (audioContext.state === "suspended") {
|
|
610
|
+
audioContext.resume();
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
pause() {
|
|
614
|
+
if (state !== "playing") return;
|
|
615
|
+
pausedAt = computeCurrentTime();
|
|
616
|
+
setState("paused");
|
|
617
|
+
},
|
|
618
|
+
seek(time) {
|
|
619
|
+
const clamped = Math.max(0, Math.min(time, _duration));
|
|
620
|
+
if (state === "playing") {
|
|
621
|
+
startTimelineTime = clamped;
|
|
622
|
+
startAudioContextTime = audioContext.currentTime;
|
|
623
|
+
} else {
|
|
624
|
+
pausedAt = clamped;
|
|
625
|
+
}
|
|
626
|
+
notifyTimeUpdate(clamped);
|
|
627
|
+
},
|
|
628
|
+
reportVideoTime(videoTime) {
|
|
629
|
+
const audioTime = computeCurrentTime();
|
|
630
|
+
_driftMs = (audioTime - videoTime) * 1e3;
|
|
631
|
+
},
|
|
632
|
+
onTimeUpdate(cb) {
|
|
633
|
+
timeUpdateListeners.add(cb);
|
|
634
|
+
return () => {
|
|
635
|
+
timeUpdateListeners.delete(cb);
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
onStateChange(cb) {
|
|
639
|
+
stateChangeListeners.add(cb);
|
|
640
|
+
return () => {
|
|
641
|
+
stateChangeListeners.delete(cb);
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
destroy() {
|
|
645
|
+
if (state === "playing") {
|
|
646
|
+
pausedAt = computeCurrentTime();
|
|
647
|
+
}
|
|
648
|
+
state = "stopped";
|
|
649
|
+
timeUpdateListeners.clear();
|
|
650
|
+
stateChangeListeners.clear();
|
|
651
|
+
_driftMs = 0;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
return clock;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/audio-scheduler.ts
|
|
658
|
+
function createAudioScheduler(options) {
|
|
659
|
+
const { audioContext } = options;
|
|
660
|
+
const clipBuffers = /* @__PURE__ */ new Map();
|
|
661
|
+
const trackGains = /* @__PURE__ */ new Map();
|
|
662
|
+
const trackVolumes = /* @__PURE__ */ new Map();
|
|
663
|
+
const trackMutedStates = /* @__PURE__ */ new Map();
|
|
664
|
+
let activeSources = [];
|
|
665
|
+
let schedulerIntervalId = null;
|
|
666
|
+
let scheduledClipIds = /* @__PURE__ */ new Set();
|
|
667
|
+
let currentComposition = null;
|
|
668
|
+
let _playbackRate = 1;
|
|
669
|
+
let _isPlaying = false;
|
|
670
|
+
let _prevTimelineTime = 0;
|
|
671
|
+
let _getCurrentTime = null;
|
|
672
|
+
const LOOK_AHEAD = 0.2;
|
|
673
|
+
const masterGain = audioContext.createGain();
|
|
674
|
+
masterGain.gain.value = 1;
|
|
675
|
+
masterGain.connect(audioContext.destination);
|
|
676
|
+
function getOrCreateTrackGain(track) {
|
|
677
|
+
let node = trackGains.get(track.id);
|
|
678
|
+
if (!node) {
|
|
679
|
+
node = audioContext.createGain();
|
|
680
|
+
const vol = trackVolumes.get(track.id) ?? track.volume ?? 1;
|
|
681
|
+
const muted = trackMutedStates.get(track.id) ?? track.muted;
|
|
682
|
+
node.gain.value = muted ? 0 : vol;
|
|
683
|
+
trackVolumes.set(track.id, vol);
|
|
684
|
+
node.connect(masterGain);
|
|
685
|
+
trackGains.set(track.id, node);
|
|
686
|
+
}
|
|
687
|
+
return node;
|
|
688
|
+
}
|
|
689
|
+
function clearSchedulerTick() {
|
|
690
|
+
if (schedulerIntervalId !== null) {
|
|
691
|
+
clearInterval(schedulerIntervalId);
|
|
692
|
+
schedulerIntervalId = null;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function stopAllSources() {
|
|
696
|
+
for (const { source } of activeSources) {
|
|
697
|
+
try {
|
|
698
|
+
source.stop();
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
source.disconnect();
|
|
702
|
+
}
|
|
703
|
+
activeSources = [];
|
|
704
|
+
}
|
|
705
|
+
function isClipActiveAt(clip, fromTime) {
|
|
706
|
+
return clip.startTime <= fromTime && fromTime < clip.startTime + clip.duration;
|
|
707
|
+
}
|
|
708
|
+
function scheduleClip(clip, track, fromTime) {
|
|
709
|
+
const buffer = clipBuffers.get(clip.id);
|
|
710
|
+
if (!buffer) return;
|
|
711
|
+
const trackGain = getOrCreateTrackGain(track);
|
|
712
|
+
const clipGain = audioContext.createGain();
|
|
713
|
+
const clipVolume = clip.volume ?? 1;
|
|
714
|
+
clipGain.gain.value = clipVolume;
|
|
715
|
+
const source = audioContext.createBufferSource();
|
|
716
|
+
source.buffer = buffer;
|
|
717
|
+
source.playbackRate.value = _playbackRate;
|
|
718
|
+
source.connect(clipGain);
|
|
719
|
+
clipGain.connect(trackGain);
|
|
720
|
+
const now = audioContext.currentTime;
|
|
721
|
+
let contextStartTime;
|
|
722
|
+
let sourceOffset;
|
|
723
|
+
let remainingDuration;
|
|
724
|
+
if (clip.startTime > fromTime) {
|
|
725
|
+
const timeUntilClipStart = (clip.startTime - fromTime) / _playbackRate;
|
|
726
|
+
contextStartTime = now + timeUntilClipStart;
|
|
727
|
+
sourceOffset = clip.inPoint;
|
|
728
|
+
remainingDuration = clip.duration;
|
|
729
|
+
} else {
|
|
730
|
+
contextStartTime = 0;
|
|
731
|
+
const elapsed = fromTime - clip.startTime;
|
|
732
|
+
sourceOffset = clip.inPoint + elapsed;
|
|
733
|
+
remainingDuration = clip.duration - elapsed;
|
|
734
|
+
}
|
|
735
|
+
if (clip.fadeIn !== void 0 && clip.fadeIn > 0) {
|
|
736
|
+
const realFadeIn = clip.fadeIn / _playbackRate;
|
|
737
|
+
if (clip.startTime > fromTime) {
|
|
738
|
+
clipGain.gain.setValueAtTime(0, contextStartTime);
|
|
739
|
+
clipGain.gain.linearRampToValueAtTime(clipVolume, contextStartTime + realFadeIn);
|
|
740
|
+
} else {
|
|
741
|
+
const fadeInEnd = clip.startTime + clip.fadeIn;
|
|
742
|
+
if (fromTime < fadeInEnd) {
|
|
743
|
+
const fadeElapsed = fromTime - clip.startTime;
|
|
744
|
+
const currentLevel = fadeElapsed > 0 ? fadeElapsed / clip.fadeIn * clipVolume : 0;
|
|
745
|
+
const fadeRemaining = (clip.fadeIn - fadeElapsed) / _playbackRate;
|
|
746
|
+
clipGain.gain.setValueAtTime(currentLevel, now);
|
|
747
|
+
clipGain.gain.linearRampToValueAtTime(clipVolume, now + fadeRemaining);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (clip.fadeOut !== void 0 && clip.fadeOut > 0) {
|
|
752
|
+
const realFadeOut = clip.fadeOut / _playbackRate;
|
|
753
|
+
const realDuration = clip.duration / _playbackRate;
|
|
754
|
+
if (clip.startTime > fromTime) {
|
|
755
|
+
const fadeOutStart = contextStartTime + realDuration - realFadeOut;
|
|
756
|
+
clipGain.gain.setValueAtTime(clipVolume, fadeOutStart);
|
|
757
|
+
clipGain.gain.linearRampToValueAtTime(0, contextStartTime + realDuration);
|
|
758
|
+
} else {
|
|
759
|
+
const fadeOutStart = clip.startTime + clip.duration - clip.fadeOut;
|
|
760
|
+
const fadeOutContextTime = now + Math.max(0, (fadeOutStart - fromTime) / _playbackRate);
|
|
761
|
+
clipGain.gain.setValueAtTime(clipVolume, fadeOutContextTime);
|
|
762
|
+
clipGain.gain.linearRampToValueAtTime(0, fadeOutContextTime + realFadeOut);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
source.start(contextStartTime, sourceOffset, remainingDuration);
|
|
766
|
+
activeSources.push({ source, clipGain });
|
|
767
|
+
}
|
|
768
|
+
function isClipInLookAhead(clip, currentTime, lookAhead) {
|
|
769
|
+
const clipEnd = clip.startTime + clip.duration;
|
|
770
|
+
return clip.startTime > currentTime && clip.startTime <= currentTime + lookAhead && clipEnd > currentTime;
|
|
771
|
+
}
|
|
772
|
+
function scheduleComposition(fromTime, composition) {
|
|
773
|
+
for (const track of composition.tracks) {
|
|
774
|
+
if (track.type !== "audio" && track.type !== "video") continue;
|
|
775
|
+
if (track.muted) continue;
|
|
776
|
+
for (const clip of track.clips) {
|
|
777
|
+
if (!isClipActiveAt(clip, fromTime)) continue;
|
|
778
|
+
if (scheduledClipIds.has(clip.id)) continue;
|
|
779
|
+
scheduledClipIds.add(clip.id);
|
|
780
|
+
scheduleClip(clip, track, fromTime);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function startSchedulerTick(fromTime, composition) {
|
|
785
|
+
clearSchedulerTick();
|
|
786
|
+
currentComposition = composition;
|
|
787
|
+
const tickStartContextTime = audioContext.currentTime;
|
|
788
|
+
const tickStartFromTime = fromTime;
|
|
789
|
+
schedulerIntervalId = setInterval(() => {
|
|
790
|
+
if (!currentComposition) return;
|
|
791
|
+
let currentTimelineTime;
|
|
792
|
+
if (_getCurrentTime) {
|
|
793
|
+
currentTimelineTime = _getCurrentTime();
|
|
794
|
+
} else {
|
|
795
|
+
const elapsed = (audioContext.currentTime - tickStartContextTime) * _playbackRate;
|
|
796
|
+
currentTimelineTime = tickStartFromTime + elapsed;
|
|
797
|
+
}
|
|
798
|
+
if (currentTimelineTime < _prevTimelineTime) {
|
|
799
|
+
scheduledClipIds.clear();
|
|
800
|
+
stopAllSources();
|
|
801
|
+
}
|
|
802
|
+
_prevTimelineTime = currentTimelineTime;
|
|
803
|
+
for (const track of currentComposition.tracks) {
|
|
804
|
+
if (track.type !== "audio") continue;
|
|
805
|
+
if (track.muted) continue;
|
|
806
|
+
for (const clip of track.clips) {
|
|
807
|
+
if (scheduledClipIds.has(clip.id)) continue;
|
|
808
|
+
if (isClipInLookAhead(clip, currentTimelineTime, LOOK_AHEAD) || isClipActiveAt(clip, currentTimelineTime)) {
|
|
809
|
+
scheduledClipIds.add(clip.id);
|
|
810
|
+
scheduleClip(clip, track, currentTimelineTime);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}, 100);
|
|
815
|
+
}
|
|
816
|
+
const scheduler = {
|
|
817
|
+
get audioContext() {
|
|
818
|
+
return audioContext;
|
|
819
|
+
},
|
|
820
|
+
loadClip(clipId, audioBuffer) {
|
|
821
|
+
clipBuffers.set(clipId, audioBuffer);
|
|
822
|
+
},
|
|
823
|
+
play(fromTime, composition, getCurrentTime) {
|
|
824
|
+
stopAllSources();
|
|
825
|
+
clearSchedulerTick();
|
|
826
|
+
scheduledClipIds = /* @__PURE__ */ new Set();
|
|
827
|
+
_prevTimelineTime = fromTime;
|
|
828
|
+
_getCurrentTime = getCurrentTime ?? null;
|
|
829
|
+
_isPlaying = true;
|
|
830
|
+
currentComposition = composition;
|
|
831
|
+
scheduleComposition(fromTime, composition);
|
|
832
|
+
startSchedulerTick(fromTime, composition);
|
|
833
|
+
},
|
|
834
|
+
pause() {
|
|
835
|
+
clearSchedulerTick();
|
|
836
|
+
stopAllSources();
|
|
837
|
+
scheduledClipIds = /* @__PURE__ */ new Set();
|
|
838
|
+
_isPlaying = false;
|
|
839
|
+
},
|
|
840
|
+
seek(time, composition) {
|
|
841
|
+
clearSchedulerTick();
|
|
842
|
+
stopAllSources();
|
|
843
|
+
scheduledClipIds = /* @__PURE__ */ new Set();
|
|
844
|
+
_prevTimelineTime = time;
|
|
845
|
+
scheduleComposition(time, composition);
|
|
846
|
+
startSchedulerTick(time, composition);
|
|
847
|
+
},
|
|
848
|
+
setPlaybackRate(rate) {
|
|
849
|
+
_playbackRate = rate;
|
|
850
|
+
if (_isPlaying && currentComposition && _getCurrentTime) {
|
|
851
|
+
stopAllSources();
|
|
852
|
+
scheduledClipIds = /* @__PURE__ */ new Set();
|
|
853
|
+
const currentTime = _getCurrentTime();
|
|
854
|
+
scheduleComposition(currentTime, currentComposition);
|
|
855
|
+
clearSchedulerTick();
|
|
856
|
+
startSchedulerTick(currentTime, currentComposition);
|
|
857
|
+
} else {
|
|
858
|
+
for (const scheduled of activeSources) {
|
|
859
|
+
scheduled.source.playbackRate.value = rate;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
setTrackVolume(trackId, volume) {
|
|
864
|
+
trackVolumes.set(trackId, volume);
|
|
865
|
+
const node = trackGains.get(trackId);
|
|
866
|
+
if (node) {
|
|
867
|
+
const isMuted = trackMutedStates.get(trackId) ?? false;
|
|
868
|
+
if (!isMuted) {
|
|
869
|
+
node.gain.setValueAtTime(volume, audioContext.currentTime);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
setTrackMute(trackId, muted) {
|
|
874
|
+
trackMutedStates.set(trackId, muted);
|
|
875
|
+
const node = trackGains.get(trackId);
|
|
876
|
+
if (node) {
|
|
877
|
+
if (muted) {
|
|
878
|
+
node.gain.value = 0;
|
|
879
|
+
} else {
|
|
880
|
+
const vol = trackVolumes.get(trackId) ?? 1;
|
|
881
|
+
node.gain.value = vol;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
destroy() {
|
|
886
|
+
clearSchedulerTick();
|
|
887
|
+
stopAllSources();
|
|
888
|
+
scheduledClipIds = /* @__PURE__ */ new Set();
|
|
889
|
+
currentComposition = null;
|
|
890
|
+
_isPlaying = false;
|
|
891
|
+
for (const node of trackGains.values()) {
|
|
892
|
+
node.disconnect();
|
|
893
|
+
}
|
|
894
|
+
trackGains.clear();
|
|
895
|
+
trackVolumes.clear();
|
|
896
|
+
trackMutedStates.clear();
|
|
897
|
+
clipBuffers.clear();
|
|
898
|
+
masterGain.disconnect();
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
return scheduler;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/playback-engine.ts
|
|
905
|
+
var DEFAULT_DECODE_TIMEOUT_MS = 1e3;
|
|
906
|
+
var DEBUG_LOOP = typeof globalThis !== "undefined" && globalThis.__PNEUMA_CRAFT_PLAYBACK_DEBUG__ === true;
|
|
907
|
+
function createPlaybackEngine(options) {
|
|
908
|
+
const compositorType = options?.compositorType ?? "canvas2d";
|
|
909
|
+
const decodeTimeoutMs = options?.decodeTimeoutMs ?? DEFAULT_DECODE_TIMEOUT_MS;
|
|
910
|
+
const subtitleRenderer = options?.subtitleRenderer;
|
|
911
|
+
let _state = "idle";
|
|
912
|
+
let _playbackRate = 1;
|
|
913
|
+
let _loop = null;
|
|
914
|
+
let _decoder = null;
|
|
915
|
+
let _compositor = null;
|
|
916
|
+
let _frameRenderer = null;
|
|
917
|
+
let _clock = null;
|
|
918
|
+
let _audioScheduler = null;
|
|
919
|
+
let _audioContext = null;
|
|
920
|
+
let _composition = null;
|
|
921
|
+
let _rafId = null;
|
|
922
|
+
let _frameInFlight = false;
|
|
923
|
+
let _seekId = 0;
|
|
924
|
+
const stateChangeListeners = /* @__PURE__ */ new Set();
|
|
925
|
+
const timeUpdateListeners = /* @__PURE__ */ new Set();
|
|
926
|
+
const frameRenderedListeners = /* @__PURE__ */ new Set();
|
|
927
|
+
function setState(newState) {
|
|
928
|
+
if (_state === newState) return;
|
|
929
|
+
_state = newState;
|
|
930
|
+
for (const cb of stateChangeListeners) {
|
|
931
|
+
try {
|
|
932
|
+
cb(newState);
|
|
933
|
+
} catch (e) {
|
|
934
|
+
console.error("[PlaybackEngine] listener error:", e);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function emitTimeUpdate(time) {
|
|
939
|
+
for (const cb of timeUpdateListeners) {
|
|
940
|
+
try {
|
|
941
|
+
cb(time);
|
|
942
|
+
} catch (e) {
|
|
943
|
+
console.error("[PlaybackEngine] listener error:", e);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function emitFrameRendered(frame) {
|
|
948
|
+
for (const cb of frameRenderedListeners) {
|
|
949
|
+
try {
|
|
950
|
+
cb(frame);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
console.error("[PlaybackEngine] listener error:", e);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function destroySubsystems() {
|
|
957
|
+
if (_rafId !== null) {
|
|
958
|
+
cancelAnimationFrame(_rafId);
|
|
959
|
+
_rafId = null;
|
|
960
|
+
}
|
|
961
|
+
_frameInFlight = false;
|
|
962
|
+
_frameRenderer?.destroy();
|
|
963
|
+
_clock?.destroy();
|
|
964
|
+
_audioScheduler?.destroy();
|
|
965
|
+
_audioContext?.close();
|
|
966
|
+
_decoder = null;
|
|
967
|
+
_compositor = null;
|
|
968
|
+
_frameRenderer = null;
|
|
969
|
+
_clock = null;
|
|
970
|
+
_audioScheduler = null;
|
|
971
|
+
_audioContext = null;
|
|
972
|
+
_composition = null;
|
|
973
|
+
}
|
|
974
|
+
function startRafLoop() {
|
|
975
|
+
if (_rafId !== null) return;
|
|
976
|
+
let lastLoggedState = null;
|
|
977
|
+
const logStateChange = (state, time, extra) => {
|
|
978
|
+
if (!DEBUG_LOOP) return;
|
|
979
|
+
if (lastLoggedState === state) return;
|
|
980
|
+
lastLoggedState = state;
|
|
981
|
+
console.log(`[engine] ${state} @ t=${time.toFixed(3)}${extra ? " " + extra : ""}`);
|
|
982
|
+
};
|
|
983
|
+
const loop = () => {
|
|
984
|
+
_rafId = null;
|
|
985
|
+
if (_state !== "playing" || !_clock || !_frameRenderer || !_composition) return;
|
|
986
|
+
const time = _clock.currentTime;
|
|
987
|
+
emitTimeUpdate(time);
|
|
988
|
+
if (!_clock.loop && time >= _composition.duration) {
|
|
989
|
+
engine.pause();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
_rafId = requestAnimationFrame(loop);
|
|
993
|
+
if (_frameInFlight) {
|
|
994
|
+
logStateChange("skip-inflight", time);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
logStateChange("render", time);
|
|
998
|
+
_frameInFlight = true;
|
|
999
|
+
const clockRef = _clock;
|
|
1000
|
+
const compositionRef = _composition;
|
|
1001
|
+
let timedOut = false;
|
|
1002
|
+
const timeoutId = setTimeout(() => {
|
|
1003
|
+
timedOut = true;
|
|
1004
|
+
_frameInFlight = false;
|
|
1005
|
+
console.warn(
|
|
1006
|
+
`[PlaybackEngine] decode timeout at t=${time.toFixed(3)}s (>${decodeTimeoutMs}ms) \u2014 skipping frame`
|
|
1007
|
+
);
|
|
1008
|
+
}, decodeTimeoutMs);
|
|
1009
|
+
_frameRenderer.renderFrame(compositionRef, time).then((frame) => {
|
|
1010
|
+
clearTimeout(timeoutId);
|
|
1011
|
+
if (timedOut) return;
|
|
1012
|
+
_frameInFlight = false;
|
|
1013
|
+
if (_state !== "playing" || _clock !== clockRef) return;
|
|
1014
|
+
clockRef.reportVideoTime(time);
|
|
1015
|
+
emitFrameRendered(frame);
|
|
1016
|
+
}).catch((err) => {
|
|
1017
|
+
clearTimeout(timeoutId);
|
|
1018
|
+
if (timedOut) return;
|
|
1019
|
+
_frameInFlight = false;
|
|
1020
|
+
console.error("[PlaybackEngine] render error:", err);
|
|
1021
|
+
});
|
|
1022
|
+
};
|
|
1023
|
+
_rafId = requestAnimationFrame(loop);
|
|
1024
|
+
}
|
|
1025
|
+
function stopRafLoop() {
|
|
1026
|
+
if (_rafId !== null) {
|
|
1027
|
+
cancelAnimationFrame(_rafId);
|
|
1028
|
+
_rafId = null;
|
|
1029
|
+
}
|
|
1030
|
+
_frameInFlight = false;
|
|
1031
|
+
}
|
|
1032
|
+
const engine = {
|
|
1033
|
+
get state() {
|
|
1034
|
+
return _state;
|
|
1035
|
+
},
|
|
1036
|
+
get currentTime() {
|
|
1037
|
+
return _clock?.currentTime ?? 0;
|
|
1038
|
+
},
|
|
1039
|
+
get playbackRate() {
|
|
1040
|
+
return _clock?.playbackRate ?? _playbackRate;
|
|
1041
|
+
},
|
|
1042
|
+
set playbackRate(rate) {
|
|
1043
|
+
_playbackRate = rate;
|
|
1044
|
+
if (_clock) {
|
|
1045
|
+
_clock.playbackRate = rate;
|
|
1046
|
+
}
|
|
1047
|
+
if (_audioScheduler) {
|
|
1048
|
+
_audioScheduler.setPlaybackRate(rate);
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
get loop() {
|
|
1052
|
+
return _clock?.loop ?? _loop;
|
|
1053
|
+
},
|
|
1054
|
+
set loop(l) {
|
|
1055
|
+
_loop = l;
|
|
1056
|
+
if (_clock) {
|
|
1057
|
+
_clock.loop = l;
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
async load(composition, resolver) {
|
|
1061
|
+
_seekId++;
|
|
1062
|
+
if (_state !== "idle") {
|
|
1063
|
+
destroySubsystems();
|
|
1064
|
+
}
|
|
1065
|
+
setState("loading");
|
|
1066
|
+
try {
|
|
1067
|
+
_audioContext = new AudioContext();
|
|
1068
|
+
_decoder = createMediaDecoder(resolver, _audioContext);
|
|
1069
|
+
_compositor = await createCompositor(
|
|
1070
|
+
composition.settings.width,
|
|
1071
|
+
composition.settings.height,
|
|
1072
|
+
compositorType
|
|
1073
|
+
);
|
|
1074
|
+
_frameRenderer = createFrameRenderer(
|
|
1075
|
+
_decoder,
|
|
1076
|
+
_compositor,
|
|
1077
|
+
composition.settings.width,
|
|
1078
|
+
composition.settings.height,
|
|
1079
|
+
subtitleRenderer
|
|
1080
|
+
);
|
|
1081
|
+
_clock = createMasterClock({
|
|
1082
|
+
audioContext: _audioContext,
|
|
1083
|
+
duration: composition.duration,
|
|
1084
|
+
frameRate: composition.settings.fps
|
|
1085
|
+
});
|
|
1086
|
+
_audioScheduler = createAudioScheduler({ audioContext: _audioContext });
|
|
1087
|
+
_clock.playbackRate = _playbackRate;
|
|
1088
|
+
_clock.loop = _loop;
|
|
1089
|
+
_composition = composition;
|
|
1090
|
+
const mediaClips = composition.tracks.filter((track) => track.type === "audio" || track.type === "video").flatMap((track) => track.clips);
|
|
1091
|
+
for (const clip of mediaClips) {
|
|
1092
|
+
try {
|
|
1093
|
+
const audioBuffer = await _decoder.decodeAudio(clip.assetId);
|
|
1094
|
+
_audioScheduler.loadClip(clip.id, audioBuffer);
|
|
1095
|
+
} catch {
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
setState("ready");
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
destroySubsystems();
|
|
1101
|
+
setState("idle");
|
|
1102
|
+
throw err;
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
play() {
|
|
1106
|
+
if (!_clock || !_audioScheduler || !_composition) {
|
|
1107
|
+
throw new Error("Cannot play: no composition loaded. Call load() first.");
|
|
1108
|
+
}
|
|
1109
|
+
if (_state === "playing") return;
|
|
1110
|
+
if (_audioContext && _audioContext.state === "suspended") {
|
|
1111
|
+
_audioContext.resume().catch((err) => {
|
|
1112
|
+
console.warn("[PlaybackEngine] AudioContext resume failed:", err);
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
_clock.play();
|
|
1116
|
+
_audioScheduler.setPlaybackRate(_playbackRate);
|
|
1117
|
+
const clockRef = _clock;
|
|
1118
|
+
_audioScheduler.play(_clock.currentTime, _composition, () => clockRef.currentTime);
|
|
1119
|
+
setState("playing");
|
|
1120
|
+
startRafLoop();
|
|
1121
|
+
},
|
|
1122
|
+
pause() {
|
|
1123
|
+
if (_state !== "playing") return;
|
|
1124
|
+
stopRafLoop();
|
|
1125
|
+
_clock?.pause();
|
|
1126
|
+
_audioScheduler?.pause();
|
|
1127
|
+
setState("paused");
|
|
1128
|
+
},
|
|
1129
|
+
seek(time) {
|
|
1130
|
+
if (!_clock || !_audioScheduler || !_composition) {
|
|
1131
|
+
throw new Error("Cannot seek: no composition loaded. Call load() first.");
|
|
1132
|
+
}
|
|
1133
|
+
_clock.seek(time);
|
|
1134
|
+
const clampedTime = _clock.currentTime;
|
|
1135
|
+
if (_state === "playing") {
|
|
1136
|
+
_audioScheduler.setPlaybackRate(_playbackRate);
|
|
1137
|
+
_audioScheduler.seek(clampedTime, _composition);
|
|
1138
|
+
}
|
|
1139
|
+
if (_state !== "playing" && _frameRenderer) {
|
|
1140
|
+
const thisSeekId = ++_seekId;
|
|
1141
|
+
_frameRenderer.renderFrame(_composition, clampedTime).then((frame) => {
|
|
1142
|
+
if (thisSeekId !== _seekId) return;
|
|
1143
|
+
emitFrameRendered(frame);
|
|
1144
|
+
emitTimeUpdate(clampedTime);
|
|
1145
|
+
}).catch((err) => {
|
|
1146
|
+
console.error("[PlaybackEngine] seek render error:", err);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
onStateChange(cb) {
|
|
1151
|
+
stateChangeListeners.add(cb);
|
|
1152
|
+
return () => {
|
|
1153
|
+
stateChangeListeners.delete(cb);
|
|
1154
|
+
};
|
|
1155
|
+
},
|
|
1156
|
+
onTimeUpdate(cb) {
|
|
1157
|
+
timeUpdateListeners.add(cb);
|
|
1158
|
+
return () => {
|
|
1159
|
+
timeUpdateListeners.delete(cb);
|
|
1160
|
+
};
|
|
1161
|
+
},
|
|
1162
|
+
onFrameRendered(cb) {
|
|
1163
|
+
frameRenderedListeners.add(cb);
|
|
1164
|
+
return () => {
|
|
1165
|
+
frameRenderedListeners.delete(cb);
|
|
1166
|
+
};
|
|
1167
|
+
},
|
|
1168
|
+
destroy() {
|
|
1169
|
+
stopRafLoop();
|
|
1170
|
+
destroySubsystems();
|
|
1171
|
+
stateChangeListeners.clear();
|
|
1172
|
+
timeUpdateListeners.clear();
|
|
1173
|
+
frameRenderedListeners.clear();
|
|
1174
|
+
_playbackRate = 1;
|
|
1175
|
+
_loop = null;
|
|
1176
|
+
_state = "idle";
|
|
1177
|
+
}
|
|
1178
|
+
};
|
|
1179
|
+
return engine;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/offline-audio-renderer.ts
|
|
1183
|
+
function createOfflineAudioRenderer() {
|
|
1184
|
+
return {
|
|
1185
|
+
async render(composition, _resolver, decodeAudio) {
|
|
1186
|
+
const sampleRate = composition.settings.sampleRate ?? 48e3;
|
|
1187
|
+
const channels = 2;
|
|
1188
|
+
const duration = composition.duration;
|
|
1189
|
+
const length = Math.ceil(duration * sampleRate);
|
|
1190
|
+
const offlineCtx = new OfflineAudioContext(channels, length, sampleRate);
|
|
1191
|
+
const audioClips = [];
|
|
1192
|
+
for (const track of composition.tracks) {
|
|
1193
|
+
if (track.type !== "audio" && track.type !== "video") continue;
|
|
1194
|
+
if (track.muted) continue;
|
|
1195
|
+
for (const clip of track.clips) {
|
|
1196
|
+
audioClips.push({ clip, track });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
for (const { clip, track } of audioClips) {
|
|
1200
|
+
let buffer;
|
|
1201
|
+
try {
|
|
1202
|
+
buffer = await decodeAudio(clip.assetId);
|
|
1203
|
+
} catch {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const source = offlineCtx.createBufferSource();
|
|
1207
|
+
source.buffer = buffer;
|
|
1208
|
+
const clipGain = offlineCtx.createGain();
|
|
1209
|
+
const clipVolume = clip.volume ?? 1;
|
|
1210
|
+
clipGain.gain.value = clipVolume;
|
|
1211
|
+
const trackGain = offlineCtx.createGain();
|
|
1212
|
+
trackGain.gain.value = track.volume;
|
|
1213
|
+
source.connect(clipGain);
|
|
1214
|
+
clipGain.connect(trackGain);
|
|
1215
|
+
trackGain.connect(offlineCtx.destination);
|
|
1216
|
+
const fadeIn = clip.fadeIn;
|
|
1217
|
+
const fadeOut = clip.fadeOut;
|
|
1218
|
+
if (fadeIn && fadeIn > 0) {
|
|
1219
|
+
clipGain.gain.setValueAtTime(0, clip.startTime);
|
|
1220
|
+
clipGain.gain.linearRampToValueAtTime(clipVolume, clip.startTime + fadeIn);
|
|
1221
|
+
}
|
|
1222
|
+
if (fadeOut && fadeOut > 0) {
|
|
1223
|
+
const fadeOutStart = clip.startTime + clip.duration - fadeOut;
|
|
1224
|
+
clipGain.gain.setValueAtTime(clipVolume, fadeOutStart);
|
|
1225
|
+
clipGain.gain.linearRampToValueAtTime(0, clip.startTime + clip.duration);
|
|
1226
|
+
}
|
|
1227
|
+
source.start(clip.startTime, clip.inPoint, clip.duration);
|
|
1228
|
+
}
|
|
1229
|
+
return offlineCtx.startRendering();
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/export-engine.ts
|
|
1235
|
+
var import_mediabunny2 = require("mediabunny");
|
|
1236
|
+
function createExportEngine(options) {
|
|
1237
|
+
const subtitleRenderer = options?.subtitleRenderer;
|
|
1238
|
+
const progressListeners = /* @__PURE__ */ new Set();
|
|
1239
|
+
let abortController = null;
|
|
1240
|
+
function notifyProgress(progress) {
|
|
1241
|
+
for (const cb of progressListeners) {
|
|
1242
|
+
try {
|
|
1243
|
+
cb(progress);
|
|
1244
|
+
} catch (e) {
|
|
1245
|
+
console.error("[ExportEngine]", e);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return {
|
|
1250
|
+
async export(composition, options2, resolver) {
|
|
1251
|
+
abortController = new AbortController();
|
|
1252
|
+
const signal = abortController.signal;
|
|
1253
|
+
const fps = options2.fps ?? composition.settings.fps;
|
|
1254
|
+
const width = options2.width ?? composition.settings.width;
|
|
1255
|
+
const height = options2.height ?? composition.settings.height;
|
|
1256
|
+
const totalFrames = Math.ceil(composition.duration * fps);
|
|
1257
|
+
const decoderAudioCtx = new OfflineAudioContext(1, 1, composition.settings.sampleRate || 48e3);
|
|
1258
|
+
const decoder = createMediaDecoder(resolver, decoderAudioCtx);
|
|
1259
|
+
const compositor = await createCompositor(width, height, "canvas2d");
|
|
1260
|
+
const renderer = createFrameRenderer(decoder, compositor, width, height, subtitleRenderer);
|
|
1261
|
+
try {
|
|
1262
|
+
const format = options2.format === "webm" ? new import_mediabunny2.WebMOutputFormat() : new import_mediabunny2.Mp4OutputFormat({ fastStart: "in-memory" });
|
|
1263
|
+
const target = new import_mediabunny2.BufferTarget();
|
|
1264
|
+
const output = new import_mediabunny2.Output({ format, target });
|
|
1265
|
+
const renderCanvas = new OffscreenCanvas(width, height);
|
|
1266
|
+
const videoSource = new import_mediabunny2.CanvasSource(renderCanvas, {
|
|
1267
|
+
codec: options2.videoCodec,
|
|
1268
|
+
bitrate: options2.videoBitrate
|
|
1269
|
+
});
|
|
1270
|
+
const audioSource = new import_mediabunny2.AudioBufferSource({
|
|
1271
|
+
codec: options2.audioCodec,
|
|
1272
|
+
bitrate: options2.audioBitrate
|
|
1273
|
+
});
|
|
1274
|
+
output.addVideoTrack(videoSource);
|
|
1275
|
+
output.addAudioTrack(audioSource);
|
|
1276
|
+
await output.start();
|
|
1277
|
+
const renderCtx = renderCanvas.getContext("2d");
|
|
1278
|
+
for (let frame = 0; frame < totalFrames; frame++) {
|
|
1279
|
+
if (signal.aborted) throw new Error("Export aborted");
|
|
1280
|
+
const time = frame / fps;
|
|
1281
|
+
const rendered = await renderer.renderFrame(composition, time);
|
|
1282
|
+
renderCtx.clearRect(0, 0, width, height);
|
|
1283
|
+
renderCtx.drawImage(rendered.image, 0, 0, width, height);
|
|
1284
|
+
rendered.image.close();
|
|
1285
|
+
await videoSource.add(time, 1 / fps);
|
|
1286
|
+
if ((frame + 1) % 5 === 0 || frame === totalFrames - 1) {
|
|
1287
|
+
notifyProgress((frame + 1) / totalFrames);
|
|
1288
|
+
}
|
|
1289
|
+
if ((frame + 1) % 5 === 0) {
|
|
1290
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
videoSource.close();
|
|
1294
|
+
if (signal.aborted) throw new Error("Export aborted");
|
|
1295
|
+
const offlineRenderer = createOfflineAudioRenderer();
|
|
1296
|
+
const audioBuffer = await offlineRenderer.render(
|
|
1297
|
+
composition,
|
|
1298
|
+
resolver,
|
|
1299
|
+
(assetId) => decoder.decodeAudio(assetId)
|
|
1300
|
+
);
|
|
1301
|
+
if (signal.aborted) throw new Error("Export aborted");
|
|
1302
|
+
await audioSource.add(audioBuffer);
|
|
1303
|
+
audioSource.close();
|
|
1304
|
+
if (signal.aborted) throw new Error("Export aborted");
|
|
1305
|
+
await output.finalize();
|
|
1306
|
+
const buffer = target.buffer;
|
|
1307
|
+
if (!buffer) throw new Error("Export produced no output");
|
|
1308
|
+
const mimeType = options2.format === "webm" ? "video/webm" : "video/mp4";
|
|
1309
|
+
return new Blob([buffer], { type: mimeType });
|
|
1310
|
+
} finally {
|
|
1311
|
+
renderer.destroy();
|
|
1312
|
+
abortController = null;
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
onProgress(cb) {
|
|
1316
|
+
progressListeners.add(cb);
|
|
1317
|
+
return () => {
|
|
1318
|
+
progressListeners.delete(cb);
|
|
1319
|
+
};
|
|
1320
|
+
},
|
|
1321
|
+
abort() {
|
|
1322
|
+
abortController?.abort();
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1327
|
+
0 && (module.exports = {
|
|
1328
|
+
createAudioScheduler,
|
|
1329
|
+
createCanvas2DCompositor,
|
|
1330
|
+
createCompositor,
|
|
1331
|
+
createExportEngine,
|
|
1332
|
+
createFrameRenderer,
|
|
1333
|
+
createGPUCompositor,
|
|
1334
|
+
createMasterClock,
|
|
1335
|
+
createMediaDecoder,
|
|
1336
|
+
createOfflineAudioRenderer,
|
|
1337
|
+
createPlaybackEngine
|
|
1338
|
+
});
|
|
1339
|
+
//# sourceMappingURL=index.cjs.map
|