mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
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:
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user