fix(studio): a film that cuts one file into many clips plays smoothly while their durations load

This commit is contained in:
Miguel Ángel
2026-10-29 08:53:03 -04:00
parent 22b53c0115
commit 5cc5157420
3 changed files with 50 additions and 23 deletions
@@ -128,19 +128,18 @@ export function useTimelinePlayer({
// Asynchronously enrich media elements still missing sourceDuration
// (header-only probe, cheap), applying each resolved value to the store.
void probeMissingSourceDurations(
mergedElements,
state.timelineProjectId,
(key, durationSeconds) => {
usePlayerStore.setState((state) => {
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
const patched = state.elements.slice();
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
return { elements: patched };
void probeMissingSourceDurations(mergedElements, state.timelineProjectId, (durations) => {
usePlayerStore.setState((state) => {
let patched: TimelineElement[] | null = null;
state.elements.forEach((element, idx) => {
const durationSeconds = durations.get(element.key ?? element.id);
if (durationSeconds === undefined || element.sourceDuration != null) return;
patched ??= state.elements.slice();
patched[idx] = { ...element, sourceDuration: durationSeconds };
});
},
);
return patched ? { elements: patched } : {};
});
});
},
[setElements, requestTimelineReady, setDuration],
);
@@ -109,6 +109,31 @@ describe("media probe registry", () => {
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
});
it("hands back every clip of one file in a single apply, so the store is written once per file", async () => {
const apply = vi.fn();
await probeMissingSourceDurations(
[
{ id: "a1", tag: "audio", src: "assets/voice.wav" },
{ id: "a2", tag: "audio", src: "assets/voice.wav" },
{ id: "a3", tag: "audio", src: "assets/voice.wav" },
{ id: "v1", tag: "video", src: "assets/clip.mp4" },
],
"project-a",
apply,
);
expect(requestedSources).toHaveLength(2);
expect(apply).toHaveBeenCalledTimes(2);
expect(apply).toHaveBeenCalledWith(
new Map([
["a1", 5],
["a2", 5],
["a3", 5],
]),
);
expect(apply).toHaveBeenCalledWith(new Map([["v1", 5]]));
});
it("probes a dropped clip's URL-spelled src at its own file", async () => {
await probeMissingSourceDurations(
[{ id: "clip", tag: "video", src: "assets/My%20clip%20%231.mp4" }],
@@ -138,7 +163,7 @@ describe("media probe registry", () => {
expect(requestedSources).toEqual([
`${window.location.origin}/api/projects/project-a/preview/assets/clip.mp4`,
]);
expect(apply).toHaveBeenCalledWith("clip", 5);
expect(apply).toHaveBeenCalledWith(new Map([["clip", 5]]));
expect(
applyCachedSourceDurations(
[
+13 -10
View File
@@ -136,26 +136,29 @@ export async function probeMissingSourceDurations<
>(
elements: T[],
projectId: string | null,
apply: (key: string, durationSeconds: number) => void,
apply: (durations: ReadonlyMap<string, number>) => void,
): Promise<void> {
const needs = elements.flatMap((el) => {
const keysBySource = new Map<string, string[]>();
for (const el of elements) {
if (
!el.src ||
el.sourceDuration != null ||
!["video", "audio"].includes(el.tag.toLowerCase())
) {
return [];
continue;
}
const source = resolveProbeSource(el.src, projectId);
return !getCachedProbe(source) && !hasFreshFailure(normalizeUrl(source))
? [{ el, source }]
: [];
});
if (needs.length === 0) return;
if (getCachedProbe(source) || hasFreshFailure(normalizeUrl(source))) continue;
const keys = keysBySource.get(source);
if (keys) keys.push(el.key ?? el.id);
else keysBySource.set(source, [el.key ?? el.id]);
}
// One apply per file, not per clip: a film that cuts one file into many clips would otherwise write the store
// once per clip, and each write re-runs every subscriber.
await Promise.allSettled(
needs.map(async ({ el, source }) => {
[...keysBySource].map(async ([source, keys]) => {
const result = await probeMediaUrl(source);
if (result) apply(el.key ?? el.id, result.duration);
if (result) apply(new Map(keys.map((key) => [key, result.duration])));
}),
);
}