Skip to content

Commit dcd0ad5

Browse files
committed
fix(cli): timeline probe is injectable and the fixture path works on windows
Two unit tests used real ffprobe, which the Ubuntu Test job lacks, and the audio fixture path doubled the drive letter on Windows. describeProject now takes the measure function, the tests pass a recorded fake, and one real-ffprobe test remains.
1 parent 4bdd839 commit dcd0ad5

2 files changed

Lines changed: 52 additions & 12 deletions

File tree

‎packages/cli/src/timeline/describeProject.test.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFileSync } from "node:child_process";
1+
import { execFileSync, spawnSync } from "node:child_process";
22
import {
33
copyFileSync,
44
mkdirSync,
@@ -9,16 +9,20 @@ import {
99
writeFileSync,
1010
} from "node:fs";
1111
import { tmpdir } from "node:os";
12-
import { dirname, join } from "node:path";
12+
import { basename, dirname, join } from "node:path";
1313
import { MEDIA_DURATION_FIXTURES } from "@hyperframes/parsers/media-duration-fixtures";
1414
import { fileURLToPath } from "node:url";
1515
import { afterEach, beforeAll, describe, expect, it } from "vitest";
1616
import { ensureDOMParser } from "../utils/dom.js";
17-
import { createProbeGate, describeProject } from "./describeProject.js";
17+
import { createProbeGate, describeProject, type MeasureMedia } from "./describeProject.js";
1818
import { formatTimeline } from "./formatTimeline.js";
1919

20-
const REAL_AUDIO = new URL("../../../../skills/media-use/audio/assets/sfx/pop.mp3", import.meta.url)
21-
.pathname;
20+
const REAL_AUDIO = fileURLToPath(
21+
new URL("../../../../skills/media-use/audio/assets/sfx/pop.mp3", import.meta.url),
22+
);
23+
const hasFfprobe = spawnSync("ffprobe", ["-version"]).status === 0;
24+
/** Recorded ffprobe answer for pop.mp3 (0.72 s), so the resolver path runs on runners without ffprobe. */
25+
const POP_SECONDS = async () => 0.72;
2226

2327
const INDEX = `<html><body>
2428
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="10">
@@ -38,12 +42,17 @@ const TINY_PNG = Buffer.from(
3842
"base64",
3943
);
4044

41-
const rowsOf = async (html: string, withSting = false, setup?: (root: string) => void) => {
45+
const rowsOf = async (
46+
html: string,
47+
withSting = false,
48+
setup?: (root: string) => void,
49+
measure?: MeasureMedia,
50+
) => {
4251
const index = project();
4352
writeFileSync(index, html);
4453
if (withSting) copyFileSync(REAL_AUDIO, join(dir, "sting.mp3"));
4554
setup?.(dir);
46-
const timeline = await describeProject(index);
55+
const timeline = await describeProject(index, measure);
4756
return { rows: timeline.tracks.flatMap((t) => t.rows), text: formatTimeline(timeline) };
4857
};
4958

@@ -243,26 +252,46 @@ describe("describeProject", () => {
243252
} = await rowsOf(
244253
`<div data-composition-id="m"><audio id="s" src="sting.mp3" data-start="0" data-playback-start="0.2" data-playback-rate="2"></audio></div>`,
245254
true,
255+
undefined,
256+
POP_SECONDS,
246257
);
247258
// (0.72 - 0.2) / 2, hand-computed
248259
expect(row!.duration).toBeCloseTo(0.26, 1);
249260
});
250261

251-
it("probes a media leaf's real duration when none is authored", async () => {
262+
it("takes a media leaf's duration from the probe when none is authored", async () => {
263+
const probed: string[] = [];
252264
const {
253265
rows: [row],
254266
text,
255267
} = await rowsOf(
256268
`<div data-composition-id="m"><audio id="sting" src="sting.mp3" data-start="0"></audio></div>`,
257269
true,
270+
undefined,
271+
async (file, tag) => {
272+
probed.push(`${tag}:${basename(file)}`);
273+
return POP_SECONDS();
274+
},
258275
);
276+
expect(probed).toEqual(["audio:sting.mp3"]);
259277
expect(row).toMatchObject({ durationAuthored: false, durationSource: "media" });
260278
expect(row!.pendingReason).toBeNull();
261279
expect(row!.duration).toBeCloseTo(0.72, 1);
262280
expect(row!.end).toBeCloseTo(0.72, 1);
263281
expect(text).toContain("duration=media");
264282
});
265283

284+
it.skipIf(!hasFfprobe)("measures a real audio file with ffprobe by default", async () => {
285+
const {
286+
rows: [row],
287+
} = await rowsOf(
288+
`<div data-composition-id="m"><audio id="sting" src="sting.mp3" data-start="0"></audio></div>`,
289+
true,
290+
);
291+
expect(row).toMatchObject({ durationSource: "media" });
292+
expect(row!.duration).toBeCloseTo(0.72, 1);
293+
});
294+
266295
it("reports pending with a reason instead of guessing when the source file is missing", async () => {
267296
const {
268297
rows: [row],

‎packages/cli/src/timeline/describeProject.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,16 @@ interface DocScope {
112112
file: string;
113113
/** Bounds concurrent ffprobe spawns for the whole run. Shared reference, not new per document. */
114114
withProbeSlot: <T>(fn: () => Promise<T>) => Promise<T>;
115+
measure: MeasureMedia;
115116
}
116117

118+
/** Source length in seconds of a media file. ffprobe in production; tests pass a recorded fake. */
119+
export type MeasureMedia = (file: string, tag: MediaTag) => Promise<number>;
120+
121+
const measureWithFfprobe: MeasureMedia = async (file, tag) =>
122+
(tag === "audio" ? await extractAudioMetadata(file) : await extractMediaMetadata(file))
123+
.durationSeconds;
124+
117125
const MEDIA_TAG = /^(video|audio|img)$/;
118126
const PROBE_CONCURRENCY = 4;
119127

@@ -147,9 +155,7 @@ async function probeSource(scope: DocScope, el: Element, tag: MediaTag): Promise
147155
if (!file) return { ok: false, reason: "source file not found" };
148156
return scope.withProbeSlot(async () => {
149157
try {
150-
const metadata =
151-
tag === "audio" ? await extractAudioMetadata(file) : await extractMediaMetadata(file);
152-
return { ok: true, seconds: metadata.durationSeconds } as const;
158+
return { ok: true, seconds: await scope.measure(file, tag) } as const;
153159
} catch (err) {
154160
return { ok: false, reason: err instanceof Error ? err.message : String(err) } as const;
155161
}
@@ -287,6 +293,7 @@ async function readSubComposition(
287293
origin,
288294
file: relative(parent.projectDir, authored).split(sep).join("/"),
289295
withProbeSlot: parent.withProbeSlot,
296+
measure: parent.measure,
290297
};
291298
const rows = await Promise.all(
292299
topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)),
@@ -307,7 +314,10 @@ function realFileInside(projectDir: string, path: string): string | null {
307314
}
308315

309316
/** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */
310-
export async function describeProject(indexPath: string): Promise<ProjectTimeline> {
317+
export async function describeProject(
318+
indexPath: string,
319+
measure: MeasureMedia = measureWithFfprobe,
320+
): Promise<ProjectTimeline> {
311321
const doc = new DOMParser().parseFromString(readFileSync(indexPath, "utf-8"), "text/html");
312322
const root = doc.querySelector("[data-composition-id]") ?? doc.body;
313323
const dir = dirname(indexPath);
@@ -319,6 +329,7 @@ export async function describeProject(indexPath: string): Promise<ProjectTimelin
319329
origin: 0,
320330
file: basename(indexPath),
321331
withProbeSlot: createProbeGate(PROBE_CONCURRENCY),
332+
measure,
322333
};
323334
const rows = (
324335
await Promise.all(topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 0)))

0 commit comments

Comments
 (0)