From 3a28ef41aeb68c43cccdf7f963e19ea8d6feb36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 3 Nov 2026 18:54:17 +0000 Subject: [PATCH] perf(studio): a long film's media durations write the store once per file, not once per clip (#6745) * fix(studio): a film that cuts one file into many clips plays smoothly while their durations load * test(studio): the metadata test counts one store write per file --- .../hooks/useTimelinePlayer.metadata.test.ts | 2 +- .../src/player/hooks/useTimelinePlayer.ts | 23 ++++++++-------- .../studio/src/player/lib/mediaProbe.test.ts | 27 ++++++++++++++++++- packages/studio/src/player/lib/mediaProbe.ts | 25 ++++++++--------- 4 files changed, 51 insertions(+), 26 deletions(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts index 61c712c0a5..dd56802b85 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts @@ -134,7 +134,7 @@ it("publishes resolved metadata only for clips that still need it", async () => expect(getMediaProbeDiagnostics()).toEqual({ cached: 30, failed: 0, inflight: 0 }); expect(elements.slice(0, 20).every((element) => element.sourceDuration === 99)).toBe(true); expect(elements.slice(20).every((element) => element.sourceDuration === 12)).toBe(true); - expect(changed).toBe(560); + expect(changed).toBe(30); // one write per file expect(unchanged).toBe(0); expect(other).toBe(0); } finally { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 0465d78873..4a398ca0cf 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -127,19 +127,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 state; - 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 } : state; + }); + }); }, [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..bf9ff3ac68 100644 --- a/packages/studio/src/player/lib/mediaProbe.ts +++ b/packages/studio/src/player/lib/mediaProbe.ts @@ -128,34 +128,35 @@ export function applyCachedSourceDurations< /** * Probe (header-only, cheap) any media elements still missing sourceDuration - * after the cache pass, applying each resolved duration via `apply(key, secs)`. - * Skips already-cached srcs. + * after the cache pass. Each file is probed once and all its clips' durations go to one + * `apply(durations)`, so a file cut into many clips writes the store once. Skips already-cached srcs. */ export async function probeMissingSourceDurations< T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string }, >( 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]); + } 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]))); }), ); }