@grundyjs/canvas-take 0.4.1 → 0.5.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 +11 -0
- package/README.md +85 -6
- package/dist/aac-PROVENANCE.md +171 -0
- package/dist/aac-licenses/COPYING.LGPLv2.1 +502 -0
- package/dist/aac-licenses/LICENSE.md +127 -0
- package/dist/aac-licenses/MPL-2.0.txt +373 -0
- package/dist/aac-licenses/bridge.c +191 -0
- package/dist/gif.d.ts +6 -0
- package/dist/gif.js +16 -2
- package/dist/index.d.ts +8 -1
- package/dist/index.js +90 -6
- package/dist/mediabunny-LICENSE.txt +373 -0
- package/dist/video-aac-worker.d.ts +1 -0
- package/dist/video-aac-worker.js +3356 -0
- package/dist/video-aac-worker.js.map +7 -0
- package/dist/video-aac.d.ts +8 -0
- package/dist/video-aac.js +174 -0
- package/dist/video-mp4.d.ts +3 -0
- package/dist/video-mp4.js +38 -0
- package/dist/video-mp4.js.map +7 -0
- package/dist/video-webcodecs.d.ts +3 -0
- package/dist/video-webcodecs.js +149 -0
- package/dist/video.d.ts +215 -0
- package/dist/video.js +503 -0
- package/docs/drafts/offline-video-api.d.ts +2 -0
- package/docs/drafts/offline-video-example.ts +38 -0
- package/docs/drafts/tsconfig.json +12 -0
- package/docs/gif-benchmark.md +129 -0
- package/docs/gif-encoder.md +4 -0
- package/docs/offline-aac.md +87 -0
- package/docs/offline-video-adapters.md +84 -0
- package/docs/offline-video-api.md +154 -0
- package/docs/video-recording.md +55 -0
- package/package.json +42 -6
- package/ru/README.md +156 -8
package/dist/video.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
function deferred() {
|
|
2
|
+
let resolve, reject;
|
|
3
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
4
|
+
void promise.catch(() => { });
|
|
5
|
+
return { promise, resolve, reject };
|
|
6
|
+
}
|
|
7
|
+
function integer(value, name, minimum = 0) {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < minimum)
|
|
9
|
+
throw new RangeError(`${name} must be a safe integer >= ${minimum}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function text(value, name) {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim())
|
|
14
|
+
throw new TypeError(`${name} must be a nonempty string`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function rounded(value, multiplier, divisor) {
|
|
18
|
+
const n = (BigInt(value) * BigInt(multiplier) * 2n + BigInt(divisor)) / (BigInt(divisor) * 2n);
|
|
19
|
+
return integer(Number(n), 'sample/timestamp arithmetic');
|
|
20
|
+
}
|
|
21
|
+
function snapshot(options) {
|
|
22
|
+
const limits = {
|
|
23
|
+
maxFramePixels: options.maxFramePixels, maxFrames: options.maxFrames, maxDurationUs: options.maxDurationUs,
|
|
24
|
+
maxAudioInputBytes: options.maxAudioInputBytes, maxOutputBytes: options.maxOutputBytes,
|
|
25
|
+
maxPendingInputs: options.maxPendingInputs === undefined ? 4 : options.maxPendingInputs,
|
|
26
|
+
maxPendingBytes: options.maxPendingBytes === undefined ? 16 * 1024 * 1024 : options.maxPendingBytes
|
|
27
|
+
};
|
|
28
|
+
for (const [name, value] of Object.entries(limits))
|
|
29
|
+
if (value !== undefined)
|
|
30
|
+
integer(value, name, 1);
|
|
31
|
+
if (options.totalFrames !== undefined)
|
|
32
|
+
integer(options.totalFrames, 'totalFrames', 1);
|
|
33
|
+
const v = options.video.config;
|
|
34
|
+
const videoConfig = Object.freeze({ codec: text(v.codec, 'video.codec'), bitstreamFormat: text(v.bitstreamFormat, 'video.bitstreamFormat'),
|
|
35
|
+
width: integer(v.width, 'width', 1), height: integer(v.height, 'height', 1), bitrate: integer(v.bitrate, 'video.bitrate', 1),
|
|
36
|
+
...(v.frameRateHint === undefined ? {} : { frameRateHint: Object.freeze({ numerator: integer(v.frameRateHint.numerator, 'frameRateHint.numerator', 1), denominator: integer(v.frameRateHint.denominator, 'frameRateHint.denominator', 1) }) }) });
|
|
37
|
+
integer(v.width * v.height * 4, 'frame byte size', 1);
|
|
38
|
+
const background = [...(options.background ?? [255, 255, 255])];
|
|
39
|
+
if (background.length !== 3 || background.some(n => !Number.isInteger(n) || n < 0 || n > 255))
|
|
40
|
+
throw new RangeError('background must contain three RGB bytes');
|
|
41
|
+
const format = Object.freeze({ mimeType: text(options.muxer.format.mimeType, 'mimeType').trim().toLowerCase(), extension: text(options.muxer.format.extension, 'extension') });
|
|
42
|
+
if (!format.mimeType.startsWith('video/'))
|
|
43
|
+
throw new TypeError('muxer MIME must be video/*');
|
|
44
|
+
let audioConfig, planes = [], sourceLength = 0;
|
|
45
|
+
const a = options.audio;
|
|
46
|
+
if (a) {
|
|
47
|
+
if (a.endPolicy !== 'error' && a.endPolicy !== 'trim-or-pad')
|
|
48
|
+
throw new TypeError('audio.endPolicy must be error or trim-or-pad');
|
|
49
|
+
audioConfig = Object.freeze({ codec: text(a.config.codec, 'audio.codec'), bitstreamFormat: text(a.config.bitstreamFormat, 'audio.bitstreamFormat'),
|
|
50
|
+
sampleRate: integer(a.config.sampleRate, 'sampleRate', 1), numberOfChannels: integer(a.config.numberOfChannels, 'numberOfChannels', 1), bitrate: integer(a.config.bitrate, 'audio.bitrate', 1) });
|
|
51
|
+
if (a.source.sampleRate !== audioConfig.sampleRate || a.source.channels.length !== audioConfig.numberOfChannels)
|
|
52
|
+
throw new RangeError('PCM configuration mismatch');
|
|
53
|
+
sourceLength = integer(a.source.channels[0]?.length, 'PCM sample count', 1);
|
|
54
|
+
const bytes = integer(sourceLength * audioConfig.numberOfChannels * 4, 'PCM byte size', 1);
|
|
55
|
+
if (limits.maxAudioInputBytes !== undefined && bytes > limits.maxAudioInputBytes)
|
|
56
|
+
throw new RangeError('maxAudioInputBytes exceeded');
|
|
57
|
+
for (const plane of a.source.channels) {
|
|
58
|
+
if (!(plane instanceof Float32Array) || !(plane.buffer instanceof ArrayBuffer) || plane.length !== sourceLength)
|
|
59
|
+
throw new TypeError('PCM channels must be equal-length nonshared Float32Arrays');
|
|
60
|
+
if (plane.some(sample => !Number.isFinite(sample) || sample < -1 || sample > 1))
|
|
61
|
+
throw new RangeError('PCM samples must be finite in [-1, 1]');
|
|
62
|
+
}
|
|
63
|
+
if (audioConfig.numberOfChannels * 4 > limits.maxPendingBytes)
|
|
64
|
+
throw new RangeError('maxPendingBytes cannot hold one audio sample');
|
|
65
|
+
planes = a.source.channels.map(plane => plane.slice());
|
|
66
|
+
}
|
|
67
|
+
return { limits: Object.freeze(limits), videoConfig, audioConfig, planes, sourceLength, background,
|
|
68
|
+
videoFactory: options.video.encoder, audioFactory: a?.encoder, audioPolicy: a?.endPolicy,
|
|
69
|
+
muxerFactory: options.muxer, format, signal: options.signal, onProgress: options.onProgress, totalFrames: options.totalFrames };
|
|
70
|
+
}
|
|
71
|
+
/** Offline timeline controller. Encoders and container implementation are always supplied. */
|
|
72
|
+
export async function createOfflineVideoExporter(options) {
|
|
73
|
+
if (options.signal?.aborted)
|
|
74
|
+
throw options.signal.reason ?? new DOMException('Export aborted', 'AbortError');
|
|
75
|
+
const cfg = snapshot(options); // Includes the PCM copy, before the first await.
|
|
76
|
+
const controller = new AbortController(), cancelled = deferred();
|
|
77
|
+
let state = 'initializing', failure;
|
|
78
|
+
let muxer, video, audio;
|
|
79
|
+
const owned = new Set(), disposed = new WeakSet();
|
|
80
|
+
let frames = 0, submittedFrames = 0, duration = 0, samples = 0, videoPackets = 0, audioPackets = 0, encodedBytes = 0;
|
|
81
|
+
let videoSeen = 0, videoOutputEnd = 0, audioOutputStart, audioOutputEnd;
|
|
82
|
+
let queuedBytes = 0, draining = false, inFlight;
|
|
83
|
+
let finishPromise;
|
|
84
|
+
const expectedVideo = new Map();
|
|
85
|
+
const queue = [], idle = new Set();
|
|
86
|
+
const tracks = { video: { config: undefined, ended: false, flushing: false },
|
|
87
|
+
audio: { config: undefined, ended: false, flushing: false } };
|
|
88
|
+
const terminal = () => ['finished', 'failed', 'aborted', 'disposed'].includes(state);
|
|
89
|
+
const guard = () => { if (terminal())
|
|
90
|
+
throw failure ?? new Error(`Offline video exporter is ${state}`); };
|
|
91
|
+
const wait = async (promise) => { const value = await Promise.race([Promise.resolve(promise), cancelled.promise]); guard(); return value; };
|
|
92
|
+
function report(phase, outputBytes) {
|
|
93
|
+
try {
|
|
94
|
+
cfg.onProgress?.(Object.freeze({ phase, framesAccepted: frames, durationUs: duration, audioSamplesAccepted: samples,
|
|
95
|
+
videoPacketsWritten: videoPackets, audioPacketsWritten: audioPackets, encodedBytes,
|
|
96
|
+
...(cfg.totalFrames === undefined ? {} : { totalFrames: cfg.totalFrames }), ...(outputBytes === undefined ? {} : { outputBytes }) }));
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
}
|
|
100
|
+
function release(instance) {
|
|
101
|
+
if (disposed.has(instance))
|
|
102
|
+
return;
|
|
103
|
+
disposed.add(instance);
|
|
104
|
+
try {
|
|
105
|
+
instance.dispose();
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
function cleanup() {
|
|
110
|
+
cfg.signal?.removeEventListener('abort', onAbort);
|
|
111
|
+
for (const instance of owned)
|
|
112
|
+
release(instance);
|
|
113
|
+
owned.clear();
|
|
114
|
+
cfg.planes.length = 0;
|
|
115
|
+
expectedVideo.clear();
|
|
116
|
+
tracks.video.config = tracks.audio.config = undefined;
|
|
117
|
+
muxer = undefined;
|
|
118
|
+
video = undefined;
|
|
119
|
+
audio = undefined;
|
|
120
|
+
}
|
|
121
|
+
function end(next, error) {
|
|
122
|
+
if (terminal())
|
|
123
|
+
return;
|
|
124
|
+
state = next;
|
|
125
|
+
failure = error;
|
|
126
|
+
cancelled.reject(error);
|
|
127
|
+
controller.abort(error);
|
|
128
|
+
inFlight?.done.reject(error);
|
|
129
|
+
for (const item of queue.splice(0))
|
|
130
|
+
item.done.reject(error);
|
|
131
|
+
for (const pending of idle)
|
|
132
|
+
pending.reject(error);
|
|
133
|
+
idle.clear();
|
|
134
|
+
queuedBytes = 0;
|
|
135
|
+
cleanup();
|
|
136
|
+
report(next);
|
|
137
|
+
}
|
|
138
|
+
function onAbort() { end('aborted', cfg.signal?.reason ?? new DOMException('Export aborted', 'AbortError')); }
|
|
139
|
+
const protocol = (message) => new Error(`Offline video protocol: ${message}`);
|
|
140
|
+
function wrap(component, error) { return new Error(`${component} failed`, { cause: error }); }
|
|
141
|
+
async function invoke(component, work) {
|
|
142
|
+
try {
|
|
143
|
+
guard();
|
|
144
|
+
return await wait(work());
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (terminal())
|
|
148
|
+
throw failure ?? error;
|
|
149
|
+
throw wrap(component, error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function acquire(component, methods, create) {
|
|
153
|
+
guard();
|
|
154
|
+
return invoke(component, () => Promise.resolve().then(() => { guard(); return create(); }).then(instance => {
|
|
155
|
+
if (terminal()) {
|
|
156
|
+
release(instance);
|
|
157
|
+
return instance;
|
|
158
|
+
}
|
|
159
|
+
if (!instance || typeof instance.dispose !== 'function' || owned.has(instance) || disposed.has(instance))
|
|
160
|
+
throw protocol('factory must return a fresh disposable instance');
|
|
161
|
+
owned.add(instance);
|
|
162
|
+
if (methods.some(name => typeof instance[name] !== 'function'))
|
|
163
|
+
throw protocol(`${component} returned missing methods`);
|
|
164
|
+
return instance;
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
function sameConfig(a, b) {
|
|
168
|
+
const { description: ad, ...ar } = a, { description: bd, ...br } = b;
|
|
169
|
+
return JSON.stringify(ar) === JSON.stringify(br) && (ad === undefined ? bd === undefined : bd !== undefined && ad.length === bd.length && ad.every((n, i) => n === bd[i]));
|
|
170
|
+
}
|
|
171
|
+
function cloneEvent(track, event) {
|
|
172
|
+
const t = tracks[track];
|
|
173
|
+
const actualTrack = event.type === 'packet' ? event.packet.track : event.track;
|
|
174
|
+
if (track !== actualTrack || t.ended)
|
|
175
|
+
throw protocol('wrong track or output after end');
|
|
176
|
+
if (event.type === 'config') {
|
|
177
|
+
const c = event.config, expected = track === 'video' ? cfg.videoConfig : cfg.audioConfig;
|
|
178
|
+
if (c.codec !== expected.codec || c.bitstreamFormat !== expected.bitstreamFormat)
|
|
179
|
+
throw protocol('codec/framing mismatch');
|
|
180
|
+
let copy;
|
|
181
|
+
if (track === 'video') {
|
|
182
|
+
const v = c;
|
|
183
|
+
if (v.width !== cfg.videoConfig.width || v.height !== cfg.videoConfig.height || !v.color ||
|
|
184
|
+
[v.color.primaries, v.color.transfer, v.color.matrix].some(value => typeof value !== 'string' || !value) || typeof v.color.fullRange !== 'boolean')
|
|
185
|
+
throw protocol('video description mismatch');
|
|
186
|
+
copy = { type: 'config', track: 'video', config: { codec: c.codec, bitstreamFormat: c.bitstreamFormat,
|
|
187
|
+
width: v.width, height: v.height, color: Object.freeze({ primaries: v.color.primaries, transfer: v.color.transfer, matrix: v.color.matrix, fullRange: v.color.fullRange }), ...(c.description === undefined ? {} : { description: c.description.slice() }) } };
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const a = c;
|
|
191
|
+
if (a.sampleRate !== cfg.audioConfig.sampleRate || a.numberOfChannels !== cfg.audioConfig.numberOfChannels)
|
|
192
|
+
throw protocol('audio description mismatch');
|
|
193
|
+
copy = { type: 'config', track: 'audio', config: { codec: c.codec, bitstreamFormat: c.bitstreamFormat,
|
|
194
|
+
sampleRate: a.sampleRate, numberOfChannels: a.numberOfChannels, ...(c.description === undefined ? {} : { description: c.description.slice() }) } };
|
|
195
|
+
}
|
|
196
|
+
if (c.description !== undefined && (!(c.description instanceof Uint8Array) || !(c.description.buffer instanceof ArrayBuffer)))
|
|
197
|
+
throw protocol('invalid decoder description bytes');
|
|
198
|
+
if (t.config && !sameConfig(t.config, copy.config))
|
|
199
|
+
throw protocol('midstream configuration change');
|
|
200
|
+
t.config = copy.config;
|
|
201
|
+
return { event: copy, bytes: copy.config.description?.byteLength ?? 0 };
|
|
202
|
+
}
|
|
203
|
+
if (!t.config)
|
|
204
|
+
throw protocol('output before decoder config');
|
|
205
|
+
if (event.type === 'packet') {
|
|
206
|
+
const p = event.packet;
|
|
207
|
+
if (!Number.isSafeInteger(p.timestampUs) || !Number.isSafeInteger(p.decodeTimestampUs) || p.decodeTimestampUs !== p.timestampUs)
|
|
208
|
+
throw protocol('invalid/reordered timestamps');
|
|
209
|
+
integer(p.durationUs, 'packet durationUs', 1);
|
|
210
|
+
integer(p.timestampUs + p.durationUs, 'packet end', -Number.MAX_SAFE_INTEGER);
|
|
211
|
+
if (!(p.data instanceof Uint8Array) || !(p.data.buffer instanceof ArrayBuffer) || !p.data.byteLength || !['key', 'delta'].includes(p.type))
|
|
212
|
+
throw protocol('invalid packet');
|
|
213
|
+
if (track === 'video') {
|
|
214
|
+
if (p.timestampUs !== videoOutputEnd || expectedVideo.get(p.timestampUs) !== p.durationUs || (!videoSeen && p.type !== 'key'))
|
|
215
|
+
throw protocol('missing, duplicated, reordered or non-key first video frame');
|
|
216
|
+
expectedVideo.delete(p.timestampUs);
|
|
217
|
+
videoOutputEnd += p.durationUs;
|
|
218
|
+
videoSeen++;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
if (audioOutputEnd !== undefined && p.timestampUs !== audioOutputEnd)
|
|
222
|
+
throw protocol('noncontiguous audio packets');
|
|
223
|
+
audioOutputStart ??= p.timestampUs;
|
|
224
|
+
audioOutputEnd = p.timestampUs + p.durationUs;
|
|
225
|
+
}
|
|
226
|
+
return { event: { type: 'packet', packet: Object.freeze({ ...p, data: p.data.slice() }) }, bytes: p.data.byteLength };
|
|
227
|
+
}
|
|
228
|
+
if (event.type !== 'end' || !t.flushing)
|
|
229
|
+
throw protocol('track ended outside flush');
|
|
230
|
+
if (track === 'video') {
|
|
231
|
+
if (expectedVideo.size || videoSeen !== submittedFrames)
|
|
232
|
+
throw protocol('video frames missing at end');
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
if (event.track !== 'audio')
|
|
236
|
+
throw protocol('invalid audio end');
|
|
237
|
+
integer(event.presentationSamples, 'presentationSamples');
|
|
238
|
+
integer(event.leadingDiscardSamples, 'leadingDiscardSamples');
|
|
239
|
+
integer(event.trailingDiscardSamples, 'trailingDiscardSamples');
|
|
240
|
+
if (event.presentationSamples !== samples)
|
|
241
|
+
throw protocol('audio presentation count mismatch');
|
|
242
|
+
const endSamples = integer(samples + event.trailingDiscardSamples, 'audio sample end');
|
|
243
|
+
integer(endSamples + event.leadingDiscardSamples, 'audio total samples');
|
|
244
|
+
const first = -rounded(event.leadingDiscardSamples, 1_000_000, cfg.audioConfig.sampleRate);
|
|
245
|
+
const last = rounded(endSamples, 1_000_000, cfg.audioConfig.sampleRate);
|
|
246
|
+
if (audioOutputStart === undefined || audioOutputEnd === undefined || Math.abs(audioOutputStart - first) > 1 || Math.abs(audioOutputEnd - last) > 1)
|
|
247
|
+
throw protocol('audio packets do not match declared trimming');
|
|
248
|
+
}
|
|
249
|
+
t.ended = true;
|
|
250
|
+
return { event: Object.freeze({ ...event }), bytes: 0 };
|
|
251
|
+
}
|
|
252
|
+
async function drain() {
|
|
253
|
+
if (draining)
|
|
254
|
+
return;
|
|
255
|
+
draining = true;
|
|
256
|
+
try {
|
|
257
|
+
while (queue.length && !terminal()) {
|
|
258
|
+
const item = inFlight = queue.shift();
|
|
259
|
+
try {
|
|
260
|
+
await wait(muxer.write(item.event));
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (!terminal())
|
|
264
|
+
end('failed', wrap('muxer.write', error));
|
|
265
|
+
item.done.reject(failure);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
queuedBytes -= item.cost;
|
|
269
|
+
if (item.event.type === 'packet') {
|
|
270
|
+
if (item.event.packet.track === 'video')
|
|
271
|
+
videoPackets++;
|
|
272
|
+
else
|
|
273
|
+
audioPackets++;
|
|
274
|
+
encodedBytes += item.bytes;
|
|
275
|
+
}
|
|
276
|
+
item.done.resolve();
|
|
277
|
+
inFlight = undefined;
|
|
278
|
+
report(state === 'initializing' ? 'initializing' : state === 'finalizing' ? 'finalizing' : 'encoding');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
draining = false;
|
|
283
|
+
inFlight = undefined;
|
|
284
|
+
if (!queue.length) {
|
|
285
|
+
for (const pending of idle)
|
|
286
|
+
pending.resolve();
|
|
287
|
+
idle.clear();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function sink(track) {
|
|
292
|
+
return Object.freeze({
|
|
293
|
+
write(event) {
|
|
294
|
+
const done = deferred();
|
|
295
|
+
try {
|
|
296
|
+
guard();
|
|
297
|
+
const bytes = event.type === 'packet' ? event.packet.data.byteLength : event.type === 'config' ? event.config.description?.byteLength ?? 0 : 0;
|
|
298
|
+
const cost = bytes + 64; // Reserve metadata space even for empty config/end events.
|
|
299
|
+
if (cost > cfg.limits.maxPendingBytes - queuedBytes)
|
|
300
|
+
throw new RangeError('maxPendingBytes exceeded');
|
|
301
|
+
const item = cloneEvent(track, event);
|
|
302
|
+
queuedBytes += cost;
|
|
303
|
+
queue.push({ ...item, cost, done });
|
|
304
|
+
void drain();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (!terminal())
|
|
308
|
+
end('failed', error);
|
|
309
|
+
done.reject(failure ?? error);
|
|
310
|
+
}
|
|
311
|
+
return done.promise;
|
|
312
|
+
},
|
|
313
|
+
fail(error) { if (!terminal())
|
|
314
|
+
end('failed', wrap(`${track} encoder`, error)); }
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function queueIdle() {
|
|
318
|
+
if (!draining && !queue.length)
|
|
319
|
+
return;
|
|
320
|
+
const pending = deferred();
|
|
321
|
+
idle.add(pending);
|
|
322
|
+
await wait(pending.promise);
|
|
323
|
+
}
|
|
324
|
+
async function feedAudio(target) {
|
|
325
|
+
if (!audio || !cfg.audioConfig)
|
|
326
|
+
return;
|
|
327
|
+
const channels = cfg.audioConfig.numberOfChannels, rate = cfg.audioConfig.sampleRate;
|
|
328
|
+
const blockSize = Math.min(2048, Math.floor(cfg.limits.maxPendingBytes / (channels * 4)));
|
|
329
|
+
let blocks = 0;
|
|
330
|
+
while (samples < target) {
|
|
331
|
+
guard();
|
|
332
|
+
const size = Math.min(blockSize, target - samples), offset = samples;
|
|
333
|
+
const channelsData = cfg.planes.map(plane => {
|
|
334
|
+
const data = new Float32Array(size);
|
|
335
|
+
if (offset < cfg.sourceLength)
|
|
336
|
+
data.set(plane.subarray(offset, Math.min(offset + size, cfg.sourceLength)));
|
|
337
|
+
return data;
|
|
338
|
+
});
|
|
339
|
+
await invoke('audio encoder.encodeAudio', () => audio.encodeAudio(Object.freeze({ sampleRate: rate, sampleOffset: offset, timestampUs: rounded(offset, 1_000_000, rate), channels: channelsData })));
|
|
340
|
+
samples += size;
|
|
341
|
+
if (++blocks % 8 === 0)
|
|
342
|
+
await wait(new Promise(resolve => setTimeout(resolve, 0)));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
cfg.signal?.addEventListener('abort', onAbort, { once: true });
|
|
346
|
+
if (cfg.signal?.aborted)
|
|
347
|
+
onAbort();
|
|
348
|
+
try {
|
|
349
|
+
guard();
|
|
350
|
+
report('initializing');
|
|
351
|
+
guard();
|
|
352
|
+
const muxerConfig = Object.freeze({ video: cfg.videoConfig, ...(cfg.audioConfig ? { audio: cfg.audioConfig } : {}) });
|
|
353
|
+
for (const [name, check] of [
|
|
354
|
+
['video', () => cfg.videoFactory.checkSupport(cfg.videoConfig)],
|
|
355
|
+
...(cfg.audioConfig ? [['audio', () => cfg.audioFactory.checkSupport(cfg.audioConfig)]] : []),
|
|
356
|
+
['muxer', () => cfg.muxerFactory.checkSupport(muxerConfig)]
|
|
357
|
+
]) {
|
|
358
|
+
const support = await invoke(`${name}.checkSupport`, check);
|
|
359
|
+
if (!support || typeof support.supported !== 'boolean' || (!support.supported && typeof support.reason !== 'string'))
|
|
360
|
+
throw protocol(`${name} returned invalid support information`);
|
|
361
|
+
if (!support.supported)
|
|
362
|
+
throw new DOMException(`${name} unsupported: ${support.reason}`, 'NotSupportedError');
|
|
363
|
+
}
|
|
364
|
+
const context = Object.freeze({ signal: controller.signal, maxPendingInputs: cfg.limits.maxPendingInputs, maxPendingBytes: cfg.limits.maxPendingBytes });
|
|
365
|
+
muxer = await acquire('muxer.create', ['write', 'finalize'], () => cfg.muxerFactory.create(muxerConfig, Object.freeze({ ...context, ...(cfg.limits.maxOutputBytes === undefined ? {} : { maxOutputBytes: cfg.limits.maxOutputBytes }) })));
|
|
366
|
+
video = await acquire('video encoder.create', ['encodeFrame', 'flush'], () => cfg.videoFactory.create(cfg.videoConfig, Object.freeze({ ...context, output: sink('video') })));
|
|
367
|
+
if (cfg.audioConfig)
|
|
368
|
+
audio = await acquire('audio encoder.create', ['encodeAudio', 'flush'], () => cfg.audioFactory.create(cfg.audioConfig, Object.freeze({ ...context, output: sink('audio') })));
|
|
369
|
+
await queueIdle();
|
|
370
|
+
guard();
|
|
371
|
+
state = 'ready';
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (!terminal())
|
|
375
|
+
end('failed', error);
|
|
376
|
+
throw failure ?? error;
|
|
377
|
+
}
|
|
378
|
+
return Object.freeze({
|
|
379
|
+
get state() { return state; },
|
|
380
|
+
addFrame(canvas, timing) {
|
|
381
|
+
if (state !== 'ready')
|
|
382
|
+
return Promise.reject(failure ?? new Error(`Cannot addFrame in ${state} state`));
|
|
383
|
+
state = 'encoding';
|
|
384
|
+
return (async () => {
|
|
385
|
+
try {
|
|
386
|
+
const timestampUs = integer(timing.timestampUs, 'timestampUs'), frameDuration = integer(timing.durationUs, 'durationUs', 1);
|
|
387
|
+
const endUs = integer(timestampUs + frameDuration, 'frame end');
|
|
388
|
+
if (timestampUs !== duration)
|
|
389
|
+
throw new RangeError('Frame timeline must start at zero and remain contiguous');
|
|
390
|
+
if (timing.keyFrame !== undefined && typeof timing.keyFrame !== 'boolean')
|
|
391
|
+
throw new TypeError('keyFrame must be boolean');
|
|
392
|
+
const keyFrame = timing.keyFrame === true;
|
|
393
|
+
if (canvas.width !== cfg.videoConfig.width || canvas.height !== cfg.videoConfig.height)
|
|
394
|
+
throw new RangeError('Canvas dimensions must match video configuration');
|
|
395
|
+
const pixels = cfg.videoConfig.width * cfg.videoConfig.height;
|
|
396
|
+
if (cfg.limits.maxFramePixels !== undefined && pixels > cfg.limits.maxFramePixels)
|
|
397
|
+
throw new RangeError('maxFramePixels exceeded');
|
|
398
|
+
if (cfg.limits.maxFrames !== undefined && frames >= cfg.limits.maxFrames)
|
|
399
|
+
throw new RangeError('maxFrames exceeded');
|
|
400
|
+
if (cfg.limits.maxDurationUs !== undefined && endUs > cfg.limits.maxDurationUs)
|
|
401
|
+
throw new RangeError('maxDurationUs exceeded');
|
|
402
|
+
if (expectedVideo.size >= cfg.limits.maxPendingInputs)
|
|
403
|
+
throw new RangeError('maxPendingInputs video look-ahead exceeded');
|
|
404
|
+
if (pixels * 4 > cfg.limits.maxPendingBytes)
|
|
405
|
+
throw new RangeError('maxPendingBytes cannot hold the frame snapshot');
|
|
406
|
+
const audioTarget = cfg.audioConfig ? rounded(endUs, cfg.audioConfig.sampleRate, 1_000_000) : 0;
|
|
407
|
+
if (cfg.audioPolicy === 'error' && audioTarget > cfg.sourceLength)
|
|
408
|
+
throw new RangeError('Prepared audio is shorter than the video timeline');
|
|
409
|
+
const ctx = canvas.getContext('2d');
|
|
410
|
+
if (!ctx)
|
|
411
|
+
throw new TypeError('Offline video requires a 2D canvas');
|
|
412
|
+
const data = new Uint8ClampedArray(ctx.getImageData(0, 0, canvas.width, canvas.height, { colorSpace: 'srgb' }).data);
|
|
413
|
+
if (data.length !== pixels * 4)
|
|
414
|
+
throw new RangeError('Invalid frame pixel data');
|
|
415
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
416
|
+
if (data[i + 3] === 255)
|
|
417
|
+
continue;
|
|
418
|
+
const alpha = data[i + 3] / 255;
|
|
419
|
+
for (let c = 0; c < 3; c++)
|
|
420
|
+
data[i + c] = Math.round(data[i + c] * alpha + cfg.background[c] * (1 - alpha));
|
|
421
|
+
data[i + 3] = 255;
|
|
422
|
+
}
|
|
423
|
+
await wait(new Promise(resolve => setTimeout(resolve, 0)));
|
|
424
|
+
expectedVideo.set(timestampUs, frameDuration);
|
|
425
|
+
submittedFrames++;
|
|
426
|
+
await invoke('video encoder.encodeFrame', () => video.encodeFrame(Object.freeze({ width: cfg.videoConfig.width, height: cfg.videoConfig.height, data,
|
|
427
|
+
timestampUs, durationUs: frameDuration, keyFrame: frames === 0 || keyFrame })));
|
|
428
|
+
await feedAudio(audioTarget);
|
|
429
|
+
guard();
|
|
430
|
+
frames++;
|
|
431
|
+
duration = endUs;
|
|
432
|
+
report('encoding');
|
|
433
|
+
guard();
|
|
434
|
+
state = 'ready';
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (!terminal())
|
|
438
|
+
end('failed', error);
|
|
439
|
+
throw failure ?? error;
|
|
440
|
+
}
|
|
441
|
+
})();
|
|
442
|
+
},
|
|
443
|
+
finish() {
|
|
444
|
+
if (state === 'finished' || state === 'finalizing')
|
|
445
|
+
return finishPromise;
|
|
446
|
+
if (state !== 'ready')
|
|
447
|
+
return Promise.reject(failure ?? new Error(`Cannot finish in ${state} state`));
|
|
448
|
+
state = 'finalizing';
|
|
449
|
+
const completion = deferred();
|
|
450
|
+
finishPromise = completion.promise;
|
|
451
|
+
void (async () => {
|
|
452
|
+
try {
|
|
453
|
+
if (!frames)
|
|
454
|
+
throw new RangeError('Offline video requires at least one frame');
|
|
455
|
+
if (cfg.audioConfig && !samples)
|
|
456
|
+
throw new RangeError('Audio presentation requires at least one sample');
|
|
457
|
+
if (cfg.audioPolicy === 'error' && samples !== cfg.sourceLength)
|
|
458
|
+
throw new RangeError('Prepared audio is longer than the video timeline');
|
|
459
|
+
report('finalizing');
|
|
460
|
+
guard();
|
|
461
|
+
tracks.video.flushing = true;
|
|
462
|
+
if (audio)
|
|
463
|
+
tracks.audio.flushing = true;
|
|
464
|
+
const encoders = [video, ...(audio ? [audio] : [])];
|
|
465
|
+
await wait(Promise.all(encoders.map((encoder, i) => Promise.resolve().then(() => invoke(i === 0 ? 'video encoder.flush' : 'audio encoder.flush', () => encoder.flush())))));
|
|
466
|
+
await queueIdle();
|
|
467
|
+
guard();
|
|
468
|
+
if (!tracks.video.ended || (cfg.audioConfig && !tracks.audio.ended))
|
|
469
|
+
throw protocol('flush completed without track end');
|
|
470
|
+
const timeline = Object.freeze({ durationUs: duration, videoFrames: frames, ...(cfg.audioConfig ? { audioPresentationSamples: samples } : {}) });
|
|
471
|
+
const blob = await invoke('muxer.finalize', () => muxer.finalize(timeline));
|
|
472
|
+
if (!(blob instanceof Blob) || !blob.size || blob.type !== cfg.format.mimeType)
|
|
473
|
+
throw protocol('muxer returned an empty or mismatched container');
|
|
474
|
+
if (cfg.limits.maxOutputBytes !== undefined && blob.size > cfg.limits.maxOutputBytes)
|
|
475
|
+
throw new RangeError('maxOutputBytes exceeded');
|
|
476
|
+
const result = Object.freeze({ ...timeline, blob, ...cfg.format, videoCodec: cfg.videoConfig.codec,
|
|
477
|
+
...(cfg.audioConfig ? { audioCodec: cfg.audioConfig.codec } : {}) });
|
|
478
|
+
state = 'finished';
|
|
479
|
+
cleanup();
|
|
480
|
+
report('finished', blob.size);
|
|
481
|
+
return result;
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (!terminal())
|
|
485
|
+
end('failed', error);
|
|
486
|
+
throw failure ?? error;
|
|
487
|
+
}
|
|
488
|
+
})().then(completion.resolve, completion.reject);
|
|
489
|
+
return finishPromise;
|
|
490
|
+
},
|
|
491
|
+
dispose() {
|
|
492
|
+
if (state === 'disposed')
|
|
493
|
+
return;
|
|
494
|
+
if (!terminal())
|
|
495
|
+
end('disposed', new DOMException('Offline video disposed', 'AbortError'));
|
|
496
|
+
else {
|
|
497
|
+
state = 'disposed';
|
|
498
|
+
controller.abort(new DOMException('Offline video disposed', 'AbortError'));
|
|
499
|
+
cleanup();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Compile-only usage example; the caller supplies the canvas, renderer and prepared narration.
|
|
2
|
+
import { createOfflineVideoExporter } from '@grundyjs/canvas-take/video';
|
|
3
|
+
import type { PreparedAudio } from '@grundyjs/canvas-take/video';
|
|
4
|
+
import { createWebCodecsVideoEncoder } from '@grundyjs/canvas-take/video/webcodecs';
|
|
5
|
+
import { createAacAudioEncoder } from '@grundyjs/canvas-take/video/aac';
|
|
6
|
+
import { createMp4Muxer } from '@grundyjs/canvas-take/video/mp4';
|
|
7
|
+
|
|
8
|
+
const videoEncoder = createWebCodecsVideoEncoder();
|
|
9
|
+
const audioEncoder = createAacAudioEncoder();
|
|
10
|
+
const mp4Muxer = createMp4Muxer();
|
|
11
|
+
declare const canvas: HTMLCanvasElement;
|
|
12
|
+
declare const narration: PreparedAudio;
|
|
13
|
+
declare function drawFrame(index: number): void;
|
|
14
|
+
|
|
15
|
+
export async function exportShort(signal: AbortSignal) {
|
|
16
|
+
const exporter = await createOfflineVideoExporter({
|
|
17
|
+
video: { encoder: videoEncoder, config: {
|
|
18
|
+
codec: 'avc1.42E028', bitstreamFormat: 'avc', width: 1080, height: 1920,
|
|
19
|
+
bitrate: 8_000_000, frameRateHint: { numerator: 30, denominator: 1 }
|
|
20
|
+
} },
|
|
21
|
+
audio: { encoder: audioEncoder, config: {
|
|
22
|
+
codec: 'mp4a.40.2', bitstreamFormat: 'aac', sampleRate: narration.sampleRate,
|
|
23
|
+
numberOfChannels: narration.channels.length, bitrate: 192_000
|
|
24
|
+
}, source: narration, endPolicy: 'trim-or-pad' },
|
|
25
|
+
muxer: mp4Muxer, signal, totalFrames: 300,
|
|
26
|
+
maxFramePixels: 1080 * 1920, maxFrames: 300, maxDurationUs: 10_000_000,
|
|
27
|
+
onProgress(progress) { console.log(progress.phase, progress.framesAccepted, progress.durationUs); }
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
// Absolute rounded boundaries prevent accumulated 1/30-second rounding error.
|
|
31
|
+
const boundary = (index: number) => Math.round(index * 1_000_000 / 30);
|
|
32
|
+
for (let i = 0; i < 300; i++) {
|
|
33
|
+
drawFrame(i);
|
|
34
|
+
await exporter.addFrame(canvas, { timestampUs: boundary(i), durationUs: boundary(i + 1) - boundary(i) });
|
|
35
|
+
}
|
|
36
|
+
return await exporter.finish();
|
|
37
|
+
} finally { exporter.dispose(); }
|
|
38
|
+
}
|