@bendyline/squisq-video-react 1.2.0 → 1.2.2

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.
@@ -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, accumulates
5
- * encoded chunks, and produces a final MP4 ArrayBuffer.
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
- // Use H.264 Baseline for maximum compatibility
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
- '-preset',
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';
@@ -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
- }