Skip to content

Commit 5e8eeed

Browse files
fix(engine,producer,lint): resolve <source> children for media extract and localize
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.
1 parent 7caf4b8 commit 5e8eeed

8 files changed

Lines changed: 155 additions & 24 deletions

File tree

packages/engine/src/services/audioMixer.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,28 @@ describe("parseAudioElements strict literal timing", () => {
8080
);
8181
});
8282

83+
describe("parseAudioElements — <source> children", () => {
84+
it("discovers audio and audible-video tracks that use <source> children", () => {
85+
const tracks = parseAudioElements(`
86+
<audio id="bgm" data-start="2" data-end="7">
87+
<source src="https://cdn.example.com/bgm.mp3" type="audio/mpeg">
88+
<source src="_remote_media/bgm.ogg" type="audio/ogg">
89+
</audio>
90+
<video id="rec" data-has-audio="true" data-start="4" data-end="10">
91+
<source src="https://cdn.example.com/rec.mp4" type="video/mp4">
92+
<source src="_remote_media/rec.webm" type="video/webm">
93+
</video>
94+
`);
95+
expect(tracks).toEqual([
96+
expect.objectContaining({ id: "bgm", src: "_remote_media/bgm.ogg", type: "audio" }),
97+
expect.objectContaining({
98+
id: "rec-audio",
99+
src: "_remote_media/rec.webm",
100+
type: "video",
101+
}),
102+
]);
103+
});
104+
});
83105
describe("processCompositionAudio", () => {
84106
const tempDirs: string[] = [];
85107

packages/engine/src/services/audioMixer.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
2121
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
2222
import { unwrapTemplate } from "../utils/htmlTemplate.js";
23-
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
23+
import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
2424
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";
2525
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
2626
import type {
@@ -512,7 +512,12 @@ export function parseAudioElements(html: string): AudioElement[] {
512512
// <audio> and <video data-has-audio> tracks differ only in the emitted id
513513

514514
// and `type`; everything else (timing, layer, volume) is read identically.
515-
const build = (el: RefResolverEl, id: string, type: AudioElement["type"]): AudioElement => {
515+
const build = (
516+
el: RefResolverEl,
517+
id: string,
518+
src: string,
519+
type: AudioElement["type"],
520+
): AudioElement => {
516521
const playbackRateAttr = el.getAttribute("data-playback-rate");
517522
const layerAttr = el.getAttribute("data-layer");
518523
const volumeAttr = el.getAttribute("data-volume");
@@ -524,7 +529,7 @@ export function parseAudioElements(html: string): AudioElement[] {
524529
const group = groupId ? groupsById.get(groupId) : undefined;
525530
return {
526531
id,
527-
src: el.getAttribute("src") as string,
532+
src,
528533
start: resolveStart(el),
529534
end: parseEnd(el.getAttribute("data-end")),
530535
mediaStart: readMediaStart(el),
@@ -553,20 +558,22 @@ export function parseAudioElements(html: string): AudioElement[] {
553558
const trackId = (el: RefResolverEl): string | null =>
554559
el.getAttribute(MEDIA_RENDER_ID_ATTR) || el.getAttribute("id");
555560

556-
for (const el of document.querySelectorAll("audio[id][src]")) {
561+
for (const el of document.querySelectorAll("audio[id]")) {
557562
const id = trackId(el);
563+
const src = resolveMediaElementSrc(el);
558564
// `memberGroupHidden` is the group's own mute: a hidden BUS drops every
559565
// member from the mix, the same way `isHidden` drops one track.
560-
if (!id || !el.getAttribute("src") || isHidden(el) || memberGroupHidden(el)) continue;
566+
if (!id || !src || isHidden(el) || memberGroupHidden(el)) continue;
561567
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
562-
elements.push(build(el, id, "audio"));
568+
elements.push(build(el, id, src, "audio"));
563569
}
564570

565-
for (const el of document.querySelectorAll('video[id][src][data-has-audio="true"]')) {
571+
for (const el of document.querySelectorAll('video[id][data-has-audio="true"]')) {
566572
const id = trackId(el);
567-
if (!id || !el.getAttribute("src") || isHidden(el)) continue;
573+
const src = resolveMediaElementSrc(el);
574+
if (!id || !src || isHidden(el)) continue;
568575
if (isKnownInactiveTimelineWindow(el, resolveStart(el))) continue;
569-
elements.push(build(el, `${id}-audio`, "video"));
576+
elements.push(build(el, `${id}-audio`, src, "video"));
570577
}
571578

572579
return elements;

packages/engine/src/services/videoFrameExtractor.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,17 @@ describe("parseVideoElements", () => {
860860
expect(Number.isNaN(v.start)).toBe(false);
861861
}
862862
});
863+
864+
it("discovers <video> elements that use <source> children", () => {
865+
const videos = parseVideoElements(
866+
'<video id="rec" data-start="1" data-duration="4">' +
867+
'<source src="https://cdn.example.com/rec.mp4" type="video/mp4">' +
868+
'<source src="_remote_media/rec.webm" type="video/webm">' +
869+
"</video>",
870+
);
871+
expect(videos).toHaveLength(1);
872+
expect(videos[0]).toMatchObject({ id: "rec", src: "_remote_media/rec.webm", start: 1, end: 5 });
873+
});
863874
});
864875

865876
describe("FrameLookupTable", () => {

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,16 +527,39 @@ export interface ExtractionResult {
527527
phaseBreakdown: ExtractionPhaseBreakdown;
528528
}
529529

530+
/** Minimal structural shape for resolving parent/`<source>` media `src`. */
531+
interface MediaSrcEl {
532+
getAttribute(name: string): string | null;
533+
querySelectorAll(selectors: string): Iterable<{ getAttribute(name: string): string | null }>;
534+
}
535+
536+
/**
537+
* Parent `src`, else a `<source src>`. Prefer local paths over http(s) so a
538+
* localized sibling wins when another `<source>` failed to download.
539+
*/
540+
export function resolveMediaElementSrc(el: MediaSrcEl): string | null {
541+
const direct = el.getAttribute("src");
542+
if (direct) return direct;
543+
let remote: string | null = null;
544+
for (const source of el.querySelectorAll("source")) {
545+
const src = source.getAttribute("src");
546+
if (!src) continue;
547+
if (!/^https?:\/\//i.test(src)) return src;
548+
remote ??= src;
549+
}
550+
return remote;
551+
}
552+
530553
export function parseVideoElements(html: string): VideoElement[] {
531554
const videos: VideoElement[] = [];
532555
const { document } = parseHTML(unwrapTemplate(html));
533556
const startCache = new Map<RefResolverEl, number>();
534557
const visiting = new Set<RefResolverEl>();
535558

536-
const videoEls = document.querySelectorAll("video[src]");
559+
const videoEls = document.querySelectorAll("video");
537560
let autoIdCounter = 0;
538561
for (const el of videoEls) {
539-
const src = el.getAttribute("src");
562+
const src = resolveMediaElementSrc(el);
540563
if (!src) continue;
541564
// Generate a stable ID for videos without one — the producer needs IDs
542565
// to track extracted frames and composite them during encoding.

packages/lint/src/rules/media.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,37 @@ describe("media rules", () => {
217217
expect(finding?.severity).toBe("error");
218218
});
219219

220+
it("accepts <source src> children in place of parent src", async () => {
221+
const html = `
222+
<html><body>
223+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
224+
<video id="rec" data-start="0" data-duration="4" muted playsinline>
225+
<source src="clip.mp4" type="video/mp4">
226+
<source src="clip.webm" type="video/webm">
227+
</video>
228+
</div>
229+
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
230+
</body></html>`;
231+
const result = await lintHyperframeHtml(html);
232+
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
233+
});
234+
235+
it("reports error for <source>-only media with no data-start", async () => {
236+
const html = `
237+
<html><body>
238+
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
239+
<video id="rec" muted playsinline>
240+
<source src="clip.mp4" type="video/mp4">
241+
</video>
242+
</div>
243+
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
244+
</body></html>`;
245+
const result = await lintHyperframeHtml(html);
246+
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
247+
expect(finding).toBeDefined();
248+
expect(finding?.elementId).toBe("rec");
249+
});
250+
220251
it("reports error for media with src but no data-start", async () => {
221252
const html = `
222253
<html><body>

packages/lint/src/rules/media.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { LintContext, HyperframeLintFinding } from "../context";
1+
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
22
import { readAttr, readDecodedAttr, stripJsComments, truncateSnippet, isMediaTag } from "../utils";
33
import { validateColorGradingContract } from "@hyperframes/parsers/color-grading-contract";
44

@@ -41,6 +41,20 @@ function hasAttrName(tagSource: string, attr: string): boolean {
4141
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
4242
}
4343

44+
/** Parent `src`, else a descendant `<source src>` (matches engine resolveMediaElementSrc). */
45+
function mediaHasResolvableSrc(tag: OpenTag, tags: readonly OpenTag[]): boolean {
46+
if (readAttr(tag.raw, "src")) return true;
47+
const end = tag.closeIndex ?? tag.endIndex;
48+
if (end == null) return false;
49+
return tags.some(
50+
(child) =>
51+
child.name === "source" &&
52+
child.index > tag.index &&
53+
child.index < end &&
54+
Boolean(readAttr(child.raw, "src")),
55+
);
56+
}
57+
4458
function classNamesFromAttr(classAttr: string | null): string[] {
4559
if (!classAttr) return [];
4660
return classAttr.split(/\s+/).filter(Boolean);
@@ -499,7 +513,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
499513
if (tag.name !== "video" && tag.name !== "audio") continue;
500514
const hasDataStart = readAttr(tag.raw, "data-start");
501515
const hasId = readAttr(tag.raw, "id");
502-
const hasSrc = readAttr(tag.raw, "src");
516+
const hasSrc = mediaHasResolvableSrc(tag, tags);
503517
if (hasSrc && !hasDataStart) {
504518
findings.push({
505519
code: "media_missing_data_start",
@@ -538,9 +552,9 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
538552
findings.push({
539553
code: "media_missing_src",
540554
severity: "error",
541-
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
555+
message: `<${tag.name} id="${hasId}"> has data-start but no src (on the element or a <source> child). The renderer cannot load this media.`,
542556
elementId: hasId,
543-
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
557+
fixHint: `Add src on the <${tag.name}> element, or a <source src="..."> child.`,
544558
snippet: truncateSnippet(tag.raw),
545559
});
546560
}

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,25 @@ describe("localizeRemoteMediaSources", () => {
15451545
expect(remoteMediaAssets.size).toBe(0);
15461546
});
15471547

1548+
it("localizes remote <source> children of a <video>", async () => {
1549+
const orig = globalThis.fetch;
1550+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1551+
(globalThis as any).fetch = async () => validTestMediaResponse();
1552+
try {
1553+
const dl = mkdtempSync(join(tmpdir(), "hf-dl-src-"));
1554+
const html = `<video id="rec" data-start="0" data-end="5" muted>
1555+
<source src="https://src-ok.example.com/rec.mp4" type="video/mp4">
1556+
<source src="https://src-ok.example.com/rec.webm" type="video/webm">
1557+
</video>`;
1558+
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
1559+
expect(result).not.toContain("https://src-ok.example.com/");
1560+
expect(remoteMediaAssets.size).toBe(2);
1561+
} finally {
1562+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1563+
(globalThis as any).fetch = orig;
1564+
}
1565+
});
1566+
15481567
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
15491568
const orig = globalThis.fetch;
15501569
// eslint-disable-next-line @typescript-eslint/no-explicit-any

packages/producer/src/services/htmlCompiler.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,8 @@ const REMOTE_MEDIA_SUBDIR = "_remote_media";
12801280
// have `>` inside quoted attribute values (data-title etc.).
12811281
const REMOTE_MEDIA_TAG_RE =
12821282
/<(?:video|audio)\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi;
1283+
// <source src> on media elements (picture uses srcset, not src).
1284+
const REMOTE_SOURCE_TAG_RE = /<source\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi;
12831285
// Match <img> tags (including agent-pipeline-emitted variants where `src` is
12841286
// not the first attribute). Producer-side localisation is the primary fix for
12851287
// the remote-<img> flicker; frameCapture's `pollImagesReady`/`decodeAllImages`
@@ -1350,10 +1352,11 @@ async function downloadAndRewriteUrls(
13501352
}
13511353

13521354
/**
1353-
* Download any remote `src` URLs on `<video>` and `<audio>` elements into a
1354-
* local subdirectory of `downloadDir`, rewrite the HTML src attributes to
1355-
* relative paths, and return the updated HTML along with a map of
1356-
* `{ relativePath → absoluteLocalPath }` for callers to add to `externalAssets`.
1355+
* Download any remote `src` URLs on `<video>` / `<audio>` elements and their
1356+
* `<source>` children into a local subdirectory of `downloadDir`, rewrite the
1357+
* HTML src attributes to relative paths, and return the updated HTML along with
1358+
* a map of `{ relativePath → absoluteLocalPath }` for callers to add to
1359+
* `externalAssets`.
13571360
*
13581361
* Skips URLs that fail to download (warns and preserves the original URL so
13591362
* the browser can still attempt the remote fetch as a fallback).
@@ -1369,12 +1372,13 @@ export async function localizeRemoteMediaSources(
13691372
html: string,
13701373
downloadDir: string,
13711374
): Promise<{ html: string; remoteMediaAssets: Map<string, string> }> {
1372-
// Collect unique HTTP URLs from <video>/<audio> src attributes.
13731375
const urlSet = new Set<string>();
1374-
const re = new RegExp(REMOTE_MEDIA_TAG_RE.source, REMOTE_MEDIA_TAG_RE.flags);
1375-
let m: RegExpExecArray | null;
1376-
while ((m = re.exec(html)) !== null) {
1377-
if (m[1]) urlSet.add(m[1]);
1376+
for (const tagRe of [REMOTE_MEDIA_TAG_RE, REMOTE_SOURCE_TAG_RE]) {
1377+
const re = new RegExp(tagRe.source, tagRe.flags);
1378+
let m: RegExpExecArray | null;
1379+
while ((m = re.exec(html)) !== null) {
1380+
if (m[1]) urlSet.add(m[1]);
1381+
}
13781382
}
13791383
return downloadAndRewriteUrls(
13801384
urlSet,

0 commit comments

Comments
 (0)