@aelionsdk/export 0.1.0-beta.1

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/audio-export.d.ts +23 -0
  4. package/dist/audio-export.d.ts.map +1 -0
  5. package/dist/audio-export.js +120 -0
  6. package/dist/checkpoint.d.ts +58 -0
  7. package/dist/checkpoint.d.ts.map +1 -0
  8. package/dist/checkpoint.js +119 -0
  9. package/dist/image-export.d.ts +40 -0
  10. package/dist/image-export.d.ts.map +1 -0
  11. package/dist/image-export.js +235 -0
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +11 -0
  15. package/dist/memory-sink.d.ts +17 -0
  16. package/dist/memory-sink.d.ts.map +1 -0
  17. package/dist/memory-sink.js +60 -0
  18. package/dist/mux-export-worker.d.ts +2 -0
  19. package/dist/mux-export-worker.d.ts.map +1 -0
  20. package/dist/mux-export-worker.js +120 -0
  21. package/dist/opfs-sink.d.ts +20 -0
  22. package/dist/opfs-sink.d.ts.map +1 -0
  23. package/dist/opfs-sink.js +113 -0
  24. package/dist/profiles.d.ts +66 -0
  25. package/dist/profiles.d.ts.map +1 -0
  26. package/dist/profiles.js +284 -0
  27. package/dist/remote-export.d.ts +56 -0
  28. package/dist/remote-export.d.ts.map +1 -0
  29. package/dist/remote-export.js +83 -0
  30. package/dist/resumable-muxed-export.d.ts +84 -0
  31. package/dist/resumable-muxed-export.d.ts.map +1 -0
  32. package/dist/resumable-muxed-export.js +533 -0
  33. package/dist/session.d.ts +47 -0
  34. package/dist/session.d.ts.map +1 -0
  35. package/dist/session.js +483 -0
  36. package/dist/sink-completion.d.ts +8 -0
  37. package/dist/sink-completion.d.ts.map +1 -0
  38. package/dist/sink-completion.js +13 -0
  39. package/dist/webm-export.d.ts +97 -0
  40. package/dist/webm-export.d.ts.map +1 -0
  41. package/dist/webm-export.js +408 -0
  42. package/dist/worker-export.d.ts +25 -0
  43. package/dist/worker-export.d.ts.map +1 -0
  44. package/dist/worker-export.js +202 -0
  45. package/dist/worker-protocol.d.ts +66 -0
  46. package/dist/worker-protocol.d.ts.map +1 -0
  47. package/dist/worker-protocol.js +1 -0
  48. package/package.json +46 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FoyonaCZY and AelionSDK contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @aelionsdk/export
