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
This commit is contained in:
Miguel Ángel
2026-11-03 18:54:17 +00:00
committed by GitHub
parent a5a6121332
commit 3a28ef41ae
4 changed files with 51 additions and 26 deletions
@@ -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 {
@@ -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],
);
@@ -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 -12
View File
@@ -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<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]);
}
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])));
}),
);
}