Skip to content

Commit 7597b80

Browse files
fix(cli): timeline reports real media durations instead of unauthored (#4169)
* fix(cli): timeline resolves real media durations instead of printing unauthored Media rows call the shared resolveMediaDuration with an ffprobe source length. Each row shows its duration source, or pending with the reason a probe failed. Absolute time (absStart/absEnd) is carried across sub-compositions. Remote, absolute and parent-relative srcs are never probed. * fix(cli): timeline probe leaves the authored-duration rule to the resolver The gate test now launches jobs while slots are held. * fix(cli): timeline reports a zero-length probe as pending instead of a measurement A still image or duration-less container is pending with a reason, never duration=media. The runOneLiner test helper now cleans up its own fixture directory. * 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 2f24573 commit 7597b80

7 files changed

Lines changed: 521 additions & 73 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default defineCommand({
2323
async run({ args }) {
2424
const project = resolveProject(args.dir);
2525
ensureDOMParser();
26-
const timeline = describeProject(project.indexPath);
26+
const timeline = await describeProject(project.indexPath);
2727
console.log(
2828
args.json ? JSON.stringify(withMeta({ timeline }), null, 2) : formatTimeline(timeline),
2929
);

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

Lines changed: 345 additions & 53 deletions
Large diffs are not rendered by default.

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

Lines changed: 155 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,38 @@ import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx"
99
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
1010
import { byStart, type ClipFact, type ClipLane } from "@hyperframes/core/clip-facts";
1111
import { parseNumeric } from "@hyperframes/core";
12+
import {
13+
readMediaOffsetSeconds,
14+
readPlaybackRate,
15+
resolveMediaDuration,
16+
type MediaDurationSource,
17+
type MediaTag,
18+
} from "@hyperframes/parsers/media-duration";
1219
import {
1320
topLevelElements,
1421
trackKindOf,
1522
type StructureNode,
1623
type TrackKind,
1724
} from "@hyperframes/parsers";
1825
import { resolveMediaStartSeconds } from "@hyperframes/core/media-timing";
19-
import { resolveReferencedDuration, resolveReferencedStart } from "@hyperframes/engine";
26+
import {
27+
extractAudioMetadata,
28+
extractMediaMetadata,
29+
resolveReferencedDuration,
30+
resolveReferencedStart,
31+
} from "@hyperframes/engine";
32+
33+
/** How `duration` was determined: the parsers resolver's names for media, "inner" for a composition host. */
34+
export type DurationSource = MediaDurationSource | "inner";
2035

2136
export interface TimelineRow extends ClipFact {
2237
trackKind: TrackKind;
2338
/** False when the source does not author a duration (media length is only known at render). */
2439
durationAuthored: boolean;
40+
/** Where `duration` came from; `null` for a non-media row with nothing authored and no children to sum. */
41+
durationSource: DurationSource | null;
42+
/** Why no duration could be resolved; `null` unless `durationSource` is "pending". */
43+
pendingReason: string | null;
2544
/** Why `data-automation` / `data-fx-chain` could not be read; `null` when fine or absent. */
2645
laneError: string | null;
2746
/** Start and end on the main timeline, in seconds. `start`/`end` are local to the owning file's composition. */
@@ -91,6 +110,113 @@ interface DocScope {
91110
origin: number;
92111
/** Project-relative path of this document, with `/` separators. */
93112
file: string;
113+
/** Bounds concurrent ffprobe spawns for the whole run. Shared reference, not new per document. */
114+
withProbeSlot: <T>(fn: () => Promise<T>) => Promise<T>;
115+
measure: MeasureMedia;
116+
}
117+
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+
125+
const MEDIA_TAG = /^(video|audio|img)$/;
126+
const PROBE_CONCURRENCY = 4;
127+
128+
/** ponytail: a 4-line gate beats importing producer's Semaphore, which would pull its whole
129+
* dependency tree into the lightweight `timeline` command just to cap ffprobe spawns. */
130+
export function createProbeGate(max: number) {
131+
let active = 0;
132+
const waiting: Array<() => void> = [];
133+
return async function withProbeSlot<T>(fn: () => Promise<T>): Promise<T> {
134+
if (active >= max) await new Promise<void>((wake) => waiting.push(wake));
135+
else active += 1;
136+
try {
137+
return await fn();
138+
} finally {
139+
const next = waiting.shift();
140+
if (next) next();
141+
else active -= 1;
142+
}
143+
};
144+
}
145+
146+
type ProbeResult = { ok: true; seconds: number } | { ok: false; reason: string };
147+
148+
/** ffprobe length of a media source. `extractMediaMetadata` and `extractAudioMetadata` already
149+
* memoize per resolved file path for the process lifetime. */
150+
async function probeSource(scope: DocScope, el: Element, tag: MediaTag): Promise<ProbeResult> {
151+
const src = el.getAttribute("src");
152+
if (!src) return { ok: false, reason: "no src attribute" };
153+
if (/^https?:\/\//i.test(src)) return { ok: false, reason: "remote source not probed" };
154+
const file = realFileInside(scope.projectDir, resolve(scope.dir, src));
155+
if (!file) return { ok: false, reason: "source file not found" };
156+
return scope.withProbeSlot(async () => {
157+
try {
158+
return { ok: true, seconds: await scope.measure(file, tag) } as const;
159+
} catch (err) {
160+
return { ok: false, reason: err instanceof Error ? err.message : String(err) } as const;
161+
}
162+
});
163+
}
164+
165+
interface DurationResolution {
166+
duration: number;
167+
durationSource: DurationSource | null;
168+
pendingReason: string | null;
169+
}
170+
171+
function resolveContainerDuration(
172+
authored: number | null,
173+
children: readonly TimelineRow[],
174+
): DurationResolution {
175+
if (authored !== null)
176+
return { duration: authored, durationSource: "authored", pendingReason: null };
177+
if (children.length === 0) return { duration: 0, durationSource: null, pendingReason: null };
178+
const inner = children.reduce((max, c) => Math.max(max, c.end), 0);
179+
return { duration: inner, durationSource: "inner", pendingReason: null };
180+
}
181+
182+
/** Media rows go through the parsers resolver; only a row it cannot settle without the file is probed. */
183+
async function resolveMediaRowDuration(
184+
scope: DocScope,
185+
el: Element,
186+
tag: MediaTag,
187+
authored: number | null,
188+
): Promise<DurationResolution> {
189+
const getAttr = (name: string) => el.getAttribute(name);
190+
const input = {
191+
tag,
192+
authoredDurationSeconds: authored,
193+
mediaStartSeconds: readMediaOffsetSeconds(getAttr),
194+
playbackRate: readPlaybackRate(getAttr),
195+
};
196+
const unprobed = resolveMediaDuration({ ...input, sourceDurationSeconds: null });
197+
if (unprobed.source !== "pending") {
198+
return {
199+
duration: unprobed.seconds ?? 0,
200+
durationSource: unprobed.source,
201+
pendingReason: null,
202+
};
203+
}
204+
const probe = await probeSource(scope, el, tag);
205+
const measured = probe.ok && probe.seconds > 0 ? probe.seconds : null;
206+
const result = resolveMediaDuration({ ...input, sourceDurationSeconds: measured });
207+
return {
208+
duration: result.seconds ?? 0,
209+
durationSource: result.source,
210+
pendingReason: result.source === "pending" ? pendingReason(probe, result.reason) : null,
211+
};
212+
}
213+
214+
/** Why a media row is pending: the probe's failure, or that it opened but reported no length. */
215+
function pendingReason(probe: ProbeResult, resolverReason: string | undefined): string {
216+
if (!probe.ok) return probe.reason;
217+
return probe.seconds > 0
218+
? (resolverReason ?? "source duration unavailable")
219+
: "source reports no duration";
94220
}
95221

96222
const roundMs = (v: number) => Math.round(v * 1000) / 1000;
@@ -108,18 +234,19 @@ function mainTimelineStart(scope: DocScope, el: Element, start: number): number
108234
});
109235
}
110236

111-
function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow {
237+
async function describeRow(scope: DocScope, node: DomNode, depth: number): Promise<TimelineRow> {
112238
const { el } = node;
113239
const { doc, startCache } = scope;
114240
const start = resolveReferencedStart(doc, el, startCache, new Set());
115241
const authored = resolveReferencedDuration(doc, el, startCache, new Set());
116242
const host = el.getAttribute("data-composition-src");
117243
const absStart = mainTimelineStart(scope, el, start);
118-
const children = host && depth === 0 ? readSubComposition(host, scope, absStart) : [];
119-
const inner = children.reduce((max, c) => Math.max(max, c.end), 0);
120-
const duration = authored ?? inner;
121-
const rate = parseNumeric(el.getAttribute("data-playback-rate"));
244+
const children = host && depth === 0 ? await readSubComposition(host, scope, absStart) : [];
122245
const kind = el.tagName.toLowerCase();
246+
const { duration, durationSource, pendingReason } = MEDIA_TAG.test(kind)
247+
? await resolveMediaRowDuration(scope, el, kind as MediaTag, authored)
248+
: resolveContainerDuration(authored, children);
249+
const rate = parseNumeric(el.getAttribute("data-playback-rate"));
123250
return {
124251
id: el.id || el.getAttribute("data-composition-id") || kind,
125252
label: null,
@@ -140,11 +267,17 @@ function describeRow(scope: DocScope, node: DomNode, depth: number): TimelineRow
140267
audioGroup: el.getAttribute(HF_AUDIO_GROUP_ATTR),
141268
role: null,
142269
durationAuthored: authored !== null,
270+
durationSource,
271+
pendingReason,
143272
children,
144273
};
145274
}
146275

147-
function readSubComposition(src: string, parent: DocScope, origin: number): TimelineRow[] {
276+
async function readSubComposition(
277+
src: string,
278+
parent: DocScope,
279+
origin: number,
280+
): Promise<TimelineRow[]> {
148281
const authored = resolve(parent.dir, src);
149282
const file = realFileInside(parent.projectDir, authored);
150283
if (!file) return [];
@@ -159,10 +292,13 @@ function readSubComposition(src: string, parent: DocScope, origin: number): Time
159292
projectDir: parent.projectDir,
160293
origin,
161294
file: relative(parent.projectDir, authored).split(sep).join("/"),
295+
withProbeSlot: parent.withProbeSlot,
296+
measure: parent.measure,
162297
};
163-
return topLevelElements(toNode(root))
164-
.map((node) => describeRow(scope, node, 1))
165-
.sort(byStart);
298+
const rows = await Promise.all(
299+
topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)),
300+
);
301+
return rows.sort(byStart);
166302
}
167303

168304
/** The file's real path when it is a regular file inside the project (symlinks resolved), else null. */
@@ -178,7 +314,10 @@ function realFileInside(projectDir: string, path: string): string | null {
178314
}
179315

180316
/** Needs a global DOMParser (`ensureDOMParser`). Reads `index.html` and one level of sub-compositions. */
181-
export function describeProject(indexPath: string): ProjectTimeline {
317+
export async function describeProject(
318+
indexPath: string,
319+
measure: MeasureMedia = measureWithFfprobe,
320+
): Promise<ProjectTimeline> {
182321
const doc = new DOMParser().parseFromString(readFileSync(indexPath, "utf-8"), "text/html");
183322
const root = doc.querySelector("[data-composition-id]") ?? doc.body;
184323
const dir = dirname(indexPath);
@@ -189,10 +328,12 @@ export function describeProject(indexPath: string): ProjectTimeline {
189328
projectDir: dir,
190329
origin: 0,
191330
file: basename(indexPath),
331+
withProbeSlot: createProbeGate(PROBE_CONCURRENCY),
332+
measure,
192333
};
193-
const rows = topLevelElements(toNode(root))
194-
.map((node) => describeRow(scope, node, 0))
195-
.sort(byStart);
334+
const rows = (
335+
await Promise.all(topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 0)))
336+
).sort(byStart);
196337
const declared = parseNumeric(root.getAttribute("data-duration"));
197338
return {
198339
duration: declared ?? rows.reduce((max, r) => Math.max(max, r.end), 0),

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ function bar(row: TimelineRow, total: number): string {
1313
return " ".repeat(from) + fill.repeat(width) + " ".repeat(BAR_WIDTH - from - width);
1414
}
1515

16+
/** Never "unauthored": says either the resolved length's source or why one is pending. */
17+
function durationNote(row: TimelineRow): string | false {
18+
switch (row.durationSource) {
19+
case "media":
20+
case "default":
21+
return `duration=${row.durationSource}`;
22+
case "inner":
23+
return "duration=inferred";
24+
case "pending":
25+
return `pending: ${row.pendingReason}`;
26+
case "authored":
27+
case null:
28+
return false;
29+
}
30+
}
31+
1632
function details(row: TimelineRow): string {
1733
const lanes = row.lanes.map(
1834
(l) => `${l.target}[${l.points.map((p) => `${n(p.t)}:${n(p.v)}`).join(" ")}]`,
@@ -22,7 +38,7 @@ function details(row: TimelineRow): string {
2238
row.volume !== null && `vol=${row.volume}`,
2339
row.playbackRate !== null && `rate=${n(row.playbackRate)}`,
2440
row.audioGroup && `group=${row.audioGroup}`,
25-
!row.durationAuthored && "duration=unauthored",
41+
durationNote(row),
2642
row.sourceFile && !row.children.length && "children=unread",
2743
row.laneError && `lanes unreadable: ${row.laneError}`,
2844
...lanes,

‎packages/parsers/src/mediaDuration.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,4 @@ export const PENDING_MEDIA_DURATION_READERS = [
125125
"engine/src/services/audioMixer.ts",
126126
"parsers/src/htmlParser.ts (defaults to 5s, reads only data-media-start)",
127127
"studio (timelineDOM.ts, timelineElementHelpers.ts, useTimelineSyncCallbacks.ts)",
128-
"cli/src/timeline/describeProject.ts describeRow (#4138)",
129128
] as const;

‎skills-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"files": 7
3131
},
3232
"hyperframes-cli": {
33-
"hash": "8fbf0e81f55bbf39",
33+
"hash": "3fa86bc183a976c3",
3434
"files": 11
3535
},
3636
"hyperframes-core": {

‎skills/hyperframes-cli/references/upgrade-info-misc.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ audio (1)
4747
- The bar is 40 columns over the whole timeline. Times are seconds.
4848
- `src=`, `vol=`, `rate=` (playback rate, only when not 1), `group=` (audio group), and `<target>[t:v ...]` (automation lane points, `t` in seconds from the clip start) appear only when the clip has them.
4949
- Clips of a sub-composition are indented one level. Their time is printed as the absolute main-timeline start-end first, then `(local <start>-<end>s)` (time inside that sub-composition), then `in <file>` (the file that declares the clip). Deeper nesting is not expanded (`children=unread` on that row).
50-
- `duration=unauthored` (dotted bar) means the element has no `data-duration`/`data-end`, so its length is only known at render time (typically media). Add a `data-duration` if the length matters.
50+
- With no `data-duration`/`data-end`, a media row still gets a resolved length and says where it came from: `duration=media` means ffprobe measured the source (with playback start and rate applied); `duration=default` means an `img` got the 3s default; `duration=inferred` means a composition host summed its children; `pending: <reason>` (dotted bar, `duration` 0) means the source could not be probed (missing file, remote `src`, ffprobe error). A non-media leaf with nothing to resolve prints no source.
5151
- `lanes unreadable: ...` means the clip's `data-automation` or `data-fx-chain` did not parse; fix the attribute.
5252

53-
`--json` prints `{ timeline: { duration, tracks: [{ kind, rows: [...] }] } }`. Each row has `id`, `kind` (tag), `trackKind`, `start`, `duration`, `end` (local to the row's own file), **`absStart`, `absEnd`, `file`** (main-timeline time and the project-relative file that declares the clip — use these, not `start`/`end`, to compare clips across nesting), `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, `laneError`, and `children` (the sub-composition's rows, one level).
53+
`--json` prints `{ timeline: { duration, tracks: [{ kind, rows: [...] }] } }`. Each row has `id`, `kind` (tag), `trackKind`, `start`, `duration`, `end` (local to the row's own file), **`absStart`, `absEnd`, `file`** (main-timeline time and the project-relative file that declares the clip — use these, not `start`/`end`, to compare clips across nesting), `trackIndex`, `src`, `sourceFile`, `volume`, `lanes`, `playbackRate`, `audioGroup`, `durationAuthored`, **`durationSource`** (`"authored" | "media" | "default" | "inner" | "pending"`, or `null` for a non-media leaf with nothing to resolve), **`pendingReason`** (why nothing resolved; `null` unless `durationSource` is `"pending"`), `laneError`, and `children` (the sub-composition's rows, one level).
5454

5555
### Query one-liners (jq, node fallback if jq is absent)
5656

0 commit comments

Comments
 (0)