fix(engine): pad odd output dimensions up to even for H.264/H.265 encode (#1802)

* fix(engine): pad odd output dimensions up to even for H.264/H.265 encode

A composition with an odd data-width or data-height (e.g. a custom 3:1
canvas at 1080x723) failed to encode to MP4. libx264/libx265 with 4:2:0
chroma subsampling (yuv420p, yuv420p10le) require both dimensions to be
even and abort before writing a packet:

  [libx264] height not divisible by 2 (1080x723)
  Error while opening encoder ... Invalid argument

Both the streaming encoder and the chunk encoder built the software
range-conversion filter ("scale=in_range=pc:out_range=tv") with no
even-dimension enforcement, so any odd-sized canvas reached libx264
unmodified and the whole render failed.

Add a shared withEvenDimensionPad helper that appends
pad=ceil(iw/2)*2:ceil(ih/2)*2 to the filter chain only for 4:2:0 pixel
formats. The pad rounds each odd dimension up by one pixel (a no-op when
already even) without scaling, so content is never resampled. Formats
that accept odd dimensions (ProRes 4444 yuva444p10le, VP9 yuva420p) are
excluded, so transparent/alpha output is untouched.

* fix(engine): extend even-dimension pad to GPU 4:2:0 encode paths

The odd-dimension pad added for libx264/libx265 only covered the software
encoder branches. nvenc, videotoolbox, qsv, and amf feed software frames
straight to the hardware encoder with no -vf chain, so an odd-sized 4:2:0
canvas on --gpu (or an auto-selected hardware encoder) reproduced the same
"height not divisible by 2" abort before any packet was written.

Add the even-dimension pad to the software-side -vf chain for those four
GPU paths in both the chunk and streaming encoders, reusing the shared
withEvenDimensionPad helper (the pad runs on CPU before the encode). vaapi
is left as-is: its existing format=nv12,hwupload conversion already aligns
odd dimensions before upload, so it is not double-padded. ProRes 4444 and
VP9 alpha stay untouched, exactly as the software fix excludes them.

nvenc/videotoolbox/qsv/amf arg construction is logic-tested (the pad filter
is asserted on the built arg list for 8-bit and 10-bit 4:2:0, with alpha
ProRes asserted padless); runtime hardware encode is not exercised here.
This commit is contained in:
Miguel Ángel
2026-06-30 10:55:22 -07:00
committed by GitHub
parent c01d1ae03d
commit 3a2f052889
6 changed files with 175 additions and 10 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { requiresEvenDimensions, withEvenDimensionPad } from "./evenDimensions.js";
describe("requiresEvenDimensions", () => {
it("flags 4:2:0 subsampled formats", () => {
expect(requiresEvenDimensions("yuv420p")).toBe(true);
expect(requiresEvenDimensions("yuv420p10le")).toBe(true);
expect(requiresEvenDimensions("yuvj420p")).toBe(true);
});
it("leaves 4:4:4 / alpha formats alone", () => {
expect(requiresEvenDimensions("yuva444p10le")).toBe(false); // ProRes 4444
expect(requiresEvenDimensions("yuva420p")).toBe(false); // VP9 alpha (own branch)
expect(requiresEvenDimensions("rgb48le")).toBe(false);
});
});
describe("withEvenDimensionPad", () => {
it("appends the even-up pad for subsampled output (odd dims bumped to even)", () => {
const vf = withEvenDimensionPad("scale=in_range=pc:out_range=tv", "yuv420p");
expect(vf).toBe("scale=in_range=pc:out_range=tv,pad=ceil(iw/2)*2:ceil(ih/2)*2");
});
it("returns just the pad when there is no existing filter chain", () => {
expect(withEvenDimensionPad("", "yuv420p")).toBe("pad=ceil(iw/2)*2:ceil(ih/2)*2");
});
it("leaves the filter chain unchanged for alpha output (even in, unchanged)", () => {
const vf = "scale=in_range=pc:out_range=tv";
expect(withEvenDimensionPad(vf, "yuva444p10le")).toBe(vf);
});
it("pad rounds UP to even: ceil(n/2)*2 is a no-op for even and +1 for odd", () => {
const evenUp = (n: number) => Math.ceil(n / 2) * 2;
expect(evenUp(1080)).toBe(1080); // even in, unchanged
expect(evenUp(723)).toBe(724); // odd in, bumped to even
expect(evenUp(1)).toBe(2);
});
});