@bendyline/squisq-video-react 1.2.0 → 1.2.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.
- package/README.md +31 -5
- package/dist/chunk-6DKLHXX3.js +47 -0
- package/dist/chunk-6DKLHXX3.js.map +1 -0
- package/dist/index.d.ts +120 -38
- package/dist/index.js +320 -25
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +3 -22
- package/dist/workers/encode.worker.js.map +1 -1
- package/package.json +8 -5
- package/src/VideoExportButton.tsx +14 -2
- package/src/VideoExportModal.tsx +37 -7
- package/src/__tests__/audioTrack.test.ts +108 -0
- package/src/__tests__/mp4MuxAudio.test.ts +72 -0
- package/src/__tests__/videoExportProps.test.tsx +73 -0
- package/src/audioTrack.ts +323 -0
- package/src/hooks/useFrameCapture.ts +1 -1
- package/src/hooks/useVideoExport.ts +187 -6
- package/src/index.ts +5 -2
- package/src/mainThreadEncoder.ts +26 -13
- package/src/mp4Mux.ts +77 -3
- package/src/workers/encode.worker.ts +6 -9
- package/dist/chunk-LZYWP4HB.js +0 -28
- package/dist/chunk-LZYWP4HB.js.map +0 -1
- package/src/__tests__/encodeParams.test.ts +0 -33
- package/src/workers/encodeParams.ts +0 -24
package/src/mainThreadEncoder.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* Requirements: Chrome 94+ / Edge 94+ (WebCodecs support).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { bitrateForQuality } from '@bendyline/squisq-video';
|
|
15
|
+
|
|
14
16
|
import { createMp4Muxer, type Mp4MuxerHandle } from './mp4Mux.js';
|
|
15
17
|
|
|
16
18
|
export interface EncoderConfig {
|
|
@@ -18,11 +20,26 @@ export interface EncoderConfig {
|
|
|
18
20
|
height: number;
|
|
19
21
|
fps: number;
|
|
20
22
|
quality: 'draft' | 'normal' | 'high';
|
|
23
|
+
/**
|
|
24
|
+
* When present, the underlying muxer is configured with an AAC audio track
|
|
25
|
+
* and {@link MainThreadEncoder.addAudioChunk} becomes usable. Absent → the
|
|
26
|
+
* encoder produces a video-only MP4 exactly as before.
|
|
27
|
+
*/
|
|
28
|
+
audio?: {
|
|
29
|
+
numberOfChannels: number;
|
|
30
|
+
sampleRate: number;
|
|
31
|
+
};
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export interface MainThreadEncoder {
|
|
24
35
|
/** Encode a single frame. The bitmap is closed after encoding. */
|
|
25
36
|
encodeFrame(bitmap: ImageBitmap, frameIndex: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
|
|
39
|
+
* Only valid when the encoder was created with an `audio` config; otherwise a
|
|
40
|
+
* no-op. Must be called before {@link finalize}.
|
|
41
|
+
*/
|
|
42
|
+
addAudioChunk?(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
|
|
26
43
|
/** Flush pending frames and finalize the MP4. Returns the MP4 ArrayBuffer. */
|
|
27
44
|
finalize(): Promise<ArrayBuffer>;
|
|
28
45
|
/** Close the encoder without producing output (e.g., on cancel). */
|
|
@@ -58,19 +75,6 @@ export async function supportsWebCodecsH264(config: EncoderConfig): Promise<bool
|
|
|
58
75
|
}
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
function bitrateForQuality(quality: string, width: number, height: number): number {
|
|
62
|
-
const pixels = width * height;
|
|
63
|
-
const baseBitrate = pixels * 4; // ~4 bits per pixel baseline
|
|
64
|
-
switch (quality) {
|
|
65
|
-
case 'draft':
|
|
66
|
-
return Math.round(baseBitrate * 0.5);
|
|
67
|
-
case 'high':
|
|
68
|
-
return Math.round(baseBitrate * 2);
|
|
69
|
-
default: // normal
|
|
70
|
-
return baseBitrate;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
78
|
/**
|
|
75
79
|
* Create a main-thread WebCodecs encoder.
|
|
76
80
|
*
|
|
@@ -94,6 +98,7 @@ export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
|
94
98
|
width: config.width,
|
|
95
99
|
height: config.height,
|
|
96
100
|
fps: config.fps,
|
|
101
|
+
...(config.audio ? { audio: config.audio } : {}),
|
|
97
102
|
});
|
|
98
103
|
|
|
99
104
|
let closed = false;
|
|
@@ -110,6 +115,9 @@ export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
|
110
115
|
});
|
|
111
116
|
|
|
112
117
|
encoder.configure({
|
|
118
|
+
// Deliberate profile split from the fallback worker (avc1.42001f, Baseline):
|
|
119
|
+
// this primary WebCodecs path targets H.264 High@4.0 for better quality up
|
|
120
|
+
// to 1080p; the wasm-fallback worker uses Baseline for max decoder compat.
|
|
113
121
|
codec: 'avc1.640028', // H.264 High profile, level 4.0 (supports up to 1080p)
|
|
114
122
|
width: config.width,
|
|
115
123
|
height: config.height,
|
|
@@ -131,6 +139,11 @@ export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
|
131
139
|
bitmap.close();
|
|
132
140
|
},
|
|
133
141
|
|
|
142
|
+
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
|
143
|
+
if (closed || !muxer.hasAudioTrack) return;
|
|
144
|
+
muxer.addAudioChunk(chunk, meta);
|
|
145
|
+
},
|
|
146
|
+
|
|
134
147
|
async finalize(): Promise<ArrayBuffer> {
|
|
135
148
|
if (closed) throw new Error('Encoder already closed');
|
|
136
149
|
await encoder.flush();
|
package/src/mp4Mux.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mp4Mux — Thin wrapper around mp4-muxer for WebCodecs encoding.
|
|
3
3
|
*
|
|
4
|
-
* Creates a Muxer instance configured for H.264 video,
|
|
5
|
-
* encoded chunks, and produces a final MP4
|
|
4
|
+
* Creates a Muxer instance configured for H.264 video (and, optionally, an
|
|
5
|
+
* AAC audio track), accumulates encoded chunks, and produces a final MP4
|
|
6
|
+
* ArrayBuffer. When no `audio` option is supplied the muxer configuration is
|
|
7
|
+
* byte-identical to the video-only path.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { Muxer, ArrayBufferTarget } from 'mp4-muxer';
|
|
@@ -11,17 +13,52 @@ export interface Mp4MuxerOptions {
|
|
|
11
13
|
width: number;
|
|
12
14
|
height: number;
|
|
13
15
|
fps: number;
|
|
16
|
+
/**
|
|
17
|
+
* When present, declares an AAC audio track alongside the video track.
|
|
18
|
+
* Feed encoded audio via {@link Mp4MuxerHandle.addAudioChunk} (from a
|
|
19
|
+
* WebCodecs `AudioEncoder`) or {@link Mp4MuxerHandle.addAudioChunkRaw}.
|
|
20
|
+
*/
|
|
21
|
+
audio?: {
|
|
22
|
+
numberOfChannels: number;
|
|
23
|
+
sampleRate: number;
|
|
24
|
+
};
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
export interface Mp4MuxerHandle {
|
|
17
28
|
/** Add an encoded video chunk to the muxer. */
|
|
18
29
|
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;
|
|
30
|
+
/**
|
|
31
|
+
* Add a raw video sample (bytes + timing) without an `EncodedVideoChunk`.
|
|
32
|
+
* Useful where no `VideoEncoder` instance is available (e.g. Node tests).
|
|
33
|
+
*/
|
|
34
|
+
addVideoChunkRaw(
|
|
35
|
+
data: Uint8Array,
|
|
36
|
+
type: 'key' | 'delta',
|
|
37
|
+
timestampMicros: number,
|
|
38
|
+
durationMicros: number,
|
|
39
|
+
meta?: EncodedVideoChunkMetadata,
|
|
40
|
+
): void;
|
|
41
|
+
/** Add an encoded audio chunk (from a WebCodecs `AudioEncoder`). */
|
|
42
|
+
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
|
|
43
|
+
/**
|
|
44
|
+
* Add a raw audio sample (bytes + timing) without an `EncodedAudioChunk`.
|
|
45
|
+
* Useful where no `AudioEncoder` instance is available (e.g. Node tests).
|
|
46
|
+
*/
|
|
47
|
+
addAudioChunkRaw(
|
|
48
|
+
data: Uint8Array,
|
|
49
|
+
type: 'key' | 'delta',
|
|
50
|
+
timestampMicros: number,
|
|
51
|
+
durationMicros: number,
|
|
52
|
+
meta?: EncodedAudioChunkMetadata,
|
|
53
|
+
): void;
|
|
54
|
+
/** Whether this muxer was created with an audio track. */
|
|
55
|
+
readonly hasAudioTrack: boolean;
|
|
19
56
|
/** Finalize and return the MP4 as an ArrayBuffer. */
|
|
20
57
|
finalize(): ArrayBuffer;
|
|
21
58
|
}
|
|
22
59
|
|
|
23
60
|
/**
|
|
24
|
-
* Create an MP4 muxer configured for H.264 video.
|
|
61
|
+
* Create an MP4 muxer configured for H.264 video, plus an optional AAC track.
|
|
25
62
|
*/
|
|
26
63
|
export function createMp4Muxer(options: Mp4MuxerOptions): Mp4MuxerHandle {
|
|
27
64
|
const target = new ArrayBufferTarget();
|
|
@@ -33,14 +70,51 @@ export function createMp4Muxer(options: Mp4MuxerOptions): Mp4MuxerHandle {
|
|
|
33
70
|
width: options.width,
|
|
34
71
|
height: options.height,
|
|
35
72
|
},
|
|
73
|
+
// Only declare the audio track when requested so the video-only
|
|
74
|
+
// configuration stays byte-identical to the historical output.
|
|
75
|
+
...(options.audio
|
|
76
|
+
? {
|
|
77
|
+
audio: {
|
|
78
|
+
codec: 'aac' as const,
|
|
79
|
+
numberOfChannels: options.audio.numberOfChannels,
|
|
80
|
+
sampleRate: options.audio.sampleRate,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
: {}),
|
|
36
84
|
fastStart: 'in-memory',
|
|
37
85
|
});
|
|
38
86
|
|
|
39
87
|
return {
|
|
88
|
+
hasAudioTrack: options.audio !== undefined,
|
|
89
|
+
|
|
40
90
|
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {
|
|
41
91
|
muxer.addVideoChunk(chunk, meta);
|
|
42
92
|
},
|
|
43
93
|
|
|
94
|
+
addVideoChunkRaw(
|
|
95
|
+
data: Uint8Array,
|
|
96
|
+
type: 'key' | 'delta',
|
|
97
|
+
timestampMicros: number,
|
|
98
|
+
durationMicros: number,
|
|
99
|
+
meta?: EncodedVideoChunkMetadata,
|
|
100
|
+
) {
|
|
101
|
+
muxer.addVideoChunkRaw(data, type, timestampMicros, durationMicros, meta);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {
|
|
105
|
+
muxer.addAudioChunk(chunk, meta);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
addAudioChunkRaw(
|
|
109
|
+
data: Uint8Array,
|
|
110
|
+
type: 'key' | 'delta',
|
|
111
|
+
timestampMicros: number,
|
|
112
|
+
durationMicros: number,
|
|
113
|
+
meta?: EncodedAudioChunkMetadata,
|
|
114
|
+
) {
|
|
115
|
+
muxer.addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta);
|
|
116
|
+
},
|
|
117
|
+
|
|
44
118
|
finalize(): ArrayBuffer {
|
|
45
119
|
muxer.finalize();
|
|
46
120
|
return target.buffer;
|
|
@@ -16,8 +16,9 @@ import type {
|
|
|
16
16
|
InitMessage,
|
|
17
17
|
FrameMessage,
|
|
18
18
|
} from './workerTypes.js';
|
|
19
|
+
import { bitrateForQuality, ffmpegVideoQualityArgs } from '@bendyline/squisq-video';
|
|
20
|
+
|
|
19
21
|
import { createMp4Muxer, type Mp4MuxerHandle } from '../mp4Mux.js';
|
|
20
|
-
import { bitrateForQuality } from './encodeParams.js';
|
|
21
22
|
|
|
22
23
|
// ── State ──────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -35,7 +36,6 @@ let ffmpegConfig: InitMessage | null = null;
|
|
|
35
36
|
|
|
36
37
|
// Frame tracking
|
|
37
38
|
let totalFramesReceived = 0;
|
|
38
|
-
let _totalFramesEncoded = 0;
|
|
39
39
|
|
|
40
40
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
41
41
|
|
|
@@ -96,14 +96,15 @@ function initWebCodecs(config: InitMessage) {
|
|
|
96
96
|
output(chunk, meta) {
|
|
97
97
|
if (cancelled) return;
|
|
98
98
|
muxer!.addVideoChunk(chunk, meta ?? undefined);
|
|
99
|
-
_totalFramesEncoded++;
|
|
100
99
|
},
|
|
101
100
|
error(err) {
|
|
102
101
|
postError(`WebCodecs encoder error: ${err.message}`);
|
|
103
102
|
},
|
|
104
103
|
});
|
|
105
104
|
|
|
106
|
-
//
|
|
105
|
+
// Deliberate profile split from mainThreadEncoder (avc1.640028, High@4.0):
|
|
106
|
+
// this worker is the max-compatibility fallback path, so it uses H.264
|
|
107
|
+
// Baseline (avc1.42001f) — widest decoder support at a small quality cost.
|
|
107
108
|
videoEncoder.configure({
|
|
108
109
|
codec: 'avc1.42001f',
|
|
109
110
|
width: config.width,
|
|
@@ -240,10 +241,7 @@ async function encodeFfmpegBatch() {
|
|
|
240
241
|
'libx264',
|
|
241
242
|
'-pix_fmt',
|
|
242
243
|
'yuv420p',
|
|
243
|
-
|
|
244
|
-
config.quality === 'draft' ? 'ultrafast' : config.quality === 'high' ? 'slow' : 'medium',
|
|
245
|
-
'-crf',
|
|
246
|
-
config.quality === 'draft' ? '28' : config.quality === 'high' ? '18' : '23',
|
|
244
|
+
...ffmpegVideoQualityArgs(config.quality),
|
|
247
245
|
segmentName,
|
|
248
246
|
]);
|
|
249
247
|
|
|
@@ -334,7 +332,6 @@ self.onmessage = async (event: MessageEvent<MainToWorkerMessage>) => {
|
|
|
334
332
|
case 'init': {
|
|
335
333
|
cancelled = false;
|
|
336
334
|
totalFramesReceived = 0;
|
|
337
|
-
_totalFramesEncoded = 0;
|
|
338
335
|
|
|
339
336
|
if (await supportsWebCodecsH264(msg)) {
|
|
340
337
|
backend = 'webcodecs';
|
package/dist/chunk-LZYWP4HB.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// src/mp4Mux.ts
|
|
2
|
-
import { Muxer, ArrayBufferTarget } from "mp4-muxer";
|
|
3
|
-
function createMp4Muxer(options) {
|
|
4
|
-
const target = new ArrayBufferTarget();
|
|
5
|
-
const muxer = new Muxer({
|
|
6
|
-
target,
|
|
7
|
-
video: {
|
|
8
|
-
codec: "avc",
|
|
9
|
-
width: options.width,
|
|
10
|
-
height: options.height
|
|
11
|
-
},
|
|
12
|
-
fastStart: "in-memory"
|
|
13
|
-
});
|
|
14
|
-
return {
|
|
15
|
-
addVideoChunk(chunk, meta) {
|
|
16
|
-
muxer.addVideoChunk(chunk, meta);
|
|
17
|
-
},
|
|
18
|
-
finalize() {
|
|
19
|
-
muxer.finalize();
|
|
20
|
-
return target.buffer;
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export {
|
|
26
|
-
createMp4Muxer
|
|
27
|
-
};
|
|
28
|
-
//# sourceMappingURL=chunk-LZYWP4HB.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/mp4Mux.ts"],"sourcesContent":["/**\n * mp4Mux — Thin wrapper around mp4-muxer for WebCodecs encoding.\n *\n * Creates a Muxer instance configured for H.264 video, accumulates\n * encoded chunks, and produces a final MP4 ArrayBuffer.\n */\n\nimport { Muxer, ArrayBufferTarget } from 'mp4-muxer';\n\nexport interface Mp4MuxerOptions {\n width: number;\n height: number;\n fps: number;\n}\n\nexport interface Mp4MuxerHandle {\n /** Add an encoded video chunk to the muxer. */\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;\n /** Finalize and return the MP4 as an ArrayBuffer. */\n finalize(): ArrayBuffer;\n}\n\n/**\n * Create an MP4 muxer configured for H.264 video.\n */\nexport function createMp4Muxer(options: Mp4MuxerOptions): Mp4MuxerHandle {\n const target = new ArrayBufferTarget();\n\n const muxer = new Muxer({\n target,\n video: {\n codec: 'avc',\n width: options.width,\n height: options.height,\n },\n fastStart: 'in-memory',\n });\n\n return {\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {\n muxer.addVideoChunk(chunk, meta);\n },\n\n finalize(): ArrayBuffer {\n muxer.finalize();\n return target.buffer;\n },\n };\n}\n"],"mappings":";AAOA,SAAS,OAAO,yBAAyB;AAkBlC,SAAS,eAAe,SAA0C;AACvE,QAAM,SAAS,IAAI,kBAAkB;AAErC,QAAM,QAAQ,IAAI,MAAM;AAAA,IACtB;AAAA,IACA,OAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO,QAAQ;AAAA,MACf,QAAQ,QAAQ;AAAA,IAClB;AAAA,IACA,WAAW;AAAA,EACb,CAAC;AAED,SAAO;AAAA,IACL,cAAc,OAA0B,MAAkC;AACxE,YAAM,cAAc,OAAO,IAAI;AAAA,IACjC;AAAA,IAEA,WAAwB;AACtB,YAAM,SAAS;AACf,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { bitrateForQuality } from '../workers/encodeParams.js';
|
|
3
|
-
|
|
4
|
-
const W = 1920;
|
|
5
|
-
const H = 1080;
|
|
6
|
-
const base = W * H * 4;
|
|
7
|
-
|
|
8
|
-
describe('bitrateForQuality', () => {
|
|
9
|
-
it('returns the ~4 bits-per-pixel baseline for normal quality', () => {
|
|
10
|
-
expect(bitrateForQuality('normal', W, H)).toBe(base);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('falls back to the normal baseline for unknown quality values', () => {
|
|
14
|
-
expect(bitrateForQuality('whatever', W, H)).toBe(base);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('halves the baseline for draft and doubles it for high', () => {
|
|
18
|
-
expect(bitrateForQuality('draft', W, H)).toBe(Math.round(base * 0.5));
|
|
19
|
-
expect(bitrateForQuality('high', W, H)).toBe(Math.round(base * 2));
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('orders bitrate draft < normal < high', () => {
|
|
23
|
-
const draft = bitrateForQuality('draft', W, H);
|
|
24
|
-
const normal = bitrateForQuality('normal', W, H);
|
|
25
|
-
const high = bitrateForQuality('high', W, H);
|
|
26
|
-
expect(draft).toBeLessThan(normal);
|
|
27
|
-
expect(normal).toBeLessThan(high);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('scales with resolution', () => {
|
|
31
|
-
expect(bitrateForQuality('normal', 640, 480)).toBeLessThan(bitrateForQuality('normal', W, H));
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure encoding-parameter helpers used by the encode worker.
|
|
3
|
-
*
|
|
4
|
-
* Kept separate from `encode.worker.ts` so they can be unit-tested directly —
|
|
5
|
-
* importing the worker module wires up `self.onmessage` and pulls in the
|
|
6
|
-
* WebCodecs / ffmpeg.wasm backends, none of which exist in a plain test runner.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Target H.264 bitrate (bits/sec) for a given quality at a given resolution.
|
|
11
|
-
* Baseline is ~4 bits per pixel; `draft` halves it and `high` doubles it.
|
|
12
|
-
*/
|
|
13
|
-
export function bitrateForQuality(quality: string, width: number, height: number): number {
|
|
14
|
-
const pixels = width * height;
|
|
15
|
-
const baseBitrate = pixels * 4; // ~4 bits per pixel baseline
|
|
16
|
-
switch (quality) {
|
|
17
|
-
case 'draft':
|
|
18
|
-
return Math.round(baseBitrate * 0.5);
|
|
19
|
-
case 'high':
|
|
20
|
-
return Math.round(baseBitrate * 2);
|
|
21
|
-
default: // normal
|
|
22
|
-
return baseBitrate;
|
|
23
|
-
}
|
|
24
|
-
}
|