fix(producer): keep clip playback rate in distributed video metadata (#4736)

parsePlanVideosJson rebuilt each video from a fixed field list that had no
playbackRate, so chunk workers indexed extracted frames at rate 1 while the
planner mixed audio at the authored rate. A data-playback-rate="1.5" clip
showed its picture at 1x in planV2/renderChunkV2/assembleV2 output, while the
single-process render was correct.

Carry playbackRate through the parser, accepting a finite number or a rate
lane, and pin it with a JSON round-trip test that checks the chunk-side frame
lookup for 1.5x, 0.5x and a rate lane.
This commit is contained in:
Val
2026-09-29 20:30:04 +00:00
committed by GitHub
parent 2eeaefb3e5
commit 3631ee3da0
2 changed files with 62 additions and 1 deletions
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { type Fps } from "@hyperframes/core";
import { type Fps, type RateSpec } from "@hyperframes/core";
import {
getFfmpegBinary,
MIXED_AUDIO_FILENAME,
@@ -173,6 +173,17 @@ function readNonNegativeInteger(value: unknown, field: string): number {
return value;
}
function readRateSpec(value: unknown, field: string): RateSpec | undefined {
if (value === undefined) return undefined;
if (typeof value === "number") return readFiniteNumber(value, field);
const lane = readRecord(value, field);
if (!Array.isArray(lane.points)) {
metadataError(`${field}.points`, "must be an array");
}
// The planner serialized this lane from the clip's parsed `data-automation` rate lane.
return lane as unknown as Exclude<RateSpec, number>;
}
function readVideoMetadata(
value: unknown,
field: string,
@@ -233,6 +244,7 @@ export function parsePlanVideosJson(value: unknown): PlanVideosJson {
start: readFiniteNumber(video.start, `${field}.start`),
end: readFiniteNumber(video.end, `${field}.end`),
mediaStart: readFiniteNumber(video.mediaStart, `${field}.mediaStart`),
playbackRate: readRateSpec(video.playbackRate, `${field}.playbackRate`),
loop: readBoolean(video.loop, `${field}.loop`),
hasAudio: readBoolean(video.hasAudio, `${field}.hasAudio`),
};
@@ -145,6 +145,55 @@ describe("distributed video metadata", () => {
);
});
it.each([
{ name: "1.5x", playbackRate: 1.5, expectedFrame: 3 },
{ name: "0.5x", playbackRate: 0.5, expectedFrame: 1 },
{
name: "rate lane",
playbackRate: {
target: "rate",
points: [
{ t: 0, v: 1.5 },
{ t: 4, v: 1.5 },
],
},
expectedFrame: 3,
},
])(
"keeps a $name clip's playback rate across the plan boundary",
({ playbackRate, expectedFrame }) => {
const metadata = extractedMetadata();
const written = buildPlanVideosJson({
videos: [video({ playbackRate })],
extracted: [metadata],
compositionEnd: 8,
});
const read = parsePlanVideosJson(JSON.parse(JSON.stringify(written)));
const lookup = createFrameLookupTable(read.videos, [extractedFrames(metadata)]);
expect(read.videos[0]?.playbackRate).toEqual(playbackRate);
expect(lookup.getActiveFramePayloads(3).get("hero")?.frameIndex).toBe(expectedFrame);
},
);
it("rejects a malformed playback rate", () => {
const valid = buildPlanVideosJson({
videos: [video()],
extracted: [extractedMetadata()],
compositionEnd: 8,
});
expect(() =>
parsePlanVideosJson({ ...valid, videos: [{ ...valid.videos[0], playbackRate: "1.5" }] }),
).toThrow(/playbackRate must be an object/);
expect(() =>
parsePlanVideosJson({
...valid,
videos: [{ ...valid.videos[0], playbackRate: { target: "rate" } }],
}),
).toThrow(/playbackRate.points must be an array/);
});
it.each([
{ loop: false, expectedFrame: 3 },
{ loop: true, expectedFrame: 2 },