From 68f8a339af5f1f73c8d03a6d07a4c8beccc59b7c Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 25 Mar 2026 20:29:54 +0700 Subject: [PATCH] temporary prompt info and image ref for task queue generations (#1220) {68D2BE71-2E3F-4AB3-BD40-8A1BED5561F6} makes it easier to differentiate which generation task is which by prompt hover and image ref thumbnails --- .../experiences/ImageTo3DExperience.tsx | 20 ++ .../components/signaled/TopBar/TaskQueue.tsx | 264 ++++++++++++++---- .../signaled/TopBar/taskEnqueueMeta.ts | 210 ++++++++++++++ .../app/src/pages/PageAngles/Angles.tsx | 6 + .../app/src/pages/PageDraw/PageDraw.tsx | 10 + .../app/src/pages/PageEdit/PageEdit.tsx | 10 + .../promptbox/src/lib/PromptBox3D.tsx | 11 + .../promptbox/src/lib/PromptBoxImage.tsx | 9 + .../promptbox/src/lib/PromptBoxVideo.tsx | 9 + 9 files changed, 489 insertions(+), 60 deletions(-) create mode 100644 frontend/apps/artcraft/app/src/components/signaled/TopBar/taskEnqueueMeta.ts diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx index f652d1f1cf..8fd1c12335 100644 --- a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx +++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx @@ -406,6 +406,18 @@ export const ImageTo3DExperience = ({ worldStartGeneration("image", note, firstPreview, subscriberId); setSelectedResultId(subscriberId); + window.__storeTaskEnqueueMeta?.({ + prompt: worldPrompt.trim() || undefined, + refImageUrls: worldImages + .map((img) => img.preview) + .filter(Boolean) as string[], + modelType: + (selectedWorldModel as any)?.tauriId || + (SPLAT_MODELS[0] as any)?.tauriId || + String(selectedWorldModel ?? SPLAT_MODELS[0]), + timestamp: Date.now(), + }); + const result = await EnqueueImageToGaussian({ image_media_tokens: readyTokens, prompt: worldPrompt.trim() || undefined, @@ -436,6 +448,14 @@ export const ImageTo3DExperience = ({ ); setSelectedResultId(subscriberId); + window.__storeTaskEnqueueMeta?.({ + prompt: snapshotPrompt || undefined, + refImageUrls: snapshotPreview ? [snapshotPreview] : undefined, + modelType: + (selectedModel as any)?.tauriId || String(selectedModel), + timestamp: Date.now(), + }); + const result = await EnqueueImageTo3dObject({ image_media_token: uploadedMediaToken || undefined, model: selectedModel, diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx index cc7073329b..c24f69d4e6 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TaskQueue.tsx @@ -48,6 +48,7 @@ import { } from "@storyteller/common"; import { coverImageCache } from "~/pages/PageImageTo3DObject/ImageTo3DStore"; import { useCreditsState } from "@storyteller/credits"; +import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta"; type InProgressTask = { id: string; @@ -58,6 +59,8 @@ type InProgressTask = { canDismiss?: boolean; estimatedTimeLeftMs?: number; modelType?: string; + prompt?: string; + refImageUrls?: string[]; }; type CompletedTask = { @@ -71,6 +74,7 @@ type CompletedTask = { mediaTokens?: string[]; mediaFileClass?: TaskMediaFileClass; batchImageToken?: string; + prompt?: string; }; type FailedTask = { @@ -81,6 +85,8 @@ type FailedTask = { status: string; failureReason?: string; failureMessage?: string; + prompt?: string; + refImageUrls?: string[]; }; const formatTimeLeft = (ms: number): string => { @@ -110,16 +116,67 @@ const InProgressCard = ({ ? formatTimeLeft(task.estimatedTimeLeftMs) : null; const isSeedance2 = task.modelType === "seedance_2p0"; - return ( -
-
-
+ const hasRefImages = + task.refImageUrls && task.refImageUrls.length > 0; + + const thumbnailContent = hasRefImages ? ( + +
+ Reference { + e.currentTarget.style.display = "none"; + }} + /> +
+
+ {task.refImageUrls!.length > 1 && ( +
+ +{task.refImageUrls!.length - 1} +
+ )} +
+ + ) : ( + +
+ +
+
+ ); + + return ( +
+
+ {thumbnailContent}
@@ -196,28 +253,38 @@ const CompletedCard = ({ role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : -1} > -
- {task.thumbnailUrl ? ( - {task.title} { - e.currentTarget.src = getPlaceholderForMediaClass( - task.mediaFileClass, - ); - e.currentTarget.style.opacity = "0.3"; - // Set the `data-brokenurl` property for debugging the broken images: - (e.currentTarget as HTMLImageElement).dataset.brokenurl = - task.thumbnailUrl; - }} - className="h-full w-full object-cover" - /> - ) : ( -
- Done -
- )} -
+ +
+ {task.thumbnailUrl ? ( + {task.title} { + e.currentTarget.src = getPlaceholderForMediaClass( + task.mediaFileClass, + ); + e.currentTarget.style.opacity = "0.3"; + // Set the `data-brokenurl` property for debugging the broken images: + (e.currentTarget as HTMLImageElement).dataset.brokenurl = + task.thumbnailUrl; + }} + className="h-full w-full object-cover" + /> + ) : ( +
+ Done +
+ )} +
+
{task.title} @@ -278,16 +345,67 @@ const FailedCard = ({ onDismiss?: () => void; }) => { const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed"; - return ( -
-
-
+ const hasRefImages = + task.refImageUrls && task.refImageUrls.length > 0; + + const thumbnailContent = hasRefImages ? ( + +
+ Reference { + e.currentTarget.style.display = "none"; + }} + /> +
+
+ {task.refImageUrls!.length > 1 && ( +
+ +{task.refImageUrls!.length - 1} +
+ )} +
+ + ) : ( + +
+ +
+
+ ); + + return ( +
+
+ {thumbnailContent}
@@ -299,38 +417,42 @@ const FailedCard = ({ {task.subtitle}
)} -
- +
+ {statusLabel} {task.failureReason && ( - - - {task.failureReason} - - +
+ +
+ {task.failureReason} +
+
+
)}
{task.failureMessage && ( - -
- {task.failureMessage} -
-
+
+ +
+ {task.failureMessage} +
+
+
)}
{onDismiss && ( @@ -380,7 +502,7 @@ export const TaskQueue = () => { primaryActionText: "", primaryActionIcon: faTrashAlt, primaryActionBtnClassName: "", - onConfirm: async () => {}, + onConfirm: async () => { }, }); const handleClearCompleted = (onSuccess?: () => void) => { @@ -538,8 +660,8 @@ export const TaskQueue = () => { if (!duration) { const actualModel = t.model_type ? ALL_MODELS_LIST.find( - (m) => m.tauriId === t.model_type || m.id === t.model_type, - ) + (m) => m.tauriId === t.model_type || m.id === t.model_type, + ) : undefined; duration = actualModel?.progressBarTime ?? @@ -555,6 +677,11 @@ export const TaskQueue = () => { const estimatedTimeLeftMs = Math.max(0, duration - elapsed); const parts = formatTitleParts(t); const canDismiss = now - createdMs > 5 * 60 * 1000; // 5 minutes + const meta = getMetaForTask( + t.id, + t.model_type ? String(t.model_type) : undefined, + createdMs, + ); return { id: t.id, title: `Generating ${parts.kind || "Task"}...`, @@ -564,6 +691,8 @@ export const TaskQueue = () => { canDismiss, estimatedTimeLeftMs, modelType: t.model_type ? String(t.model_type) : undefined, + prompt: meta?.prompt, + refImageUrls: meta?.refImageUrls, }; }); @@ -594,10 +723,17 @@ export const TaskQueue = () => { ? coverImageCache.get(mediaToken) : undefined; + const meta = getMetaForTask( + t.id, + t.model_type ? String(t.model_type) : undefined, + t.created_at.getTime(), + ); + return { id: t.id, ...formatTitleParts(t), thumbnailUrl: serverThumbnail || cachedThumbnail || undefined, + prompt: meta?.prompt, imageUrls: t.completed_item?.primary_media_file?.cdn_url ? [t.completed_item?.primary_media_file?.cdn_url] : [], @@ -636,6 +772,11 @@ export const TaskQueue = () => { fr?.failure_message && fr.failure_type !== "unknown" ? fr.failure_message : undefined; + const meta = getMetaForTask( + t.id, + t.model_type ? String(t.model_type) : undefined, + t.created_at.getTime(), + ); return { id: t.id, title: parts.title || "Task", @@ -644,6 +785,8 @@ export const TaskQueue = () => { status: t.task_status, failureReason: failureReason || fr?.failure_message || undefined, failureMessage, + prompt: meta?.prompt, + refImageUrls: meta?.refImageUrls, }; }); @@ -670,6 +813,7 @@ export const TaskQueue = () => { } }; + cleanupOldEntries(); load(); const id = setInterval(load, 5000); diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/taskEnqueueMeta.ts b/frontend/apps/artcraft/app/src/components/signaled/TopBar/taskEnqueueMeta.ts new file mode 100644 index 0000000000..7ef5a0d64b --- /dev/null +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/taskEnqueueMeta.ts @@ -0,0 +1,210 @@ +/** + * Hacky and temporary lightweight cache that stores enqueue metadata (prompt, ref image thumbnails) + * so failed/in-progress tasks in the TaskQueue can display them. + * + * Keyed by approximate timestamp + model type because the Rust backend + * does not expose the frontend_subscriber_id on TaskQueueItem responses. + * + * Ref images are converted to small base64 thumbnails at store time so they + * survive page refreshes (blob URLs do not). + */ + +const STORAGE_KEY = "task_enqueue_meta"; +const MAX_ENTRIES = 100; +const MATCH_WINDOW_MS = 30_000; // 30-second window for timestamp matching +const THUMB_SIZE = 144; // px – 2x the 72px card thumbnail for retina + +export interface EnqueueMeta { + prompt?: string; + refImageUrls?: string[]; + modelType?: string; + timestamp: number; // Date.now() at enqueue time +} + +interface StoredEntry { + id: string; + prompt?: string; + refImageUrls?: string[]; // base64 data-URLs after conversion + modelType?: string; + timestamp: number; + matchedTaskId?: string; // set once matched to a specific task +} + +interface TaskMatch { + prompt?: string; + refImageUrls?: string[]; +} + +// In-memory cache of task_id → matched metadata +const matchedTasks = new Map(); + +function readEntries(): StoredEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function writeEntries(entries: StoredEntry[]) { + const trimmed = entries.slice(-MAX_ENTRIES); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); + } catch { + // localStorage full – drop oldest half and retry + const half = trimmed.slice(Math.floor(trimmed.length / 2)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(half)); + } catch { + // give up + } + } +} + +let entryCounter = 0; + +/** + * Convert a blob/object URL to a small base64 data-URL thumbnail. + * Returns the original URL if conversion fails (e.g. CORS, non-image). + */ +function toBase64Thumb(url: string): Promise { + return new Promise((resolve) => { + // Already a data URL — keep as-is + if (url.startsWith("data:")) { + resolve(url); + return; + } + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + try { + const canvas = document.createElement("canvas"); + const scale = Math.min(THUMB_SIZE / img.width, THUMB_SIZE / img.height, 1); + canvas.width = Math.round(img.width * scale); + canvas.height = Math.round(img.height * scale); + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(url); + return; + } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL("image/jpeg", 0.6)); + } catch { + resolve(url); + } + }; + img.onerror = () => resolve(url); + img.src = url; + }); +} + +/** + * Call this at enqueue time to store the prompt + ref image URLs. + * Blob URLs are asynchronously converted to base64 thumbnails for persistence. + */ +export function storeEnqueueMeta(meta: EnqueueMeta) { + const entryId = `${Date.now()}_${entryCounter++}`; + + // Write immediately with original URLs so same-session lookups work + const entry: StoredEntry = { + id: entryId, + prompt: meta.prompt, + refImageUrls: meta.refImageUrls, + modelType: meta.modelType, + timestamp: meta.timestamp, + }; + const entries = readEntries(); + entries.push(entry); + writeEntries(entries); + + // Then convert blob URLs to base64 in the background for persistence + if (meta.refImageUrls && meta.refImageUrls.length > 0) { + Promise.all(meta.refImageUrls.map(toBase64Thumb)).then((dataUrls) => { + const current = readEntries(); + const found = current.find((e) => e.id === entryId); + if (found) { + found.refImageUrls = dataUrls; + writeEntries(current); + } + }); + } +} + +/** + * Try to match a task to stored enqueue metadata. + * Uses timestamp proximity + model type matching. + * Once matched, the entry stores the taskId so it survives page refresh. + */ +export function getMetaForTask( + taskId: string, + modelType: string | undefined, + createdAtMs: number, +): TaskMatch | undefined { + // Check in-memory cache first + const cached = matchedTasks.get(taskId); + if (cached) return cached; + + const entries = readEntries(); + + // First check if this task was already matched in a previous session + const previousMatch = entries.find((e) => e.matchedTaskId === taskId); + if (previousMatch) { + const result: TaskMatch = { + prompt: previousMatch.prompt, + refImageUrls: previousMatch.refImageUrls, + }; + matchedTasks.set(taskId, result); + return result; + } + + // Otherwise find the best unmatched entry by timestamp + model proximity + let bestIdx = -1; + let bestDiff = Infinity; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.matchedTaskId) continue; // already matched to another task + + const entryModel = entry.modelType?.toLowerCase(); + const taskModel = modelType?.toLowerCase(); + if (entryModel !== taskModel) continue; + + const diff = Math.abs(entry.timestamp - createdAtMs); + if (diff < MATCH_WINDOW_MS && diff < bestDiff) { + bestDiff = diff; + bestIdx = i; + } + } + + if (bestIdx === -1) return undefined; + + // Mark as matched with this taskId (persists across refresh) + entries[bestIdx].matchedTaskId = taskId; + writeEntries(entries); + + const matched = entries[bestIdx]; + const result: TaskMatch = { + prompt: matched.prompt, + refImageUrls: matched.refImageUrls, + }; + matchedTasks.set(taskId, result); + return result; +} + +/** + * Clean up old entries (older than 24 hours). + */ +export function cleanupOldEntries() { + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + const entries = readEntries().filter((e) => e.timestamp > cutoff); + writeEntries(entries); +} + +// Expose globally so libs/ packages can call without cross-package imports. +declare global { + interface Window { + __storeTaskEnqueueMeta?: (meta: EnqueueMeta) => void; + } +} +window.__storeTaskEnqueueMeta = storeEnqueueMeta; diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx index f26ac38a1c..5a32b4a895 100644 --- a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx @@ -272,6 +272,12 @@ export const Angles = () => { const subscriberId = uuidv4(); state.startGeneration(subscriberId); + window.__storeTaskEnqueueMeta?.({ + prompt: "", + modelType: (selectedModel as any)?.tauriId || "flux_2_lora_angles", + timestamp: Date.now(), + }); + try { await EnqueueEditImage({ model: selectedModel ?? ("flux_2_lora_angles" as any), diff --git a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx index 958f2a2667..24407bf4b5 100644 --- a/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageDraw/PageDraw.tsx @@ -796,6 +796,16 @@ const PageDraw = () => { return; } + window.__storeTaskEnqueueMeta?.({ + prompt, + refImageUrls: (options?.images || []) + .map((img) => img.url) + .filter(Boolean), + modelType: + (selectedImageModel as any)?.tauriId || String(selectedImageModel), + timestamp: Date.now(), + }); + try { let result; diff --git a/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx b/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx index 6448257cc9..f3c743774b 100644 --- a/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageEdit/PageEdit.tsx @@ -390,6 +390,16 @@ const PageEdit = () => { crypto?.randomUUID?.() ?? `inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`; + window.__storeTaskEnqueueMeta?.({ + prompt, + refImageUrls: (options?.images || []) + .map((img) => img.url) + .filter(Boolean), + modelType: + (selectedImageModel as any)?.tauriId || String(selectedImageModel), + timestamp: Date.now(), + }); + try { let result; diff --git a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx index 46fc1d2b9c..3fbe1f8211 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBox3D.tsx @@ -395,6 +395,17 @@ export const PromptBox3D = ({ request.common_aspect_ratio = commonAspectRatio; } + window.__storeTaskEnqueueMeta?.({ + prompt, + refImageUrls: referenceImages + ?.map((image) => image.url) + .filter(Boolean), + modelType: + (selectedImageModel as any)?.tauriId || + String(selectedImageModel), + timestamp: Date.now(), + }); + const generateResponse = await EnqueueEditImage(request); console.log("generateResponse", generateResponse); diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx index e9dc5792e2..d6caeaad21 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxImage.tsx @@ -297,6 +297,15 @@ export const PromptBoxImage = ({ .filter((t) => t.length > 0); } + window.__storeTaskEnqueueMeta?.({ + prompt, + refImageUrls: referenceImages + ?.map((img) => img.url) + .filter(Boolean), + modelType: (selectedModel as any)?.tauriId || String(selectedModel), + timestamp: Date.now(), + }); + const generateResponse = await EnqueueTextToImage(request); console.debug("PromptBoxImage - generateResponse", generateResponse); diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index e96fae6913..c9b87d8b32 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -596,6 +596,15 @@ export const PromptBoxVideo = ({ return request; }; + window.__storeTaskEnqueueMeta?.({ + prompt, + refImageUrls: referenceImages + ?.map((img) => img.url) + .filter(Boolean), + modelType: (selectedModel as any)?.tauriId || String(selectedModel), + timestamp: Date.now(), + }); + const subscriberIds: string[] = []; const enqueuePromises: Promise[] = [];