feat(lint): flag video/img src pointing at an audio file (#3741)

* feat(lint): flag video/img src pointing at an audio file

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(lint): drop m4a from the audio src kinds

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Xuanru Li
2026-09-06 19:08:51 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent b0ac581d8e
commit 0d5d3f3eb3
2 changed files with 74 additions and 7 deletions
+56
View File
@@ -853,6 +853,62 @@ describe("media_src_kind_mismatch", () => {
expect(result.findings.find((f) => f.code === "media_src_kind_mismatch")).toBeUndefined();
});
it("errors when <img> src is an audio file", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<img id="logo" src="https://cdn.example.com/brand-assets/music/theme.mp3?sig=abc">
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_src_kind_mismatch");
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("logo");
expect(finding?.message).toContain("an audio file");
});
it("errors when <video> src is an audio file", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="bg" src="theme.wav" data-start="0" data-duration="5" muted></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_src_kind_mismatch");
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("bg");
});
it("does not flag <audio> pointing at a video container", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="sfx" src="whoosh.mp4" data-start="0"></audio>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "media_src_kind_mismatch")).toBeUndefined();
});
it("does not flag .ogg or .m4a, whose containers can carry video too", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.ogg" data-start="0" data-duration="5" muted></video>
<video id="v2" src="clip.m4a" data-start="0" data-duration="5" muted></video>
<img id="i1" src="clip.ogg" data-start="0" data-duration="5" />
<img id="i2" src="clip.m4a" data-start="0" data-duration="5" />
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "media_src_kind_mismatch")).toBeUndefined();
});
it("does not flag extensionless or audio src", async () => {
const html = `
<html><body>
+18 -7
View File
@@ -67,7 +67,17 @@ const VIDEO_SRC_EXT = new Set([
"mpeg",
]);
function srcKind(src: string): "image" | "video" | null {
const AUDIO_SRC_EXT = new Set(["mp3", "wav", "aac", "flac", "opus", "aiff", "wma"]);
type SrcKind = "image" | "video" | "audio";
const SRC_KIND_NOUN: Record<SrcKind, string> = {
image: "an image",
video: "a video",
audio: "an audio file",
};
function srcKind(src: string): SrcKind | null {
const stripped = src.trim();
if (!stripped) return null;
const lower = stripped.toLowerCase();
@@ -76,6 +86,7 @@ function srcKind(src: string): "image" | "video" | null {
if (!mime) return null;
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
return null;
}
if (lower.startsWith("blob:")) return null;
@@ -95,6 +106,7 @@ function srcKind(src: string): "image" | "video" | null {
const ext = base.slice(dot + 1).toLowerCase();
if (IMAGE_SRC_EXT.has(ext)) return "image";
if (VIDEO_SRC_EXT.has(ext)) return "video";
if (AUDIO_SRC_EXT.has(ext)) return "audio";
return null;
}
@@ -106,19 +118,18 @@ function findMediaSrcKindMismatchFindings(ctx: LintContext): HyperframeLintFindi
if (!src) continue;
const kind = srcKind(src);
if (kind === null) continue;
if (tag.name === "video" && kind !== "image") continue;
if (tag.name === "img" && kind !== "video") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
const expected = tag.name === "video" ? "video" : "image";
if (kind === expected) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_src_kind_mismatch",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> src is a ${kind}, not a ${expected}. The producer fail-closes when the tag and file kind disagree.`,
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> src is ${SRC_KIND_NOUN[kind]}, not ${SRC_KIND_NOUN[expected]}. The producer fail-closes when the tag and file kind disagree.`,
elementId,
fixHint:
tag.name === "video"
? "Use <img> for a still, or point <video> at a video URL (mp4/webm/mov/…)."
: "Use <video> for a video URL, or point <img> at a still (png/jpg/webp/…).",
? "Use <img> for a still, <audio> for sound, or point <video> at a video URL (mp4/webm/mov/…)."
: "Use <video> for a video URL, <audio> for sound, or point <img> at a still (png/jpg/webp/…).",
snippet: truncateSnippet(tag.raw),
});
}