diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts
index e5664c8cf3..fcdf3d0317 100644
--- a/packages/lint/src/rules/media.test.ts
+++ b/packages/lint/src/rules/media.test.ts
@@ -695,6 +695,31 @@ describe("media rules", () => {
expect(findings.every((finding) => finding.fixHint?.includes("data-var-src"))).toBe(true);
});
+ it.each([
+ {
+ script: `const target = document.getElementById("clip"); { const target = document.getElementById("poster"); target.src = "b.png"; } target.src = "b.mp4";`,
+ expected: ["clip"],
+ },
+ {
+ script: `const target = document.getElementById("clip"); function update(target) { target.src = "b.png"; }`,
+ expected: [],
+ },
+ {
+ script: `function updateVideo() { const target = document.getElementById("clip"); target.src = "b.mp4"; } function updateImage() { const target = document.getElementById("poster"); target.src = "b.png"; }`,
+ expected: ["clip"],
+ },
+ ])("checks the media source write in its own scope ($expected)", async ({ script, expected }) => {
+ const html = `
+

+
`;
+ const result = await lintHyperframeHtml(html);
+ expect(
+ result.findings
+ .filter((finding) => finding.code === "media_runtime_src_mutation")
+ .map((finding) => finding.elementId),
+ ).toEqual(expected);
+ });
+
it("does not flag img or script src mutations", async () => {
const html = `
diff --git a/packages/parsers/src/mediaSrcMutation.test.ts b/packages/parsers/src/mediaSrcMutation.test.ts
new file mode 100644
index 0000000000..be5db2bc95
--- /dev/null
+++ b/packages/parsers/src/mediaSrcMutation.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest";
+import { extractMediaSrcMutations } from "./mediaSrcMutation.js";
+
+describe("extractMediaSrcMutations", () => {
+ it.each([
+ {
+ label: "block",
+ script: `const media = document.getElementById("video"); { const media = document.getElementById("image"); media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "sibling functions",
+ script: `function updateImage() { const media = document.getElementById("image"); media.src = "image.png"; } function updateVideo() { const media = document.getElementById("video"); media.src = "new.mp4"; }`,
+ },
+ {
+ label: "arrow function",
+ script: `const media = document.getElementById("video"); const updateImage = () => { const media = document.getElementById("image"); media.src = "image.png"; }; media.src = "new.mp4";`,
+ },
+ ])("keeps $label bindings separate", ({ script }) => {
+ expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
+ "#image",
+ "#video",
+ ]);
+ });
+
+ it.each([
+ {
+ label: "parameter",
+ script: `const media = document.getElementById("video"); function update(media) { media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "destructured parameter",
+ script: `const media = document.getElementById("video"); function update({ media }) { media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "unknown local",
+ script: `const media = document.getElementById("video"); { const media = findImage(); media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "destructured local",
+ script: `const media = document.getElementById("video"); { const { media } = assets; media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "catch parameter",
+ script: `const media = document.getElementById("video"); try { update(); } catch(media) { media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ {
+ label: "loop variable",
+ script: `const media = document.getElementById("video"); for (const media of images) { media.src = "image.png"; } media.src = "new.mp4";`,
+ },
+ ])("does not inherit an outer target through a $label", ({ script }) => {
+ expect(extractMediaSrcMutations(script)).toEqual([
+ { selector: "#video", operation: "src_assignment", raw: `media.src = "new.mp4"` },
+ ]);
+ });
+
+ it("resolves a closure and its scoped aliases", () => {
+ const script = `const media = document.getElementById("video"); { const alias = media; function update() { alias.setAttribute("src", "new.mp4"); } }`;
+ expect(extractMediaSrcMutations(script)).toEqual([
+ {
+ selector: "#video",
+ operation: "set_attribute",
+ raw: `alias.setAttribute("src", "new.mp4")`,
+ },
+ ]);
+ });
+
+ it("keeps var declarations in their enclosing function", () => {
+ const script = `function update() { { var media = document.getElementById("video"); } media.src = "new.mp4"; }`;
+ expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
+ "#video",
+ ]);
+ });
+
+ it("retains a parameter that is redeclared and initialized with var", () => {
+ const script = `function update(media) { var media = document.getElementById("video"); media.src = "new.mp4"; }`;
+ expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
+ "#video",
+ ]);
+ });
+
+ it("does not inherit an outer target through a named class expression", () => {
+ const script = `const media = document.getElementById("video"); const View = class media { update() { media.src = "not-a-video"; } }; media.src = "new.mp4";`;
+ expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
+ "#video",
+ ]);
+ });
+
+ it("does not leak block declarations outside their scope", () => {
+ const script = `{ const media = document.getElementById("video"); } media.src = "new.mp4";`;
+ expect(extractMediaSrcMutations(script)).toEqual([]);
+ });
+
+ it("retains direct and computed DOM lookups", () => {
+ const script = `document["getElementById"]("video")["src"] = "new.mp4"; document.querySelector("#audio").setAttribute("SRC", "new.wav");`;
+ expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([
+ "#video",
+ "#audio",
+ ]);
+ });
+
+ it("keeps module parsing and rejects malformed scripts", () => {
+ expect(
+ extractMediaSrcMutations(
+ `export const media = document.getElementById("video"); media.src = "new.mp4";`,
+ ),
+ ).toHaveLength(1);
+ expect(extractMediaSrcMutations("const media = ;")).toEqual([]);
+ });
+
+ it("does not recurse through circular aliases", () => {
+ expect(extractMediaSrcMutations(`let a = b; let b = a; a.src = "new.mp4";`)).toEqual([]);
+ });
+});
diff --git a/packages/parsers/src/mediaSrcMutation.ts b/packages/parsers/src/mediaSrcMutation.ts
index 23812f8374..942ecfd437 100644
--- a/packages/parsers/src/mediaSrcMutation.ts
+++ b/packages/parsers/src/mediaSrcMutation.ts
@@ -7,7 +7,17 @@ export interface MediaSrcMutation {
raw: string;
}
-function parseProgram(script: string): any {
+interface Binding {
+ initializer: acorn.Expression | null;
+ scope: Scope;
+}
+
+interface Scope {
+ parent?: Scope;
+ bindings: Map;
+}
+
+function parseProgram(script: string): acorn.Program {
try {
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" });
} catch {
@@ -15,28 +25,183 @@ function parseProgram(script: string): any {
}
}
-function literalString(node: any): string | undefined {
+function literalString(node: acorn.AnyNode | null | undefined): string | undefined {
if (node?.type === "Literal" && typeof node.value === "string") return node.value;
- if (node?.type === "TemplateLiteral" && node.expressions?.length === 0) {
- return node.quasis?.[0]?.value?.cooked;
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
+ return node.quasis[0]?.value.cooked ?? undefined;
}
return undefined;
}
-function memberName(node: any): string | undefined {
+function memberName(node: acorn.AnyNode | null | undefined): string | undefined {
if (node?.type !== "MemberExpression") return undefined;
- if (!node.computed && node.property?.type === "Identifier") return node.property.name;
+ if (!node.computed && node.property.type === "Identifier") return node.property.name;
return literalString(node.property);
}
-function lookupSelector(node: any, bindings: ReadonlyMap): string | undefined {
- if (node?.type === "Identifier") return bindings.get(node.name);
- if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") {
+function isFunctionScope(node: acorn.AnyNode): boolean {
+ return (
+ node.type === "Program" ||
+ node.type === "FunctionDeclaration" ||
+ node.type === "FunctionExpression" ||
+ node.type === "ArrowFunctionExpression" ||
+ node.type === "StaticBlock"
+ );
+}
+
+function isScope(node: acorn.AnyNode): boolean {
+ return (
+ isFunctionScope(node) ||
+ node.type === "BlockStatement" ||
+ node.type === "CatchClause" ||
+ node.type === "ForStatement" ||
+ node.type === "ForInStatement" ||
+ node.type === "ForOfStatement" ||
+ node.type === "SwitchStatement" ||
+ node.type === "ClassDeclaration" ||
+ node.type === "ClassExpression"
+ );
+}
+
+function declarePattern(
+ pattern: acorn.Pattern,
+ initializer: acorn.Expression | null,
+ scope: Scope,
+ initializerScope = scope,
+): void {
+ switch (pattern.type) {
+ case "Identifier":
+ if (initializer || !scope.bindings.has(pattern.name)) {
+ scope.bindings.set(pattern.name, { initializer, scope: initializerScope });
+ }
+ break;
+ case "RestElement":
+ declarePattern(pattern.argument, null, scope);
+ break;
+ case "AssignmentPattern":
+ declarePattern(pattern.left, null, scope);
+ break;
+ case "ArrayPattern":
+ case "ObjectPattern":
+ declarePatterns(destructuredPatterns(pattern), scope);
+ break;
+ }
+}
+
+function declarePatterns(patterns: readonly acorn.Pattern[], scope: Scope): void {
+ for (const pattern of patterns) declarePattern(pattern, null, scope);
+}
+
+function destructuredPatterns(pattern: acorn.ObjectPattern | acorn.ArrayPattern): acorn.Pattern[] {
+ if (pattern.type === "ObjectPattern") {
+ return pattern.properties.map((property) =>
+ property.type === "RestElement" ? property.argument : property.value,
+ );
+ }
+ return pattern.elements.filter((element): element is acorn.Pattern => element !== null);
+}
+
+function declareOptionalPattern(pattern: acorn.Pattern | null | undefined, scope: Scope): void {
+ if (pattern) declarePattern(pattern, null, scope);
+}
+
+function declareScopedName(node: acorn.AnyNode, scope: Scope): void {
+ switch (node.type) {
+ case "FunctionExpression":
+ case "FunctionDeclaration":
+ declareOptionalPattern(node.id, scope);
+ declarePatterns(node.params, scope);
+ break;
+ case "ArrowFunctionExpression":
+ declarePatterns(node.params, scope);
+ break;
+ case "ClassDeclaration":
+ case "ClassExpression":
+ declareOptionalPattern(node.id, scope);
+ break;
+ case "CatchClause":
+ declareOptionalPattern(node.param, scope);
+ break;
+ case "ImportDeclaration":
+ declarePatterns(
+ node.specifiers.map((specifier) => specifier.local),
+ scope,
+ );
+ break;
+ }
+}
+
+function lookupBinding(name: string, scope: Scope): Binding | undefined {
+ for (let current: Scope | undefined = scope; current; current = current.parent) {
+ const binding = current.bindings.get(name);
+ if (binding) return binding;
+ }
+ return undefined;
+}
+
+function collectBindings(
+ ast: acorn.Program,
+): (ancestors: readonly acorn.AnyNode[], functionOnly?: boolean) => Scope {
+ const root: Scope = { bindings: new Map() };
+ const scopes = new WeakMap([[ast, root]]);
+ const scopeAt = (ancestors: readonly acorn.AnyNode[], functionOnly = false): Scope => {
+ let parent = root;
+ let selected = root;
+ for (const node of ancestors) {
+ if (!isScope(node)) continue;
+ let scope = scopes.get(node);
+ if (!scope) {
+ scope = { parent, bindings: new Map() };
+ scopes.set(node, scope);
+ }
+ parent = scope;
+ if (!functionOnly || isFunctionScope(node)) selected = scope;
+ }
+ return selected;
+ };
+
+ acornWalk.fullAncestor(ast, (node, _state, ancestors) => {
+ const scope = scopeAt(ancestors);
+ if (node.type === "VariableDeclarator") {
+ const declaration = ancestors.at(-2);
+ const bindingScope = scopeAt(
+ ancestors,
+ declaration?.type === "VariableDeclaration" && declaration.kind === "var",
+ );
+ declarePattern(node.id, node.init ?? null, bindingScope, scope);
+ return;
+ }
+ if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id) {
+ declarePattern(node.id, null, scopeAt(ancestors.slice(0, -1)));
+ }
+ declareScopedName(node, scope);
+ });
+ return scopeAt;
+}
+
+function lookupSelector(
+ node: acorn.Expression | acorn.Super | null | undefined,
+ scope: Scope,
+ seen = new Set(),
+): string | undefined {
+ if (node?.type === "Identifier") {
+ const binding = lookupBinding(node.name, scope);
+ if (!binding || seen.has(binding)) return undefined;
+ seen.add(binding);
+ return lookupSelector(binding.initializer, binding.scope, seen);
+ }
+ return selectorFromDomLookup(node);
+}
+
+function selectorFromDomLookup(
+ node: acorn.Expression | acorn.Super | null | undefined,
+): string | undefined {
+ if (node?.type !== "CallExpression" || node.callee.type !== "MemberExpression") {
return undefined;
}
const method = memberName(node.callee);
if (method !== "getElementById" && method !== "querySelector") return undefined;
- const value = literalString(node.arguments?.[0]);
+ const value = literalString(node.arguments[0]);
if (!value) return undefined;
return method === "getElementById" ? `#${value}` : value;
}
@@ -45,20 +210,17 @@ function lookupSelector(node: any, bindings: ReadonlyMap): strin
export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
try {
const ast = parseProgram(script);
- const bindings = new Map();
- acornWalk.simple(ast, {
- VariableDeclarator(node: any) {
- if (node.id?.type !== "Identifier") return;
- const selector = lookupSelector(node.init, bindings);
- if (selector) bindings.set(node.id.name, selector);
- },
- });
-
+ const scopeAt = collectBindings(ast);
const mutations: MediaSrcMutation[] = [];
- acornWalk.simple(ast, {
- AssignmentExpression(node: any) {
- if (node.operator !== "=" || memberName(node.left) !== "src") return;
- const selector = lookupSelector(node.left.object, bindings);
+ acornWalk.ancestor(ast, {
+ AssignmentExpression(node, _state, ancestors) {
+ if (
+ node.operator !== "=" ||
+ node.left.type !== "MemberExpression" ||
+ memberName(node.left) !== "src"
+ )
+ return;
+ const selector = lookupSelector(node.left.object, scopeAt(ancestors));
if (!selector) return;
mutations.push({
selector,
@@ -66,10 +228,11 @@ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
raw: script.slice(node.start, node.end),
});
},
- CallExpression(node: any) {
- if (memberName(node.callee) !== "setAttribute") return;
- if (literalString(node.arguments?.[0])?.toLowerCase() !== "src") return;
- const selector = lookupSelector(node.callee.object, bindings);
+ CallExpression(node, _state, ancestors) {
+ if (node.callee.type !== "MemberExpression" || memberName(node.callee) !== "setAttribute")
+ return;
+ if (literalString(node.arguments[0])?.toLowerCase() !== "src") return;
+ const selector = lookupSelector(node.callee.object, scopeAt(ancestors));
if (!selector) return;
mutations.push({
selector,
diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts
index cbd1a105d0..57e791e152 100644
--- a/packages/producer/src/services/render/stages/probeStage.test.ts
+++ b/packages/producer/src/services/render/stages/probeStage.test.ts
@@ -1034,6 +1034,45 @@ describe("runProbeStage — forceScreenshot threading", () => {
expect(mediaPreflightComposition).toBe(input.composition);
});
+ it("probes a video source write after an inner binding shadows its variable", async () => {
+ resetRetryMocks();
+ capturedCfgs.length = 0;
+ const { runProbeStage } = await import("./probeStage.js");
+ const input = makeProbeInput({});
+ input.composition.duration = 5;
+ input.composition.videos.push({
+ id: "clip",
+ src: "a.mp4",
+ start: 0,
+ end: 5,
+ mediaStart: 0,
+ loop: false,
+ hasAudio: false,
+ });
+ input.compiled.html = `
+ `;
+ browserMediaResults = [
+ {
+ id: "clip",
+ tagName: "video",
+ src: "b.mp4",
+ start: 0,
+ end: 5,
+ duration: 5,
+ mediaStart: 0,
+ loop: false,
+ hasAudio: false,
+ volume: 1,
+ muted: true,
+ },
+ ];
+ await runProbeStage(input);
+ expect(capturedCfgs.length).toBeGreaterThan(0);
+ expect(input.composition.videos[0]?.src).toBe("b.mp4");
+ });
+
it("does not probe for img or script src mutations", async () => {
resetRetryMocks();
capturedCfgs.length = 0;