mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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:
@@ -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
|
||||
Reference in New Issue
Block a user