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
70 changes: 70 additions & 0 deletions packages/lint/src/rules/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,3 +796,73 @@ describe("audio_carve_ungrouped_sources", () => {
expect(finding?.severity).toBe("warning");
});
});

describe("media_src_kind_mismatch", () => {
it("errors when <video> src is an image", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="bg" src="still.jpg" 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");
expect(finding?.message).toContain("image");
});

it("errors when <img> src is a video", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<img id="still" src="clip.mov">
</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("still");
expect(finding?.message).toContain("video");
});

it("errors when <video> src is a data:image URI", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video src="data:image/png;base64,aaaa" data-start="0" muted></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_src_kind_mismatch")).toBe(true);
});

it("does not flag matching video/img src kinds", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted></video>
<img id="i1" src="still.png">
</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>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="https://images.unsplash.com/photo-1566041510394" data-start="0" muted></video>
<audio id="a1" src="still.jpg" 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();
});
});
87 changes: 87 additions & 0 deletions packages/lint/src/rules/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,90 @@ function hasAttrName(tagSource: string, attr: string): boolean {
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
}

const IMAGE_SRC_EXT = new Set([
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"heic",
"heif",
"tiff",
"ico",
]);
const VIDEO_SRC_EXT = new Set([
"mp4",
"mov",
"avi",
"webm",
"mkv",
"flv",
"wmv",
"m4v",
"mpg",
"mpeg",
]);

function srcKind(src: string): "image" | "video" | null {
const stripped = src.trim();
if (!stripped) return null;
const lower = stripped.toLowerCase();
if (lower.startsWith("data:")) {
const mime = /^data:([^;,]+)/i.exec(stripped)?.[1]?.toLowerCase();
if (!mime) return null;
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
return null;
}
if (lower.startsWith("blob:")) return null;
let pathname = stripped;
try {
if (/^https?:/i.test(stripped)) {
pathname = decodeURIComponent(new URL(stripped).pathname);
} else {
pathname = stripped.split("?")[0]?.split("#")[0] ?? stripped;
}
} catch {
pathname = stripped.split("?")[0]?.split("#")[0] ?? stripped;
}
const base = pathname.split("/").pop() ?? "";
const dot = base.lastIndexOf(".");
if (dot < 0) return null;
const ext = base.slice(dot + 1).toLowerCase();
if (IMAGE_SRC_EXT.has(ext)) return "image";
if (VIDEO_SRC_EXT.has(ext)) return "video";
return null;
}

function findMediaSrcKindMismatchFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
for (const tag of ctx.tags) {
if (tag.name !== "video" && tag.name !== "img") continue;
const src = readAttr(tag.raw, "src");
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";
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.`,
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/…).",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
}

/** Parent `src`, else a descendant `<source src>` (matches engine resolveMediaElementSrc). */
function mediaHasResolvableSrc(tag: OpenTag, tags: readonly OpenTag[]): boolean {
if (readAttr(tag.raw, "src")) return true;
Expand Down Expand Up @@ -459,6 +543,9 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},

// media_src_kind_mismatch
findMediaSrcKindMismatchFindings,

// placeholder_media_url
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
Expand Down
Loading