From 4054b1b7e8cbf680c2a44861a0c523037af503e9 Mon Sep 17 00:00:00 2001 From: NingLin Liu <224592858+ninglinLiu@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:17:11 +0800 Subject: [PATCH] fix(rtc-node): size NV12 and I420A chroma planes from the chroma height getPlaneLength multiplied chromaWidth by itself for NV12 and I420A, so VideoFrame.fromOwnedInfo copied too many bytes for landscape frames (reading past the native buffer) and too few for portrait frames. Fixes #746 --- .changeset/fix-nv12-i420a-plane-length.md | 5 ++++ packages/livekit-rtc/src/video_frame.test.ts | 28 ++++++++++++++++++++ packages/livekit-rtc/src/video_frame.ts | 7 ++--- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-nv12-i420a-plane-length.md create mode 100644 packages/livekit-rtc/src/video_frame.test.ts 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; } };