mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): enhance multi-image generation to track individual image statuses and prioritize display of the first successful result
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ export type CanvasImageGenerationType = "generation" | "edit";
|
||||
|
||||
export type CanvasNodeImage = {
|
||||
id: string;
|
||||
status: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
content: string;
|
||||
storageKey: string;
|
||||
naturalWidth: number;
|
||||
|
||||
Reference in New Issue
Block a user