@bendyline/squisq-video-react 1.1.2 → 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 -20
- 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 -21
- package/dist/chunk-LZYWP4HB.js +0 -28
- package/dist/chunk-LZYWP4HB.js.map +0 -1
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,6 +16,8 @@ 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
22
|
|
|
21
23
|
// ── State ──────────────────────────────────────────────────────────
|
|
@@ -34,7 +36,6 @@ let ffmpegConfig: InitMessage | null = null;
|
|
|
34
36
|
|
|
35
37
|
// Frame tracking
|
|
36
38
|
let totalFramesReceived = 0;
|
|
37
|
-
let _totalFramesEncoded = 0;
|
|
38
39
|
|
|
39
40
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
40
41
|
|
|
@@ -95,14 +96,15 @@ function initWebCodecs(config: InitMessage) {
|
|
|
95
96
|
output(chunk, meta) {
|
|
96
97
|
if (cancelled) return;
|
|
97
98
|
muxer!.addVideoChunk(chunk, meta ?? undefined);
|
|
98
|
-
_totalFramesEncoded++;
|
|
99
99
|
},
|
|
100
100
|
error(err) {
|
|
101
101
|
postError(`WebCodecs encoder error: ${err.message}`);
|
|
102
102
|
},
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
//
|
|
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.
|
|
106
108
|
videoEncoder.configure({
|
|
107
109
|
codec: 'avc1.42001f',
|
|
108
110
|
width: config.width,
|
|
@@ -112,19 +114,6 @@ function initWebCodecs(config: InitMessage) {
|
|
|
112
114
|
});
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
function bitrateForQuality(quality: string, width: number, height: number): number {
|
|
116
|
-
const pixels = width * height;
|
|
117
|
-
const baseBitrate = pixels * 4; // ~4 bits per pixel baseline
|
|
118
|
-
switch (quality) {
|
|
119
|
-
case 'draft':
|
|
120
|
-
return Math.round(baseBitrate * 0.5);
|
|
121
|
-
case 'high':
|
|
122
|
-
return Math.round(baseBitrate * 2);
|
|
123
|
-
default: // normal
|
|
124
|
-
return baseBitrate;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
117
|
async function encodeFrameWebCodecs(msg: FrameMessage) {
|
|
129
118
|
if (!videoEncoder || cancelled) {
|
|
130
119
|
msg.bitmap.close();
|
|
@@ -252,10 +241,7 @@ async function encodeFfmpegBatch() {
|
|
|
252
241
|
'libx264',
|
|
253
242
|
'-pix_fmt',
|
|
254
243
|
'yuv420p',
|
|
255
|
-
|
|
256
|
-
config.quality === 'draft' ? 'ultrafast' : config.quality === 'high' ? 'slow' : 'medium',
|
|
257
|
-
'-crf',
|
|
258
|
-
config.quality === 'draft' ? '28' : config.quality === 'high' ? '18' : '23',
|
|
244
|
+
...ffmpegVideoQualityArgs(config.quality),
|
|
259
245
|
segmentName,
|
|
260
246
|
]);
|
|
261
247
|
|
|
@@ -346,7 +332,6 @@ self.onmessage = async (event: MessageEvent<MainToWorkerMessage>) => {
|
|
|
346
332
|
case 'init': {
|
|
347
333
|
cancelled = false;
|
|
348
334
|
totalFramesReceived = 0;
|
|
349
|
-
_totalFramesEncoded = 0;
|
|
350
335
|
|
|
351
336
|
if (await supportsWebCodecsH264(msg)) {
|
|
352
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":[]}
|