2
+
3
+ Streaming WebCodecs and WebM export pipeline for AelionSDK
4
+
5
+ Install with `npm install @aelionsdk/export@next`.
6
+
7
+ Version 0.1.0-beta.1 is a prerelease and its API may change before the first stable release. This package is part of [AelionSDK](https://github.com/FoyonaCZY/AelionSDK); see the repository README for supported browsers, examples and deployment requirements.
@@ -0,0 +1,23 @@
1
+ import type { StreamTargetChunk } from 'mediabunny';
2
+ import type { OfflineAudioRequest } from './webm-export.js';
3
+ export interface WavExportOptions {
4
+ readonly durationUs: number;
5
+ readonly sampleRate: number;
6
+ readonly channelCount: number;
7
+ readonly sampleFormat?: 's16' | 'f32';
8
+ readonly sink: WritableStream<StreamTargetChunk>;
9
+ readonly cleanupSink?: (reason: unknown) => void | Promise<void>;
10
+ readonly renderAudio: (request: OfflineAudioRequest, signal?: AbortSignal) => Promise<Float32Array>;
11
+ readonly signal?: AbortSignal;
12
+ readonly onProgress?: (progress: number) => void;
13
+ readonly blockFrames?: number;
14
+ }
15
+ export interface WavExportResult {
16
+ readonly mimeType: 'audio/wav';
17
+ readonly audioFrames: number;
18
+ readonly durationUs: number;
19
+ readonly bytesWritten: number;
20
+ readonly rf64: boolean;
21
+ }
22
+ export declare function exportWav(options: WavExportOptions): Promise<WavExportResult>;
23
+ //# sourceMappingURL=audio-export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-export.d.ts","sourceRoot":"","sources":["../src/audio-export.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,iBAAiB,CAAC,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,QAAQ,CAAC,WAAW,EAAE,CACpB,OAAO,EAAE,mBAAmB,EAC5B,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,YAAY,CAAC,CAAC;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAgED,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CA0EnF"}
@@ -0,0 +1,120 @@
1
+ import { AelionError, throwIfAborted } from '@aelionsdk/core';
2
+ function ascii(view, offset, value) {
3
+ for (let index = 0; index < value.length; index += 1) {
4
+ view.setUint8(offset + index, value.charCodeAt(index));
5
+ }
6
+ }
7
+ function wavHeader(frames, sampleRate, channelCount, bytesPerSample, floatingPoint) {
8
+ const dataBytes = frames * channelCount * bytesPerSample;
9
+ const rf64 = dataBytes + 36 > 0xffff_ffff;
10
+ const header = new Uint8Array(rf64 ? 80 : 44);
11
+ const view = new DataView(header.buffer);
12
+ ascii(view, 0, rf64 ? 'RF64' : 'RIFF');
13
+ view.setUint32(4, rf64 ? 0xffff_ffff : dataBytes + 36, true);
14
+ ascii(view, 8, 'WAVE');
15
+ let offset = 12;
16
+ if (rf64) {
17
+ ascii(view, offset, 'ds64');
18
+ view.setUint32(offset + 4, 28, true);
19
+ view.setBigUint64(offset + 8, BigInt(dataBytes + header.byteLength - 8), true);
20
+ view.setBigUint64(offset + 16, BigInt(dataBytes), true);
21
+ view.setBigUint64(offset + 24, BigInt(frames), true);
22
+ view.setUint32(offset + 32, 0, true);
23
+ offset += 36;
24
+ }
25
+ ascii(view, offset, 'fmt ');
26
+ view.setUint32(offset + 4, 16, true);
27
+ view.setUint16(offset + 8, floatingPoint ? 3 : 1, true);
28
+ view.setUint16(offset + 10, channelCount, true);
29
+ view.setUint32(offset + 12, sampleRate, true);
30
+ view.setUint32(offset + 16, sampleRate * channelCount * bytesPerSample, true);
31
+ view.setUint16(offset + 20, channelCount * bytesPerSample, true);
32
+ view.setUint16(offset + 22, bytesPerSample * 8, true);
33
+ ascii(view, offset + 24, 'data');
34
+ view.setUint32(offset + 28, rf64 ? 0xffff_ffff : dataBytes, true);
35
+ return { bytes: header, rf64 };
36
+ }
37
+ function encodePcm(pcm, sampleFormat) {
38
+ const bytesPerSample = sampleFormat === 's16' ? 2 : 4;
39
+ const bytes = new Uint8Array(pcm.length * bytesPerSample);
40
+ const view = new DataView(bytes.buffer);
41
+ for (let index = 0; index < pcm.length; index += 1) {
42
+ const sample = Math.max(-1, Math.min(1, pcm[index] ?? 0));
43
+ if (sampleFormat === 's16') {
44
+ view.setInt16(index * 2, sample < 0 ? Math.round(sample * 32_768) : Math.round(sample * 32_767), true);
45
+ }
46
+ else {
47
+ view.setFloat32(index * 4, sample, true);
48
+ }
49
+ }
50
+ return bytes;
51
+ }
52
+ export async function exportWav(options) {
53
+ if (!Number.isSafeInteger(options.durationUs) || options.durationUs <= 0) {
54
+ throw new RangeError('durationUs must be a positive safe integer');
55
+ }
56
+ if (!Number.isSafeInteger(options.sampleRate) || options.sampleRate <= 0) {
57
+ throw new RangeError('sampleRate must be a positive safe integer');
58
+ }
59
+ if (!Number.isSafeInteger(options.channelCount) || options.channelCount <= 0) {
60
+ throw new RangeError('channelCount must be a positive safe integer');
61
+ }
62
+ const sampleFormat = options.sampleFormat ?? 's16';
63
+ const bytesPerSample = sampleFormat === 's16' ? 2 : 4;
64
+ const totalFrames = Math.floor((options.durationUs * options.sampleRate) / 1_000_000);
65
+ const header = wavHeader(totalFrames, options.sampleRate, options.channelCount, bytesPerSample, sampleFormat === 'f32');
66
+ const writer = options.sink.getWriter();
67
+ let audioFrames = 0;
68
+ let position = header.bytes.byteLength;
69
+ try {
70
+ throwIfAborted(options.signal, 'WAV export');
71
+ await writer.write({ type: 'write', data: header.bytes, position: 0 });
72
+ const blockFrames = options.blockFrames ?? 4_096;
73
+ if (!Number.isSafeInteger(blockFrames) || blockFrames <= 0) {
74
+ throw new RangeError('blockFrames must be a positive safe integer');
75
+ }
76
+ while (audioFrames < totalFrames) {
77
+ throwIfAborted(options.signal, 'WAV export');
78
+ const frameCount = Math.min(blockFrames, totalFrames - audioFrames);
79
+ const pcm = await options.renderAudio({
80
+ startFrame: audioFrames,
81
+ frameCount,
82
+ sampleRate: options.sampleRate,
83
+ channelCount: options.channelCount,
84
+ }, options.signal);
85
+ if (pcm.length !== frameCount * options.channelCount) {
86
+ throw new RangeError('renderAudio returned an unexpected interleaved PCM length');
87
+ }
88
+ const data = encodePcm(pcm, sampleFormat);
89
+ await writer.write({ type: 'write', data, position });
90
+ position += data.byteLength;
91
+ audioFrames += frameCount;
92
+ options.onProgress?.(audioFrames / totalFrames);
93
+ }
94
+ await writer.close();
95
+ if (totalFrames === 0)
96
+ options.onProgress?.(1);
97
+ return {
98
+ mimeType: 'audio/wav',
99
+ audioFrames,
100
+ durationUs: options.durationUs,
101
+ bytesWritten: position,
102
+ rf64: header.rf64,
103
+ };
104
+ }
105
+ catch (cause) {
106
+ await writer.abort(cause).catch(() => undefined);
107
+ await Promise.resolve(options.cleanupSink?.(cause)).catch(() => undefined);
108
+ if (cause instanceof AelionError || cause instanceof RangeError)
109
+ throw cause;
110
+ throw new AelionError([
111
+ {
112
+ code: 'EXPORT_AUDIO_WRITE_FAILED',
113
+ severity: 'error',
114
+ message: cause instanceof Error ? cause.message : 'Audio export failed',
115
+ recoverable: true,
116
+ cause,
117
+ },
118
+ ]);
119
+ }
120
+ }
@@ -0,0 +1,58 @@
1
+ import type { ExportProfileId } from './profiles.js';
2
+ export interface ExportCheckpoint {
3
+ readonly version: 1;
4
+ readonly contentId: string;
5
+ readonly profileId: ExportProfileId;
6
+ readonly totalUnits: number;
7
+ readonly completedUnits: number;
8
+ readonly outputBytes: number;
9
+ readonly state?: Readonly<Record<string, string | number | boolean | null>>;
10
+ readonly updatedAtMs: number;
11
+ }
12
+ export interface ExportCheckpointStore {
13
+ load(key: string, signal?: AbortSignal): Promise<ExportCheckpoint | undefined>;
14
+ save(key: string, checkpoint: ExportCheckpoint, signal?: AbortSignal): Promise<void>;
15
+ delete(key: string, signal?: AbortSignal): Promise<void>;
16
+ }
17
+ export declare class MemoryExportCheckpointStore implements ExportCheckpointStore {
18
+ #private;
19
+ load(key: string, signal?: AbortSignal): Promise<ExportCheckpoint | undefined>;
20
+ save(key: string, checkpoint: ExportCheckpoint, signal?: AbortSignal): Promise<void>;
21
+ delete(key: string, signal?: AbortSignal): Promise<void>;
22
+ }
23
+ /**
24
+ * Durable browser checkpoint store. A new instance with the same namespace
25
+ * resumes committed units after a page reload; callers may inject sessionStorage
26
+ * or a test double instead of localStorage.
27
+ */
28
+ export declare class BrowserStorageExportCheckpointStore implements ExportCheckpointStore {
29
+ #private;
30
+ constructor(options?: {
31
+ readonly storage?: Storage;
32
+ readonly namespace?: string;
33
+ });
34
+ load(key: string, signal?: AbortSignal): Promise<ExportCheckpoint | undefined>;
35
+ save(key: string, checkpoint: ExportCheckpoint, signal?: AbortSignal): Promise<void>;
36
+ delete(key: string, signal?: AbortSignal): Promise<void>;
37
+ }
38
+ export interface CheckpointedExportUnitResult {
39
+ readonly outputBytes: number;
40
+ readonly state?: ExportCheckpoint['state'];
41
+ }
42
+ export interface RunCheckpointedExportOptions {
43
+ readonly key: string;
44
+ readonly contentId: string;
45
+ readonly profileId: ExportProfileId;
46
+ readonly totalUnits: number;
47
+ readonly store: ExportCheckpointStore;
48
+ readonly processUnit: (unitIndex: number, checkpoint: ExportCheckpoint | undefined, signal?: AbortSignal) => Promise<CheckpointedExportUnitResult>;
49
+ readonly signal?: AbortSignal;
50
+ readonly now?: () => number;
51
+ readonly onProgress?: (completedUnits: number, totalUnits: number) => void;
52
+ }
53
+ /**
54
+ * Runs independently committable export units. A unit must be idempotent for
55
+ * `(contentId, profileId, unitIndex)`; the checkpoint is advanced only after it commits.
56
+ */
57
+ export declare function runCheckpointedExport(options: RunCheckpointedExportOptions): Promise<ExportCheckpoint>;
58
+ //# sourceMappingURL=checkpoint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkpoint.d.ts","sourceRoot":"","sources":["../src/checkpoint.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,119 @@
1
+ import { throwIfAborted } from '@aelionsdk/core';
2
+ export class MemoryExportCheckpointStore {
3
+ #entries = new Map();
4
+ load(key, signal) {
5
+ throwIfAborted(signal, 'Load export checkpoint');
6
+ return Promise.resolve(this.#entries.get(key));
7
+ }
8
+ save(key, checkpoint, signal) {
9
+ throwIfAborted(signal, 'Save export checkpoint');
10
+ this.#entries.set(key, structuredClone(checkpoint));
11
+ return Promise.resolve();
12
+ }
13
+ delete(key, signal) {
14
+ throwIfAborted(signal, 'Delete export checkpoint');
15
+ this.#entries.delete(key);
16
+ return Promise.resolve();
17
+ }
18
+ }
19
+ /**
20
+ * Durable browser checkpoint store. A new instance with the same namespace
21
+ * resumes committed units after a page reload; callers may inject sessionStorage
22
+ * or a test double instead of localStorage.
23
+ */
24
+ export class BrowserStorageExportCheckpointStore {
25
+ #storage;
26
+ #namespace;
27
+ constructor(options = {}) {
28
+ const storage = options.storage ?? globalThis.localStorage;
29
+ this.#storage = storage;
30
+ this.#namespace = options.namespace ?? 'aelion-export-checkpoint';
31
+ }
32
+ load(key, signal) {
33
+ throwIfAborted(signal, 'Load export checkpoint');
34
+ const value = this.#storage.getItem(this.#key(key));
35
+ if (value === null)
36
+ return Promise.resolve(undefined);
37
+ try {
38
+ const parsed = JSON.parse(value);
39
+ if (parsed === null ||
40
+ typeof parsed !== 'object' ||
41
+ Reflect.get(parsed, 'version') !== 1 ||
42
+ typeof Reflect.get(parsed, 'contentId') !== 'string' ||
43
+ typeof Reflect.get(parsed, 'profileId') !== 'string') {
44
+ this.#storage.removeItem(this.#key(key));
45
+ return Promise.resolve(undefined);
46
+ }
47
+ return Promise.resolve(parsed);
48
+ }
49
+ catch {
50
+ this.#storage.removeItem(this.#key(key));
51
+ return Promise.resolve(undefined);
52
+ }
53
+ }
54
+ save(key, checkpoint, signal) {
55
+ throwIfAborted(signal, 'Save export checkpoint');
56
+ this.#storage.setItem(this.#key(key), JSON.stringify(checkpoint));
57
+ return Promise.resolve();
58
+ }
59
+ delete(key, signal) {
60
+ throwIfAborted(signal, 'Delete export checkpoint');
61
+ this.#storage.removeItem(this.#key(key));
62
+ return Promise.resolve();
63
+ }
64
+ #key(key) {
65
+ if (key.length === 0)
66
+ throw new TypeError('Checkpoint key must not be empty');
67
+ return `${this.#namespace}:${encodeURIComponent(key)}`;
68
+ }
69
+ }
70
+ function compatibleCheckpoint(checkpoint, options) {
71
+ if (checkpoint === undefined)
72
+ return undefined;
73
+ if (checkpoint.contentId !== options.contentId ||
74
+ checkpoint.profileId !== options.profileId ||
75
+ checkpoint.totalUnits !== options.totalUnits ||
76
+ checkpoint.completedUnits < 0 ||
77
+ checkpoint.completedUnits > checkpoint.totalUnits) {
78
+ return undefined;
79
+ }
80
+ return checkpoint;
81
+ }
82
+ /**
83
+ * Runs independently committable export units. A unit must be idempotent for
84
+ * `(contentId, profileId, unitIndex)`; the checkpoint is advanced only after it commits.
85
+ */
86
+ export async function runCheckpointedExport(options) {
87
+ if (!Number.isSafeInteger(options.totalUnits) || options.totalUnits <= 0) {
88
+ throw new RangeError('totalUnits must be a positive safe integer');
89
+ }
90
+ throwIfAborted(options.signal, 'Checkpointed export');
91
+ let checkpoint = compatibleCheckpoint(await options.store.load(options.key, options.signal), options);
92
+ if (checkpoint === undefined) {
93
+ await options.store.delete(options.key, options.signal);
94
+ checkpoint = {
95
+ version: 1,
96
+ contentId: options.contentId,
97
+ profileId: options.profileId,
98
+ totalUnits: options.totalUnits,
99
+ completedUnits: 0,
100
+ outputBytes: 0,
101
+ updatedAtMs: (options.now ?? Date.now)(),
102
+ };
103
+ }
104
+ options.onProgress?.(checkpoint.completedUnits, checkpoint.totalUnits);
105
+ for (let unitIndex = checkpoint.completedUnits; unitIndex < options.totalUnits; unitIndex += 1) {
106
+ throwIfAborted(options.signal, 'Checkpointed export');
107
+ const result = await options.processUnit(unitIndex, checkpoint, options.signal);
108
+ checkpoint = {
109
+ ...checkpoint,
110
+ completedUnits: unitIndex + 1,
111
+ outputBytes: checkpoint.outputBytes + result.outputBytes,
112
+ ...(result.state === undefined ? {} : { state: result.state }),
113
+ updatedAtMs: (options.now ?? Date.now)(),
114
+ };
115
+ await options.store.save(options.key, checkpoint, options.signal);
116
+ options.onProgress?.(checkpoint.completedUnits, checkpoint.totalUnits);
117
+ }
118
+ return checkpoint;
119
+ }
@@ -0,0 +1,40 @@
1
+ import { type Rational } from '@aelionsdk/core';
2
+ import type { StreamTargetChunk } from 'mediabunny';
3
+ import type { OfflineFrameRequest } from './webm-export.js';
4
+ export interface StillImageExportOptions {
5
+ readonly timeUs: number;
6
+ readonly width: number;
7
+ readonly height: number;
8
+ readonly format: 'png' | 'jpeg' | 'webp';
9
+ readonly quality?: number;
10
+ readonly sink: WritableStream<StreamTargetChunk>;
11
+ readonly cleanupSink?: (reason: unknown) => void | Promise<void>;
12
+ readonly renderFrame: (request: OfflineFrameRequest, signal?: AbortSignal) => Promise<VideoFrame>;
13
+ readonly signal?: AbortSignal;
14
+ }
15
+ export interface StillImageExportResult {
16
+ readonly mimeType: `image/${'png' | 'jpeg' | 'webp'}`;
17
+ readonly bytesWritten: number;
18
+ readonly timeUs: number;
19
+ }
20
+ export interface GifExportOptions {
21
+ readonly durationUs: number;
22
+ readonly width: number;
23
+ readonly height: number;
24
+ readonly frameRate: Rational;
25
+ readonly loopCount?: number;
26
+ readonly sink: WritableStream<StreamTargetChunk>;
27
+ readonly cleanupSink?: (reason: unknown) => void | Promise<void>;
28
+ readonly renderFrame: (request: OfflineFrameRequest, signal?: AbortSignal) => Promise<VideoFrame>;
29
+ readonly signal?: AbortSignal;
30
+ readonly onProgress?: (progress: number) => void;
31
+ }
32
+ export interface GifExportResult {
33
+ readonly mimeType: 'image/gif';
34
+ readonly videoFrames: number;
35
+ readonly durationUs: number;
36
+ readonly bytesWritten: number;
37
+ }
38
+ export declare function exportStillImage(options: StillImageExportOptions): Promise<StillImageExportResult>;
39
+ export declare function exportGif(options: GifExportOptions): Promise<GifExportResult>;
40
+ //# sourceMappingURL=image-export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-export.d.ts","sourceRoot":"","sources":["../src/image-export.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,QAAQ,EACd,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE5D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;IACzC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,iBAAiB,CAAC,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,QAAQ,CAAC,WAAW,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAClG,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,QAAQ,EAAE,SAAS,KAAK,GAAG,MAAM,GAAG,MAAM,EAAE,CAAC;IACtD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC;IAC7B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,iBAAiB,CAAC,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,QAAQ,CAAC,WAAW,EAAE,CAAC,OAAO,EAAE,mBAAmB,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IAClG,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAkCD,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CA0CjC;AA+GD,wBAAsB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,eAAe,CAAC,CAuEnF"}
@@ -0,0 +1,235 @@
1
+ import { AelionError, frameDurationUs, frameStartUs, throwIfAborted, } from '@aelionsdk/core';
2
+ function assertDimensions(width, height) {
3
+ if (!Number.isSafeInteger(width) ||
4
+ !Number.isSafeInteger(height) ||
5
+ width <= 0 ||
6
+ height <= 0 ||
7
+ width > 65_535 ||
8
+ height > 65_535) {
9
+ throw new RangeError('Image dimensions must be integers between 1 and 65535');
10
+ }
11
+ }
12
+ async function cleanupFailure(writer, cleanupSink, cause) {
13
+ await writer.abort(cause).catch(() => undefined);
14
+ await Promise.resolve(cleanupSink?.(cause)).catch(() => undefined);
15
+ if (cause instanceof AelionError || cause instanceof RangeError)
16
+ throw cause;
17
+ throw new AelionError([
18
+ {
19
+ code: 'EXPORT_IMAGE_WRITE_FAILED',
20
+ severity: 'error',
21
+ message: cause instanceof Error ? cause.message : 'Image export failed',
22
+ recoverable: true,
23
+ cause,
24
+ },
25
+ ]);
26
+ }
27
+ export async function exportStillImage(options) {
28
+ assertDimensions(options.width, options.height);
29
+ if (!Number.isSafeInteger(options.timeUs) || options.timeUs < 0) {
30
+ throw new RangeError('timeUs must be a non-negative safe integer');
31
+ }
32
+ if (options.quality !== undefined && (options.quality < 0 || options.quality > 1)) {
33
+ throw new RangeError('quality must be between 0 and 1');
34
+ }
35
+ const writer = options.sink.getWriter();
36
+ try {
37
+ throwIfAborted(options.signal, 'Still image export');
38
+ const frame = await options.renderFrame({
39
+ frameIndex: 0,
40
+ timestampUs: options.timeUs,
41
+ durationUs: 1,
42
+ width: options.width,
43
+ height: options.height,
44
+ }, options.signal);
45
+ const canvas = new OffscreenCanvas(options.width, options.height);
46
+ try {
47
+ const context = canvas.getContext('2d');
48
+ if (context === null)
49
+ throw new Error('2D canvas is unavailable');
50
+ context.drawImage(frame, 0, 0, options.width, options.height);
51
+ }
52
+ finally {
53
+ frame.close();
54
+ }
55
+ throwIfAborted(options.signal, 'Still image export');
56
+ const mimeType = `image/${options.format}`;
57
+ const blob = await canvas.convertToBlob({
58
+ type: mimeType,
59
+ ...(options.quality === undefined ? {} : { quality: options.quality }),
60
+ });
61
+ const bytes = new Uint8Array(await blob.arrayBuffer());
62
+ await writer.write({ type: 'write', data: bytes, position: 0 });
63
+ await writer.close();
64
+ return { mimeType, bytesWritten: bytes.byteLength, timeUs: options.timeUs };
65
+ }
66
+ catch (cause) {
67
+ return cleanupFailure(writer, options.cleanupSink, cause);
68
+ }
69
+ }
70
+ function littleEndian16(value) {
71
+ return [value & 0xff, (value >>> 8) & 0xff];
72
+ }
73
+ function gifPalette() {
74
+ const palette = new Uint8Array(256 * 3);
75
+ for (let index = 0; index < 256; index += 1) {
76
+ palette[index * 3] = Math.round((((index >>> 5) & 7) * 255) / 7);
77
+ palette[index * 3 + 1] = Math.round((((index >>> 2) & 7) * 255) / 7);
78
+ palette[index * 3 + 2] = Math.round(((index & 3) * 255) / 3);
79
+ }
80
+ return palette;
81
+ }
82
+ function quantize(image) {
83
+ const pixels = new Uint8Array(image.width * image.height);
84
+ for (let index = 0; index < pixels.length; index += 1) {
85
+ const offset = index * 4;
86
+ pixels[index] =
87
+ (((image.data[offset] ?? 0) >>> 5) << 5) |
88
+ (((image.data[offset + 1] ?? 0) >>> 5) << 2) |
89
+ ((image.data[offset + 2] ?? 0) >>> 6);
90
+ }
91
+ return pixels;
92
+ }
93
+ function lzwData(pixels) {
94
+ const codes = [256];
95
+ let literalsSinceClear = 0;
96
+ for (const pixel of pixels) {
97
+ if (literalsSinceClear >= 250) {
98
+ codes.push(256);
99
+ literalsSinceClear = 0;
100
+ }
101
+ codes.push(pixel);
102
+ literalsSinceClear += 1;
103
+ }
104
+ codes.push(257);
105
+ const packed = new Uint8Array(Math.ceil((codes.length * 9) / 8));
106
+ let bitOffset = 0;
107
+ for (const code of codes) {
108
+ for (let bit = 0; bit < 9; bit += 1) {
109
+ if ((code & (1 << bit)) !== 0) {
110
+ const byteIndex = bitOffset >>> 3;
111
+ packed[byteIndex] = (packed[byteIndex] ?? 0) | (1 << (bitOffset & 7));
112
+ }
113
+ bitOffset += 1;
114
+ }
115
+ }
116
+ const blocks = [8];
117
+ for (let offset = 0; offset < packed.length; offset += 255) {
118
+ const size = Math.min(255, packed.length - offset);
119
+ blocks.push(size, ...packed.subarray(offset, offset + size));
120
+ }
121
+ blocks.push(0);
122
+ return Uint8Array.from(blocks);
123
+ }
124
+ function gifHeader(width, height, loopCount) {
125
+ const [widthLow, widthHigh] = littleEndian16(width);
126
+ const [heightLow, heightHigh] = littleEndian16(height);
127
+ const [loopLow, loopHigh] = littleEndian16(loopCount);
128
+ return Uint8Array.from([
129
+ ...new TextEncoder().encode('GIF89a'),
130
+ widthLow,
131
+ widthHigh,
132
+ heightLow,
133
+ heightHigh,
134
+ 0xf7,
135
+ 0,
136
+ 0,
137
+ ...gifPalette(),
138
+ 0x21,
139
+ 0xff,
140
+ 0x0b,
141
+ ...new TextEncoder().encode('NETSCAPE2.0'),
142
+ 3,
143
+ 1,
144
+ loopLow,
145
+ loopHigh,
146
+ 0,
147
+ ]);
148
+ }
149
+ function gifFrame(width, height, delayCs, pixels) {
150
+ const [widthLow, widthHigh] = littleEndian16(width);
151
+ const [heightLow, heightHigh] = littleEndian16(height);
152
+ const [delayLow, delayHigh] = littleEndian16(Math.max(1, Math.min(65_535, delayCs)));
153
+ const control = Uint8Array.from([0x21, 0xf9, 4, 0, delayLow, delayHigh, 0, 0]);
154
+ const descriptor = Uint8Array.from([
155
+ 0x2c,
156
+ 0,
157
+ 0,
158
+ 0,
159
+ 0,
160
+ widthLow,
161
+ widthHigh,
162
+ heightLow,
163
+ heightHigh,
164
+ 0,
165
+ ]);
166
+ const image = lzwData(pixels);
167
+ const result = new Uint8Array(control.length + descriptor.length + image.length);
168
+ result.set(control, 0);
169
+ result.set(descriptor, control.length);
170
+ result.set(image, control.length + descriptor.length);
171
+ return result;
172
+ }
173
+ export async function exportGif(options) {
174
+ assertDimensions(options.width, options.height);
175
+ if (!Number.isSafeInteger(options.durationUs) || options.durationUs <= 0) {
176
+ throw new RangeError('durationUs must be a positive safe integer');
177
+ }
178
+ const loopCount = options.loopCount ?? 0;
179
+ if (!Number.isSafeInteger(loopCount) || loopCount < 0 || loopCount > 65_535) {
180
+ throw new RangeError('loopCount must be an integer between 0 and 65535');
181
+ }
182
+ const frameCount = Math.ceil((options.durationUs * options.frameRate.numerator) /
183
+ (1_000_000 * options.frameRate.denominator));
184
+ const writer = options.sink.getWriter();
185
+ let position = 0;
186
+ let videoFrames = 0;
187
+ try {
188
+ const header = gifHeader(options.width, options.height, loopCount);
189
+ await writer.write({ type: 'write', data: header, position });
190
+ position += header.byteLength;
191
+ const canvas = new OffscreenCanvas(options.width, options.height);
192
+ const context = canvas.getContext('2d', { willReadFrequently: true });
193
+ if (context === null)
194
+ throw new Error('2D canvas is unavailable');
195
+ for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
196
+ throwIfAborted(options.signal, 'GIF export');
197
+ const timestampUs = frameStartUs(frameIndex, options.frameRate);
198
+ const durationUs = Math.min(frameDurationUs(frameIndex, options.frameRate), options.durationUs - timestampUs);
199
+ if (durationUs <= 0)
200
+ break;
201
+ const frame = await options.renderFrame({
202
+ frameIndex,
203
+ timestampUs,
204
+ durationUs,
205
+ width: options.width,
206
+ height: options.height,
207
+ }, options.signal);
208
+ try {
209
+ context.clearRect(0, 0, options.width, options.height);
210
+ context.drawImage(frame, 0, 0, options.width, options.height);
211
+ }
212
+ finally {
213
+ frame.close();
214
+ }
215
+ const data = gifFrame(options.width, options.height, Math.round(durationUs / 10_000), quantize(context.getImageData(0, 0, options.width, options.height)));
216
+ await writer.write({ type: 'write', data, position });
217
+ position += data.byteLength;
218
+ videoFrames += 1;
219
+ options.onProgress?.(videoFrames / frameCount);
220
+ }
221
+ const trailer = Uint8Array.of(0x3b);
222
+ await writer.write({ type: 'write', data: trailer, position });
223
+ position += 1;
224
+ await writer.close();
225
+ return {
226
+ mimeType: 'image/gif',
227
+ videoFrames,
228
+ durationUs: options.durationUs,
229
+ bytesWritten: position,
230
+ };
231
+ }
232
+ catch (cause) {
233
+ return cleanupFailure(writer, options.cleanupSink, cause);
234
+ }
235
+ }
@@ -0,0 +1,12 @@
1
+ export * from './audio-export.js';
2
+ export * from './checkpoint.js';
3
+ export * from './image-export.js';
4
+ export * from './memory-sink.js';
5
+ export * from './opfs-sink.js';
6
+ export * from './profiles.js';
7
+ export * from './remote-export.js';
8
+ export * from './resumable-muxed-export.js';
9
+ export * from './session.js';
10
+ export * from './webm-export.js';
11
+ export * from './worker-export.js';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC"}