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 @@ -379,6 +379,62 @@ describe("media rules", () => {
expect(finding).toBeUndefined();
});

it("warns on raw src mutations targeting existing video and audio", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<video id="clip" src="video-a.mp4"></video>
<audio id="voice" src="audio-a.wav"></audio>
</div>
<script>
const clip = document.getElementById("clip");
clip.src = "video-b.mp4";
document.querySelector("#voice").setAttribute("src", "audio-b.wav");
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(finding) => finding.code === "media_runtime_src_mutation",
);
expect(findings).toHaveLength(2);
expect(findings.map((finding) => finding.elementId).sort()).toEqual(["clip", "voice"]);
expect(findings.every((finding) => finding.fixHint?.includes("data-var-src"))).toBe(true);
});

it("does not flag img or script src mutations", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<img id="poster" src="a.png"><script id="loader"></script>
</div>
<script>
document.getElementById("poster").src = "b.png";
document.getElementById("loader").setAttribute("src", "loader-b.js");
</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
expect(
result.findings.find((finding) => finding.code === "media_runtime_src_mutation"),
).toBeUndefined();
});

it("warns when a source child of existing media is mutated", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<video id="clip"><source id="clip-source" src="video-a.mp4"></video>
</div>
<script>document.getElementById("clip-source").src = "video-b.mp4";</script>
</body></html>`;

const result = await lintHyperframeHtml(html);
expect(
result.findings.find((finding) => finding.code === "media_runtime_src_mutation"),
).toMatchObject({ severity: "warning", elementId: "clip" });
});

it("does not flag <video> inside a sub-composition (runtime drives nested media)", async () => {
// The runtime's global media sweep (querySelectorAll("video, audio")) drives
// media at any nesting depth, and startResolver re-bases each nested clip's
Expand Down
43 changes: 43 additions & 0 deletions packages/lint/src/rules/media.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
import { extractMediaSrcMutations } from "@hyperframes/parsers";
import { parseHTML } from "linkedom";

/**
* Does the GSAP call that names `#id` also set `volume` in the same call?
Expand Down Expand Up @@ -322,6 +324,46 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
return findings;
}

function findRuntimeMediaSrcMutationFindings(ctx: LintContext): HyperframeLintFinding[] {
const { document } = parseHTML(ctx.source);
const findings: HyperframeLintFinding[] = [];
for (const script of ctx.scripts) {
for (const mutation of extractMediaSrcMutations(script.content)) {
let targets: Element[];
try {
const id = /^#[A-Za-z_][\w-]*$/.test(mutation.selector) ? mutation.selector.slice(1) : null;
const idTarget = id ? document.getElementById(id) : null;
targets = id
? idTarget
? [idTarget]
: []
: [...document.querySelectorAll(mutation.selector)];
} catch {
continue;
}
const mediaTargets = targets
.map((element) => {
const name = element.tagName.toLowerCase();
if (name === "video" || name === "audio") return element;
return name === "source" ? element.closest("video, audio") : null;
})
.filter((element): element is Element => element !== null);
if (mediaTargets.length === 0) continue;
findings.push({
code: "media_runtime_src_mutation",
severity: "warning",
message: `Inline script mutates the source of existing managed media via ${mutation.operation === "src_assignment" ? ".src assignment" : "setAttribute('src', ...)"}. Browser probing can reconcile synchronous writes, but external or delayed writes can still diverge between preview and extraction.`,
elementId: mediaTargets[0]?.getAttribute("id") || undefined,
selector: mutation.selector,
fixHint:
"Author the final static src, or bind data-var-src to a declared image/string variable so the selected source is applied before media discovery and extraction.",
snippet: truncateSnippet(mutation.raw),
});
}
}
return findings;
}

export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => {
Expand Down Expand Up @@ -727,6 +769,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =

// imperative_media_control
findImperativeMediaControlFindings,
findRuntimeMediaSrcMutationFindings,

// audio_volume_double_automation
findVolumeDoubleAutomationFindings,
Expand Down
1 change: 1 addition & 0 deletions packages/parsers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from "./compositionContract.js";
// browser-safe.
export { decodeUrlPathVariants } from "./utils/urlPath.js";
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
export { extractMediaSrcMutations, type MediaSrcMutation } from "./mediaSrcMutation.js";
export {
FONT_ALIAS_MAP,
FONT_ALIAS_KEYS,
Expand Down
85 changes: 85 additions & 0 deletions packages/parsers/src/mediaSrcMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";

export interface MediaSrcMutation {
selector: string;
operation: "src_assignment" | "set_attribute";
raw: string;
}

function parseProgram(script: string): any {
try {
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" });
} catch {
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "module" });
}
}

function literalString(node: any): 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;
}
return undefined;
}

function memberName(node: any): string | undefined {
if (node?.type !== "MemberExpression") return undefined;
if (!node.computed && node.property?.type === "Identifier") return node.property.name;
return literalString(node.property);
}

