Browse / github.com/storytold/artcraft / patch
fix(promptbox): preserve current media references across async completion
fix(promptbox): preserve current media references across async completion ## Problem Pending reference uploads and library selections can commit against stale lists. Removing a reference during a video upload or metadata probe restores the removed item when that operation finishes. Independent completions can overwrite one another. The audio library path has the same snapshot problem, and audio uploads can overwrite each other when callbacks finish before the next render. The desktop video prompt uses this hook and exposes removal of existing references during uploads. ## Change Use the latest committed video/audio arrays for both local upload and asynchronous l
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- rudycelekli (imported from pull request #2083, open)
- Base
b5614ad333839649d8f4e779d955b89b3c22f376- Size
- +752 −43 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
de2a5077f5407cf7a09a4cfe
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/promptbox/src/lib/PromptBoxAudio.spec.tsx frontend/libs/components/promptbox/src/lib/PromptBoxAudio.tsx frontend/libs/components/promptbox/src/lib/common/AudioReferenceRow.tsx frontend/libs/components/promptbox/src/lib/common/AudioReferenceRow.upload.spec.tsx frontend/libs/components/promptbox/src/lib/deck/useDeckMedia.spec.tsx frontend/libs/components/promptbox/src/lib/deck/useDeckMedia.tsx
new file mode 100644@@ -0,0 +1,144 @@+import { act } from "react";+import { createRoot, type Root } from "react-dom/client";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { PromptBoxAudio } from "./PromptBoxAudio";+import { usePromptAudioStore, type RefAudio } from "./promptStore";+import type { OmniGenAudioModelDetails, UploadMediaFn } from "@storyteller/api";+import { UploaderStates } from "@storyteller/common";+import * as gallery from "@storyteller/ui-gallery-modal";++const TRACK: RefAudio = {+ id: "old", url: "https://example.com/old.mp3", file: new File([], "old.mp3"),+ mediaToken: "old-token", duration: 5,+};+const MODELS = [+ { model: "many", full_name: "Many", audio_references_supported: true, audio_references_max: 3, image_references_supported: true },+ { model: "one", full_name: "One", audio_references_supported: true, audio_references_max: 1 },+ { model: "none", full_name: "None", audio_references_supported: false, audio_references_max: 0 },+] as OmniGenAudioModelDetails[];++let root: Root;+let container: HTMLDivElement;+let probes: HTMLAudioElement[];+let imageUpload: Parameters<UploadMediaFn>[0];+let finishImageUpload: () => void;++describe("audio prompt library completion", () => {+ beforeEach(async () => {+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;+ usePromptAudioStore.setState({ referenceAudios: [TRACK], referenceImages: [], selectedModelId: "many", prompt: "" });+ probes = [];+ vi.stubGlobal("URL", class extends URL {+ static createObjectURL = vi.fn(() => "blob:test");+ static revokeObjectURL = vi.fn();+ });+ const createElement = document.createElement.bind(document);+ vi.spyOn(document, "createElement").mockImplementation((tag: string, options?: ElementCreationOptions) => {+ const element = createElement(tag, options);+ if (tag === "audio") probes.push(element as HTMLAudioElement);+ return element;+ });+ container = document.createElement("div");+ document.body.append(container);+ root = createRoot(container);+ const uploadImage: UploadMediaFn = (args) => new Promise((resolve) => {+ imageUpload = args;+ finishImageUpload = resolve;+ });+ await act(async () => root.render(<PromptBoxAudio models={MODELS} uploadImage={uploadImage} />));+ });++ afterEach(async () => {+ await act(async () => root.unmount());+ container.remove();+ vi.restoreAllMocks();+ vi.unstubAllGlobals();+ });++ it("does not restore a removed reference after pending library metadata arrives", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await clickLabel("Remove audio");+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceAudios.map((a) => a.mediaToken)).toEqual(["new"]);+ });++ it("preserves a reference added while library metadata was pending", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await act(async () => usePromptAudioStore.getState().setReferenceAudios([TRACK, { ...TRACK, id: "other", mediaToken: "other-token" }]));+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceAudios.map((a) => a.mediaToken)).toEqual(["old-token", "other-token", "new"]);+ });++ it("uses the current model's count limit after metadata completes", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await act(async () => usePromptAudioStore.getState().setSelectedModelId("one"));+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceAudios).toEqual([TRACK]);+ });++ it("does not append audio when the current model no longer supports it", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await act(async () => usePromptAudioStore.getState().setSelectedModelId("none"));+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceAudios).toEqual([TRACK]);+ });++ it("checks total duration against references added during metadata loading", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await act(async () => usePromptAudioStore.getState().setReferenceAudios([{ ...TRACK, duration: 595 }]));+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceAudios).toHaveLength(1);+ expect(usePromptAudioStore.getState().referenceAudios[0].duration).toBe(595);+ });++ it("clears a current image when completing a valid audio selection", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3" }]);+ await act(async () => usePromptAudioStore.getState().setReferenceImages([{ id: "image", url: "image.png", mediaToken: "image-token", file: new File([], "image.png") }]));+ await finishMetadata(10);+ expect(usePromptAudioStore.getState().referenceImages).toEqual([]);+ });++ it("keeps ordinary library selection with known duration working", async () => {+ await select([{ id: "new", fullImage: "https://example.com/new.mp3", durationMillis: 10000 }]);+ await act(async () => { await (gallery as unknown as { pendingSelection: Promise<void> }).pendingSelection; });+ expect(probes).toHaveLength(0);+ expect(usePromptAudioStore.getState().referenceAudios.map((a) => a.mediaToken)).toEqual(["old-token", "new"]);+ });++ it("clears audio added while an image upload was pending", async () => {+ await act(async () => usePromptAudioStore.getState().setReferenceAudios([]));+ const input = container.querySelector<HTMLInputElement>('input[accept="image/*"]')!;+ Object.defineProperty(input, "files", { value: [new File([], "image.png", { type: "image/png" })] });+ await act(async () => input.dispatchEvent(new Event("change", { bubbles: true })));+ await act(async () => usePromptAudioStore.getState().setReferenceAudios([TRACK]));+ await act(async () => {+ imageUpload.progressCallback({ status: UploaderStates.success, data: "uploaded-image" });+ finishImageUpload();+ });+ expect(usePromptAudioStore.getState().referenceAudios).toEqual([]);+ expect(usePromptAudioStore.getState().referenceImages.map((image) => image.mediaToken)).toEqual(["uploaded-image"]);+ });+});++async function select(items: unknown[]) {+ (gallery as unknown as { choose: (items: unknown[]) => void }).choose(items);+ const open = Array.from(container.querySelectorAll("button")).find((button) => button.textContent === "From library");+ if (!open) throw new Error("Missing live reference-row library button");+ await act(async () => open.click());+ await clickLabel("Use library selection");+}++async function clickLabel(label: string) {+ const button = container.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);+ if (!button) throw new Error(`Missing ${label}`);+ await act(async () => button.click());+}++async function finishMetadata(duration: number) {+ expect(probes).toHaveLength(1);+ await act(async () => {+ Object.defineProperty(probes[0], "duration", { value: duration });+ probes[0].dispatchEvent(new Event("loadedmetadata"));+ await (gallery as unknown as { pendingSelection: Promise<void> }).pendingSelection;+ });+}@@ -1,4 +1,4 @@-import { useMemo, useRef, useState, ReactNode } from "react";+import { useLayoutEffect, useMemo, useRef, useState, ReactNode } from "react"; import { toast } from "@storyteller/ui-toaster"; import { GalleryModal,@@ -167,6 +167,10 @@ export const PromptBoxAudio = ({ const imageRefsSupported = supports( selectedModel?.image_references_supported, );+ const audioLibraryLimitRef = useRef(audioRefsSupported ? maxAudioRefs : 0);+ useLayoutEffect(() => {+ audioLibraryLimitRef.current = audioRefsSupported ? maxAudioRefs : 0;+ }, [audioRefsSupported, maxAudioRefs]); const requiresAudioRef = AUDIO_MODELS_REQUIRING_AUDIO_REF.has( selectedModel?.model ?? "", );@@ -202,7 +206,7 @@ export const PromptBoxAudio = ({ // Seed Audio can't combine audio and image references — adding one kind // clears the other so the request is always valid. const handleReferenceAudiosChange = (audios: typeof referenceAudios) => {- if (audios.length > 0 && referenceImages.length > 0) {+ if (audios.length > 0 && usePromptAudioStore.getState().referenceImages.length > 0) { setReferenceImages([]); toast.error("Removed image reference — it can't be combined with audio"); }@@ -261,8 +265,11 @@ export const PromptBoxAudio = ({ ); const added: RefAudio[] = [];- let total = referenceAudios.reduce((sum, a) => sum + a.duration, 0);+ const currentAudios = usePromptAudioStore.getState().referenceAudios;+ const currentSlots = Math.max(0, audioLibraryLimitRef.current - currentAudios.length);+ let total = currentAudios.reduce((sum, a) => sum + a.duration, 0); for (let i = 0; i < picked.length; i++) {+ if (added.length >= currentSlots) break; const item = picked[i]!; const duration = durations[i]!; if (total + duration > AUDIO_REF_MAX_DURATION_SECONDS) {@@ -281,7 +288,7 @@ export const PromptBoxAudio = ({ }); } if (added.length > 0) {- handleReferenceAudiosChange([...referenceAudios, ...added]);+ handleReferenceAudiosChange([...currentAudios, ...added]); } } finally { setIsAudioLibraryProcessing(false);@@ -290,7 +297,7 @@ export const PromptBoxAudio = ({ }; const handleReferenceImagesChange = (images: typeof referenceImages) => {- if (images.length > 0 && referenceAudios.length > 0) {+ if (images.length > 0 && usePromptAudioStore.getState().referenceAudios.length > 0) { setReferenceAudios([]); toast.error("Removed audio reference — it can't be combined with an image"); }@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useImperativeHandle,+ useLayoutEffect, useRef, useState, } from "react";@@ -73,9 +74,15 @@ export const AudioReferenceRow = forwardRef< // Latest-value refs so async upload callbacks append to fresh state. const referenceAudiosRef = useRef(referenceAudios);- referenceAudiosRef.current = referenceAudios; const referenceImagesRef = useRef(referenceImages);- referenceImagesRef.current = referenceImages;+ const limitsRef = useRef({ maxAudioCount, maxAudioRefDuration, imageSupported });+ useLayoutEffect(() => {+ referenceAudiosRef.current = referenceAudios;+ referenceImagesRef.current = referenceImages;+ limitsRef.current = { maxAudioCount, maxAudioRefDuration, imageSupported };+ }, [+ referenceAudios, referenceImages, maxAudioCount, maxAudioRefDuration, imageSupported,+ ]); const processAudioFiles = async (files: File[]) => { if (files.length === 0) return;@@ -90,13 +97,14 @@ export const AudioReferenceRow = forwardRef< for (const file of filesToProcess) { const duration = await getAudioFileDuration(file);+ if (referenceAudiosRef.current.length >= limitsRef.current.maxAudioCount) break; const currentTotal = referenceAudiosRef.current.reduce( (sum, audio) => sum + audio.duration, 0, );- if (currentTotal + duration > maxAudioRefDuration) {+ if (currentTotal + duration > limitsRef.current.maxAudioRefDuration) { toast.error(- `Total audio duration cannot exceed ${maxAudioRefDuration}s`,+ `Total audio duration cannot exceed ${limitsRef.current.maxAudioRefDuration}s`, ); break; }@@ -108,6 +116,14 @@ export const AudioReferenceRow = forwardRef< assetFile: file, progressCallback: (newState) => { if (newState.status === UploaderStates.success && newState.data) {+ setIsUploadingAudio(false);+ const current = referenceAudiosRef.current;+ if (current.length >= limitsRef.current.maxAudioCount) return;+ const currentDuration = current.reduce((sum, audio) => sum + audio.duration, 0);+ if (currentDuration + duration > limitsRef.current.maxAudioRefDuration) {+ toast.error(`Total audio duration cannot exceed ${limitsRef.current.maxAudioRefDuration}s`);+ return;+ } const refAudio: RefAudio = { id: Math.random().toString(36).substring(7), url: URL.createObjectURL(file),@@ -115,11 +131,8 @@ export const AudioReferenceRow = forwardRef< mediaToken: newState.data, duration, };- setIsUploadingAudio(false);- onReferenceAudiosChange([- ...referenceAudiosRef.current,- refAudio,- ]);+ referenceAudiosRef.current = [...current, refAudio];+ onReferenceAudiosChange(referenceAudiosRef.current); } else if ( newState.status === UploaderStates.assetError || newState.status === UploaderStates.imageCreateError@@ -149,13 +162,15 @@ export const AudioReferenceRow = forwardRef< assetFile: file, progressCallback: (newState) => { if (newState.status === UploaderStates.success && newState.data) {+ setIsUploadingImage(false);+ if (!limitsRef.current.imageSupported) return; const refImage: RefImage = { id: Math.random().toString(36).substring(7), url: URL.createObjectURL(file), file, mediaToken: newState.data, };- setIsUploadingImage(false);+ referenceImagesRef.current = [refImage]; onReferenceImagesChange?.([refImage]); } else if ( newState.status === UploaderStates.assetError ||@@ -187,7 +202,8 @@ export const AudioReferenceRow = forwardRef< ); const removeAudio = (id: string) => {- onReferenceAudiosChange(referenceAudios.filter((a) => a.id !== id));+ referenceAudiosRef.current = referenceAudiosRef.current.filter((a) => a.id !== id);+ onReferenceAudiosChange(referenceAudiosRef.current); if (audioInputRef.current) audioInputRef.current.value = ""; }; new file mode 100644@@ -0,0 +1,165 @@+import { act, createRef, useState, type RefObject } from "react";+import { createRoot, type Root } from "react-dom/client";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import { UploaderStates } from "@storyteller/common";+import { AudioReferenceRow, type AudioReferenceRowHandle } from "./AudioReferenceRow";+import type { RefAudio } from "../promptStore";+import type { UploadMediaFn } from "@storyteller/api";++const FILE = new File(["audio"], "track.mp3", { type: "audio/mpeg" });+const EXISTING: RefAudio = { id: "existing", url: "existing.mp3", file: FILE, mediaToken: "existing-token", duration: 595 };++let root: Root;+let container: HTMLDivElement;+let handle: RefObject<AudioReferenceRowHandle | null>;+let probes: HTMLAudioElement[];+let uploads: Array<{ args: Parameters<UploadMediaFn>[0]; resolve: () => void }>;+let tracks: RefAudio[];+let setTracks: (next: RefAudio[]) => void;+let setLimit: (next: number) => void;+let setImageSupported: (next: boolean) => void;+let images: unknown[];++describe("audio prompt upload completion", () => {+ beforeEach(async () => {+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;+ probes = [];+ uploads = [];+ images = [];+ handle = createRef<AudioReferenceRowHandle>();+ const nativeCreate = document.createElement.bind(document);+ vi.spyOn(document, "createElement").mockImplementation((tag: string, options?: ElementCreationOptions) => {+ const element = nativeCreate(tag, options);+ if (tag === "audio") probes.push(element as HTMLAudioElement);+ return element;+ });+ vi.stubGlobal("URL", class extends URL {+ static createObjectURL = vi.fn(() => "blob:test");+ static revokeObjectURL = vi.fn();+ });+ container = document.createElement("div");+ document.body.append(container);+ root = createRoot(container);+ await act(async () => root.render(<Harness />));+ });++ afterEach(async () => {+ await act(async () => root.unmount());+ container.remove();+ vi.restoreAllMocks();+ vi.unstubAllGlobals();+ });++ it("uses current model capacity before beginning an upload after metadata", async () => {+ const pending = handle.current!.addAudioFiles([FILE]);+ await act(async () => setLimit(0));+ await metadata(0, 10);+ expect(uploads).toHaveLength(0);+ await pending;+ expect(tracks).toEqual([]);+ });++ it("does not append an upload when the model capacity became zero", async () => {+ const pending = handle.current!.addAudioFiles([FILE]);+ await metadata(0, 10);+ await act(async () => setLimit(0));+ await complete(0, "new");+ await pending;+ expect(tracks).toEqual([]);+ });++ it("enforces the current total duration at upload completion", async () => {+ const pending = handle.current!.addAudioFiles([FILE]);+ await metadata(0, 10);+ await act(async () => setTracks([EXISTING]));+ await complete(0, "new");+ await pending;+ expect(tracks).toEqual([EXISTING]);+ });++ it("retains both independent callbacks completing before the next render", async () => {+ const first = handle.current!.addAudioFiles([FILE]);+ const second = handle.current!.addAudioFiles([FILE]);+ await metadata(0, 10);+ await metadata(1, 20);+ await act(async () => {+ uploads[0].args.progressCallback({ status: UploaderStates.success, data: "first" });+ uploads[1].args.progressCallback({ status: UploaderStates.success, data: "second" });+ uploads[0].resolve();+ uploads[1].resolve();+ await Promise.all([first, second]);+ });+ expect(tracks.map((a) => a.mediaToken)).toEqual(["first", "second"]);+ });++ it("does not exceed a one-track limit with concurrent callbacks", async () => {+ await act(async () => setLimit(1));+ const first = handle.current!.addAudioFiles([FILE]);+ const second = handle.current!.addAudioFiles([FILE]);+ await metadata(0, 10);+ await metadata(1, 20);+ await act(async () => {+ uploads[0].args.progressCallback({ status: UploaderStates.success, data: "first" });+ uploads[1].args.progressCallback({ status: UploaderStates.success, data: "second" });+ uploads[0].resolve();+ uploads[1].resolve();+ await Promise.all([first, second]);+ });+ expect(tracks.map((a) => a.mediaToken)).toEqual(["first"]);+ });++ it("keeps an ordinary two-file sequential upload working", async () => {+ const pending = handle.current!.addAudioFiles([FILE, FILE]);+ await metadata(0, 10);+ await complete(0, "first");+ await metadata(1, 20);+ await complete(1, "second");+ await pending;+ expect(tracks.map((a) => a.mediaToken)).toEqual(["first", "second"]);+ });++ it("discards an image completion when the current model stopped supporting images", async () => {+ let pending!: Promise<void>;+ await act(async () => { pending = handle.current!.addImageFile(new File([], "image.png")); });+ await act(async () => setImageSupported(false));+ await complete(0, "image-token");+ await pending;+ expect(images).toEqual([]);+ });++ it("retains an ordinary image upload", async () => {+ let pending!: Promise<void>;+ await act(async () => { pending = handle.current!.addImageFile(new File([], "image.png")); });+ await complete(0, "image-token");+ await pending;+ expect(images).toHaveLength(1);+ });+});++function Harness() {+ const [current, update] = useState<RefAudio[]>([]);+ const [limit, updateLimit] = useState(3);+ const [imageSupported, updateImageSupported] = useState(true);+ tracks = current;+ setTracks = update;+ setLimit = updateLimit;+ setImageSupported = updateImageSupported;+ const upload: UploadMediaFn = (args) => new Promise((resolve) => uploads.push({ args, resolve }));+ return <AudioReferenceRow ref={handle} referenceAudios={current} onReferenceAudiosChange={update}+ maxAudioCount={limit} maxAudioRefDuration={600} uploadAudio={upload} uploadImage={upload}+ imageSupported={imageSupported} onReferenceImagesChange={(next) => { images = next; }} />;+}++async function metadata(index: number, duration: number) {+ await act(async () => {+ Object.defineProperty(probes[index], "duration", { value: duration });+ probes[index].dispatchEvent(new Event("loadedmetadata"));+ });+}++async function complete(index: number, token: string) {+ await act(async () => {+ uploads[index].args.progressCallback({ status: UploaderStates.success, data: token });+ uploads[index].resolve();+ });+}new file mode 100644@@ -0,0 +1,321 @@+import { act, useState, useLayoutEffect, type ReactElement } from "react";+import { createRoot, type Root } from "react-dom/client";+import { UploaderStates } from "@storyteller/common";+import type { UploadMediaFn } from "@storyteller/api";+import { useDeckMedia, type DeckMediaRefLike } from "./useDeckMedia";++let root: Root;+let container: HTMLDivElement;+let deck: ReturnType<typeof useDeckMedia>;+let references: DeckMediaRefLike[];+let audios: DeckMediaRefLike[];+let onRemoved: (() => void) | undefined;+let probes: HTMLVideoElement[];+let audioProbes: HTMLAudioElement[];+let uploads: Array<{ args: Parameters<UploadMediaFn>[0]; resolve: () => void }>;+const originalCreate = document.createElement.bind(document);++beforeEach(() => {+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);+ probes = [];+ audioProbes = [];+ uploads = [];+ onRemoved = undefined;+ vi.spyOn(document, "createElement").mockImplementation((tag, options) => {+ const element = originalCreate(tag, options);+ if (tag === "video") probes.push(element as HTMLVideoElement);+ if (tag === "audio") audioProbes.push(element as HTMLAudioElement);+ return element;+ });+ vi.stubGlobal("URL", class extends URL {+ static createObjectURL = vi.fn(() => `blob:${Math.random()}`);+ static revokeObjectURL = vi.fn();+ });+ container = document.createElement("div");+ document.body.append(container);+ root = createRoot(container);+});++afterEach(async () => {+ await act(async () => root.unmount());+ container.remove();+ vi.restoreAllMocks();+ vi.unstubAllGlobals();+});++it("does not restore a reference removed while another video uploads", async () => {+ await render([reference("old")]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processVideoFiles([file("new")]); });+ await metadata(0);+ await act(async () => container.querySelector("button")!.click());+ expect(references).toEqual([]);+ await finish(0, "new", pending);+ expect(references.map((item) => item.mediaToken)).toEqual(["new"]);+});++it("keeps both independently completed video uploads", async () => {+ await render([]);+ let first!: Promise<void>;+ let second!: Promise<void>;+ await act(async () => {+ first = deck.processVideoFiles([file("a")]);+ second = deck.processVideoFiles([file("b")]);+ });+ await metadata(0);+ await metadata(1);+ await finish(1, "b", second);+ await finish(0, "a", first);+ expect(references.map((item) => item.mediaToken)).toEqual(["b", "a"]);+});++it("preserves ordinary sequential multi-file uploads", async () => {+ await render([]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processVideoFiles([file("a"), file("b")]); });+ await metadata(0);+ await finish(0, "a");+ await metadata(1);+ await finish(1, "b", pending);+ expect(references.map((item) => item.mediaToken)).toEqual(["a", "b"]);+});++it("preserves an existing reference when a normal new upload finishes", async () => {+ await render([reference("old")]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processVideoFiles([file("new")]); });+ await metadata(0);+ await finish(0, "new", pending);+ expect(references.map((item) => item.mediaToken)).toEqual(["old", "new"]);+});++it.each([+ { maxVideos: 1, maxSeconds: 15 },+ { maxVideos: 3, maxSeconds: 3 },+])("keeps the first committed upload when parallel completion exceeds $maxVideos videos / $maxSeconds seconds", async ({ maxVideos, maxSeconds }) => {+ await render([], maxVideos, maxSeconds);+ let first!: Promise<void>;+ let second!: Promise<void>;+ await act(async () => {+ first = deck.processVideoFiles([file("a")]);+ second = deck.processVideoFiles([file("b")]);+ });+ await metadata(0);+ await metadata(1);+ await finish(0, "a", first);+ await finish(1, "b", second);+ expect(references.map((item) => item.mediaToken)).toEqual(["a"]);+ // Both metadata probe URLs plus the rejected reference preview are released.+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(3);+});++it("keeps a removal committed before passive effects when an upload completes", async () => {+ await render([reference("old")]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processVideoFiles([file("new")]); });+ await metadata(0);+ onRemoved = () => {+ uploads[0].args.progressCallback({ status: UploaderStates.success, data: "new" });+ uploads[0].resolve();+ };+ await act(async () => container.querySelector("button")!.click());+ await pending;+ expect(references.map((item) => item.mediaToken)).toEqual(["new"]);+});++it("does not restore references removed while a library video is probing metadata", async () => {+ await render([reference("old")]);+ await act(async () => deck.openGallery("video"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library"); });+ await act(async () => container.querySelector("button")!.click());+ await metadata(0);+ await pending;+ expect(references.map((item) => item.mediaToken)).toEqual(["library"]);+});++it("preserves an upload completing while a library video is probing metadata", async () => {+ await render([]);+ let upload!: Promise<void>;+ await act(async () => { upload = deck.processVideoFiles([file("upload")]); });+ await metadata(0);+ await act(async () => deck.openGallery("video"));+ let library!: Promise<void>;+ await act(async () => { library = selectLibrary("library"); });+ await finish(0, "upload", upload);+ await metadata(1);+ await library;+ expect(references.map((item) => item.mediaToken)).toEqual(["upload", "library"]);+});++it("uses a stricter model's current limits when an upload finishes", async () => {+ await render([]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processVideoFiles([file("new")]); });+ await metadata(0);+ await render([], 0);+ await finish(0, "new", pending);+ expect(references).toEqual([]);+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);+});++it("uses current model limits after library video metadata finishes", async () => {+ await render([]);+ await act(async () => deck.openGallery("video"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library"); });+ await render([], 0);+ await metadata(0);+ await pending;+ expect(references).toEqual([]);+});++it("preserves ordinary library video selection", async () => {+ await render([reference("old")]);+ await act(async () => deck.openGallery("video"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library"); });+ await metadata(0);+ await pending;+ expect(references.map((item) => item.mediaToken)).toEqual(["old", "library"]);+});++it("does not restore audio removed while a library selection awaits duration results", async () => {+ await render([], 3, 15, [reference("old")]);+ await act(async () => deck.openGallery("audio"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library"); });+ await act(async () => container.querySelectorAll("button")[1].click());+ Object.defineProperty(audioProbes[0], "duration", { configurable: true, value: 2 });+ await act(async () => audioProbes[0].dispatchEvent(new Event("loadedmetadata")));+ await pending;+ expect(audios.map((item) => item.mediaToken)).toEqual(["library"]);+});++it("keeps audio uploads completing before the next render", async () => {+ await render([]);+ let first!: Promise<void>;+ let second!: Promise<void>;+ await act(async () => {+ first = deck.processAudioFiles([audioFile("a")]);+ second = deck.processAudioFiles([audioFile("b")]);+ });+ await metadata(0, "audio");+ await metadata(1, "audio");+ await act(async () => {+ uploads[1].args.progressCallback({ status: UploaderStates.success, data: "b" });+ uploads[1].resolve();+ uploads[0].args.progressCallback({ status: UploaderStates.success, data: "a" });+ uploads[0].resolve();+ await Promise.all([first, second]);+ });+ expect(audios.map((item) => item.mediaToken)).toEqual(["b", "a"]);+});++it("uses current model limits when an audio upload finishes", async () => {+ await render([]);+ let pending!: Promise<void>;+ await act(async () => { pending = deck.processAudioFiles([audioFile("new")]); });+ await metadata(0, "audio");+ await render([], 0);+ await finish(0, "new", pending);+ expect(audios).toEqual([]);+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);+});++it("preserves an audio upload completing while library audio metadata loads", async () => {+ await render([]);+ let upload!: Promise<void>;+ await act(async () => { upload = deck.processAudioFiles([audioFile("upload")]); });+ await metadata(0, "audio");+ await act(async () => deck.openGallery("audio"));+ let library!: Promise<void>;+ await act(async () => { library = selectLibrary("library"); });+ await finish(0, "upload", upload);+ await metadata(1, "audio");+ await library;+ expect(audios.map((item) => item.mediaToken)).toEqual(["upload", "library"]);+});++it("preserves ordinary audio library selection with known duration", async () => {+ await render([], 3, 15, [reference("old")]);+ await act(async () => deck.openGallery("audio"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library", 2000); await pending; });+ expect(audios.map((item) => item.mediaToken)).toEqual(["old", "library"]);+});++it("uses current model limits when library audio metadata finishes", async () => {+ await render([]);+ await act(async () => deck.openGallery("audio"));+ let pending!: Promise<void>;+ await act(async () => { pending = selectLibrary("library"); });+ await render([], 0);+ await metadata(0, "audio");+ await pending;+ expect(audios).toEqual([]);+});++function Harness({ initial, maxVideos, maxSeconds, initialAudios }: { initial: DeckMediaRefLike[]; maxVideos: number; maxSeconds: number; initialAudios: DeckMediaRefLike[] }) {+ const [videos, setVideos] = useState(initial);+ references = videos;+ const [audioRefs, setAudios] = useState(initialAudios);+ audios = audioRefs;+ deck = useDeckMedia({+ referenceImages: [], setReferenceImages: () => {}, maxImages: 0,+ referenceVideos: videos, setReferenceVideos: setVideos,+ maxVideos, maxVideoTotalSec: maxSeconds,+ referenceAudios: audioRefs, setReferenceAudios: setAudios,+ maxAudios: maxVideos, maxAudioTotalSec: maxSeconds,+ ownGalleryModal: true,+ uploadVideo: (args) => new Promise<void>((resolve) => uploads.push({ args, resolve })),+ uploadAudio: (args) => new Promise<void>((resolve) => uploads.push({ args, resolve })),+ });+ useLayoutEffect(() => {+ if (videos.length === 0 && onRemoved) {+ const complete = onRemoved;+ onRemoved = undefined;+ complete();+ }+ }, [videos]);+ return <>+ <button onClick={() => setVideos(videos.filter((video) => video.id !== "old"))}>Remove old reference</button>+ <button onClick={() => setAudios(audioRefs.filter((audio) => audio.id !== "old"))}>Remove old audio</button>+ </>;+}++async function render(initial: DeckMediaRefLike[], maxVideos = 3, maxSeconds = 15, initialAudios: DeckMediaRefLike[] = []) {+ await act(async () => root.render(<Harness initial={initial} maxVideos={maxVideos} maxSeconds={maxSeconds} initialAudios={initialAudios} />));+}++function reference(id: string): DeckMediaRefLike {+ return { id, mediaToken: id, url: `https://example.com/${id}.mp4`, duration: 2 };+}++function file(name: string) {+ return new File([], `${name}.mp4`, { type: "video/mp4" });+}++function audioFile(name: string) {+ return new File([], `${name}.mp3`, { type: "audio/mpeg" });+}++async function metadata(index: number, kind: "video" | "audio" = "video") {+ const video = kind === "video" ? probes[index] : audioProbes[index];+ Object.defineProperty(video, "duration", { configurable: true, value: 2 });+ await act(async () => video.dispatchEvent(new Event("loadedmetadata")));+}++async function finish(index: number, token: string, pending?: Promise<void>) {+ await act(async () => {+ const upload = uploads[index];+ upload.args.progressCallback({ status: UploaderStates.success, data: token });+ upload.resolve();+ if (pending) await pending;+ });+}++function selectLibrary(id: string, durationMillis?: number) {+ const modal = deck.galleryModal as ReactElement<{ onUseSelected: (items: Array<{ id: string; fullImage: string; durationMillis?: number }>) => Promise<void> }>;+ return modal.props.onUseSelected([{ id, fullImage: `https://example.com/${id}.mp4`, durationMillis }]);+}@@ -1,4 +1,4 @@-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";+import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal"; import { type UploadMediaFn } from "@storyteller/api"; import { downloadFileFromUrl } from "@storyteller/tauri-api";@@ -174,10 +174,19 @@ export function useDeckMedia< referenceImagesRef.current = referenceImages; }, [referenceImages]); + const referenceVideosRef = useRef(referenceVideos);+ const videoLimitsRef = useRef({ maxVideos, maxVideoTotalSec });+ useLayoutEffect(() => {+ referenceVideosRef.current = referenceVideos;+ videoLimitsRef.current = { maxVideos, maxVideoTotalSec };+ }, [referenceVideos, maxVideos, maxVideoTotalSec]);+ const referenceAudiosRef = useRef(referenceAudios);- useEffect(() => {+ const audioLimitsRef = useRef({ maxAudios, maxAudioTotalSec });+ useLayoutEffect(() => { referenceAudiosRef.current = referenceAudios;- }, [referenceAudios]);+ audioLimitsRef.current = { maxAudios, maxAudioTotalSec };+ }, [referenceAudios, maxAudios, maxAudioTotalSec]); // Committed refs live in the caller's store, whose update can render one // pass before this hook's entry-removal state lands — briefly showing the@@ -352,9 +361,7 @@ export function useDeckMedia< }; const processVideoFiles = async (files: File[]) => {- // Snapshot the committed state at call time so removes that happened- // before this call are respected (don't re-read a stale ref).- const baseVideos = [...referenceVideos];+ const baseVideos = referenceVideosRef.current; const availableSlots = Math.max(0, maxVideos - baseVideos.length); if (availableSlots <= 0) { toast.error(videoLimitMessage(maxVideos, maxVideoTotalSec), {@@ -364,14 +371,20 @@ export function useDeckMedia< } const filesToProcess = files.slice(0, availableSlots);- let committed = baseVideos; for (const file of filesToProcess) { const duration = await getVideoDuration(file);- const currentTotal = committed.reduce((sum, v) => sum + v.duration, 0);+ const currentTotal = referenceVideosRef.current.reduce(+ (sum, v) => sum + v.duration,+ 0,+ ); - if (currentTotal + duration > maxVideoTotalSec) {- toast.error(`Total video duration cannot exceed ${maxVideoTotalSec}s`, {+ const limits = videoLimitsRef.current;+ if (+ referenceVideosRef.current.length >= limits.maxVideos ||+ currentTotal + duration > limits.maxVideoTotalSec+ ) {+ toast.error(videoLimitMessage(limits.maxVideos, limits.maxVideoTotalSec), { id: "video-ref-limit", }); break;@@ -392,8 +405,23 @@ export function useDeckMedia< duration, }); setUploadingVideo(null);- committed = [...committed, refVideo];- setReferenceVideos?.(committed);+ const current = referenceVideosRef.current;+ const limits = videoLimitsRef.current;+ if (+ current.length >= limits.maxVideos ||+ current.reduce((sum, v) => sum + v.duration, 0) + duration > limits.maxVideoTotalSec+ ) {+ URL.revokeObjectURL(entry.previewUrl);+ toast.error(videoLimitMessage(limits.maxVideos, limits.maxVideoTotalSec), {+ id: "video-ref-limit",+ });+ return;+ }+ // Uploads can finish before another render/effect. Publish to the+ // shared ref immediately so concurrent completions append safely.+ const next = [...current, refVideo];+ referenceVideosRef.current = next;+ setReferenceVideos?.(next); }; if (uploadVideo) {@@ -449,8 +477,12 @@ export function useDeckMedia< 0, ); - if (currentTotal + duration > maxAudioTotalSec) {- toast.error(`Total audio duration cannot exceed ${maxAudioTotalSec}s`);+ const limits = audioLimitsRef.current;+ if (+ referenceAudiosRef.current.length >= limits.maxAudios ||+ currentTotal + duration > limits.maxAudioTotalSec+ ) {+ toast.error(`Max ${limits.maxAudios} audio tracks / ${limits.maxAudioTotalSec}s total`); break; } @@ -466,7 +498,19 @@ export function useDeckMedia< duration, }); setUploadingAudio(null);- setReferenceAudios?.([...referenceAudiosRef.current, refAudio]);+ const current = referenceAudiosRef.current;+ const limits = audioLimitsRef.current;+ if (+ current.length >= limits.maxAudios ||+ current.reduce((sum, a) => sum + a.duration, 0) + duration > limits.maxAudioTotalSec+ ) {+ URL.revokeObjectURL(entry.previewUrl);+ toast.error(`Max ${limits.maxAudios} audio tracks / ${limits.maxAudioTotalSec}s total`);+ return;+ }+ const next = [...current, refAudio];+ referenceAudiosRef.current = next;+ setReferenceAudios?.(next); }; if (uploadAudio) {@@ -552,9 +596,7 @@ export function useDeckMedia< const handleGalleryImages = async (selectedItems: GalleryItem[]) => { if (galleryTarget === "video") {- // Snapshot committed state at call time to avoid re-reading a stale- // ref after removes.- const baseVideos = [...referenceVideos];+ const baseVideos = referenceVideosRef.current; const availableSlots = Math.max(0, maxVideos - baseVideos.length); if (availableSlots <= 0) { toast.error(videoLimitMessage(maxVideos, maxVideoTotalSec), {@@ -577,13 +619,18 @@ export function useDeckMedia< itemsToProcess.map((item) => getVideoDurationFromSrc(item.fullImage)), ); + const current = referenceVideosRef.current;+ const limits = videoLimitsRef.current; const newVideos: TVideo[] = [];- let currentTotal = baseVideos.reduce((sum, v) => sum + v.duration, 0);+ let currentTotal = current.reduce((sum, v) => sum + v.duration, 0); let exceeded = false; for (let i = 0; i < itemsToProcess.length; i++) { const item = itemsToProcess[i]!; const duration = durations[i]!;- if (currentTotal + duration > maxVideoTotalSec) {+ if (+ current.length + newVideos.length >= limits.maxVideos ||+ currentTotal + duration > limits.maxVideoTotalSec+ ) { exceeded = true; break; }@@ -600,12 +647,14 @@ export function useDeckMedia< } if (exceeded) { toast.error(- `Total video duration cannot exceed ${maxVideoTotalSec}s`,+ videoLimitMessage(limits.maxVideos, limits.maxVideoTotalSec), { id: "video-ref-limit" }, ); } if (newVideos.length > 0) {- setReferenceVideos?.([...baseVideos, ...newVideos]);+ const next = [...current, ...newVideos];+ referenceVideosRef.current = next;+ setReferenceVideos?.(next); } } finally { setIsProcessingGallery(false);@@ -614,7 +663,7 @@ export function useDeckMedia< return; } if (galleryTarget === "audio") {- const baseAudios = [...referenceAudios];+ const baseAudios = referenceAudiosRef.current; const availableSlots = Math.max(0, maxAudios - baseAudios.length); if (availableSlots <= 0) { toast.error(`Max ${maxAudios} audio tracks / ${maxAudioTotalSec}s total`, {@@ -641,13 +690,18 @@ export function useDeckMedia< ), ); + const current = referenceAudiosRef.current;+ const limits = audioLimitsRef.current; const newAudios: TAudio[] = [];- let currentTotal = baseAudios.reduce((sum, a) => sum + a.duration, 0);+ let currentTotal = current.reduce((sum, a) => sum + a.duration, 0); let exceeded = false; for (let i = 0; i < itemsToProcess.length; i++) { const item = itemsToProcess[i]!; const duration = durations[i]!;- if (currentTotal + duration > maxAudioTotalSec) {+ if (+ current.length + newAudios.length >= limits.maxAudios ||+ currentTotal + duration > limits.maxAudioTotalSec+ ) { exceeded = true; break; }@@ -664,12 +718,14 @@ export function useDeckMedia< } if (exceeded) { toast.error(- `Total audio duration cannot exceed ${maxAudioTotalSec}s`,+ `Max ${limits.maxAudios} audio tracks / ${limits.maxAudioTotalSec}s total`, { id: "audio-ref-limit" }, ); } if (newAudios.length > 0) {- setReferenceAudios?.([...baseAudios, ...newAudios]);+ const next = [...current, ...newAudios];+ referenceAudiosRef.current = next;+ setReferenceAudios?.(next); } } finally { setIsProcessingGallery(false);