diff --git a/.changeset/fix-nv12-i420a-plane-length.md b/.changeset/fix-nv12-i420a-plane-length.md new file mode 100644 index 00000000..945f8b8d --- /dev/null +++ b/.changeset/fix-nv12-i420a-plane-length.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Size the chroma planes of NV12 and I420A frames from the chroma height when copying them out of native memory diff --git a/packages/livekit-rtc/src/video_frame.test.ts b/packages/livekit-rtc/src/video_frame.test.ts new file mode 100644 index 00000000..bd1c9c07 --- /dev/null +++ b/packages/livekit-rtc/src/video_frame.test.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { VideoBufferType } from '@livekit/rtc-ffi-bindings'; +import { describe, expect, it } from 'vitest'; +import { getPlaneLength } from './video_frame.js'; + +describe('getPlaneLength', () => { + it.each([ + // 8x4: luma 32, each chroma plane 4x2 = 8 + [VideoBufferType.I420, 8, 4, 32 + 8 * 2], + [VideoBufferType.I420A, 8, 4, 32 * 2 + 8 * 2], + [VideoBufferType.NV12, 8, 4, 32 + 8 * 2], + // 4x8: luma 32, each chroma plane 2x4 = 8 + [VideoBufferType.I420, 4, 8, 32 + 8 * 2], + [VideoBufferType.I420A, 4, 8, 32 * 2 + 8 * 2], + [VideoBufferType.NV12, 4, 8, 32 + 8 * 2], + // odd dimensions round the chroma planes up: 5x3 -> 3x2 + [VideoBufferType.I420A, 5, 3, 15 * 2 + 6 * 2], + [VideoBufferType.NV12, 5, 3, 15 + 6 * 2], + ])('sizes buffer type %i at %ix%i as %i bytes', (type, width, height, expected) => { + expect(getPlaneLength(type, width, height)).toBe(expected); + }); + + it('sizes a 1280x720 NV12 frame from its chroma height', () => { + expect(getPlaneLength(VideoBufferType.NV12, 1280, 720)).toBe(1_382_400); + }); +}); diff --git a/packages/livekit-rtc/src/video_frame.ts b/packages/livekit-rtc/src/video_frame.ts index 012a650b..a31430d1 100644 --- a/packages/livekit-rtc/src/video_frame.ts +++ b/packages/livekit-rtc/src/video_frame.ts @@ -103,7 +103,8 @@ export class VideoFrame { } } -const getPlaneLength = (type: VideoBufferType, width: number, height: number): number => { +/** @internal */ +export const getPlaneLength = (type: VideoBufferType, width: number, height: number): number => { const chromaWidth = Math.trunc((width + 1) / 2); const chromaHeight = Math.trunc((height + 1) / 2); switch (type) { @@ -118,13 +119,13 @@ const getPlaneLength = (type: VideoBufferType, width: number, height: number): n case VideoBufferType.I420: return width * height + chromaWidth * chromaHeight * 2; case VideoBufferType.I420A: - return width * height * 2 + chromaWidth * chromaWidth * 2; + return width * height * 2 + chromaWidth * chromaHeight * 2; case VideoBufferType.I422: return width * height + chromaWidth * height * 2; case VideoBufferType.I010: return width * height * 2 + chromaWidth * chromaHeight * 4; case VideoBufferType.NV12: - return width * height + chromaWidth * chromaWidth * 2; + return width * height + chromaWidth * chromaHeight * 2; } };