@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.
@@ -0,0 +1,3 @@
1
+ import type { VideoEncoderFactory } from './video.js';
2
+ /** First profile: H.264 Baseline, AVC framing, quality mode, one drained native input at a time. */
3
+ export declare function createWebCodecsVideoEncoder(): VideoEncoderFactory;
@@ -0,0 +1,149 @@
1
+ /** First profile: H.264 Baseline, AVC framing, quality mode, one drained native input at a time. */
2
+ export function createWebCodecsVideoEncoder() {
3
+ function nativeConfig(config) {
4
+ if (!/^avc1\.42[0-9a-f]{4}$/i.test(config.codec) || config.bitstreamFormat !== 'avc') {
5
+ throw new DOMException('WebCodecs adapter requires H.264 Baseline (avc1.42xxxx) with avc framing', 'NotSupportedError');
6
+ }
7
+ if (![config.width, config.height, config.bitrate].every(n => Number.isSafeInteger(n) && n > 0) || config.width % 2 || config.height % 2) {
8
+ throw new RangeError('H.264 requires positive even dimensions and a positive integer bitrate');
9
+ }
10
+ return { codec: config.codec, width: config.width, height: config.height, bitrate: config.bitrate,
11
+ latencyMode: 'quality', avc: { format: 'avc' },
12
+ ...(config.frameRateHint ? { framerate: config.frameRateHint.numerator / config.frameRateHint.denominator } : {}) };
13
+ }
14
+ return {
15
+ async checkSupport(config) {
16
+ try {
17
+ const native = nativeConfig(config);
18
+ if (typeof VideoEncoder === 'undefined' || typeof VideoFrame === 'undefined')
19
+ return { supported: false, reason: 'WebCodecs VideoEncoder/VideoFrame unavailable (requires a secure context)' };
20
+ const support = await VideoEncoder.isConfigSupported(native);
21
+ return support.supported ? { supported: true } : { supported: false, reason: `WebCodecs does not support ${config.codec} at ${config.width}x${config.height}` };
22
+ }
23
+ catch (error) {
24
+ return { supported: false, reason: error instanceof Error ? error.message : String(error) };
25
+ }
26
+ },
27
+ create(config, context) {
28
+ const native = nativeConfig(config);
29
+ let encoder, disposed = false, ended = false, error;
30
+ let pending;
31
+ let tail = Promise.resolve(), bytes = 0, events = 0, description;
32
+ let rejectFailure;
33
+ const failed = new Promise((_, reject) => { rejectFailure = reject; });
34
+ void failed.catch(() => { });
35
+ const guard = () => {
36
+ if (error !== undefined)
37
+ throw error;
38
+ if (disposed || context.signal.aborted)
39
+ throw context.signal.reason ?? new DOMException('Video encoder disposed', 'AbortError');
40
+ };
41
+ function fail(reason) {
42
+ if (disposed || error !== undefined)
43
+ return;
44
+ error = reason instanceof Error ? reason : new Error('WebCodecs encoder failed', { cause: reason });
45
+ rejectFailure(error);
46
+ context.output.fail(error);
47
+ }
48
+ const wait = async (work) => { const value = await Promise.race([work, failed]); guard(); return value; };
49
+ function enqueue(event, cost) {
50
+ if (cost > context.maxPendingBytes - bytes || events >= context.maxPendingInputs + 1)
51
+ throw new RangeError('WebCodecs maxPendingBytes/maxPendingInputs exceeded');
52
+ bytes += cost;
53
+ events++;
54
+ tail = tail.then(() => { guard(); return context.output.write(event); }).then(() => { bytes -= cost; events--; });
55
+ void tail.catch(fail);
56
+ }
57
+ function abort() { dispose(); }
58
+ function dispose() {
59
+ if (disposed)
60
+ return;
61
+ disposed = true;
62
+ context.signal.removeEventListener('abort', abort);
63
+ rejectFailure(context.signal.reason ?? new DOMException('Video encoder disposed', 'AbortError'));
64
+ if (encoder && encoder.state !== 'closed')
65
+ encoder.close();
66
+ encoder = undefined;
67
+ pending = undefined;
68
+ description = undefined;
69
+ }
70
+ try {
71
+ guard();
72
+ encoder = new VideoEncoder({ error: fail, output(chunk, metadata) {
73
+ if (disposed || error !== undefined)
74
+ return;
75
+ try {
76
+ guard();
77
+ if (!pending || chunk.timestamp !== pending.timestampUs || (pending.keyFrame && chunk.type !== 'key'))
78
+ throw new Error('WebCodecs dropped, reordered or changed a requested key frame');
79
+ // Baseline forbids B pictures. Also verify the actual avcC profile, not only callback order.
80
+ if (metadata?.decoderConfig) {
81
+ const c = metadata.decoderConfig, color = c.colorSpace;
82
+ const d = c.description;
83
+ const avcc = d ? (ArrayBuffer.isView(d) ? new Uint8Array(d.buffer, d.byteOffset, d.byteLength) : new Uint8Array(d)).slice() : undefined;
84
+ if (!avcc || avcc.length < 7 || avcc[0] !== 1 || avcc[1] !== 66 || c.codec !== config.codec || c.codedWidth !== config.width || c.codedHeight !== config.height)
85
+ throw new Error('WebCodecs returned incompatible AVC Baseline decoder configuration');
86
+ if (color?.primaries !== 'bt709' || !['bt709', 'iec61966-2-1'].includes(color.transfer ?? '') || color.matrix !== 'bt709' || typeof color.fullRange !== 'boolean')
87
+ throw new Error('WebCodecs returned unsupported or missing SDR color metadata');
88
+ description = { codec: config.codec, bitstreamFormat: 'avc', width: config.width, height: config.height, description: avcc,
89
+ color: { primaries: color.primaries, transfer: color.transfer, matrix: color.matrix, fullRange: color.fullRange } };
90
+ enqueue({ type: 'config', track: 'video', config: description }, avcc.byteLength + 64);
91
+ }
92
+ if (!description)
93
+ throw new Error('WebCodecs did not provide an AVC decoder description');
94
+ if (chunk.byteLength + 64 > context.maxPendingBytes - bytes)
95
+ throw new RangeError('WebCodecs maxPendingBytes exceeded');
96
+ const data = new Uint8Array(chunk.byteLength);
97
+ chunk.copyTo(data);
98
+ // Duration comes from the submitted interval, including a long final hold.
99
+ enqueue({ type: 'packet', packet: { track: 'video', timestampUs: pending.timestampUs, decodeTimestampUs: pending.timestampUs,
100
+ durationUs: pending.durationUs, type: chunk.type, data } }, data.byteLength + 64);
101
+ pending = undefined;
102
+ }
103
+ catch (reason) {
104
+ fail(reason);
105
+ }
106
+ } });
107
+ encoder.configure(native);
108
+ context.signal.addEventListener('abort', abort, { once: true });
109
+ guard();
110
+ }
111
+ catch (reason) {
112
+ dispose();
113
+ throw reason;
114
+ }
115
+ return {
116
+ async encodeFrame(frame) {
117
+ guard();
118
+ if (ended || pending)
119
+ throw new Error('Video encoder is ended or busy');
120
+ pending = { timestampUs: frame.timestampUs, durationUs: frame.durationUs, keyFrame: frame.keyFrame === true };
121
+ let input;
122
+ try {
123
+ input = new VideoFrame(frame.data, { format: 'RGBA', codedWidth: config.width, codedHeight: config.height,
124
+ timestamp: frame.timestampUs, duration: frame.durationUs,
125
+ colorSpace: { primaries: 'bt709', transfer: 'iec61966-2-1', matrix: 'rgb', fullRange: true } });
126
+ encoder.encode(input, { keyFrame: frame.keyFrame });
127
+ }
128
+ finally {
129
+ input?.close();
130
+ }
131
+ await wait(encoder.flush());
132
+ if (pending)
133
+ throw new Error('WebCodecs did not output the submitted frame');
134
+ await wait(tail);
135
+ },
136
+ async flush() {
137
+ guard();
138
+ if (ended)
139
+ throw new Error('Video encoder already ended');
140
+ ended = true;
141
+ await wait(encoder.flush());
142
+ await wait(tail);
143
+ await wait(context.output.write({ type: 'end', track: 'video' }));
144
+ },
145
+ dispose
146
+ };
147
+ }
148
+ };
149
+ }
@@ -0,0 +1,215 @@
1
+ /** Adapter-neutral offline video contracts. Concrete codecs and containers are supplied by the caller. */
2
+ export type Awaitable<T> = T | Promise<T>;
3
+ export type Support = {
4
+ readonly supported: true;
5
+ } | {
6
+ readonly supported: false;
7
+ readonly reason: string;
8
+ };
9
+ export type Track = 'video' | 'audio';
10
+ export interface VideoEncodeConfig {
11
+ readonly codec: string;
12
+ readonly bitstreamFormat: string;
13
+ readonly width: number;
14
+ readonly height: number;
15
+ readonly bitrate: number;
16
+ /** Encoder hint only. Timing always comes from submitted frame intervals. */
17
+ readonly frameRateHint?: {
18
+ readonly numerator: number;
19
+ readonly denominator: number;
20
+ };
21
+ }
22
+ export interface AudioEncodeConfig {
23
+ readonly codec: string;
24
+ readonly bitstreamFormat: string;
25
+ readonly sampleRate: number;
26
+ readonly numberOfChannels: number;
27
+ readonly bitrate: number;
28
+ }
29
+ export interface FrameTiming {
30
+ readonly timestampUs: number;
31
+ readonly durationUs: number;
32
+ readonly keyFrame?: boolean;
33
+ }
34
+ export interface RawVideoFrame extends FrameTiming {
35
+ readonly width: number;
36
+ readonly height: number;
37
+ /** Exclusively owned opaque sRGB RGBA8 snapshot. */
38
+ readonly data: Uint8ClampedArray;
39
+ }
40
+ export interface PreparedAudio {
41
+ readonly sampleRate: number;
42
+ /** Equal-length planar float32 channels, copied before asynchronous initialization. */
43
+ readonly channels: readonly Float32Array[];
44
+ }
45
+ export interface RawAudioBlock {
46
+ readonly sampleRate: number;
47
+ readonly sampleOffset: number;
48
+ readonly timestampUs: number;
49
+ /** Exclusively owned block buffers; never aliases the retained source. */
50
+ readonly channels: readonly Float32Array[];
51
+ }
52
+ export interface DecoderDescription {
53
+ readonly codec: string;
54
+ readonly bitstreamFormat: string;
55
+ readonly description?: Uint8Array;
56
+ }
57
+ export interface VideoDescription extends DecoderDescription {
58
+ readonly width: number;
59
+ readonly height: number;
60
+ readonly color: {
61
+ readonly primaries: string;
62
+ readonly transfer: string;
63
+ readonly matrix: string;
64
+ readonly fullRange: boolean;
65
+ };
66
+ }
67
+ export interface AudioDescription extends DecoderDescription {
68
+ readonly sampleRate: number;
69
+ readonly numberOfChannels: number;
70
+ }
71
+ export interface EncodedPacket {
72
+ readonly track: Track;
73
+ /** Signed codec timestamps; presentation after trimming starts at zero. */
74
+ readonly timestampUs: number;
75
+ readonly decodeTimestampUs: number;
76
+ readonly durationUs: number;
77
+ readonly type: 'key' | 'delta';
78
+ readonly data: Uint8Array;
79
+ }
80
+ export type CodecEvent = {
81
+ readonly type: 'config';
82
+ readonly track: 'video';
83
+ readonly config: VideoDescription;
84
+ } | {
85
+ readonly type: 'config';
86
+ readonly track: 'audio';
87
+ readonly config: AudioDescription;
88
+ } | {
89
+ readonly type: 'packet';
90
+ readonly packet: EncodedPacket;
91
+ } | {
92
+ readonly type: 'end';
93
+ readonly track: 'video';
94
+ } | {
95
+ readonly type: 'end';
96
+ readonly track: 'audio';
97
+ readonly presentationSamples: number;
98
+ readonly leadingDiscardSamples: number;
99
+ readonly trailingDiscardSamples: number;
100
+ };
101
+ export interface CodecOutputSink {
102
+ /** Ordered per track. Promise settles after muxer consumption; bytes are borrowed until then. */
103
+ write(event: CodecEvent): Promise<void>;
104
+ /** Report a delayed callback/worker failure even when no encode call is pending. */
105
+ fail(error: unknown): void;
106
+ }
107
+ export interface SessionContext {
108
+ readonly signal: AbortSignal;
109
+ /** Adapter input bound; also caps video intervals awaiting packets in the controller. */
110
+ readonly maxPendingInputs: number;
111
+ /** Work-byte budget; the controller reserves 64 additional bytes per queued event. */
112
+ readonly maxPendingBytes: number;
113
+ }
114
+ export interface EncoderContext extends SessionContext {
115
+ readonly output: CodecOutputSink;
116
+ }
117
+ export interface MuxerContext extends SessionContext {
118
+ readonly maxOutputBytes?: number;
119
+ }
120
+ export interface OfflineVideoEncoder {
121
+ encodeFrame(frame: RawVideoFrame): Promise<void>;
122
+ /** Emits end after all delayed packets and waits for all output writes. */
123
+ flush(): Promise<void>;
124
+ dispose(): void;
125
+ }
126
+ export interface OfflineAudioEncoder {
127
+ encodeAudio(block: RawAudioBlock): Promise<void>;
128
+ flush(): Promise<void>;
129
+ dispose(): void;
130
+ }
131
+ export interface VideoEncoderFactory {
132
+ checkSupport(config: VideoEncodeConfig): Promise<Support>;
133
+ create(config: VideoEncodeConfig, context: EncoderContext): Awaitable<OfflineVideoEncoder>;
134
+ }
135
+ export interface AudioEncoderFactory {
136
+ checkSupport(config: AudioEncodeConfig): Promise<Support>;
137
+ create(config: AudioEncodeConfig, context: EncoderContext): Awaitable<OfflineAudioEncoder>;
138
+ }
139
+ export interface MuxerConfig {
140
+ readonly video: VideoEncodeConfig;
141
+ readonly audio?: AudioEncodeConfig;
142
+ }
143
+ export interface TimelineSummary {
144
+ readonly durationUs: number;
145
+ readonly videoFrames: number;
146
+ readonly audioPresentationSamples?: number;
147
+ }
148
+ export interface OfflineVideoMuxer {
149
+ /** Serial across tracks; must not wait for a future track/packet to resolve this write. */
150
+ write(event: CodecEvent): Promise<void>;
151
+ /** Returns one complete, playable container after both track-end events. */
152
+ finalize(timeline: TimelineSummary): Promise<Blob>;
153
+ dispose(): void;
154
+ }
155
+ export interface MuxerFactory {
156
+ readonly format: {
157
+ readonly mimeType: string;
158
+ readonly extension: string;
159
+ };
160
+ checkSupport(config: MuxerConfig): Promise<Support>;
161
+ create(config: MuxerConfig, context: MuxerContext): Awaitable<OfflineVideoMuxer>;
162
+ }
163
+ export interface OfflineVideoProgress {
164
+ readonly phase: 'initializing' | 'encoding' | 'finalizing' | 'finished' | 'failed' | 'aborted' | 'disposed';
165
+ readonly framesAccepted: number;
166
+ readonly durationUs: number;
167
+ readonly audioSamplesAccepted: number;
168
+ readonly videoPacketsWritten: number;
169
+ readonly audioPacketsWritten: number;
170
+ readonly encodedBytes: number;
171
+ readonly totalFrames?: number;
172
+ /** Known only after container finalization in the first implementation. */
173
+ readonly outputBytes?: number;
174
+ }
175
+ export interface OfflineVideoOptions {
176
+ readonly video: {
177
+ readonly encoder: VideoEncoderFactory;
178
+ readonly config: VideoEncodeConfig;
179
+ };
180
+ readonly audio?: {
181
+ readonly encoder: AudioEncoderFactory;
182
+ readonly config: AudioEncodeConfig;
183
+ readonly source: PreparedAudio;
184
+ readonly endPolicy: 'error' | 'trim-or-pad';
185
+ };
186
+ readonly muxer: MuxerFactory;
187
+ readonly signal?: AbortSignal;
188
+ readonly background?: readonly [number, number, number];
189
+ readonly totalFrames?: number;
190
+ readonly onProgress?: (progress: OfflineVideoProgress) => void;
191
+ readonly maxFramePixels?: number;
192
+ readonly maxFrames?: number;
193
+ readonly maxDurationUs?: number;
194
+ readonly maxAudioInputBytes?: number;
195
+ readonly maxOutputBytes?: number;
196
+ readonly maxPendingInputs?: number;
197
+ readonly maxPendingBytes?: number;
198
+ }
199
+ export interface OfflineVideoResult extends TimelineSummary {
200
+ readonly blob: Blob;
201
+ readonly mimeType: string;
202
+ readonly extension: string;
203
+ readonly videoCodec: string;
204
+ readonly audioCodec?: string;
205
+ }
206
+ export type OfflineVideoState = 'ready' | 'encoding' | 'finalizing' | 'finished' | 'failed' | 'aborted' | 'disposed';
207
+ export interface OfflineVideoExporter {
208
+ readonly state: OfflineVideoState;
209
+ /** Snapshot immediately, before the first yield; await before the next operation. */
210
+ addFrame(canvas: HTMLCanvasElement | OffscreenCanvas, timing: FrameTiming): Promise<void>;
211
+ finish(): Promise<OfflineVideoResult>;
212
+ dispose(): void;
213
+ }
214
+ /** Offline timeline controller. Encoders and container implementation are always supplied. */
215
+ export declare function createOfflineVideoExporter(options: OfflineVideoOptions): Promise<OfflineVideoExporter>;