fixed angles history persistence, non blocking generations

This commit is contained in:
bflatastic committed 2026-03-10 23:35:03 +07:00
1 parent 7fca050c56
commit 167a7f996e
2 files changed
+123 -119

No files matched your search

@@ -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<ImageBundle[]>([]);
const fileInputRef = useRef<HTMLInputElement>(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<ImageBundle[]>(
() =>
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 ? (
<>
<img
src={sourceImageUrl}
alt="Source"
className="absolute inset-0 h-full w-full object-contain"
/>
{isProcessing && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="relative">
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" />
<FontAwesomeIcon
icon={faCrosshairs}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400"
/>
</div>
<span className="text-lg font-semibold text-white">
Generating Angle...
</span>
</div>
</div>
)}
</>
<img
src={sourceImageUrl}
alt="Source"
className="absolute inset-0 h-full w-full object-contain"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-ui-background">
<LoadingSpinner className="h-12 w-12" />
@@ -758,12 +687,11 @@ export const Angles = () => {
<GenerateButton
variant="primary"
onClick={handleGenerate}
disabled={isProcessing || !sourceMediaToken}
loading={isProcessing}
disabled={!sourceMediaToken}
credits={anglesCredits}
className="whitespace-nowrap px-5 py-1.5 text-sm"
>
{isProcessing ? "Generating..." : "Generate"}
Generate
</GenerateButton>
</div>
</div>
@@ -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<AnglesState>((set, get) => ({
generatedAngles: [],
activeAngleId: null,
isProcessing: false,
pendingSubscriberId: null,
pendingSubscriberIds: [],
isLoadingImage: false,
setSourceImage: (url, mediaToken) => {
@@ -131,8 +139,35 @@ export const useAnglesStore = create<AnglesState>((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<AnglesState>((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<AnglesState>((set, get) => ({
angleConfig: { ...DEFAULT_CONFIG },
generateFromBestAngles: false,
isProcessing: false,
pendingSubscriberId: null,
pendingSubscriberIds: [],
isLoadingImage: false,
});
},
@@ -187,8 +229,42 @@ export const useAnglesStore = create<AnglesState>((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);
});