diff --git a/packages/producer/src/services/distributed/assemble.test.ts b/packages/producer/src/services/distributed/assemble.test.ts index 5eaa5aaa38..12eb6e54e8 100644 --- a/packages/producer/src/services/distributed/assemble.test.ts +++ b/packages/producer/src/services/distributed/assemble.test.ts @@ -16,7 +16,16 @@ import { spawnSync } from "node:child_process"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -245,7 +254,23 @@ describe("assemble()", () => { makeMp4Chunk(chunkBPath, 5); const outputPath = join(planDir, "output.mp4"); - const result = await assemble(planDir, [chunkAPath, chunkBPath], null, outputPath); + const oldWorkDir = `${outputPath}.assemble-work`; + mkdirSync(oldWorkDir); + const sentinel = join(oldWorkDir, "keep.txt"); + writeFileSync(sentinel, "unrelated data"); + const pending = assemble(planDir, [chunkAPath, chunkBPath], null, outputPath); + const staging = readdirSync(planDir).filter((name) => + name.startsWith("output.mp4.assemble-work-"), + ); + const stagingMode = + staging.length === 1 ? statSync(join(planDir, staging[0]!)).mode & 0o777 : null; + const result = await pending; + expect(staging).toHaveLength(1); + if (process.platform !== "win32") expect(stagingMode).toBe(0o700); + expect(readFileSync(sentinel, "utf8")).toBe("unrelated data"); + expect( + readdirSync(planDir).filter((name) => name.startsWith("output.mp4.assemble-work-")), + ).toEqual([]); expect(result.outputPath).toBe(outputPath); expect(existsSync(outputPath)).toBe(true); @@ -301,6 +326,34 @@ describe("assemble()", () => { TIMEOUT_MS, ); + it( + "cleans private staging after a failed concat without deleting existing scratch data", + async () => { + if (!hasFfmpeg) return; + const chunks: ChunkSliceJson[] = [ + { index: 0, startFrame: 0, endFrame: 5 }, + { index: 1, startFrame: 5, endFrame: 10 }, + ]; + const planDir = buildPlanDir("mp4", chunks, 10, false); + const invalidChunk = join(planDir, "invalid.mp4"); + writeFileSync(invalidChunk, "not a video"); + const outputPath = join(planDir, "failed.mp4"); + const oldWorkDir = `${outputPath}.assemble-work`; + mkdirSync(oldWorkDir); + const sentinel = join(oldWorkDir, "keep.txt"); + writeFileSync(sentinel, "unrelated data"); + await expect( + assemble(planDir, [invalidChunk, invalidChunk], null, outputPath), + ).rejects.toThrow("concat-copy failed"); + expect(readFileSync(sentinel, "utf8")).toBe("unrelated data"); + expect( + readdirSync(planDir).filter((name) => name.startsWith("failed.mp4.assemble-work-")), + ).toEqual([]); + expect(existsSync(outputPath)).toBe(false); + }, + TIMEOUT_MS, + ); + it( "single-chunk render stamps exact r_frame_rate on the output container", async () => { diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index a959aba644..2b2f36509e 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -27,6 +27,7 @@ import { cpSync, existsSync, mkdirSync, + mkdtempSync, readFileSync, readdirSync, rmSync, @@ -157,9 +158,7 @@ export async function assemble( if (!existsSync(dirname(outputPath))) { mkdirSync(dirname(outputPath), { recursive: true }); } - const workDir = `${outputPath}.assemble-work`; - if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true }); - mkdirSync(workDir, { recursive: true }); + const workDir = mkdtempSync(`${outputPath}.assemble-work-`); try { const concatOutputPath = join(workDir, `concat.${plan.dimensions.format}`);