fix(cli): audio and timeline find media the way preview and render load it (#5846)

audio and timeline joined a clip's src to its composition's folder by hand,
so a project-root asset used from a nested scene, or a percent-encoded file
name, was "not found" although preview, check and render load it.
resolveAuthoredAssetFile now owns that lookup (the sub-composition rewrite,
then the decoded project-root resolve), and audio, timeline and
media-treatment all use it.
This commit is contained in:
Miguel Ángel
2026-10-18 15:01:14 +00:00
committed by GitHub
parent bb55640f5c
commit ab2e939558
6 changed files with 87 additions and 15 deletions
@@ -1,4 +1,6 @@
import { resolve } from "node:path";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import type { ProjectTimeline, TimelineRow } from "../../timeline/describeProject.js";
import { fileTime, filmTargets, filmTime, type Placement } from "./targets.js";
@@ -127,6 +129,38 @@ describe("filmTargets", () => {
});
});
it("finds media the way preview and render load it: root assets from a nested scene, percent-encoded names", () => {
const projectDir = realpathSync(mkdtempSync(join(tmpdir(), "hf-audio-targets-")));
try {
mkdirSync(join(projectDir, "assets"));
mkdirSync(join(projectDir, "compositions", "sfx"), { recursive: true });
writeFileSync(join(projectDir, "assets", "music bed.mp3"), "");
writeFileSync(join(projectDir, "compositions", "sfx", "hit.wav"), "");
const scene = { absStart: 0, absEnd: 5, file: "compositions/scene.html" } as const;
const nested: ProjectTimeline = {
...timeline,
tracks: [
{
...timeline.tracks[0]!,
rows: [
row({ ...scene, ref: "#bed", kind: "audio", src: "assets/music%20bed.mp3" }),
row({ ...scene, ref: "#hit", kind: "audio", src: "sfx/hit.wav" }),
],
},
],
};
const paths = filmTargets(projectDir, nested, { by: "all" }).map((t) => t.path);
expect(paths).toEqual([
join(projectDir, "assets", "music bed.mp3"),
join(projectDir, "compositions", "sfx", "hit.wav"),
]);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it("says when an element is missing or is not sound", () => {
expect(() => filmTargets("/p", timeline, { by: "element", ref: "#nope" })).toThrow(
/was not found/,
+4 -2
View File
@@ -1,4 +1,5 @@
import { dirname, join, resolve } from "node:path";
import { resolveAuthoredAssetFile } from "@hyperframes/parsers/asset-resolution";
import type { ProjectTimeline, TimelineRow } from "../../timeline/describeProject.js";
/** Where a file sits in the film: film time = start + (file time − mediaStart) / playbackRate. */
@@ -74,9 +75,10 @@ function targetOf(projectDir: string, row: TimelineRow, query: FilmQuery): Audio
const placement = placementOf(row);
const from = query.by === "range" ? Math.max(placement.start, query.start) : placement.start;
const to = query.by === "range" ? Math.min(placement.end, query.end) : placement.end;
const declaredIn = dirname(join(projectDir, row.file));
return {
path: resolve(declaredIn, row.src!),
path:
resolveAuthoredAssetFile(projectDir, row.file, row.src!) ??
resolve(dirname(join(projectDir, row.file)), row.src!),
file: row.src!,
ref: row.ref || null,
kind: row.kind as "audio" | "video",
+6 -11
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { relative, resolve } from "node:path";
import {
HF_COLOR_GRADING_ATTR,
HF_COLOR_GRADING_HUE_CURVE_KEYS,
@@ -16,9 +16,8 @@ import {
import {
cleanAssetUrl,
isRemoteOrInlineUrl,
resolveExistingLocalAsset,
resolveAuthoredAssetFile,
} from "@hyperframes/parsers/asset-resolution";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import {
isHTMLElement,
parseSourceDocument as parseMutableSource,
@@ -546,14 +545,10 @@ export function resolveMediaTreatmentSource(
"Media analysis requires a local project asset; freeze remote media with media-use first",
);
}
const cleanSource = cleanAssetUrl(sourceUrl);
if (!cleanSource) throw new Error("Selected media has no analyzable local src");
const projectRelative = cleanSource.startsWith("/")
? cleanSource
: rewriteAssetPath(compositionFile, cleanSource, (path) => existsSync(join(projectDir, path)));
const asset = resolveExistingLocalAsset(projectDir, projectRelative);
if (!asset) throw new Error(`Media file not found: ${source}`);
return asset.resolved;
if (!cleanAssetUrl(sourceUrl)) throw new Error("Selected media has no analyzable local src");
const file = resolveAuthoredAssetFile(projectDir, compositionFile, sourceUrl);
if (!file) throw new Error(`Media file not found: ${source}`);
return file;
}
function parseGrading(
@@ -317,6 +317,30 @@ describe("describeProject", () => {
expect(row!.mediaStart).toBe(0.2);
});
it("probes a nested scene's media where preview loads it: a root asset with a percent-encoded name", async () => {
const probed: string[] = [];
const { rows } = await rowsOf(
`<div data-composition-id="m" data-duration="5">
<div id="scene" data-composition-src="compositions/bed.html" data-start="0" data-duration="5"></div>
</div>`,
false,
(root) => {
mkdirSync(join(root, "assets"));
copyFileSync(REAL_AUDIO, join(root, "assets", "music bed.mp3"));
writeFileSync(
join(root, "compositions", "bed.html"),
`<template><div data-composition-id="bed"><audio id="bed" src="assets/music%20bed.mp3" data-start="0"></audio></div></template>`,
);
},
async (file, tag) => {
probed.push(`${tag}:${basename(file)}`);
return POP_SECONDS();
},
);
expect(probed).toEqual(["audio:music bed.mp3"]);
expect(rows.find((r) => r.id === "bed")).toMatchObject({ durationSource: "media" });
});
it("takes a media leaf's duration from the probe when none is authored", async () => {
const probed: string[] = [];
const {
+3 -1
View File
@@ -10,6 +10,7 @@ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
import { byStart, type ClipFact, type ClipLane } from "@hyperframes/core/clip-facts";
import { fpsToNumber, parseFpsWithDefault, parseNumeric } from "@hyperframes/core";
import { findSyncPartner, readLinkTiming, syncOffsetFrames } from "@hyperframes/core/media-link";
import { resolveAuthoredAssetFile } from "@hyperframes/parsers/asset-resolution";
import { readCompositionFps } from "../utils/compositionFps.js";
import {
readMediaOffsetSeconds,
@@ -195,7 +196,8 @@ async function probeSource(scope: DocScope, el: Element, tag: MediaTag): Promise
const src = el.getAttribute("src");
if (!src) return { ok: false, reason: "no src attribute" };
if (/^https?:\/\//i.test(src)) return { ok: false, reason: "remote source not probed" };
const file = realFileInside(scope.projectDir, resolve(scope.dir, src));
const found = resolveAuthoredAssetFile(scope.projectDir, scope.file, src);
const file = found && realFileInside(scope.projectDir, found);
if (!file) return { ok: false, reason: "source file not found" };
return scope.withProbeSlot(async () => {
try {
+15
View File
@@ -2,6 +2,7 @@ import { scanHtmlOpeningTags, decodeAuthoredAttribute } from "./htmlAttributeSpa
import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync } from "node:fs";
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
import { decodeUrlPathVariants } from "./composition.js";
import { rewriteAssetPath } from "./rewriteSubCompPaths.js";
/** The subset of `node:path` that `isWithinProjectRoot` needs to run under
* an injected platform (tests pass `path.win32` / `path.posix`). */
@@ -138,6 +139,20 @@ export function resolveExistingLocalAsset(
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
}
/** The project file a `src` authored in `compositionFile` loads in preview and render; null when none exists. */
export function resolveAuthoredAssetFile(
projectDir: string,
compositionFile: string,
src: string,
): string | null {
const cleanSrc = cleanAssetUrl(src.trim());
if (!cleanSrc) return null;
const rootRelative = cleanSrc.startsWith("/")
? cleanSrc
: rewriteAssetPath(compositionFile, cleanSrc, (path) => existsSync(join(projectDir, path)));
return resolveExistingLocalAsset(projectDir, rootRelative)?.resolved ?? null;
}
// Candidates for a variant whose join escaped the project root, re-anchored at the root.
function reanchoredCandidates(variant: string, baseDir: string, compiledDir?: string): string[] {
const baseAbs = resolve(baseDir);