mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(lint): stop flagging dir on <html>; a render test now guards RTL export (#5744)
This commit is contained in:
@@ -108,12 +108,6 @@ Source: `packages/lint/src/rules/composition.ts`.
|
||||
|
||||
**Fix:** Keep `data-composition-id` on exactly one element: the composition root. Remove it from metadata or duplicate hosts.
|
||||
|
||||
### `html_dir_attribute_breaks_render`
|
||||
|
||||
**Error.** `<html dir="rtl">` or `<html dir="auto">` renders correctly in preview and snapshot but produces a fully blank or black video from render — a confirmed, silent failure. `dir="ltr"` is not flagged.
|
||||
|
||||
**Fix:** Remove `dir` from `<html>`, keep `lang`, and scope the direction to individual text-containing elements instead — `direction: rtl` in CSS, or `dir="auto"` on the element.
|
||||
|
||||
### `invalid_parent_traversal_in_asset_path`
|
||||
|
||||
**Error.** Asset paths use `../` to traverse above the project root; render and preview clamp or misresolve them.
|
||||
|
||||
@@ -1594,70 +1594,6 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("html_dir_attribute_breaks_render", () => {
|
||||
const CODE = "html_dir_attribute_breaks_render";
|
||||
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);
|
||||
|
||||
it('flags dir="rtl" on <html>', async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">مرحبا</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = find(result.findings);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain('dir="rtl"');
|
||||
expect(finding?.fixHint).toContain("direction: rtl");
|
||||
});
|
||||
|
||||
it('flags dir="auto" on <html>', async () => {
|
||||
const html = `<html dir="AUTO"><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = find(result.findings);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.fixHint).toContain('dir="auto"');
|
||||
});
|
||||
|
||||
it('does not flag dir="ltr"', async () => {
|
||||
const html = `<html dir="ltr"><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag invalid dir values that browsers treat as ltr", async () => {
|
||||
const html = `<html dir="bogus"><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag when <html> has no dir attribute", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not flag dir="rtl" scoped to an individual element (the documented fix)', async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">
|
||||
<p style="direction: rtl;">مرحبا</p>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(find(result.findings)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subcomposition_blanks_before_host", () => {
|
||||
const find = (findings: Array<{ code: string }>) =>
|
||||
findings.find((f) => f.code === "subcomposition_blanks_before_host");
|
||||
|
||||
@@ -1224,38 +1224,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
.filter((tag) => readJsonAttr(tag.raw, "data-composition-variables"))
|
||||
.flatMap((tag) => variablesDeclarationFindings(tag, tags)),
|
||||
|
||||
// html_dir_attribute_breaks_render — valid non-LTR dir values on
|
||||
// <html> renders correctly in preview/snapshot but produces a fully
|
||||
// blank/black video from render, with no other lint/validate/inspect
|
||||
// check catching it (output file size, far smaller than expected, is the
|
||||
// only tell). Confirmed independently by two separate reports, both
|
||||
// diagnosing the same exact trigger and the same fix: drop dir from
|
||||
// <html>, keep lang, and scope `direction: rtl` to individual
|
||||
// text-containing elements via CSS instead (text still bidi-shapes
|
||||
// correctly). Advisory-only — this does not attempt to fix the render
|
||||
// pipeline's own root cause (suspected to be a capture step that clips a
|
||||
// fixed top-left-origin screenshot region, which RTL layout can shift the
|
||||
// actual content away from), only surfaces the already-confirmed footgun
|
||||
// before someone hits it blind.
|
||||
({ tags }) => {
|
||||
const htmlTag = findHtmlTag(tags);
|
||||
if (!htmlTag) return [];
|
||||
const dir = readAttr(htmlTag.raw, "dir");
|
||||
if (!dir) return [];
|
||||
const normalizedDir = dir.toLowerCase();
|
||||
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
|
||||
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
|
||||
return [
|
||||
{
|
||||
code: "html_dir_attribute_breaks_render",
|
||||
severity: "error",
|
||||
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render. This is a confirmed, silent failure.`,
|
||||
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead. Text still shapes correctly via the browser's own bidi algorithm.`,
|
||||
snippet: truncateSnippet(htmlTag.raw),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// subcomposition_blanks_before_host
|
||||
// Warns when a full-bleed sub-composition slot ends before the host composition
|
||||
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "html-dir-rtl",
|
||||
"description": "Regression guard for right-to-left documents: dir=\"rtl\" on <html> must export the same frame preview shows. A magenta page background sits behind the root, so a blank export or a capture shifted by the RTL scroll origin collapses PSNR.",
|
||||
"tags": ["regression", "rtl", "edge"],
|
||||
"minPsnr": 30,
|
||||
"maxFrameFailures": 0,
|
||||
"minAudioCorrelation": 0,
|
||||
"maxAudioLagWindows": 1,
|
||||
"renderConfig": {
|
||||
"fps": 12,
|
||||
"workers": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:75a439558edcfd99b92e196713c40eae5f992c3a8b155f6dc8f573a7e14e1440
|
||||
size 250280
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:398c7814fb49799c6720a510270a87cc9246a7c30c86c0fc355473e7008d86e0
|
||||
size 8106
|
||||
@@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=640, height=360" />
|
||||
<title>HTML dir rtl</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: rgb(255, 0, 255);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 640px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: #0b3d91;
|
||||
}
|
||||
|
||||
#start-block {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 32px;
|
||||
width: 160px;
|
||||
height: 96px;
|
||||
background: #f5c400;
|
||||
}
|
||||
|
||||
#end-block {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
right: 32px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
background: #19c37d;
|
||||
}
|
||||
|
||||
#headline {
|
||||
position: absolute;
|
||||
top: 160px;
|
||||
right: 32px;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
#progress {
|
||||
position: absolute;
|
||||
right: 32px;
|
||||
bottom: 32px;
|
||||
width: 576px;
|
||||
height: 24px;
|
||||
background: #ff4d4d;
|
||||
transform-origin: right center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-width="640"
|
||||
data-height="360"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
>
|
||||
<div id="start-block"></div>
|
||||
<div id="end-block"></div>
|
||||
<h1 id="headline">مرحبا بالعالم</h1>
|
||||
<div id="progress"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#progress", { scaleX: 0 }, { scaleX: 1, duration: 2, ease: "none" }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -67,7 +67,8 @@
|
||||
"vfr-screen-recording": 6,
|
||||
"vignelli-stacking": 40,
|
||||
"webm-transparency": 60,
|
||||
"webm-vp9": 4
|
||||
"webm-vp9": 4,
|
||||
"html-dir-rtl": 8
|
||||
},
|
||||
"excluded": {
|
||||
"chrome-screenshot-bottom-edge": "Was absent from the hand-written shard matrix, so it has never run in CI and its cost and pass state are unknown. Triage and either schedule it or record a real reason here.",
|
||||
|
||||
Reference in New Issue
Block a user