mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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/,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user