Skip to content

Commit 0539f58

Browse files
committed
integrated opus wasm encoder
1 parent b2b4cdd commit 0539f58

22 files changed

+885
-484
lines changed

playground/export.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export async function exportComposition(composition: core.Composition) {
77
if (loader.style.display != 'none') return;
88

99
try {
10-
const encoder = new core.WebcodecsEncoder(composition, { debug: true, fps });
10+
const encoder = new core.Encoder(composition, { debug: true, fps });
1111

1212
encoder.on('render', (event) => {
1313
const { progress, total } = event.detail;
@@ -25,7 +25,7 @@ export async function exportComposition(composition: core.Composition) {
2525
],
2626
});
2727
loader.style.display = 'block';
28-
await encoder.export(fileHandle);
28+
await encoder.render(fileHandle);
2929
} catch (e) {
3030
if (e instanceof DOMException) {
3131
// user canceled file picker
@@ -52,3 +52,7 @@ fpsButton.addEventListener('click', () => {
5252

5353
if (!Number.isNaN(value)) fps = value
5454
});
55+
56+
if (!('showSaveFilePicker' in window)) {
57+
Object.assign(window, { showSaveFilePicker: async () => undefined });
58+
}
Lines changed: 60 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,57 +6,53 @@
66
*/
77

88
import { ArrayBufferTarget, Muxer } from 'mp4-muxer';
9+
import { bufferToI16Interleaved, getVideoEncoderConfigs, resampleBuffer } from '../utils';
10+
import { OpusEncoder } from './opus';
11+
import { toOpusSampleRate } from './utils';
912
import { ExportError } from '../errors';
10-
import * as utils from '../utils';
1113

1214
import type { frame } from '../types';
13-
import type { EncoderOptions } from './config.types';
14-
15-
type CanvasEncoderOptions = Omit<EncoderOptions, 'resolution' | 'debug'>;
15+
import type { EncoderInit } from './interfaces';
1616

1717
/**
1818
* Generic encoder that allows you to encode
1919
* a canvas frame by frame
2020
*/
21-
export class CanvasEncoder implements Required<CanvasEncoderOptions> {
21+
export class CanvasEncoder implements Required<EncoderInit> {
2222
private canvas: HTMLCanvasElement | OffscreenCanvas;
2323
private muxer?: Muxer<ArrayBufferTarget>;
2424
private videoEncoder?: VideoEncoder;
25-
private audioEncoder?: AudioEncoder;
26-
private onready?: () => void;
2725

2826
public frame: frame = 0;
29-
public sampleRate: number; // 44100
30-
public numberOfChannels: number; // 2
31-
public videoBitrate: number // 10e6
32-
public gpuBatchSize: number; // 5
33-
public fps: number; // 30
27+
public sampleRate: number;
28+
public numberOfChannels: number;
29+
public videoBitrate: number;
30+
public gpuBatchSize: number;
31+
public fps: number;
3432

3533
public height: number;
3634
public width: number;
37-
38-
private encodedAudio = false;
35+
public audio: boolean;
3936

4037
/**
4138
* Create a new Webcodecs encoder
4239
* @param canvas - The canvas to encode
43-
* @param options - Configure the output
40+
* @param init - Configure the output
4441
* @example
45-
* ```typescript
42+
* ```
4643
* const encoder = new CanvasEncoder(canvas, { fps: 60 });
4744
* ```
4845
*/
49-
public constructor(canvas: HTMLCanvasElement | OffscreenCanvas, options?: CanvasEncoderOptions) {
46+
public constructor(canvas: HTMLCanvasElement | OffscreenCanvas, init?: EncoderInit) {
5047
this.canvas = canvas;
5148
this.width = canvas.width;
5249
this.height = canvas.height;
53-
this.fps = options?.fps ?? 30;
54-
this.sampleRate = options?.sampleRate ?? 44100;
55-
this.numberOfChannels = options?.numberOfChannels ?? 2;
56-
this.videoBitrate = options?.videoBitrate ?? 10e6;
57-
this.gpuBatchSize = options?.gpuBatchSize ?? 5;
58-
59-
this.init();
50+
this.fps = init?.fps ?? 30;
51+
this.sampleRate = toOpusSampleRate(init?.sampleRate ?? 48000);
52+
this.numberOfChannels = init?.numberOfChannels ?? 2;
53+
this.videoBitrate = init?.videoBitrate ?? 10e6;
54+
this.gpuBatchSize = init?.gpuBatchSize ?? 5;
55+
this.audio = init?.audio ?? false;
6056
}
6157

6258
/**
@@ -65,29 +61,23 @@ export class CanvasEncoder implements Required<CanvasEncoderOptions> {
6561
*/
6662
private async init() {
6763
// First check whether web codecs are supported
68-
const [video, audio] = await utils.getSupportedEncoderConfigs({
69-
video: {
70-
height: Math.round(this.height),
71-
width: Math.round(this.width),
72-
bitrate: this.videoBitrate,
73-
fps: this.fps,
74-
},
75-
audio: {
76-
sampleRate: this.sampleRate,
77-
numberOfChannels: this.numberOfChannels,
78-
bitrate: 128_000, // 128 kbps
79-
}
64+
const configs = await getVideoEncoderConfigs({
65+
height: Math.round(this.height),
66+
width: Math.round(this.width),
67+
bitrate: this.videoBitrate,
68+
fps: this.fps,
8069
});
8170

8271
this.muxer = new Muxer({
8372
target: new ArrayBufferTarget(),
84-
video: { ...video, codec: 'avc' },
73+
video: { ...configs[0], codec: 'avc' },
8574
firstTimestampBehavior: 'offset',
8675
fastStart: 'in-memory',
87-
audio: {
88-
...audio,
89-
codec: audio.codec == 'opus' ? 'opus' : 'aac',
90-
},
76+
audio: this.audio ? {
77+
numberOfChannels: this.numberOfChannels,
78+
sampleRate: this.sampleRate,
79+
codec: 'opus',
80+
} : undefined,
9181
});
9282

9383
const init: VideoEncoderInit = {
@@ -98,18 +88,7 @@ export class CanvasEncoder implements Required<CanvasEncoderOptions> {
9888
};
9989

10090
this.videoEncoder = new VideoEncoder(init);
101-
this.videoEncoder.configure(video);
102-
103-
this.audioEncoder = new AudioEncoder({
104-
output: (chunk, meta) => {
105-
meta && this.muxer?.addAudioChunk(chunk, meta);
106-
},
107-
error: console.error,
108-
});
109-
110-
this.audioEncoder.configure(audio);
111-
112-
this.onready?.()
91+
this.videoEncoder.configure(configs[0]);
11392
}
11493

11594
/**
@@ -118,7 +97,7 @@ export class CanvasEncoder implements Required<CanvasEncoderOptions> {
11897
* @returns {Promise<void>} - A promise that resolves when the frame has been encoded
11998
*/
12099
public async encodeVideo(canvas?: HTMLCanvasElement | OffscreenCanvas): Promise<void> {
121-
if (!this.videoEncoder) await this.ready();
100+
if (!this.videoEncoder) await this.init();
122101

123102
if (this.videoEncoder!.encodeQueueSize > this.gpuBatchSize) {
124103
await new Promise((resolve) => {
@@ -142,40 +121,41 @@ export class CanvasEncoder implements Required<CanvasEncoderOptions> {
142121
* @returns {Promise<void>} - A promise that resolves when the audio has been added to the encoder queue
143122
*/
144123
public async encodeAudio(buffer: AudioBuffer): Promise<void> {
145-
if (!this.audioEncoder) await this.ready();
124+
if (!this.muxer) await this.init();
146125

147-
if (buffer.sampleRate != this.sampleRate || buffer.numberOfChannels != this.numberOfChannels) {
148-
buffer = utils.resampleBuffer(buffer, this.sampleRate, this.numberOfChannels);
149-
}
126+
const data = resampleBuffer(buffer, this.sampleRate, this.numberOfChannels);
150127

151-
this.audioEncoder?.encode(
152-
new AudioData({
153-
format: 'f32-planar',
154-
sampleRate: buffer.sampleRate,
155-
numberOfChannels: buffer.numberOfChannels,
156-
numberOfFrames: buffer.length,
157-
timestamp: 0,
158-
data: utils.bufferToF32Planar(buffer),
159-
})
160-
);
128+
const encoder = new OpusEncoder({
129+
output: (chunk, meta) => {
130+
this.muxer?.addAudioChunkRaw(
131+
chunk.data,
132+
chunk.type,
133+
chunk.timestamp,
134+
chunk.duration,
135+
meta
136+
);
137+
},
138+
error: console.error,
139+
});
161140

162-
this.encodedAudio = true;
141+
await encoder.configure({
142+
numberOfChannels: this.numberOfChannels,
143+
sampleRate: this.sampleRate,
144+
});
145+
146+
encoder.encode({
147+
data: bufferToI16Interleaved(data),
148+
numberOfFrames: data.length,
149+
});
163150
}
164151

165152
/**
166153
* Finalizes the rendering process and exports the result as an MP4
167154
* @returns {Promise<Blob>} - The rendered video as a Blob
168155
*/
169-
public async export(): Promise<Blob> {
156+
public async finalize(): Promise<Blob> {
170157
// encode empty buffer
171-
if (!this.encodedAudio) {
172-
const args = [this.numberOfChannels, 1, this.sampleRate] as const;
173-
const context = new OfflineAudioContext(...args);
174-
await this.encodeAudio(context.createBuffer(...args));
175-
}
176-
177158
await this.videoEncoder?.flush();
178-
await this.audioEncoder?.flush();
179159

180160
this.muxer?.finalize();
181161

@@ -192,12 +172,9 @@ export class CanvasEncoder implements Required<CanvasEncoderOptions> {
192172
}
193173

194174
/**
195-
* Wait until the encoder is ready
196-
* @returns {Promise<void>} - A promise that resolves when the encoder is ready
175+
* @deprecated use `finalize` instead
197176
*/
198-
private async ready() {
199-
await new Promise<void>((resolve) => {
200-
this.onready = () => resolve();
201-
})
177+
public async export(): Promise<Blob> {
178+
return this.finalize();
202179
}
203180
}

src/encoders/config.types.ts

Lines changed: 0 additions & 79 deletions
This file was deleted.

0 commit comments

Comments
 (0)