From ab2e939558ba073ab725530d98f47a791ea6942d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 18 Oct 2026 15:01:14 +0000 Subject: [PATCH] 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. --- .../cli/src/audio/analysis/targets.test.ts | 36 ++++++++++++++++++- packages/cli/src/audio/analysis/targets.ts | 6 ++-- packages/cli/src/commands/media-treatment.ts | 17 ++++----- .../cli/src/timeline/describeProject.test.ts | 24 +++++++++++++ packages/cli/src/timeline/describeProject.ts | 4 ++- packages/parsers/src/assetResolution.ts | 15 ++++++++ 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/audio/analysis/targets.test.ts b/packages/cli/src/audio/analysis/targets.test.ts index 1e02812ed2..4884480067 100644 --- a/packages/cli/src/audio/analysis/targets.test.ts +++ b/packages/cli/src/audio/analysis/targets.test.ts @@ -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/, diff --git a/packages/cli/src/audio/analysis/targets.ts b/packages/cli/src/audio/analysis/targets.ts index 16884bc39e..3287fd2a35 100644 --- a/packages/cli/src/audio/analysis/targets.ts +++ b/packages/cli/src/audio/analysis/targets.ts @@ -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", diff --git a/packages/cli/src/commands/media-treatment.ts b/packages/cli/src/commands/media-treatment.ts index ca61c3dea1..83a48ce2af 100644 --- a/packages/cli/src/commands/media-treatment.ts +++ b/packages/cli/src/commands/media-treatment.ts @@ -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( diff --git a/packages/cli/src/timeline/describeProject.test.ts b/packages/cli/src/timeline/describeProject.test.ts index 2585a110ca..c5a985b6f2 100644 --- a/packages/cli/src/timeline/describeProject.test.ts +++ b/packages/cli/src/timeline/describeProject.test.ts @@ -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( + `
+
+
`, + false, + (root) => { + mkdirSync(join(root, "assets")); + copyFileSync(REAL_AUDIO, join(root, "assets", "music bed.mp3")); + writeFileSync( + join(root, "compositions", "bed.html"), + ``, + ); + }, + 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 { diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index cfb356b76e..87d00493e7 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -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 { diff --git a/packages/parsers/src/assetResolution.ts b/packages/parsers/src/assetResolution.ts index 591cb0a5c2..0a6bc25774 100644 --- a/packages/parsers/src/assetResolution.ts +++ b/packages/parsers/src/assetResolution.ts @@ -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);