fix(producer): preserve runtime audio variables in distributed plans (#2725)

* fix(producer): preserve runtime audio variables in distributed plans

* fix(producer): prefer runtime duration for volume sampling
This commit is contained in:
James Russo
2026-07-21 19:49:39 -04:00
committed by GitHub
parent 63539a0cde
commit 465c9e7641
9 changed files with 66 additions and 10 deletions
@@ -81,6 +81,25 @@ describe("distributed warning policy", () => {
}); });
}); });
describe("distributed synthetic render job", () => {
it("threads render variables into the plan browser probe job", () => {
const variables = {
voiceoverSrc: "assets/voiceover.wav",
narrationDurationSeconds: 56.738,
};
const job = buildSyntheticRenderJob({
fps: { num: 30, den: 1 },
format: "mp4",
quality: "high",
hdrMode: "force-sdr",
entryFile: "index.html",
variables,
});
expect(job.config.variables).toEqual(variables);
});
});
describe("resolveChunkPlan", () => { describe("resolveChunkPlan", () => {
it("returns 1 chunk when totalFrames fits in configChunkSize", () => { it("returns 1 chunk when totalFrames fits in configChunkSize", () => {
const result = resolveChunkPlan(60, 240, 16); const result = resolveChunkPlan(60, 240, 16);
@@ -782,6 +782,7 @@ export async function plan(
entryFile: config.entryFile ?? "index.html", entryFile: config.entryFile ?? "index.html",
logger: config.logger, logger: config.logger,
producerConfig: cfg, producerConfig: cfg,
variables: config.variables,
}); });
const entryFile = config.entryFile ?? "index.html"; const entryFile = config.entryFile ?? "index.html";
const htmlPath = join(projectDir, entryFile); const htmlPath = join(projectDir, entryFile);
@@ -106,6 +106,8 @@ export interface SyntheticRenderJobInput {
entryFile: string; entryFile: string;
logger?: ProducerLogger; logger?: ProducerLogger;
producerConfig?: RenderConfig["producerConfig"]; producerConfig?: RenderConfig["producerConfig"];
/** Render-time overrides consumed by the plan browser probe. */
variables?: RenderConfig["variables"];
} }
/** /**
@@ -133,6 +135,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
hdrMode: input.hdrMode, hdrMode: input.hdrMode,
strictness: input.strictness, strictness: input.strictness,
producerConfig: input.producerConfig, producerConfig: input.producerConfig,
variables: input.variables,
}; };
return createRenderJob(renderConfig); return createRenderJob(renderConfig);
} }
@@ -1763,11 +1763,11 @@ h1 { font-size: 2rem; }`;
}); });
describe("discoverAudioVolumeAutomationFromTimeline", () => { describe("discoverAudioVolumeAutomationFromTimeline", () => {
it("samples video-derived audio volume without firing GSAP callbacks", async () => { it("prefers runtime duration over stale data-end while sampling video-derived audio", async () => {
class TestAudioElement {} class TestAudioElement {}
class TestVideoElement { class TestVideoElement {
id = "bg-video"; id = "bg-video";
dataset = { start: "0", duration: "1", volume: "0" }; dataset = { start: "0", end: "0.25", duration: "1", volume: "0" };
volume = 0; volume = 0;
} }
@@ -2134,10 +2134,10 @@ export async function discoverAudioVolumeAutomationFromTimeline(
const endAttr = Number.parseFloat(el.dataset.end ?? ""); const endAttr = Number.parseFloat(el.dataset.end ?? "");
const durationAttr = Number.parseFloat(el.dataset.duration ?? ""); const durationAttr = Number.parseFloat(el.dataset.duration ?? "");
const end = const end =
Number.isFinite(endAttr) && endAttr > start Number.isFinite(durationAttr) && durationAttr > 0
? endAttr
: Number.isFinite(durationAttr) && durationAttr > 0
? start + durationAttr ? start + durationAttr
: Number.isFinite(endAttr) && endAttr > start
? endAttr
: duration; : duration;
const sampleStart = Math.max(0, start); const sampleStart = Math.max(0, start);
const sampleEnd = Math.min(duration, end); const sampleEnd = Math.min(duration, end);
@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test";
import { resolveBrowserMediaEnd } from "./shared.js";
describe("resolveBrowserMediaEnd", () => {
it("prefers a runtime duration over a stale compiler-clamped end", () => {
expect(resolveBrowserMediaEnd(0, 5.04, 56.738)).toBe(56.738);
});
it("projects a runtime duration from the browser-local start", () => {
expect(resolveBrowserMediaEnd(2, 7.04, 56.738)).toBe(58.738);
});
it("falls back to data-end when runtime duration is unavailable", () => {
expect(resolveBrowserMediaEnd(0, 5.04, Number.NaN)).toBe(5.04);
expect(resolveBrowserMediaEnd(0, 5.04, 0)).toBe(5.04);
});
});
@@ -53,6 +53,19 @@ export interface CompositionMetadata {
*/ */
export const BROWSER_MEDIA_EPSILON = 0.0001; export const BROWSER_MEDIA_EPSILON = 0.0001;
/**
* Resolve the browser/runtime end for a media element.
*
* `data-end` is compiler-generated metadata, while `data-duration` is the
* authored/runtime value. A render variable can replace a placeholder source
* and update `data-duration` after compilation, leaving the compiler-clamped
* `data-end` stale. Prefer the live duration when present so the audio/video
* extraction window follows the runtime media slot.
*/
export function resolveBrowserMediaEnd(start: number, end: number, duration: number): number {
return Number.isFinite(duration) && duration > 0 ? start + duration : end;
}
export function writeFileExclusiveSync(path: string, data: NodeJS.ArrayBufferView | string): void { export function writeFileExclusiveSync(path: string, data: NodeJS.ArrayBufferView | string): void {
try { try {
writeFileSync(path, data, { flag: "wx", mode: 0o600 }); writeFileSync(path, data, { flag: "wx", mode: 0o600 });
@@ -103,6 +103,8 @@ mock.module("../../htmlCompiler.js", () => ({
mock.module("../shared.js", () => ({ mock.module("../shared.js", () => ({
BROWSER_MEDIA_EPSILON: 0.0001, BROWSER_MEDIA_EPSILON: 0.0001,
projectBrowserEndToCompositionTimeline: () => 0, projectBrowserEndToCompositionTimeline: () => 0,
resolveBrowserMediaEnd: (_start: number, end: number, duration: number) =>
Number.isFinite(duration) && duration > 0 ? _start + duration : end,
writeCompiledArtifacts: () => {}, writeCompiledArtifacts: () => {},
})); }));
@@ -55,6 +55,7 @@ import type { ProducerLogger } from "../../../logger.js";
import { import {
BROWSER_MEDIA_EPSILON, BROWSER_MEDIA_EPSILON,
projectBrowserEndToCompositionTimeline, projectBrowserEndToCompositionTimeline,
resolveBrowserMediaEnd,
writeCompiledArtifacts, writeCompiledArtifacts,
type CompositionMetadata, type CompositionMetadata,
} from "../shared.js"; } from "../shared.js";
@@ -454,7 +455,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const projectedEnd = projectBrowserEndToCompositionTimeline( const projectedEnd = projectBrowserEndToCompositionTimeline(
existing.start, existing.start,
el.start, el.start,
el.end, resolveBrowserMediaEnd(el.start, el.end, el.duration),
); );
if ( if (
projectedEnd > 0 && projectedEnd > 0 &&
@@ -482,7 +483,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
id: el.id, id: el.id,
src, src,
start: el.start, start: el.start,
end: el.end, end: resolveBrowserMediaEnd(el.start, el.end, el.duration),
mediaStart: el.mediaStart, mediaStart: el.mediaStart,
loop: el.loop, loop: el.loop,
hasAudio: el.hasAudio && !el.muted, hasAudio: el.hasAudio && !el.muted,
@@ -500,7 +501,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const projectedEnd = projectBrowserEndToCompositionTimeline( const projectedEnd = projectBrowserEndToCompositionTimeline(
existing.start, existing.start,
el.start, el.start,
el.end, resolveBrowserMediaEnd(el.start, el.end, el.duration),
); );
if ( if (
projectedEnd > 0 && projectedEnd > 0 &&
@@ -527,7 +528,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
id: el.id, id: el.id,
src, src,
start: el.start, start: el.start,
end: el.end, end: resolveBrowserMediaEnd(el.start, el.end, el.duration),
mediaStart: el.mediaStart, mediaStart: el.mediaStart,
layer: 0, layer: 0,
volume: el.volume, volume: el.volume,