diff --git a/packages/engine/src/utils/ffprobe.test.ts b/packages/engine/src/utils/ffprobe.test.ts index 6f50e71ac..214183d34 100644 --- a/packages/engine/src/utils/ffprobe.test.ts +++ b/packages/engine/src/utils/ffprobe.test.ts @@ -316,6 +316,41 @@ describe("ffprobe missing-binary fallback", () => { expect(meta.hasAlpha).toBe(true); }); + it("normalizes omitted video color components to empty strings", async () => { + const { spawn } = createSpawnSpy([ + { + kind: "exit", + code: 0, + stdout: JSON.stringify({ + streams: [ + { + codec_type: "video", + codec_name: "h264", + width: 64, + height: 64, + r_frame_rate: "30/1", + avg_frame_rate: "30/1", + pix_fmt: "yuv420p", + color_space: "bt709", + }, + ], + format: { duration: "1" }, + }), + }, + ]); + vi.resetModules(); + vi.doMock("child_process", () => ({ spawn })); + + const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js"); + const metadata = await extractMediaMetadataMocked("/tmp/partial-color.mp4"); + + expect(metadata.colorSpace).toEqual({ + colorPrimaries: "", + colorTransfer: "", + colorSpace: "bt709", + }); + }); + // Regression: newer libavformat builds (and the output of `hyperframes // remove-background` itself) write the VP9-alpha sidecar tag as // `ALPHA_MODE` (uppercase). The lowercase-only check classified those diff --git a/packages/producer/src/services/distributed/planV2.test.ts b/packages/producer/src/services/distributed/planV2.test.ts index 6cf1088d2..f1660a574 100644 --- a/packages/producer/src/services/distributed/planV2.test.ts +++ b/packages/producer/src/services/distributed/planV2.test.ts @@ -53,8 +53,24 @@ function createV1Plan( video?: boolean; omitVideoMetadata?: boolean; fpsDen?: number; + videoColorSpace?: unknown; + videoCodec?: string; + videoId?: string; + videoSrcPath?: string; + framePattern?: string; }, ): string { + const { + audio = false, + video = false, + omitVideoMetadata = false, + fpsDen = 1, + videoColorSpace = null, + videoCodec = "h264", + videoId = "hero", + videoSrcPath = "/fixture/hero.mp4", + framePattern = "frame_%05d.jpg", + } = options ?? {}; const planDir = join(root, "v1"); mkdirSync(join(planDir, "compiled"), { recursive: true }); mkdirSync(join(planDir, "meta"), { recursive: true }); @@ -69,7 +85,7 @@ function createV1Plan( ); writeFileSync(join(planDir, "meta", "encoder.json"), "{}"); writeFileSync(join(planDir, "meta", "composition.json"), "{}"); - if (options?.video) { + if (video) { const framesDir = join(planDir, "video-frames", "hero"); mkdirSync(framesDir, { recursive: true }); writeFileSync(join(framesDir, "frame_00001.jpg"), "frame zero"); @@ -80,7 +96,7 @@ function createV1Plan( JSON.stringify({ videos: [ { - id: "hero", + id: videoId, src: "hero.mp4", start: 0, end: 1, @@ -91,9 +107,9 @@ function createV1Plan( ], extracted: [ { - videoId: "hero", - srcPath: "/fixture/hero.mp4", - framePattern: "frame_%05d.jpg", + videoId, + srcPath: videoSrcPath, + framePattern, fps: 30, totalFrames: 3, metadata: { @@ -102,21 +118,21 @@ function createV1Plan( width: 16, height: 16, fps: 30, - videoCodec: "h264", + videoCodec, hasAudio: false, isVFR: false, hasAlpha: false, - colorSpace: null, + colorSpace: videoColorSpace, }, }, ], }), ); - if (options.omitVideoMetadata === true) { + if (omitVideoMetadata) { rmSync(join(planDir, "meta", "videos.json")); } } - if (options?.audio) writeFileSync(join(planDir, "audio.aac"), "assemble-only-audio"); + if (audio) writeFileSync(join(planDir, "audio.aac"), "assemble-only-audio"); writeFileSync( join(planDir, "plan.json"), JSON.stringify({ @@ -124,13 +140,13 @@ function createV1Plan( planHash: "1".repeat(64), chunkCount: 2, totalFrames: 2, - hasAudio: options?.audio === true, + hasAudio: audio, ffmpegVersion: "ffmpeg fixture", producerVersion: "0.0.0-test", fontSnapshotSha: "font-snapshot-fixture", dimensions: { fpsNum: 30, - fpsDen: options?.fpsDen ?? 1, + fpsDen, width: 16, height: 16, format: "mp4", @@ -202,6 +218,87 @@ describe("Plan v2 manifest", () => { ); }); + it("omits the extraction-cache completion sentinel from exact frame dependencies", () => { + const root = tempPath("hf-plan-v2-extraction-sentinel-"); + const v1 = createV1Plan(root, { video: true }); + writeFileSync(join(v1, "video-frames", "hero", ".hf-complete"), ""); + refreshV1PlanHash(v1); + + const result = createPlanV2FromV1(v1, join(root, "v2")); + const manifest = readPlanV2Manifest(result.planDir); + + expect(result.limitations.videoDependencyMode).toBe("exact-rendered-frames"); + expect(manifest.artifacts.some((artifact) => artifact.path.endsWith("/.hf-complete"))).toBe( + false, + ); + expect(manifest.artifacts.some((artifact) => artifact.path.endsWith("/frame_00001.jpg"))).toBe( + true, + ); + }); + + it("omits the extraction-cache completion sentinel from the full-source fallback", () => { + const root = tempPath("hf-plan-v2-full-source-extraction-sentinel-"); + const v1 = createV1Plan(root, { video: true, omitVideoMetadata: true }); + writeFileSync(join(v1, "video-frames", "hero", ".hf-complete"), ""); + refreshV1PlanHash(v1); + + const result = createPlanV2FromV1(v1, join(root, "v2")); + const manifest = readPlanV2Manifest(result.planDir); + + expect(result.limitations.videoDependencyMode).toBe("full-source-pack"); + expect(manifest.artifacts.some((artifact) => artifact.path.endsWith("/.hf-complete"))).toBe( + false, + ); + expect(manifest.artifacts.some((artifact) => artifact.path.endsWith("/frame_00003.jpg"))).toBe( + true, + ); + }); + + it("keeps non-canonical completion-marker paths over-included in full-source mode", () => { + const root = tempPath("hf-plan-v2-nested-extraction-sentinel-"); + const v1 = createV1Plan(root, { video: true, omitVideoMetadata: true }); + const nestedDir = join(v1, "video-frames", "hero", "nested"); + mkdirSync(nestedDir); + writeFileSync(join(nestedDir, ".hf-complete"), "unknown future artifact"); + refreshV1PlanHash(v1); + + const result = createPlanV2FromV1(v1, join(root, "v2")); + const manifest = readPlanV2Manifest(result.planDir); + const nestedMarker = manifest.artifacts.find( + (artifact) => artifact.path === "video-frames/hero/nested/.hf-complete", + ); + + expect(result.limitations.videoDependencyMode).toBe("full-source-pack"); + expect(nestedMarker).toEqual(expect.objectContaining({ chunks: "all", assembler: false })); + }); + + for (const omitVideoMetadata of [false, true]) { + const dependencyMode = omitVideoMetadata ? "full-source" : "exact-dependency"; + for (const malformedSentinel of ["non-empty file", "directory", "symlink"]) { + it(`rejects a ${malformedSentinel} extraction sentinel in ${dependencyMode} mode`, () => { + if (malformedSentinel === "symlink" && process.platform === "win32") return; + const root = tempPath( + `hf-plan-v2-malformed-extraction-sentinel-${dependencyMode}-${malformedSentinel}-`, + ); + const v1 = createV1Plan(root, { video: true, omitVideoMetadata }); + const sentinelPath = join(v1, "video-frames", "hero", ".hf-complete"); + if (malformedSentinel === "symlink") { + symlinkSync(join(v1, "compiled", "asset.txt"), sentinelPath); + } else if (malformedSentinel === "directory") { + mkdirSync(sentinelPath); + writeFileSync(join(sentinelPath, "unexpected"), "not cache metadata"); + } else { + writeFileSync(sentinelPath, "not cache metadata"); + } + refreshV1PlanHash(v1); + + expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow( + ".hf-complete must be a zero-byte regular file", + ); + }); + } + } + it("selects audio only for the assembler", () => { const root = tempPath("hf-plan-v2-targets-"); const result = createPlanV2FromV1(createV1Plan(root, { audio: true }), join(root, "v2")); @@ -231,6 +328,116 @@ describe("Plan v2 manifest", () => { ); }); + const partialColorSpaceCases = [ + { + name: "matrix-only", + colorSpace: { colorTransfer: "", colorPrimaries: "", colorSpace: "bt709" }, + }, + { + name: "transfer-only", + colorSpace: { colorTransfer: "bt709", colorPrimaries: "", colorSpace: "" }, + }, + { + name: "primaries-only", + colorSpace: { colorTransfer: "", colorPrimaries: "bt709", colorSpace: "" }, + }, + ]; + + for (const testCase of partialColorSpaceCases) { + it(`preserves ${testCase.name} video color metadata`, () => { + const root = tempPath(`hf-plan-v2-${testCase.name}-color-`); + const result = createPlanV2FromV1( + createV1Plan(root, { video: true, videoColorSpace: testCase.colorSpace }), + join(root, "v2"), + ); + const materializedDir = join(root, "materialized"); + materializePlanV2Target(result.planDir, { role: "chunk", chunkIndex: 0 }, materializedDir); + + expect( + JSON.parse(readFileSync(join(materializedDir, "meta", "videos.json"), "utf-8")), + ).toEqual( + expect.objectContaining({ + extracted: [ + expect.objectContaining({ + metadata: expect.objectContaining({ colorSpace: testCase.colorSpace }), + }), + ], + }), + ); + }); + } + + it("preserves null video color metadata", () => { + const root = tempPath("hf-plan-v2-null-color-"); + expect(() => + createPlanV2FromV1( + createV1Plan(root, { video: true, videoColorSpace: null }), + join(root, "v2"), + ), + ).not.toThrow(); + }); + + const colorComponents = ["colorTransfer", "colorPrimaries", "colorSpace"]; + const invalidColorValues: Array<{ name: string; value: unknown }> = [ + { name: "missing", value: undefined }, + { name: "null", value: null }, + { name: "number", value: 709 }, + { name: "object", value: { name: "bt709" } }, + ]; + + for (const component of colorComponents) { + for (const invalid of invalidColorValues) { + it(`rejects a ${invalid.name} ${component} color component`, () => { + const root = tempPath(`hf-plan-v2-invalid-${component}-${invalid.name}-`); + const colorSpace: Record = { + colorTransfer: "bt709", + colorPrimaries: "bt709", + colorSpace: "bt709", + }; + colorSpace[component] = invalid.value; + const v1 = createV1Plan(root, { video: true, videoColorSpace: colorSpace }); + + expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow( + `metadata.colorSpace.${component} must be a string`, + ); + }); + } + } + + const unrelatedEmptyStringCases = [ + { + name: "video codec", + options: { videoCodec: "" }, + field: "metadata.videoCodec", + }, + { + name: "video identifier", + options: { videoId: "" }, + field: "videos[0].id", + }, + { + name: "video source path", + options: { videoSrcPath: "" }, + field: "srcPath", + }, + { + name: "frame pattern", + options: { framePattern: "" }, + field: "framePattern", + }, + ]; + + for (const testCase of unrelatedEmptyStringCases) { + it(`continues to reject an empty ${testCase.name}`, () => { + const root = tempPath(`hf-plan-v2-empty-${testCase.name.replaceAll(" ", "-")}-`); + const v1 = createV1Plan(root, { video: true, ...testCase.options }); + + expect(() => createPlanV2FromV1(v1, join(root, "v2"))).toThrow( + `${testCase.field} must be a non-empty string`, + ); + }); + } + it("falls back to the full source frame pack when video metadata is absent", () => { const root = tempPath("hf-plan-v2-video-fallback-"); const result = createPlanV2FromV1( diff --git a/packages/producer/src/services/distributed/planV2.ts b/packages/producer/src/services/distributed/planV2.ts index d1e68adfd..2f5af2be5 100644 --- a/packages/producer/src/services/distributed/planV2.ts +++ b/packages/producer/src/services/distributed/planV2.ts @@ -15,6 +15,7 @@ import { closeSync, copyFileSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, openSync, @@ -53,6 +54,11 @@ import { } from "./shared.js"; const PLAN_V2_HASH_PREFIX = "hyperframes-plan-manifest-hash-v2\x00"; +// The engine's content-addressed extraction cache keeps this ownership marker +// beside numbered frames. Distributed plan() materializes the cache directory +// recursively, so v2 must recognize (and omit) the marker without weakening +// fail-closed handling for any other unexpected filename. +const EXTRACTION_CACHE_COMPLETE_SENTINEL = ".hf-complete"; export const PLAN_V2_MATERIALIZATION_MARKER = ".hyperframes-plan-v2.json"; export { PLAN_V2_INTEGRITY_UNRECOVERABLE, PlanV2IntegrityError }; @@ -209,6 +215,39 @@ function sha256File(path: string): string { return hash.digest("hex"); } +function assertValidExtractionCacheCompleteSentinel(path: string): void { + const sentinel = lstatSync(path); + if (!sentinel.isFile() || sentinel.size !== 0) { + throw new PlanV2IntegrityError( + `${EXTRACTION_CACHE_COMPLETE_SENTINEL} must be a zero-byte regular file`, + ); + } +} + +function validateExtractionCacheCompleteSentinels(planV1Dir: string): void { + const videoRoot = join(planV1Dir, "video-frames"); + if (!existsSync(videoRoot)) return; + + for (const videoEntry of readdirSync(videoRoot, { withFileTypes: true })) { + if (!videoEntry.isDirectory()) continue; + const videoDir = join(videoRoot, videoEntry.name); + if (readdirSync(videoDir).includes(EXTRACTION_CACHE_COMPLETE_SENTINEL)) { + const sentinelPath = join(videoDir, EXTRACTION_CACHE_COMPLETE_SENTINEL); + assertValidExtractionCacheCompleteSentinel(sentinelPath); + } + } +} + +function isExtractionCacheCompleteSentinelPath(path: string): boolean { + const segments = path.split("/"); + return ( + segments.length === 3 && + segments[0] === "video-frames" && + segments[1] !== "" && + segments[2] === EXTRACTION_CACHE_COMPLETE_SENTINEL + ); +} + // This is the fail-safe policy table for every v1 artifact class. Keeping the // branches together makes new artifact classes visibly fall through to both roles. // fallow-ignore-next-line complexity @@ -244,6 +283,10 @@ function listVideoFramePaths(planV1Dir: string, videos: PlanVideosJson): Extract const frameNames = readdirSync(outputDir).sort(); const framePaths = new Map(); for (const frameName of frameNames) { + if (frameName === EXTRACTION_CACHE_COMPLETE_SENTINEL) { + assertValidExtractionCacheCompleteSentinel(join(outputDir, frameName)); + continue; + } // ffmpeg's image sequence starts at 1. Preserve sparse indexes so a // materialized chunk can carry only the frames it actually requests. const match = /(\d+)(?=\.[^.]+$)/.exec(frameName); @@ -300,9 +343,15 @@ function readVideoMetadata( throw new PlanV2IntegrityError(`${field}.colorSpace must be an object or null`); } colorSpace = { - colorTransfer: readString(value.colorSpace.colorTransfer, `${field}.colorTransfer`), - colorPrimaries: readString(value.colorSpace.colorPrimaries, `${field}.colorPrimaries`), - colorSpace: readString(value.colorSpace.colorSpace, `${field}.colorSpace`), + colorTransfer: readColorComponent( + value.colorSpace.colorTransfer, + `${field}.colorSpace.colorTransfer`, + ), + colorPrimaries: readColorComponent( + value.colorSpace.colorPrimaries, + `${field}.colorSpace.colorPrimaries`, + ), + colorSpace: readColorComponent(value.colorSpace.colorSpace, `${field}.colorSpace.colorSpace`), }; } return { @@ -481,8 +530,10 @@ function buildPlanV2Publication(planV1Dir: string): PlanV2Publication { if (!isRecord(dimensions)) { throw new PlanV2IntegrityError("v1 plan.json.dimensions must be an object"); } + validateExtractionCacheCompleteSentinels(planV1Dir); const videoDependencyPlan = buildVideoChunkDependencies(planV1Dir, dimensions); for (const file of listFiles(planV1Dir)) { + if (isExtractionCacheCompleteSentinelPath(file.path)) continue; const targets = artifactTargets(file.path, videoDependencyPlan.dependencies); if ( file.path.startsWith("video-frames/") && @@ -657,6 +708,13 @@ function readString(value: unknown, field: string): string { return value; } +function readColorComponent(value: unknown, field: string): string { + if (typeof value !== "string") { + throw new PlanV2IntegrityError(`${field} must be a string`); + } + return value; +} + function readPositiveInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { throw new PlanV2IntegrityError(`${field} must be a positive integer`); diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/meta.json b/packages/producer/tests/distributed/plan-v2-partial-color/meta.json new file mode 100644 index 000000000..711b2b291 --- /dev/null +++ b/packages/producer/tests/distributed/plan-v2-partial-color/meta.json @@ -0,0 +1,17 @@ +{ + "name": "Distributed: Plan v2 partial color metadata", + "description": "Valid H.264 SDR video whose stream declares only color_space=bt709. The source omits color_transfer and color_primaries, exercising Plan v2 conversion of ffprobe-normalized empty color components across a two-chunk render.", + "tags": ["distributed", "mp4", "h264", "sdr", "plan-v2"], + + "minPsnr": 30, + "maxFrameFailures": 0, + + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + + "renderConfig": { + "fps": 24, + "chunkSize": 12, + "maxParallelChunks": 2 + } +} diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html b/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html new file mode 100644 index 000000000..86237903a --- /dev/null +++ b/packages/producer/tests/distributed/plan-v2-partial-color/output/compiled.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50bb159e7aad75a5672f4918411635b1fd5ef291e217f63734b4589ef949d263 +size 1438 diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 b/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 new file mode 100644 index 000000000..f3a4056d8 --- /dev/null +++ b/packages/producer/tests/distributed/plan-v2-partial-color/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f7c0a9d6609ead40648c7a3fba862c7fbedc97bcf42700363613e509debc504 +size 4474 diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/src/index.html b/packages/producer/tests/distributed/plan-v2-partial-color/src/index.html new file mode 100644 index 000000000..a334aac6f --- /dev/null +++ b/packages/producer/tests/distributed/plan-v2-partial-color/src/index.html @@ -0,0 +1,69 @@ + + + + + + Plan v2 partial color metadata regression + + + +
+ +
PARTIAL COLOR METADATA
+
+ + diff --git a/packages/producer/tests/distributed/plan-v2-partial-color/src/partial-color.mp4 b/packages/producer/tests/distributed/plan-v2-partial-color/src/partial-color.mp4 new file mode 100644 index 000000000..a1e96093b --- /dev/null +++ b/packages/producer/tests/distributed/plan-v2-partial-color/src/partial-color.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce6b3deecd76d6d80f7b2d3dc4c33f988acf7826b14f670edd62c9c312c89af6 +size 2251