diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index bae493bfa8..c77394de09 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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], ); diff --git a/packages/studio/src/player/lib/mediaProbe.test.ts b/packages/studio/src/player/lib/mediaProbe.test.ts index 8f04af7bed..de25ffa168 100644 --- a/packages/studio/src/player/lib/mediaProbe.test.ts +++ b/packages/studio/src/player/lib/mediaProbe.test.ts @@ -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( [ diff --git a/packages/studio/src/player/lib/mediaProbe.ts b/packages/studio/src/player/lib/mediaProbe.ts index 9c92498505..07b5f1e903 100644 --- a/packages/studio/src/player/lib/mediaProbe.ts +++ b/packages/studio/src/player/lib/mediaProbe.ts @@ -136,26 +136,29 @@ export async function probeMissingSourceDurations< >( elements: T[], projectId: string | null, - apply: (key: string, durationSeconds: number) => void, + apply: (durations: ReadonlyMap) => void, ): Promise { - const needs = elements.flatMap((el) => { + const keysBySource = new Map(); + 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]))); }), ); }