mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(runtime): render media treatments deterministically
This commit is contained in:
@@ -81,6 +81,114 @@ describe("media rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports grading controls placed outside their schema sections", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"preset":"skin-soft","intensity":0.58,"highlights":-0.06,"temperature":0.02}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "color_grading_invalid_structure");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("highlights");
|
||||
expect(finding?.fixHint).toContain('"adjust"');
|
||||
});
|
||||
|
||||
it("accepts grading controls inside their schema sections", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"preset":"skin-soft","intensity":0.58,"adjust":{"highlights":-0.06,"temperature":0.02},"details":{"vignette":0.03},"effects":{"blur":0.1,"chromaBleed":0.2,"tapeDamage":0.3,"tapeTracking":0.4,"tapeNoise":0.5,"tapeSpeed":0.6,"filmArtifacts":0.4,"halftone":0.5,"halftoneSize":0.6,"twoInkPrint":0.7,"twoInkPrintSize":0.8,"ascii":0.9,"asciiSize":0.4,"asciiInvert":1,"dither":0.8,"ditherSize":0.3,"bloom":0.5,"bloomRadius":8,"asciiStyle":4,"asciiColor":1,"asciiRotation":1,"monoScreen":0.5,"monoScreenSize":0.4,"monoScreenAngle":0.3,"monoScreenSpread":0.2,"monoScreenShape":3,"monoScreenInvert":1,"scanlines":0.3,"scanlineCount":0.4,"scanlineSoftness":0.5,"chromaticAberration":0.2,"chromaticAngle":0.6,"crtCurvature":0.25,"digitalGlitch":0.4,"digitalGlitchColorSplit":0.45,"digitalGlitchLineTear":0.5,"digitalGlitchPixelate":0.55,"digitalGlitchBlockAmount":0.6,"digitalGlitchBlockDisplacement":0.7,"digitalGlitchBlockOpacity":0.2,"digitalGlitchSpeed":0.7,"engraving":1,"engravingSpacing":0.4117647,"engravingMinThickness":0.2,"engravingMaxThickness":0.4571429,"engravingAngle":0.25,"engravingContrast":0.4666667,"engravingSharpness":0.59,"engravingWave":0.2,"engravingWaveFrequency":0.2222222},"palette":["#ff6b66","#080717","#d9339f","#3c185f"],"lut":null}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code.startsWith("color_grading_"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts crosshatch controls in the effects section", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"effects":{"crosshatch":1,"crosshatchSpacing":0.28,"crosshatchThickness":0.25,"crosshatchAngle":0.25,"crosshatchContrast":0.3333333,"crosshatchEdges":0.5,"crosshatchLineWeight":0,"crosshatchWave":0.33,"crosshatchWaveFrequency":0.2222222}}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code.startsWith("color_grading_"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts Kuwahara controls in the effects section", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"effects":{"kuwahara":1,"kuwaharaRadius":0.142857,"kuwaharaSharpness":0.3125,"kuwaharaSaturation":0.5}}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code.startsWith("color_grading_"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports malformed or out-of-range color grading palettes", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"effects":{"dither":1},"palette":["#000000","red"]}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "color_grading_invalid_structure");
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.fixHint).toContain("2 to 6");
|
||||
expect(finding?.fixHint).toContain("#RRGGBB");
|
||||
});
|
||||
|
||||
it("reports malformed grading JSON", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"preset":"skin-soft"'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "color_grading_invalid_json")?.severity).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports invalid string values for structured grading sections", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="5" src="clip.mp4" muted data-color-grading='{"adjust":"cinematic"}'></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "color_grading_invalid_structure")?.severity,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("reports color grading on non-media elements", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="background" data-color-grading='{"preset":"skin-soft"}'></div>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "color_grading_non_media")?.severity).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports warning for media with preload=none", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, readDecodedAttr, truncateSnippet, isMediaTag } from "../utils";
|
||||
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -247,6 +248,59 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
return findings;
|
||||
},
|
||||
|
||||
// color_grading_* — grading is a structured media-only contract. Unknown
|
||||
// keys are ignored by the runtime, so catch them before an agent can report
|
||||
// controls that never actually rendered.
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
const raw = readDecodedAttr(tag.raw, "data-color-grading");
|
||||
if (raw === null) continue;
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
const report = (code: string, message: string, fixHint: string) => {
|
||||
findings.push({
|
||||
code,
|
||||
severity: "error",
|
||||
message,
|
||||
elementId,
|
||||
fixHint,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
};
|
||||
if (tag.name !== "video" && tag.name !== "img") {
|
||||
report(
|
||||
"color_grading_non_media",
|
||||
`data-color-grading on <${tag.name}> has no effect. The shader runtime only grades real <video> and <img> elements.`,
|
||||
"Move the grading attribute to the real <video> or <img> media element. Do not attach it to a wrapper or CSS background.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
report(
|
||||
"color_grading_invalid_json",
|
||||
"data-color-grading contains malformed JSON and will not render.",
|
||||
'Use valid JSON, for example {"preset":"skin-soft","intensity":0.6,"adjust":{"highlights":-0.08}}.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const issue of validateColorGradingContract(parsed)) {
|
||||
report(
|
||||
"color_grading_invalid_structure",
|
||||
`data-color-grading ${issue.path} ${issue.message}.`,
|
||||
issue.hint ??
|
||||
"Use the documented media-treatment contract and correct or remove the invalid value.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_missing_muted
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
Reference in New Issue
Block a user