fix(producer): accept partial color metadata in plan v2 (#2814)

* fix(producer): accept partial color metadata in plan v2

* test(engine): make partial color probe hermetic

* fix(producer): validate plan v2 sentinels in fallback mode
This commit is contained in:
James Russo
2026-07-26 21:48:51 -04:00
committed by GitHub
parent 98a4cd70fd
commit 58869f0878
8 changed files with 409 additions and 14 deletions
+35
View File
@@ -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
@@ -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<string, unknown> = {
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(
@@ -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<number, string>();
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`);
@@ -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
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50bb159e7aad75a5672f4918411635b1fd5ef291e217f63734b4589ef949d263
size 1438
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f7c0a9d6609ead40648c7a3fba862c7fbedc97bcf42700363613e509debc504
size 4474
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta content="width=320, height=180" name="viewport" />
<title>Plan v2 partial color metadata regression</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
width: 320px;
height: 180px;
margin: 0;
overflow: hidden;
background: #111827;
}
#main-comp,
#source {
position: absolute;
inset: 0;
width: 320px;
height: 180px;
}
#source {
object-fit: cover;
}
#label {
position: absolute;
right: 12px;
bottom: 12px;
padding: 5px 8px;
border: 1px solid rgba(255, 255, 255, 0.8);
color: #fff;
background: rgba(17, 24, 39, 0.75);
font: 700 11px Arial, sans-serif;
letter-spacing: 0.08em;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-start="0"
data-duration="1"
data-width="320"
data-height="180"
data-no-timeline
>
<video
id="source"
class="clip"
src="partial-color.mp4"
data-start="0"
data-duration="1"
data-track-index="0"
muted
playsinline
></video>
<div id="label">PARTIAL COLOR METADATA</div>
</div>
</body>
</html>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce6b3deecd76d6d80f7b2d3dc4c33f988acf7826b14f670edd62c9c312c89af6
size 2251