feat(lint): error on crossorigin on media (breaks preview) (#1793)

`crossorigin` on <video>/<audio> forces a CORS-checked fetch. The
server-side renderer downloads media directly (no CORS) so renders
always work, but Studio preview runs in the browser — a media host that
omits Access-Control-Allow-Origin silently fails the load, so the media
shows blank/black in preview while the render looks fine, hiding the bug.

Plain displayed media never needs crossorigin; it's only required to read
pixels/samples back (canvas/WebGL texture, WebAudio createMediaElementSource)
and only when the host is known CORS-enabled. New rule
media_crossorigin_breaks_preview flags it as an error with that guidance.


Claude-Session: https://claude.ai/code/session_01NsmfF5FzhqXY6hZ8buXgUE

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-06-29 20:57:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 04700ea9c5
commit 1faeba4aa5
2 changed files with 55 additions and 0 deletions
+28
View File
@@ -267,4 +267,32 @@ describe("media rules", () => {
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" crossorigin="anonymous" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("does not flag media without crossorigin", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeUndefined();
});
});
+27
View File
@@ -483,6 +483,33 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},
// media_crossorigin_breaks_preview — `crossorigin` on <video>/<audio> forces a
// CORS-checked fetch. The server-side renderer downloads media directly (no CORS),
// so it always works there; but Studio preview runs in the browser, where a media
// host that omits Access-Control-Allow-Origin silently fails the load — the media
// shows BLANK/black in preview while renders look fine, hiding the bug. Plain
// displayed media never needs crossorigin; it's only required to read pixels/samples
// back (canvas/WebGL texture, WebAudio createMediaElementSource) AND only when the
// host is known CORS-enabled.
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
if (!hasAttrName(tag.raw, "crossorigin")) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_crossorigin_breaks_preview",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has crossorigin, which forces a CORS-checked fetch. If the media host omits Access-Control-Allow-Origin, the load silently fails in Studio preview (media shows BLANK/black) while server-side renders still work — hiding the bug.`,
elementId,
fixHint:
"Remove the crossorigin attribute unless you read the media back via canvas/WebGL/WebAudio AND the host is known to send CORS headers. Plain displayed media never needs it.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// video_audio_double_source — catches audible <video> paired with a separate
// <audio> pointing to the same file, which causes double playback at runtime
({ tags }) => {