feat(canvas): enhance multi-image generation to track individual image statuses and prioritize display of the first successful result

This commit is contained in:
HouYunFei
2026-08-06 11:36:37 +08:00
parent de49430976
commit 58719739c7
9 changed files with 78 additions and 34 deletions
+41 -21
View File
@@ -563,7 +563,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
if (!props.node.metadata?.content && !props.isBatchRoot) return <EmptyImageContent {...props} />;
return (
<ImageContent
@@ -636,6 +636,8 @@ function ImageContent({
const batchCount = images.length;
const isBatchRoot = batchCount > 1;
const primaryImageId = node.metadata?.primaryImageId || images[0]?.id;
const primaryImage = images.find((image) => image.id === primaryImageId);
const primaryContent = primaryImage?.content || node.metadata?.content;
return (
<BatchFrame batchCount={batchCount} batchExpanded={batchExpanded} onToggleBatch={onToggleBatch}>
@@ -645,13 +647,17 @@ function ImageContent({
.map((image, index) => <ExpandedImageCard key={image.id} node={node} image={image} index={index} onSetPrimary={() => onSetBatchPrimary?.(image.id)} />)
: null}
<div className="h-full w-full overflow-hidden rounded-3xl">
<img
src={node.metadata!.content!}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
{primaryContent ? (
<img
src={primaryContent}
alt={node.title}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
/>
) : (
<ImageSlotStatus image={primaryImage} />
)}
</div>
{isBatchRoot ? (
<button
@@ -707,19 +713,33 @@ function ExpandedImageCard({ node, image, index, onSetPrimary }: { node: CanvasN
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<img src={image.content} alt={node.title} draggable={false} className="pointer-events-none h-full w-full select-none object-contain" />
<button
type="button"
className="absolute right-3 top-3 flex h-9 items-center gap-1.5 rounded-xl border border-white/20 bg-black/70 px-2.5 text-xs font-medium text-white shadow-[0_8px_20px_rgba(15,23,42,.24)] backdrop-blur-md transition hover:scale-[1.02] hover:bg-black/80"
style={{ color: "#fff" }}
onClick={(event) => {
event.stopPropagation();
onSetPrimary();
}}
>
<Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
</button>
{image.content ? <img src={image.content} alt={node.title} draggable={false} className="pointer-events-none h-full w-full select-none object-contain" /> : <ImageSlotStatus image={image} />}
{image.content ? (
<button
type="button"
className="absolute right-3 top-3 flex h-9 items-center gap-1.5 rounded-xl border border-white/20 bg-black/70 px-2.5 text-xs font-medium text-white shadow-[0_8px_20px_rgba(15,23,42,.24)] backdrop-blur-md transition hover:scale-[1.02] hover:bg-black/80"
style={{ color: "#fff" }}
onClick={(event) => {
event.stopPropagation();
onSetPrimary();
}}
>
<Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
</button>
) : null}
</div>
);
}
function ImageSlotStatus({ image }: { image?: CanvasNodeImage }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const failed = image?.status === "error";
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-6 text-center" style={{ background: theme.node.fill, color: failed ? theme.node.text : theme.node.activeStroke }}>
{failed ? <span className="text-xs leading-5">{image.errorDetails || t("canvas.node.failed")}</span> : <div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />}
{!failed ? <span className="text-[10px] tracking-[0.2em]">{t("canvas.node.generating")}</span> : null}
</div>
);
}
@@ -48,7 +48,7 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
const images = await Promise.all((node.metadata.images || []).map(async (image) => ({ ...image, content: await resolveImageUrl(image.storageKey, image.content) })));
const images = await Promise.all((node.metadata.images || []).map(async (image) => (image.content ? { ...image, content: await resolveImageUrl(image.storageKey, image.content) } : image)));
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content), images } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
@@ -112,7 +112,19 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
}
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: i18n.t("canvas.generation.interrupted") } } : node));
return nodes.map((node) =>
node.metadata?.status === "loading"
? {
...node,
metadata: {
...node.metadata,
status: "error" as const,
errorDetails: i18n.t("canvas.generation.interrupted"),
images: node.metadata.images?.map((image) => (image.status === "loading" ? { ...image, status: "error" as const, errorDetails: i18n.t("canvas.generation.interrupted") } : image)),
},
}
: node,
);
}
export function isGenerationCanceled(error: unknown) {
+14 -6
View File
@@ -293,8 +293,14 @@ function InfiniteCanvasPage() {
});
setRunningNodeId((current) => (current === runningId ? null : current));
if (!affectedNodeIds.size) return;
setNodes((prev) => prev.map((node) => (affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
}, []);
setNodes((prev) =>
prev.map((node) =>
affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING
? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined, images: node.metadata.images?.map((image) => (image.status === NODE_STATUS_LOADING ? { ...image, status: NODE_STATUS_ERROR, errorDetails: t("common.requestCanceled") } : image)) } }
: node,
),
);
}, [t]);
const confirmStopGeneration = useCallback(
(nodeId: string) => {
@@ -1454,7 +1460,7 @@ function InfiniteCanvasPage() {
prev.map((node) => {
if (node.id !== nodeId) return node;
const image = node.metadata?.images?.find((item) => item.id === imageId);
if (!image) return node;
if (!image?.content) return node;
const size = fitNodeSize(image.naturalWidth, image.naturalHeight);
return {
...node,
@@ -2063,7 +2069,7 @@ function InfiniteCanvasPage() {
metadata: {
prompt: effectivePrompt,
status: NODE_STATUS_LOADING,
images: [],
images: imageIds.map((id) => ({ id, status: NODE_STATUS_LOADING, content: "", storageKey: "", naturalWidth: 0, naturalHeight: 0, bytes: 0, mimeType: "" })),
...generationMetadata,
},
};
@@ -2119,11 +2125,11 @@ function InfiniteCanvasPage() {
: await requestGeneration({ ...generationConfig, count: "1" }, effectivePrompt, { signal: controller.signal }).then((items) => items[0]);
const uploaded = await uploadImage(image.dataUrl);
const imageSize = fitNodeSize(uploaded.width, uploaded.height, imageConfig.width, imageConfig.height);
const item: CanvasNodeImage = { id: imageId, content: uploaded.url, storageKey: uploaded.storageKey, naturalWidth: uploaded.width, naturalHeight: uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType };
const item: CanvasNodeImage = { id: imageId, status: NODE_STATUS_SUCCESS, content: uploaded.url, storageKey: uploaded.storageKey, naturalWidth: uploaded.width, naturalHeight: uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType };
setNodes((prev) =>
prev.map((node) => {
if (node.id !== rootId) return node;
const images = [...(node.metadata?.images || []), item].sort((a, b) => imageIds.indexOf(a.id) - imageIds.indexOf(b.id));
const images = node.metadata?.images?.map((image) => (image.id === imageId ? item : image)) || [];
if (node.metadata?.primaryImageId) return { ...node, metadata: { ...node.metadata, images } };
const center = { x: node.position.x + node.width / 2, y: node.position.y + node.height / 2 };
return {
@@ -2152,6 +2158,7 @@ function InfiniteCanvasPage() {
const errorDetails = error instanceof Error ? error.message : t("canvas.projectPage.generationFailed");
if (!firstError) firstError = errorDetails;
hasFailure = true;
setNodes((prev) => prev.map((node) => (node.id === rootId ? { ...node, metadata: { ...node.metadata, images: node.metadata?.images?.map((image) => (image.id === imageId ? { ...image, status: NODE_STATUS_ERROR, errorDetails } : image)) } } : node)));
}
return false;
}),
@@ -2456,6 +2463,7 @@ function InfiniteCanvasPage() {
const imageSize = fitNodeSize(uploadedImage.width, uploadedImage.height, imageConfig.width, imageConfig.height);
const retryImage: CanvasNodeImage = {
id: node.metadata?.primaryImageId || nanoid(),
status: NODE_STATUS_SUCCESS,
content: uploadedImage.url,
storageKey: uploadedImage.storageKey,
naturalWidth: uploadedImage.width,
+2
View File
@@ -27,6 +27,8 @@ export type CanvasImageGenerationType = "generation" | "edit";
export type CanvasNodeImage = {
id: string;
status: CanvasNodeStatus;
errorDetails?: string;
content: string;
storageKey: string;
naturalWidth: number;