temporary prompt info and image ref for task queue generations (#1220)

<img width="490" height="683"
alt="{68D2BE71-2E3F-4AB3-BD40-8A1BED5561F6}"
src="https://github.com/user-attachments/assets/edcc6090-3f78-4512-8314-474116f93898"
/>

makes it easier to differentiate which generation task is which by
prompt hover and image ref thumbnails
This commit is contained in:
BFlat authored and GitHub committed 2026-03-25 20:29:54 +07:00
1 parent 338b0b6313
commit 68f8a339af
9 files changed
+489 -60

No files matched your search

@@ -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,
@@ -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 (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
const hasRefImages =
task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
className="h-full w-full object-cover opacity-50"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
<div className="absolute inset-0 bg-black/30" />
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-base-fg/60"
className="animate-spin text-white/80"
size="lg"
/>
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
</Tooltip>
) : (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-base-fg/60"
size="lg"
/>
</div>
</Tooltip>
);
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5 truncate font-medium text-base-fg/90">
@@ -196,28 +253,38 @@ const CompletedCard = ({
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
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"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
Done
</div>
)}
</div>
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
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"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-base-fg/40">
Done
</div>
)}
</div>
</Tooltip>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-base-fg/90">
{task.title}
@@ -278,16 +345,67 @@ const FailedCard = ({
onDismiss?: () => void;
}) => {
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
const hasRefImages =
task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded">
<img
src={task.refImageUrls![0]}
alt="Reference"
className="h-full w-full object-cover opacity-50"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
<div className="absolute inset-0 bg-red-900/40" />
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-red-400"
size="lg"
/>
</div>
{task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1}
</div>
)}
</div>
</Tooltip>
) : (
<Tooltip
content={task.prompt || ""}
position="bottom"
strategy="fixed"
className="max-w-[320px] text-wrap text-xs"
zIndex={50}
delay={200}
disabled={!task.prompt}
>
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<FontAwesomeIcon
icon={faCircleExclamation}
className="text-red-400"
size="lg"
/>
</div>
</Tooltip>
);
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
{thumbnailContent}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
<div className="truncate font-medium text-base-fg/90">
@@ -299,38 +417,42 @@ const FailedCard = ({
{task.subtitle}
</div>
)}
<div className="mt-1 flex items-center gap-1.5">
<span className="rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
<Tooltip
content={task.failureReason}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-danger text-xs"
zIndex={50}
delay={300}
>
<span className="truncate text-[11px] text-red-400/60">
{task.failureReason}
</span>
</Tooltip>
<div className="min-w-0 overflow-hidden">
<Tooltip
content={task.failureReason}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap bg-danger text-xs"
zIndex={50}
delay={300}
>
<div className="truncate text-[11px] text-red-400/60">
{task.failureReason}
</div>
</Tooltip>
</div>
)}
</div>
{task.failureMessage && (
<Tooltip
content={task.failureMessage}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={200}
>
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
{task.failureMessage}
</div>
</Tooltip>
<div className="overflow-hidden">
<Tooltip
content={task.failureMessage}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={200}
>
<div className="mt-0.5 cursor-default truncate text-[11px] text-base-fg/40">
{task.failureMessage}
</div>
</Tooltip>
</div>
)}
</div>
{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);
@@ -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<string, TaskMatch>();
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<string> {
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;
@@ -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),
@@ -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;
@@ -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;
@@ -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);
@@ -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);
@@ -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<void>[] = [];