Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/lint/src/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
25 changes: 18 additions & 7 deletions packages/lint/src/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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),
});
}
Expand Down
Loading