From f6c09ce86c447c6337e54b79cb509799f42d77b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 29 Oct 2026 14:20:43 -0400 Subject: [PATCH] fix(studio): preserve identity for stale metadata completions --- .../hooks/useTimelinePlayer.metadata.test.ts | 156 ++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 2 +- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts new file mode 100644 index 0000000000..69102c30f7 --- /dev/null +++ b/packages/studio/src/player/hooks/useTimelinePlayer.metadata.test.ts @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { useTimelinePlayer } from "./useTimelinePlayer"; +import { usePlayerStore } from "../store/playerStore"; +import { getMediaProbeDiagnostics, resetMediaProbeRegistry } from "../lib/mediaProbe"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +it("publishes resolved metadata only for clips that still need it", async () => { + vi.useFakeTimers({ + toFake: ["setInterval", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame"], + }); + const { promise: ready, resolve: release } = Promise.withResolvers(); + const requested = new Set(); + const bytes = new Uint8Array(44 + 192000); + const view = new DataView(bytes.buffer); + const word = (at: number, text: string) => + [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0))); + word(0, "RIFF"); + view.setUint32(4, bytes.length - 8, true); + word(8, "WAVE"); + word(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, 8000, true); + view.setUint32(28, 16000, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + word(36, "data"); + view.setUint32(40, 192000, true); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + requested.add(String(input)); + await ready; + return new Response(bytes, { + headers: { "content-type": "audio/wav", "content-length": String(bytes.length) }, + }); + }), + ); + resetMediaProbeRegistry(); + usePlayerStore.getState().reset(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const current: { value?: ReturnType } = {}; + function Harness() { + current.value = useTimelinePlayer(); + return null; + } + act(() => root.render(React.createElement(Harness))); + const api = current.value; + if (!api) throw new Error("player hook did not mount"); + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const frame = iframe.contentWindow; + if (!frame) throw new Error("preview frame did not attach"); + Object.assign(frame, { + __player: { + play() {}, + pause() {}, + seek() {}, + getTime: () => 0, + getDuration: () => 600, + isPlaying: () => false, + }, + postMessage() {}, + scrollTo() {}, + }); + act(() => { + api.iframeRef.current = iframe; + api.onIframeLoad(); + }); + const clips = Array.from({ length: 600 }, (_, index) => ({ + id: `clip-${index}`, + label: `Clip ${index}`, + start: index, + duration: 1, + track: 0, + kind: "audio", + tagName: "audio", + compositionId: null, + parentCompositionId: null, + compositionSrc: null, + assetUrl: `https://example.test/source-${index % 30}.wav`, + })); + const dispatch = () => + window.dispatchEvent( + new MessageEvent("message", { + source: frame, + data: { source: "hf-preview", type: "timeline", clips, durationInFrames: 18000, fps: 30 }, + }), + ); + let stop = () => {}; + try { + act(() => { + dispatch(); + dispatch(); + }); + expect(usePlayerStore.getState().elements).toHaveLength(600); + act(() => usePlayerStore.getState().setTimelineReady(true)); + act(() => + usePlayerStore.setState((state) => ({ + elements: state.elements + .slice(20) + .map((element, index) => (index < 20 ? { ...element, sourceDuration: 99 } : element)), + })), + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + let changed = 0; + let unchanged = 0; + let other = 0; + stop = usePlayerStore.subscribe((state, before) => { + if (state.elements !== before.elements) changed++; + else if (Object.entries(state).every(([key, value]) => value === Reflect.get(before, key))) + unchanged++; + else other++; + }); + await act(async () => { + release(); + const started = Date.now(); + while (getMediaProbeDiagnostics().cached < 30 && Date.now() - started < 4000) + await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const elements = usePlayerStore.getState().elements; + expect(elements).toHaveLength(580); + expect(requested.size).toBe(30); + 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); + console.log({ + changed, + unchanged, + other, + metadataSources: requested.size, + clips: elements.length, + }); + expect(changed).toBe(560); + expect(unchanged).toBe(0); + expect(other).toBe(0); + } finally { + stop(); + act(() => root.unmount()); + usePlayerStore.getState().reset(); + resetMediaProbeRegistry(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + document.body.innerHTML = ""; + } +}); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index bae493bfa8..35352cd139 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -134,7 +134,7 @@ export function useTimelinePlayer({ (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 {}; + 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 };