function lookupSelector(node: any, bindings: ReadonlyMap<string, string>): string | undefined {
if (node?.type === "Identifier") return bindings.get(node.name);
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]);
if (!value) return undefined;
return method === "getElementById" ? `#${value}` : value;
}

/** Find literal source writes whose target is a statically resolvable DOM lookup. */
export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
try {
const ast = parseProgram(script);
const bindings = new Map<string, string>();
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 mutations: MediaSrcMutation[] = [];
acornWalk.simple(ast, {
AssignmentExpression(node: any) {
if (node.operator !== "=" || memberName(node.left) !== "src") return;
const selector = lookupSelector(node.left.object, bindings);
if (!selector) return;
mutations.push({
selector,
operation: "src_assignment",
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);
if (!selector) return;
mutations.push({
selector,
operation: "set_attribute",
raw: script.slice(node.start, node.end),
});
},
});
return mutations;
} catch {
return [];
}
}
2 changes: 1 addition & 1 deletion packages/producer/src/services/render/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export interface RenderExtractionObservability {
* a coarse proxy for the ts=1784144554 field signal shape (147-clip
* composition, 130 word-level caption divs authored-clip-count-scaled
* failure). Static scan; dynamic script-inserted timed clips land in
* the probe-stage's `hasRuntimeInsertedMedia` path (PR #2474).
* the probe-stage's `hasRuntimeMediaChanges` path (PR #2474).
*/
authoredTimedClipCount?: number;
}
Expand Down
108 changes: 108 additions & 0 deletions packages/producer/src/services/render/stages/probeStage.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, mock } from "bun:test";
import { createHash } from "node:crypto";
import {
hasAutoStartVideos,
hasScriptedAudioVolumeAutomation,
Expand Down Expand Up @@ -583,6 +584,113 @@ describe("runProbeStage — forceScreenshot threading", () => {
expect(capturedCfgs.length).toBeGreaterThan(0);
});

it("probes and reconciles synchronous src mutations on existing video and audio", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const videoA = `assets/video-a-${hash("sanitized-video-a")}.mp4`;
const videoB = `assets/video-b-${hash("sanitized-video-b")}.mp4`;
const audioA = `assets/audio-a-${hash("sanitized-audio-a")}.wav`;
const audioB = `assets/audio-b-${hash("sanitized-audio-b")}.wav`;
expect(hash("sanitized-video-a")).not.toBe(hash("sanitized-video-b"));
expect(hash("sanitized-audio-a")).not.toBe(hash("sanitized-audio-b"));
browserMediaResults = [
{
id: "clip",
tagName: "video",
src: videoB,
start: 0,
end: 5,
duration: 5,
mediaStart: 0,
loop: false,
hasAudio: false,
volume: 1,
muted: true,
},
{
id: "voice",
tagName: "audio",
src: audioB,
start: 0,
end: 5,
duration: 5,
mediaStart: 0,
loop: false,
hasAudio: true,
volume: 1,
muted: false,
},
];
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
input.composition.duration = 5;
input.composition.videos.push({
id: "clip",
src: videoA,
start: 0,
end: 5,
mediaStart: 0,
loop: false,
hasAudio: false,
});
input.composition.audios.push({
id: "voice",
src: audioA,
start: 0,
end: 5,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
});
input.compiled.html = `<video id="clip" src="${videoA}"></video>
<audio id="voice" src="${audioA}"></audio>
<script>
const clip = document.getElementById("clip");
clip.src = ${JSON.stringify(videoB)};
document.querySelector("#voice").setAttribute("src", ${JSON.stringify(audioB)});
</script>`;

await runProbeStage(input);

expect(capturedCfgs.length).toBeGreaterThan(0);
expect(input.composition.videos[0]?.src).toBe(videoB);
expect(input.composition.audios[0]?.src).toBe(audioB);
expect(mediaPreflightComposition).toBe(input.composition);
});

it("does not probe for img or script src mutations", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
input.composition.duration = 5;
input.compiled.html = `<img id="poster" src="a.png"><script id="loader"></script>
<script>
document.getElementById("poster").src = "b.png";
document.getElementById("loader").setAttribute("src", "loader-b.js");
</script>`;

await runProbeStage(input);

expect(capturedCfgs).toHaveLength(0);
});

it("probes when a source child of existing media is mutated", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({});
input.composition.duration = 5;
input.compiled.html = `<video id="clip"><source id="clip-source" src="video-a.mp4"></video>
<script>document.getElementById("clip-source").src = "video-b.mp4";</script>`;

await runProbeStage(input);

expect(capturedCfgs.length).toBeGreaterThan(0);
});

it("launches a probe when a static-duration composition uses the Audio constructor", async () => {
capturedCfgs.length = 0;
const { runProbeStage } = await import("./probeStage.js");
Expand Down
Loading
Loading