diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx index 25644c25bd..3fdb35f896 100644 --- a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx @@ -28,7 +28,6 @@ import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; import { SliderV2 } from "@storyteller/ui-sliderv2"; // import { Switch } from "@headlessui/react"; import { EnqueueEditImage } from "@storyteller/tauri-api"; -import { listen } from "@tauri-apps/api/event"; import { ClassyModelSelector, ANGLES_PAGE_MODEL_LIST, @@ -57,10 +56,6 @@ export const Angles = () => { width: window.innerWidth, height: window.innerHeight, }); - const [pendingGenerations, setPendingGenerations] = useState< - { id: string; count: number }[] - >([]); - const [historyBundles, setHistoryBundles] = useState([]); const fileInputRef = useRef(null); @@ -111,59 +106,30 @@ export const Angles = () => { // The token of whatever is currently selected (for HistoryStack highlight) const selectedImageToken = activeAngleId ?? sourceMediaToken ?? undefined; - // ─── Event listener for generation results ─────────────────────────────────── - // Angle models route through ImageGeneration task type, which fires - // text_to_image_generation_complete_event (not image_edit_complete_event). - useEffect(() => { - const unlisten = listen<{ - status: string; - data: { - generated_images: Array<{ - media_token: string; - cdn_url: string; - maybe_thumbnail_template?: string; - }>; - maybe_frontend_subscriber_id?: string; - }; - }>("text_to_image_generation_complete_event", (wrappedEvent) => { - const event = wrappedEvent.payload.data; - const state = useAnglesStore.getState(); - if (!state.pendingSubscriberId || !state.isProcessing) return; + // Event listener is at module level in AnglesStore.ts — persists across navigations. - // Accept if subscriber ID matches OR if the backend didn't echo it back - const idMatches = - !event.maybe_frontend_subscriber_id || - event.maybe_frontend_subscriber_id === state.pendingSubscriberId; - if (!idMatches) return; + // Derive history bundles from store (no local state needed) + const historyBundles = useMemo( + () => + generatedAngles.map((angle) => ({ + images: [ + { + url: angle.imageUrl, + mediaToken: angle.id, + thumbnailUrlTemplate: angle.thumbnailUrlTemplate, + fullImageUrl: angle.imageUrl, + }, + ], + })), + [generatedAngles], + ); - const images = event.generated_images.map((img) => ({ - cdn_url: img.cdn_url, - media_token: img.media_token, - })); - - state.completeGeneration(state.pendingSubscriberId, images); - - // Add to history bundles - const newBundle: ImageBundle = { - images: event.generated_images.map((img) => ({ - url: img.cdn_url, - mediaToken: img.media_token, - thumbnailUrlTemplate: img.maybe_thumbnail_template, - fullImageUrl: img.cdn_url, - })), - }; - setHistoryBundles((prev) => [...prev, newBundle]); - - // Resolve pending placeholder - const resolveId = - event.maybe_frontend_subscriber_id ?? state.pendingSubscriberId; - setPendingGenerations((prev) => prev.filter((p) => p.id !== resolveId)); - }); - - return () => { - unlisten.then((fn) => fn()); - }; - }, []); + // Derive pending placeholders from store + const pendingSubscriberIds = useAnglesStore((s) => s.pendingSubscriberIds); + const pendingGenerations = useMemo( + () => pendingSubscriberIds.map((id) => ({ id, count: 1 })), + [pendingSubscriberIds], + ); // Window resize handler (debounced to avoid excessive re-renders) useEffect(() => { @@ -280,11 +246,10 @@ export const Angles = () => { // ─── Generate with subscriber pattern ─────────────────────────────────────── const handleGenerate = useCallback(async () => { const state = useAnglesStore.getState(); - if (!state.sourceMediaToken || state.isProcessing) return; + if (!state.sourceMediaToken) return; const subscriberId = uuidv4(); state.startGeneration(subscriberId); - setPendingGenerations((prev) => [...prev, { id: subscriberId, count: 1 }]); try { await EnqueueEditImage({ @@ -302,13 +267,7 @@ export const Angles = () => { } catch (error) { console.error("Error generating angle:", error); toast.error("Failed to generate angle"); - useAnglesStore.setState({ - isProcessing: false, - pendingSubscriberId: null, - }); - setPendingGenerations((prev) => - prev.filter((p) => p.id !== subscriberId), - ); + useAnglesStore.getState().removePendingSubscriber(subscriberId); } }, [selectedModel, selectedProvider]); @@ -387,8 +346,7 @@ export const Angles = () => { const handleChangeImage = useCallback(() => { resetSource(); - setHistoryBundles([]); - setPendingGenerations([]); + useAnglesStore.getState().clearGeneratedAngles(); }, [resetSource]); // Slider handlers that snap to allowed values @@ -465,23 +423,12 @@ export const Angles = () => { ); const handleHistoryImageRemove = useCallback((image: BaseSelectorImage) => { - setHistoryBundles((prev) => - prev - .map((bundle) => ({ - ...bundle, - images: bundle.images.filter( - (img) => img.mediaToken !== image.mediaToken, - ), - })) - .filter((bundle) => bundle.images.length > 0), - ); + useAnglesStore.getState().removeGeneratedAngle(image.mediaToken); }, []); const handleHistoryClear = useCallback(() => { - setHistoryBundles([]); - setPendingGenerations([]); - setActiveAngle(null); - }, [setActiveAngle]); + useAnglesStore.getState().clearGeneratedAngles(); + }, []); return ( <> @@ -537,29 +484,11 @@ export const Angles = () => { className="absolute inset-0 h-full w-full object-contain" /> ) : sourceImageUrl ? ( - <> - Source - {isProcessing && ( -
-
-
-
- -
- - Generating Angle... - -
-
- )} - + Source ) : (
@@ -758,12 +687,11 @@ export const Angles = () => { - {isProcessing ? "Generating..." : "Generate"} + Generate
diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts b/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts index 3b54ae440f..9ef2f79437 100644 --- a/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts @@ -1,8 +1,10 @@ import { create } from "zustand"; +import { listen } from "@tauri-apps/api/event"; export interface GeneratedAngle { id: string; imageUrl: string; + thumbnailUrlTemplate?: string; rotation: number; tilt: number; zoom: number; @@ -41,7 +43,7 @@ interface AnglesState { // Processing isProcessing: boolean; - pendingSubscriberId: string | null; + pendingSubscriberIds: string[]; isLoadingImage: boolean; // Actions @@ -54,12 +56,18 @@ interface AnglesState { addGeneratedAngle: (angle: GeneratedAngle) => void; setActiveAngle: (id: string | null) => void; getActiveAngle: () => GeneratedAngle | null; - setIsProcessing: (value: boolean) => void; + removeGeneratedAngle: (id: string) => void; + clearGeneratedAngles: () => void; + removePendingSubscriber: (subscriberId: string) => void; setIsLoadingImage: (value: boolean) => void; startGeneration: (subscriberId: string) => void; completeGeneration: ( subscriberId: string, - images: Array<{ cdn_url: string; media_token: string }>, + images: Array<{ + cdn_url: string; + media_token: string; + maybe_thumbnail_template?: string; + }>, ) => void; resetSource: () => void; clearAll: () => void; @@ -80,7 +88,7 @@ export const useAnglesStore = create((set, get) => ({ generatedAngles: [], activeAngleId: null, isProcessing: false, - pendingSubscriberId: null, + pendingSubscriberIds: [], isLoadingImage: false, setSourceImage: (url, mediaToken) => { @@ -131,8 +139,35 @@ export const useAnglesStore = create((set, get) => ({ ); }, - setIsProcessing: (value) => { - set({ isProcessing: value }); + removeGeneratedAngle: (id) => { + set((s) => { + const filtered = s.generatedAngles.filter((a) => a.id !== id); + return { + generatedAngles: filtered, + activeAngleId: s.activeAngleId === id ? null : s.activeAngleId, + }; + }); + }, + + clearGeneratedAngles: () => { + set({ + generatedAngles: [], + activeAngleId: null, + isProcessing: false, + pendingSubscriberIds: [], + }); + }, + + removePendingSubscriber: (subscriberId) => { + set((s) => { + const remaining = s.pendingSubscriberIds.filter( + (id) => id !== subscriberId, + ); + return { + pendingSubscriberIds: remaining, + isProcessing: remaining.length > 0, + }; + }); }, setIsLoadingImage: (value) => { @@ -140,27 +175,34 @@ export const useAnglesStore = create((set, get) => ({ }, startGeneration: (subscriberId) => { - set({ isProcessing: true, pendingSubscriberId: subscriberId }); + set((s) => ({ + isProcessing: true, + pendingSubscriberIds: [...s.pendingSubscriberIds, subscriberId], + })); }, completeGeneration: (subscriberId, images) => { const state = get(); - if (state.pendingSubscriberId !== subscriberId) return; + if (!state.pendingSubscriberIds.includes(subscriberId)) return; const newAngles: GeneratedAngle[] = images.map((img) => ({ id: img.media_token, imageUrl: img.cdn_url, + thumbnailUrlTemplate: img.maybe_thumbnail_template, rotation: state.angleConfig.rotation, tilt: state.angleConfig.tilt, zoom: state.angleConfig.zoom, timestamp: Date.now(), })); + const remaining = state.pendingSubscriberIds.filter( + (id) => id !== subscriberId, + ); set((s) => ({ generatedAngles: [...s.generatedAngles, ...newAngles], activeAngleId: newAngles[0]?.id ?? s.activeAngleId, - isProcessing: false, - pendingSubscriberId: null, + isProcessing: remaining.length > 0, + pendingSubscriberIds: remaining, })); }, @@ -172,7 +214,7 @@ export const useAnglesStore = create((set, get) => ({ angleConfig: { ...DEFAULT_CONFIG }, generateFromBestAngles: false, isProcessing: false, - pendingSubscriberId: null, + pendingSubscriberIds: [], isLoadingImage: false, }); }, @@ -187,8 +229,42 @@ export const useAnglesStore = create((set, get) => ({ generatedAngles: [], activeAngleId: null, isProcessing: false, - pendingSubscriberId: null, + pendingSubscriberIds: [], isLoadingImage: false, }); }, })); + +// ─── Module-level event listener ──────────────────────────────────────────────── +// Persists across page navigations so generation results are never lost. +// Angle models route through ImageGeneration task type, which fires +// text_to_image_generation_complete_event (not image_edit_complete_event). +listen<{ + status: string; + data: { + generated_images: Array<{ + media_token: string; + cdn_url: string; + maybe_thumbnail_template?: string; + }>; + maybe_frontend_subscriber_id?: string; + }; +}>("text_to_image_generation_complete_event", (wrappedEvent) => { + const event = wrappedEvent.payload.data; + const state = useAnglesStore.getState(); + if (state.pendingSubscriberIds.length === 0) return; + + const subscriberId = event.maybe_frontend_subscriber_id; + if (subscriberId && !state.pendingSubscriberIds.includes(subscriberId)) + return; + + const resolvedId = subscriberId ?? state.pendingSubscriberIds[0]; + + const images = event.generated_images.map((img) => ({ + cdn_url: img.cdn_url, + media_token: img.media_token, + maybe_thumbnail_template: img.maybe_thumbnail_template, + })); + + state.completeGeneration(resolvedId, images); +});