mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): a film that cuts one file into many clips plays smoothly while their durations load
This commit is contained in:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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])));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user