Skip to content

Commit deeb8d4

Browse files
fix(producer): plan hash no longer depends on the scratch folder (#5782)
1 parent 8e4ce74 commit deeb8d4

2 files changed

Lines changed: 66 additions & 14 deletions

File tree

‎packages/producer/src/services/distributed/plan.test.ts‎

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
* inside the regression harness (`bun run --cwd packages/producer docker:test`).
1616
*/
1717

18-
import { spawnSync } from "node:child_process";
1918
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
19+
import { spawnSync } from "node:child_process";
2020
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2121
import { tmpdir } from "node:os";
2222
import { join } from "node:path";
@@ -788,10 +788,11 @@ describe("plan() — golden planDir + planHash determinism", () => {
788788
// variant generates a 1s silent wav via `ffmpeg`, references it from
789789
// the composition, and runs plan() — exercising the audio-mix path
790790
// that produced the original bug.
791-
const HAS_FFMPEG = (() => {
792-
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
793-
return spawnSync("ffmpeg", ["-version"]).status === 0;
794-
})();
791+
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
792+
const ffmpegOrThrow = (args: string[]) => {
793+
const ffmpeg = spawnSync("ffmpeg", ["-nostdin", "-loglevel", "error", ...args]);
794+
if (ffmpeg.status !== 0) throw new Error(`ffmpeg fixture failed: ${ffmpeg.stderr?.toString()}`);
795+
};
795796

796797
it.skipIf(!HAS_FFMPEG)(
797798
"plan.json.planHash matches recompute on an audio-bearing composition",
@@ -801,11 +802,7 @@ describe("plan() — golden planDir + planHash determinism", () => {
801802
// Generate a 1s mono silent wav. PCM keeps the file tiny without
802803
// pulling in an audio asset fixture.
803804
const audioPath = join(audioProjectDir, "silence.wav");
804-
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
805-
const ffmpeg = spawnSync("ffmpeg", [
806-
"-nostdin",
807-
"-v",
808-
"error",
805+
ffmpegOrThrow([
809806
"-f",
810807
"lavfi",
811808
"-i",
@@ -816,9 +813,6 @@ describe("plan() — golden planDir + planHash determinism", () => {
816813
"pcm_s16le",
817814
audioPath,
818815
]);
819-
if (ffmpeg.status !== 0) {
820-
throw new Error(`ffmpeg silence-wav generation failed: ${ffmpeg.stderr?.toString()}`);
821-
}
822816
writeFileSync(
823817
join(audioProjectDir, "index.html"),
824818
`<!doctype html>
@@ -852,6 +846,46 @@ describe("plan() — golden planDir + planHash determinism", () => {
852846
},
853847
TIMEOUT_MS,
854848
);
849+
850+
it.skipIf(!HAS_FFMPEG)(
851+
"plans a video-bearing project to the same planHash from different plan folders",
852+
async () => {
853+
const videoProjectDir = join(runRoot, "project-with-video");
854+
mkdirSync(videoProjectDir, { recursive: true });
855+
ffmpegOrThrow([
856+
"-f",
857+
"lavfi",
858+
"-i",
859+
"testsrc=size=320x240:rate=30:duration=1",
860+
"-pix_fmt",
861+
"yuv420p",
862+
"-c:v",
863+
"libx264",
864+
join(videoProjectDir, "clip.mp4"),
865+
]);
866+
writeFileSync(
867+
join(videoProjectDir, "index.html"),
868+
`<!doctype html>
869+
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
870+
<video id="clip" src="clip.mp4" data-start="0" muted></video>
871+
</div>`,
872+
"utf-8",
873+
);
874+
875+
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
876+
const dirA = join(runRoot, "plan-video-a");
877+
const dirB = join(runRoot, "nested", "plan-video-b");
878+
for (const dir of [dirA, dirB]) mkdirSync(dir, { recursive: true });
879+
const a = await plan(videoProjectDir, config, dirA);
880+
const b = await plan(videoProjectDir, config, dirB);
881+
882+
expect(a.planHash).toBe(b.planHash);
883+
const videosJson = (dir: string) => readFileSync(join(dir, "meta", "videos.json"), "utf-8");
884+
expect(videosJson(dirB)).toBe(videosJson(dirA));
885+
expect(JSON.parse(videosJson(dirA)).extracted[0].srcPath).toBe("compiled/clip.mp4");
886+
},
887+
TIMEOUT_MS,
888+
);
855889
});
856890

857891
describe.skipIf(spawnSync("ffmpeg", ["-version"]).status !== 0)("stitched mp4 plans", () => {

‎packages/producer/src/services/distributed/plan.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,18 @@ function pathInside(root: string, path: string): string | null {
998998
return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? null : rel;
999999
}
10001000

1001+
/**
1002+
* Name a video source without the planner's scratch folder, so `meta/videos.json`
1003+
* (and the planHash over it) is the same on every run with the same inputs.
1004+
*/
1005+
function stableSourcePath(roots: ReadonlyArray<[root: string, prefix: string]>, srcPath: string) {
1006+
for (const [root, prefix] of roots) {
1007+
const rel = pathInside(root, srcPath);
1008+
if (rel !== null) return [prefix, ...rel.split(sep)].filter(Boolean).join("/");
1009+
}
1010+
return srcPath;
1011+
}
1012+
10011013
/**
10021014
* Return a planDir-relative path chunks can extract a deferred video from.
10031015
* Sources the plan already ships under `compiled/` are reused; anything else
@@ -1329,12 +1341,18 @@ export async function buildLocalExecutionPlan(
13291341
workCompiledDir: compiledDir,
13301342
workVideoFramesDir: stagedVideoFrames,
13311343
});
1344+
// Staged frames live inside compiledDir, so they must match first.
1345+
const sourceRoots: Array<[string, string]> = [
1346+
[stagedVideoFrames, "video-frames"],
1347+
[compiledDir, "compiled"],
1348+
[projectDir, ""],
1349+
];
13321350
const planVideosJson: PlanVideosJson = buildPlanVideosJson({
13331351
videos: composition.videos,
13341352
compositionEnd: job.duration ?? Number.NaN,
13351353
extracted: (extractResult.extractionResult?.extracted ?? []).map((ext) => ({
13361354
videoId: ext.videoId,
1337-
srcPath: ext.srcPath,
1355+
srcPath: stableSourcePath(sourceRoots, ext.srcPath),
13381356
framePattern: ext.framePattern,
13391357
fps: ext.fps,
13401358
totalFrames: ext.totalFrames,

0 commit comments

Comments
 (0)