fix(engine,producer,lint): resolve <source> children for media extract and localize (#3238)

Parent src-only scans skipped multi-format <video>/<audio> markup, so those
elements were never extracted, downloaded, or mixed and rendered blank/silent.
Lint now accepts a child <source src> as a resolvable media src.
This commit is contained in:
Val
2026-08-26 20:17:12 +00:00
committed by GitHub
parent c9f43ebcfb
commit 97991bbd35
8 changed files with 155 additions and 24 deletions
@@ -80,6 +80,28 @@ describe("parseAudioElements strict literal timing", () => {
); );
}); });
describe("parseAudioElements — <source> children", () => {
it("discovers audio and audible-video tracks that use <source> children", () => {
const tracks = parseAudioElements(`
<audio id="bgm" data-start="2" data-end="7">
<source src="https://cdn.example.com/bgm.mp3" type="audio/mpeg">
<source src="_remote_media/bgm.ogg" type="audio/ogg">
</audio>
<video id="rec" data-has-audio="true" data-start="4" data-end="10">
<source src="https://cdn.example.com/rec.mp4" type="video/mp4">
<source src="_remote_media/rec.webm" type="video/webm">
</video>
`);
expect(tracks).toEqual([
expect.objectContaining({ id: "bgm", src: "_remote_media/bgm.ogg", type: "audio" }),
expect.objectContaining({
id: "rec-audio",
src: "_remote_media/rec.webm",
type: "video",
}),
]);
});
});
describe("processCompositionAudio", () => { describe("processCompositionAudio", () => {
const tempDirs: string[] = []; const tempDirs: string[] = [];
+16 -9
View File
@@ -20,7 +20,7 @@ import {
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js"; import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js"; import { unwrapTemplate } from "../utils/htmlTemplate.js";
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js"; import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js"; import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js"; import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
import type { import type {
@@ -512,7 +512,12 @@ export function parseAudioElements(html: string): AudioElement[] {
// <audio> and <video data-has-audio> tracks differ only in the emitted id // <audio> and <video data-has-audio> tracks differ only in the emitted id
// and `type`; everything else (timing, layer, volume) is read identically. // and `type`; everything else (timing, layer, volume) is read identically.
const build = (el: RefResolverEl, id: string, type: AudioElement["type"]): AudioElement => { const build = (
el: RefResolverEl,
id: string,
src: string,
type: AudioElement["type"],
): AudioElement => {
const playbackRateAttr = el.getAttribute("data-playback-rate"); const playbackRateAttr = el.getAttribute("data-playback-rate");
const layerAttr = el.getAttribute("data-layer"); const layerAttr = el.getAttribute("data-layer");
const volumeAttr = el.getAttribute("data-volume"); const volumeAttr = el.getAttribute("data-volume");
@@ -524,7 +529,7 @@ export function parseAudioElements(html: string): AudioElement[] {
const group = groupId ? groupsById.get(groupId) : undefined; const group = groupId ? groupsById.get(groupId) : undefined;
return { return {
id, id,
src: el.getAttribute("src") as string, src,
start: resolveStart(el), start: resolveStart(el),
end: parseEnd(el.getAttribute("data-end")), end: parseEnd(el.getAttribute("data-end")),
mediaStart: readMediaStart(el), mediaStart: readMediaStart(el),
@@ -553,20 +558,22 @@ export function parseAudioElements(html: string): AudioElement[] {
const trackId = (el: RefResolverEl): string | null => const trackId = (el: RefResolverEl): string | null =>
el.getAttribute(MEDIA_RENDER_ID_ATTR) || el.getAttribute("id"); el.getAttribute(MEDIA_RENDER_ID_ATTR) || el.getAttribute("id");
for (const el of document.querySelectorAll("audio[id][src]")) { for (const el of document.querySelectorAll("audio[id]")) {
const id = trackId(el); const id = trackId(el);
const src = resolveMediaElementSrc(el);
// `memberGroupHidden` is the group's own mute: a hidden BUS drops every // `memberGroupHidden` is the group's own mute: a hidden BUS drops every
// member from the mix, the same way `isHidden` drops one track. // member from the mix, the same way `isHidden` drops one track.
if (!id || !el.getAttribute("src") || isHidden(el) || memberGroupHidden(el)) continue; if (!id || !src || isHidden(el) || memberGroupHidden(el)) continue;
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue; if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
elements.push(build(el, id, "audio")); elements.push(build(el, id, src, "audio"));
} }
for (const el of document.querySelectorAll('video[id][src][data-has-audio="true"]')) { for (const el of document.querySelectorAll('video[id][data-has-audio="true"]')) {
const id = trackId(el); const id = trackId(el);
if (!id || !el.getAttribute("src") || isHidden(el)) continue; const src = resolveMediaElementSrc(el);
if (!id || !src || isHidden(el)) continue;
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue; if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
elements.push(build(el, `${id}-audio`, "video")); elements.push(build(el, `${id}-audio`, src, "video"));
} }
return elements; return elements;
@@ -871,6 +871,17 @@ describe("parseVideoElements", () => {
expect(Number.isNaN(v.start)).toBe(false); expect(Number.isNaN(v.start)).toBe(false);
} }
}); });
it("discovers <video> elements that use <source> children", () => {
const videos = parseVideoElements(
'<video id="rec" data-start="1" data-duration="4">' +
'<source src="https://cdn.example.com/rec.mp4" type="video/mp4">' +
'<source src="_remote_media/rec.webm" type="video/webm">' +
"</video>",
);
expect(videos).toHaveLength(1);
expect(videos[0]).toMatchObject({ id: "rec", src: "_remote_media/rec.webm", start: 1, end: 5 });
});
}); });
describe("FrameLookupTable", () => { describe("FrameLookupTable", () => {
@@ -528,16 +528,39 @@ export interface ExtractionResult {
phaseBreakdown: ExtractionPhaseBreakdown; phaseBreakdown: ExtractionPhaseBreakdown;
} }
/** Minimal structural shape for resolving parent/`<source>` media `src`. */
interface MediaSrcEl {
getAttribute(name: string): string | null;
querySelectorAll(selectors: string): Iterable<{ getAttribute(name: string): string | null }>;
}
/**
* Parent `src`, else a `<source src>`. Prefer local paths over http(s) so a
* localized sibling wins when another `<source>` failed to download.
*/
export function resolveMediaElementSrc(el: MediaSrcEl): string | null {
const direct = el.getAttribute("src");
if (direct) return direct;
let remote: string | null = null;
for (const source of el.querySelectorAll("source")) {
const src = source.getAttribute("src");
if (!src) continue;
if (!/^https?:\/\//i.test(src)) return src;
remote ??= src;
}
return remote;
}
export function parseVideoElements(html: string): VideoElement[] { export function parseVideoElements(html: string): VideoElement[] {
const videos: VideoElement[] = []; const videos: VideoElement[] = [];
const { document } = parseHTML(unwrapTemplate(html)); const { document } = parseHTML(unwrapTemplate(html));
const startCache = new Map<RefResolverEl, number>(); const startCache = new Map<RefResolverEl, number>();
const visiting = new Set<RefResolverEl>(); const visiting = new Set<RefResolverEl>();
const videoEls = document.querySelectorAll("video[src]"); const videoEls = document.querySelectorAll("video");
let autoIdCounter = 0; let autoIdCounter = 0;
for (const el of videoEls) { for (const el of videoEls) {
const src = el.getAttribute("src"); const src = resolveMediaElementSrc(el);
if (!src) continue; if (!src) continue;
// Generate a stable ID for videos without one — the producer needs IDs // Generate a stable ID for videos without one — the producer needs IDs
// to track extracted frames and composite them during encoding. // to track extracted frames and composite them during encoding.
+31
View File
@@ -217,6 +217,37 @@ describe("media rules", () => {
expect(finding?.severity).toBe("error"); expect(finding?.severity).toBe("error");
}); });
it("accepts <source src> children in place of parent src", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="rec" data-start="0" data-duration="4" muted playsinline>
<source src="clip.mp4" type="video/mp4">
<source src="clip.webm" type="video/webm">
</video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
});
it("reports error for <source>-only media with no data-start", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="rec" muted playsinline>
<source src="clip.mp4" type="video/mp4">
</video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.elementId).toBe("rec");
});
it("reports error for media with src but no data-start", async () => { it("reports error for media with src but no data-start", async () => {
const html = ` const html = `
<html><body> <html><body>
+18 -4
View File
@@ -1,4 +1,4 @@
import type { LintContext, HyperframeLintFinding } from "../context"; import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils"; import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract"; import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
@@ -41,6 +41,20 @@ function hasAttrName(tagSource: string, attr: string): boolean {
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs); return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
} }
/** 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;
const end = tag.closeIndex ?? tag.endIndex;
if (end == null) return false;
return tags.some(
(child) =>
child.name === "source" &&
child.index > tag.index &&
child.index < end &&
Boolean(readAttr(child.raw, "src")),
);
}
function classNamesFromAttr(classAttr: string | null): string[] { function classNamesFromAttr(classAttr: string | null): string[] {
if (!classAttr) return []; if (!classAttr) return [];
return classAttr.split(/\s+/).filter(Boolean); return classAttr.split(/\s+/).filter(Boolean);
@@ -499,7 +513,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
if (tag.name !== "video" && tag.name !== "audio") continue; if (tag.name !== "video" && tag.name !== "audio") continue;
const hasDataStart = readAttr(tag.raw, "data-start"); const hasDataStart = readAttr(tag.raw, "data-start");
const hasId = readAttr(tag.raw, "id"); const hasId = readAttr(tag.raw, "id");
const hasSrc = readAttr(tag.raw, "src"); const hasSrc = mediaHasResolvableSrc(tag, tags);
if (hasSrc && !hasDataStart) { if (hasSrc && !hasDataStart) {
findings.push({ findings.push({
code: "media_missing_data_start", code: "media_missing_data_start",
@@ -538,9 +552,9 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
findings.push({ findings.push({
code: "media_missing_src", code: "media_missing_src",
severity: "error", severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`, message: `<${tag.name} id="${hasId}"> has data-start but no src (on the element or a <source> child). The renderer cannot load this media.`,
elementId: hasId, elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`, fixHint: `Add src on the <${tag.name}> element, or a <source src="..."> child.`,
snippet: truncateSnippet(tag.raw), snippet: truncateSnippet(tag.raw),
}); });
} }
@@ -1574,6 +1574,25 @@ describe("localizeRemoteMediaSources", () => {
expect(remoteMediaAssets.size).toBe(0); expect(remoteMediaAssets.size).toBe(0);
}); });
it("localizes remote <source> children of a <video>", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => validTestMediaResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-src-"));
const html = `<video id="rec" data-start="0" data-end="5" muted>
<source src="https://src-ok.example.com/rec.mp4" type="video/mp4">
<source src="https://src-ok.example.com/rec.webm" type="video/webm">
</video>`;
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
expect(result).not.toContain("https://src-ok.example.com/");
expect(remoteMediaAssets.size).toBe(2);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = orig;
}
});
it("rewrites src in both double-quoted and single-quoted attributes", async () => { it("rewrites src in both double-quoted and single-quoted attributes", async () => {
const orig = globalThis.fetch; const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
+10 -6
View File
@@ -1280,6 +1280,8 @@ const REMOTE_MEDIA_SUBDIR = "_remote_media";
// have `>` inside quoted attribute values (data-title etc.). // have `>` inside quoted attribute values (data-title etc.).
const REMOTE_MEDIA_TAG_RE = const REMOTE_MEDIA_TAG_RE =
/<(?:video|audio)\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi; /<(?:video|audio)\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi;
// <source src> on media elements (picture uses srcset, not src).
const REMOTE_SOURCE_TAG_RE = /<source\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi;
// Match <img> tags (including agent-pipeline-emitted variants where `src` is // Match <img> tags (including agent-pipeline-emitted variants where `src` is
// not the first attribute). Producer-side localisation is the primary fix for // not the first attribute). Producer-side localisation is the primary fix for
// the remote-<img> flicker; frameCapture's `pollImagesReady`/`decodeAllImages` // the remote-<img> flicker; frameCapture's `pollImagesReady`/`decodeAllImages`
@@ -1350,10 +1352,11 @@ async function downloadAndRewriteUrls(
} }
/** /**
* Download any remote `src` URLs on `<video>` and `<audio>` elements into a * Download any remote `src` URLs on `<video>` / `<audio>` elements and their
* local subdirectory of `downloadDir`, rewrite the HTML src attributes to * `<source>` children into a local subdirectory of `downloadDir`, rewrite the
* relative paths, and return the updated HTML along with a map of * HTML src attributes to relative paths, and return the updated HTML along with
* `{ relativePath → absoluteLocalPath }` for callers to add to `externalAssets`. * a map of `{ relativePath → absoluteLocalPath }` for callers to add to
* `externalAssets`.
* *
* Skips URLs that fail to download (warns and preserves the original URL so * Skips URLs that fail to download (warns and preserves the original URL so
* the browser can still attempt the remote fetch as a fallback). * the browser can still attempt the remote fetch as a fallback).
@@ -1369,13 +1372,14 @@ export async function localizeRemoteMediaSources(
html: string, html: string,
downloadDir: string, downloadDir: string,
): Promise<{ html: string; remoteMediaAssets: Map<string, string> }> { ): Promise<{ html: string; remoteMediaAssets: Map<string, string> }> {
// Collect unique HTTP URLs from <video>/<audio> src attributes.
const urlSet = new Set<string>(); const urlSet = new Set<string>();
const re = new RegExp(REMOTE_MEDIA_TAG_RE.source, REMOTE_MEDIA_TAG_RE.flags); for (const tagRe of [REMOTE_MEDIA_TAG_RE, REMOTE_SOURCE_TAG_RE]) {
const re = new RegExp(tagRe.source, tagRe.flags);
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) { while ((m = re.exec(html)) !== null) {
if (m[1]) urlSet.add(m[1]); if (m[1]) urlSet.add(m[1]);
} }
}
return downloadAndRewriteUrls( return downloadAndRewriteUrls(
urlSet, urlSet,
html, html,