Skip to content

Commit e8d2d09

Browse files
committed
fix(producer): a render refuses to publish a video smaller than it asked for
The final output check compares the encoded width and height with the requested size. A non-native --resolution with HDR footage is refused before calibration launches Chrome, and the refusal names --sdr or the project size.
1 parent 4282367 commit e8d2d09

5 files changed

Lines changed: 89 additions & 1 deletion

File tree

‎packages/producer/src/services/render/artifactTransaction.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,49 @@ describe("ArtifactTransaction", () => {
291291
transaction.rollback();
292292
});
293293

294+
it("rejects a video encoded at a different size than the render asked for", async () => {
295+
// A 4K export of a 1080x1920 HDR project once published a 1080x1920 file as done.
296+
const dir = tempDir();
297+
const probe: ArtifactDurationProbe = async () => ({
298+
durationSeconds: 2,
299+
width: 1080,
300+
height: 1920,
301+
});
302+
const transaction = new ArtifactTransaction(join(dir, "render.mp4"), "file", undefined, probe);
303+
writeFileSync(transaction.stagingPath, "fake-mp4-bytes-that-are-non-empty");
304+
305+
await expect(
306+
transaction.validate({
307+
expectedDurationSeconds: 2,
308+
expectedWidth: 2160,
309+
expectedHeight: 3840,
310+
}),
311+
).rejects.toThrow("expected 2160x3840, probed 1080x1920");
312+
313+
transaction.rollback();
314+
});
315+
316+
it("accepts the one-pixel pad a 4:2:0 encode gives an odd side", async () => {
317+
const dir = tempDir();
318+
const probe: ArtifactDurationProbe = async () => ({
319+
durationSeconds: 2,
320+
width: 1082,
321+
height: 724,
322+
});
323+
const transaction = new ArtifactTransaction(join(dir, "render.mp4"), "file", undefined, probe);
324+
writeFileSync(transaction.stagingPath, "fake-mp4-bytes-that-are-non-empty");
325+
326+
await expect(
327+
transaction.validate({
328+
expectedDurationSeconds: 2,
329+
expectedWidth: 1081,
330+
expectedHeight: 724,
331+
}),
332+
).resolves.toBeUndefined();
333+
334+
transaction.rollback();
335+
});
336+
294337
it("rejects a video whose probed frame count is far short of the captured frame count", async () => {
295338
// #3395 field packet frame shortfalls (148 of 300, 1240 of 1566) are
296339
// both well beyond the single-frame tolerance, so the frame-count check
@@ -502,6 +545,12 @@ describe("buildArtifactExpectation", () => {
502545
}
503546
});
504547

548+
it("carries the requested output size", () => {
549+
expect(
550+
buildArtifactExpectation({ ...base, outputFormat: "mp4", width: 2160, height: 3840 }),
551+
).toMatchObject({ expectedWidth: 2160, expectedHeight: 3840 });
552+
});
553+
505554
it("names the video playlist and requires the master for hls", () => {
506555
expect(buildArtifactExpectation({ ...base, outputFormat: "hls" })).toEqual({
507556
expectedDurationSeconds: 4,

‎packages/producer/src/services/render/artifactTransaction.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const defaultFileSystem: ArtifactTransactionFileSystem = {
4242
export interface ArtifactDurationProbeResult {
4343
durationSeconds: number;
4444
frames?: number;
45+
width?: number;
46+
height?: number;
4547
}
4648

4749
/**
@@ -68,6 +70,8 @@ async function defaultArtifactDurationProbe(path: string): Promise<ArtifactDurat
6870
return {
6971
durationSeconds: meta.durationSeconds,
7072
frames: meta.frames,
73+
width: meta.width,
74+
height: meta.height,
7175
};
7276
}
7377

@@ -97,6 +101,9 @@ export interface ArtifactValidationExpectation {
97101
expectedDurationSeconds: number;
98102
fps?: number;
99103
expectedFrames?: number;
104+
/** The output size the render was asked for; a route that ignores it must not publish. */
105+
expectedWidth?: number;
106+
expectedHeight?: number;
100107
toleranceSeconds?: number;
101108
/** Path relative to the staging directory to run the duration probe against. */
102109
probeTarget?: string;
@@ -122,13 +129,17 @@ export function buildArtifactExpectation(input: {
122129
durationSeconds: number;
123130
fps: number;
124131
expectedFrames: number | undefined;
132+
width?: number;
133+
height?: number;
125134
}): ArtifactValidationExpectation | undefined {
126135
if (input.outputFormat === "png-sequence" || input.outputFormat === "gif") return undefined;
127136
if (!Number.isFinite(input.durationSeconds) || input.durationSeconds <= 0) return undefined;
128137
const expectation: ArtifactValidationExpectation = {
129138
expectedDurationSeconds: input.durationSeconds,
130139
fps: input.fps,
131140
expectedFrames: input.expectedFrames,
141+
expectedWidth: input.width,
142+
expectedHeight: input.height,
132143
};
133144
if (input.outputFormat !== "hls") return expectation;
134145
return {
@@ -239,6 +250,23 @@ function assertFrameCountWithinTolerance(
239250
);
240251
}
241252

253+
function assertSizeMatches(
254+
expected: ArtifactValidationExpectation,
255+
probed: ArtifactDurationProbeResult,
256+
stagingPath: string,
257+
): void {
258+
const { expectedWidth: width, expectedHeight: height } = expected;
259+
if (width === undefined || height === undefined) return;
260+
if (probed.width === undefined || probed.height === undefined) return;
261+
// A 4:2:0 encode pads an odd side up by one pixel.
262+
const fits = (want: number, got: number): boolean => got === want || got === want + (want % 2);
263+
if (fits(width, probed.width) && fits(height, probed.height)) return;
264+
throw new Error(
265+
`Render artifact has the wrong size: expected ${width}x${height}, ` +
266+
`probed ${probed.width}x${probed.height}. Artifact: ${stagingPath}`,
267+
);
268+
}
269+
242270
/**
243271
* Stages a render beside its final destination and promotes only a validated
244272
* artifact. File promotion uses one atomic replacement rename, so an existing
@@ -342,6 +370,7 @@ export class ArtifactTransaction {
342370
// above carries the gate on its own.
343371
assertFrameCountWithinTolerance(expected.expectedFrames, probed.frames, probePath);
344372
}
373+
assertSizeMatches(expected, probed, probePath);
345374
}
346375

347376
private async runDurationProbe(probePath: string): Promise<ArtifactDurationProbeResult> {

‎packages/producer/src/services/render/outputScaleRoute.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ describe("assertOutputScaleSupported", () => {
1111
expect(() => assertOutputScaleSupported(scale, "hdr_layered")).toThrow(/hdr_layered/);
1212
});
1313

14+
it("names the ways out that apply: --sdr for HDR footage, mp4 for shader transitions", () => {
15+
expect(() => assertOutputScaleSupported(2, "hdr_layered")).toThrow(/add --sdr/);
16+
expect(() => assertOutputScaleSupported(2, "hdr_layered")).toThrow(/render as mp4/);
17+
expect(() => assertOutputScaleSupported(2, "hdr_layered")).not.toThrow(/page-side compositing/);
18+
});
19+
1420
it("lets every route run at the composition's size, and the screenshot routes scale", () => {
1521
for (const route of ["sdr_streaming", "sdr_disk", "sdr_segmented", "hdr_layered"] as const)
1622
expect(() => assertOutputScaleSupported(1, route)).not.toThrow();

‎packages/producer/src/services/render/outputScaleRoute.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function assertOutputScaleSupported(
2626
if (reason === null) return;
2727
throw new Error(
2828
`[Render] --resolution other than the composition's size is not supported on the "${route}" ` +
29-
`capture route: ${reason}. Render at the composition's size, or as mp4 with page-side compositing.`,
29+
`capture route: ${reason}. Render at the composition's size; or, for HDR footage, add --sdr to ` +
30+
"render this size in standard colour; or, for shader transitions, render as mp4.",
3031
);
3132
}

‎packages/producer/src/services/renderOrchestrator.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3560,6 +3560,7 @@ async function executeRenderPipeline(input: {
35603560
// comps that route to the layered/HDR paths.)
35613561
const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]);
35623562
const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0);
3563+
if (hasHdrContent) assertOutputScaleSupported(deviceScaleFactor, "hdr_layered");
35633564
// DE priority inversion eligibility — evaluated BEFORE capture calibration
35643565
// because when every multi-worker resolution would be inverted to 1 anyway,
35653566
// the calibration stage (a throwaway Chrome launch + timeline-spread sample
@@ -5084,6 +5085,8 @@ async function executeRenderPipeline(input: {
50845085
durationSeconds: job.duration,
50855086
fps: fpsToNumber(job.config.fps),
50865087
expectedFrames: captureTotalFrames,
5088+
width: outputWidth,
5089+
height: outputHeight,
50875090
}),
50885091
);
50895092

0 commit comments

Comments
 (0)