Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/core/Codec.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
38 changes: 32 additions & 6 deletions src/core/Converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Codec, Partial<EncoderOptions>> = {
[H264]: {
Expand Down Expand Up @@ -84,6 +84,20 @@ const DEFAULT_ENCODER_OPTIONS: Record<Codec, Partial<EncoderOptions>> = {
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
}
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -719,14 +733,26 @@ function streamDurationToMicroseconds(stream: Stream): number {
function resolveEncoderOptions(
stream: Stream,
codec: Codec,
encoderOptions?: Partial<EncoderOptions>
encoderOptions?: Partial<EncoderOptions>,
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,
Expand Down
20 changes: 19 additions & 1 deletion src/core/Muxer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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.
*
Expand Down
14 changes: 13 additions & 1 deletion tests/Converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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')
Expand Down
Loading