From 3506a0b5cc4a9483dd075c710e1c4dd06a729c22 Mon Sep 17 00:00:00 2001 From: Benjamin Robinet Date: Sat, 16 May 2026 00:29:15 +0200 Subject: [PATCH] feat: Implement av1 codec --- src/core/Codec.ts | 9 ++++++--- src/core/Converter.ts | 38 ++++++++++++++++++++++++++++++++------ src/core/Muxer.ts | 20 +++++++++++++++++++- tests/Converter.test.ts | 14 +++++++++++++- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/src/core/Codec.ts b/src/core/Codec.ts index 0929efa..e217d15 100644 --- a/src/core/Codec.ts +++ b/src/core/Codec.ts @@ -1,18 +1,21 @@ import { FF_ENCODER_LIBX264 as H264, - FF_ENCODER_LIBX265 as H265 + FF_ENCODER_LIBX265 as H265, + FF_ENCODER_LIBSVTAV1 as AV1 } from 'node-av/constants' export const CODECS = [ H264, - H265 + H265, + AV1 ] export const DEFAULT_CODEC = H264 export { H264, - H265 + H265, + AV1 } export type Codec = typeof CODECS[number] diff --git a/src/core/Converter.ts b/src/core/Converter.ts index f32a105..0549188 100644 --- a/src/core/Converter.ts +++ b/src/core/Converter.ts @@ -52,7 +52,7 @@ import { import type { Packet } from './Packet' import { Muxer as FSVMuxer } from './Muxer' -import { assertCodec, DEFAULT_CODEC, H264, H265, type Codec } from './Codec' +import { assertCodec, DEFAULT_CODEC, H264, H265, AV1, type Codec } from './Codec' const DEFAULT_ENCODER_OPTIONS: Record> = { [H264]: { @@ -84,6 +84,20 @@ const DEFAULT_ENCODER_OPTIONS: Record> = { refs: '1', pixel_format: AV_PIX_FMT_YUV420P } + }, + [AV1]: { + threadCount: 0, + threadType: FF_THREAD_FRAME, + gopSize: 5, + maxBFrames: 0, + // SVT-AV1 errors out when both a target bitrate and CRF are set; node-av + // defaults bitrate to 1 Mbps so we must explicitly disable it. + bitrate: 0, + options: { + crf: '30', + preset: '8', + pixel_format: AV_PIX_FMT_YUV420P + } } } @@ -270,7 +284,7 @@ async function transcode(source: string | Buffer, { logger?.info(`Initializing encoder with codec ${outputCodec}`) using encoder = await Encoder.create( outputCodec as FFEncoderCodec, - resolveEncoderOptions(videoStream, outputCodec, encoderOptions) + resolveEncoderOptions(videoStream, outputCodec, encoderOptions, decoder) ) // Setting AV_CODEC_FLAG_GLOBAL_HEADER causes the encoder to place SPS/PPS @@ -383,14 +397,14 @@ async function transcodeAlpha(source: string | Buffer, { throw new Error('No video stream found in input') } - const resolvedEncoderOptions = resolveEncoderOptions(videoStream, outputCodec, encoderOptions) - logger?.info(`Initializing decoder with codec ${inputCodec || '[auto]'}`) using decoder = await Decoder.create( videoStream, inputCodec as FFDecoderCodec ) + const resolvedEncoderOptions = resolveEncoderOptions(videoStream, outputCodec, encoderOptions, decoder) + logger?.info(`Initializing color encoder with codec ${outputCodec}`) using colorEncoder = await Encoder.create( outputCodec as FFEncoderCodec, @@ -719,14 +733,26 @@ function streamDurationToMicroseconds(stream: Stream): number { function resolveEncoderOptions( stream: Stream, codec: Codec, - encoderOptions?: Partial + encoderOptions?: Partial, + decoder?: Decoder ): EncoderOptions { const defaultEncoderOptions = DEFAULT_ENCODER_OPTIONS[codec] + // SVT-AV1 derives the encoded frame rate from the codec context time base + // and rejects anything above 240 fps. MP4 streams typically have a very + // fine time base (e.g. 1/15360) which SVT-AV1 misinterprets, so for AV1 + // we use the inverted average frame rate as the time base instead. + const timeBase = codec === AV1 + ? { num: stream.avgFrameRate.den, den: stream.avgFrameRate.num } + : stream.timeBase + return { ...defaultEncoderOptions, - timeBase: stream.timeBase, + timeBase, frameRate: stream.avgFrameRate, + // Passing the decoder lets node-av propagate the source framerate to the + // encoder's codec context at lazy-open time, which SVT-AV1 needs. + ...(decoder && { decoder }), ...encoderOptions, options: { ...defaultEncoderOptions?.options, diff --git a/src/core/Muxer.ts b/src/core/Muxer.ts index bcf8e15..ab7cc20 100644 --- a/src/core/Muxer.ts +++ b/src/core/Muxer.ts @@ -69,9 +69,12 @@ function muxTrack(packets: Packet[], { }: MuxOptions): Buffer { const chunks: Buffer[] = [] const frames: ManifestFrame[] = [] + const usesAVCC = needsAVCC(config.codec) for (const packet of packets) { - const data = Buffer.from(annexBToAVCC(packet.data)) + const data = usesAVCC + ? Buffer.from(annexBToAVCC(packet.data)) + : Buffer.from(packet.data) const previous = frames[frames.length - 1] @@ -103,6 +106,21 @@ function muxTrack(packets: Packet[], { ]) } +/** + * Returns true when the given WebCodecs codec string requires Annex B → AVCC + * conversion before muxing. + * + * H.264 (`avc1.*`) and H.265 (`hvc1.*` / `hev1.*`) packets emitted by ffmpeg + * are in Annex B byte-stream format and must be converted to AVCC (length + * prefixed) for WebCodecs. AV1 (`av01.*`) packets are raw OBU bitstreams and + * are passed through unchanged. + */ +function needsAVCC(codec: string): boolean { + return codec.startsWith('avc1') + || codec.startsWith('hvc1') + || codec.startsWith('hev1') +} + /** * Converts an Annex B bitstream to AVCC format. * diff --git a/tests/Converter.test.ts b/tests/Converter.test.ts index 3b767a6..2cac2da 100644 --- a/tests/Converter.test.ts +++ b/tests/Converter.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest' import { Converter } from '../src/core/Converter' import { Demuxer } from '../src/core/Demuxer' -import { H264, H265 } from '../src/core/Codec' +import { H264, H265, AV1 } from '../src/core/Codec' describe('Converter', () => { describe('mp4/H.264 input (non-alpha)', () => { @@ -57,6 +57,18 @@ describe('Converter', () => { expect([19, 20]).toContain(nalUnitType) // IDR_W_RADL (19) or IDR_N_LP (20) }) + it('converts with libsvtav1 output codec', async () => { + const fsv = demux(await Converter.convert(H264_MP4_FIXTURE, { + outputCodec: AV1 + })) + + expect(fsv.width).toBe(320) + expect(fsv.frames.length).toBeGreaterThan(0) + expect(fsv.config.codec.startsWith('av01')).toBe(true) + expect(fsv.frames[0].chunk.type).toBe('key') + expect(fsv.frames[0].chunk.byteLength).toBeGreaterThan(0) + }) + it('throws for a non-existent input path', async () => { await expect( Converter.convert('/does/not/exist.mp4')