feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用

This commit is contained in:
ddcat666
2026-10-10 04:49:54 +08:00
57 changed files with 3559 additions and 166 deletions

View File

@@ -0,0 +1,103 @@
import { Popover } from "antd";
import { ArrowDown, ArrowUp, Eye, EyeOff, Layers3, RefreshCw } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import type { CanvasNodeData } from "@/types/canvas";
export function CanvasImageLayerControls({ node }: { node: CanvasNodeData }) {
const { updateNode, extractLayerMaterials } = useCanvasNodeActions();
const group = node.metadata?.imageLayerGroup;
if (!group) return null;
const change = (nodeId: string, direction?: number) =>
updateNode?.(node.id, (current) => {
const live = current.metadata?.imageLayerGroup;
if (!live) return current;
const layers = live.layers.map((layer) => ({ ...layer }));
const index = layers.findIndex((layer) => layer.nodeId === nodeId);
if (index < 0) return current;
if (direction === undefined) layers[index].visible = !layers[index].visible;
else {
const target = index + direction;
if (target < 0 || target >= layers.length) return current;
[layers[index], layers[target]] = [layers[target], layers[index]];
}
return { ...current, metadata: { ...current.metadata, imageLayerGroup: { ...live, layers, compositeStatus: "updating", compositeError: undefined } } };
});
const panel = (
<div className="w-64 space-y-2" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
<div className="text-sm font-medium">图层顺序(上方在前)</div>
{group.incomplete ? (
<p role="status" className="text-xs text-amber-600 dark:text-amber-400">
已收纳 {group.incomplete.completed}/{group.incomplete.total} 层,{group.incomplete.failed} 层尚未通过。{group.incomplete.missingBackground ? "当前预览缺少完整背景。" : "当前显示部分合成。"}展开查看失败层并单独重试。
</p>
) : null}
<div className="max-h-64 space-y-1 overflow-y-auto">
{[...group.layers].reverse().map((layer, reverseIndex) => {
const index = group.layers.length - 1 - reverseIndex;
return (
<div key={layer.nodeId} className="flex items-center gap-1 rounded px-1 py-1">
<button type="button" aria-label={`切换第 ${index + 1} 层可见性`} aria-pressed={layer.visible} className="p-1" onClick={() => change(layer.nodeId)}>
{layer.visible ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</button>
<span className="flex-1 text-xs">第 {index + 1} 层</span>
<button type="button" aria-label={`第 ${index + 1} 层上移`} disabled={index === group.layers.length - 1} className="p-1 disabled:opacity-30" onClick={() => change(layer.nodeId, 1)}>
<ArrowUp className="size-4" />
</button>
<button type="button" aria-label={`第 ${index + 1} 层下移`} disabled={index === 0} className="p-1 disabled:opacity-30" onClick={() => change(layer.nodeId, -1)}>
<ArrowDown className="size-4" />
</button>
</div>
);
})}
</div>
<p className="text-xs opacity-60">隐藏和排序只更新合成图,不调用模型。拖动画布节点不会改变合成坐标。</p>
{extractLayerMaterials ? (
<button type="button" disabled={node.metadata?.imageLayerMaterials?.status === "loading"} className="text-xs underline disabled:opacity-50" onClick={() => void extractLayerMaterials(node)}>
{node.metadata?.imageLayerMaterials?.status === "loading" ? "正在另存独立素材…" : node.metadata?.imageLayerMaterials?.groupId ? "重新另存独立素材(免费)" : "另存独立素材(免费)"}
</button>
) : null}
<p className="text-xs opacity-60">独立素材只裁去外部透明留白,保留场景内部背景;原位图层保持不变。</p>
{node.metadata?.imageLayerMaterials?.error ? (
<p role="alert" className="text-xs text-amber-600 dark:text-amber-400">
{node.metadata.imageLayerMaterials.error}
</p>
) : null}
{group.compositeStatus === "updating" ? (
<p role="status" className="text-xs">
正在更新合成图…
</p>
) : null}
{group.compositeStatus === "error" ? (
<div role="alert" className="space-y-1 text-xs">
<p>{group.compositeError}</p>
<button
type="button"
className="flex items-center gap-1 underline"
onClick={() =>
updateNode?.(node.id, (current) =>
current.metadata?.imageLayerGroup ? { ...current, metadata: { ...current.metadata, imageLayerGroup: { ...current.metadata.imageLayerGroup, compositeStatus: "updating", compositeError: undefined } } } : current,
)
}
>
<RefreshCw className="size-3" />
重试合成
</button>
</div>
) : null}
</div>
);
return (
<Popover trigger="click" content={panel} placement="leftTop">
<button
type="button"
title="管理图层"
aria-label="管理图层"
className="grid size-6 place-items-center rounded bg-black/60 text-white"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<Layers3 className="size-3.5" />
</button>
</Popover>
);
}

View File

@@ -23,6 +23,7 @@ export type CanvasNodeActionContextValue = {
openArtCritique?: (node: CanvasNodeData) => void;
/** 全景节点导出截图:上传 dataUrl 并在源节点右侧创建派生图片节点。 */
addPanoramaCaptureNode?: (node: CanvasNodeData, dataUrl: string, title: string) => Promise<void> | void;
extractLayerMaterials?: (node: CanvasNodeData) => Promise<void> | void;
};
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});

View File

@@ -1,47 +1,76 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Tag } from "antd";
import { Button, Input, Tag } from "antd";
import { Layers3, Plus, RotateCcw, X } from "lucide-react";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/stores/use-config-store";
import { AppModal } from "@/components/ui/product/app-modal";
import { selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { imageLayerOutputSize, imageLayerPlannerError, type ImageLayerPlan } from "@/lib/canvas/canvas-image-layer-plan";
import { supportsExperimentalLayerExtraction, supportsLayerDecomposition } from "@/lib/canvas/canvas-image-layers";
import type { ImageLayerExtraction } from "@/lib/canvas/canvas-image-layer-strategy";
import { navigateToSettings } from "@/lib/settings-navigation";
export type CanvasImageLayerDecompositionPayload = {
prompt: string;
plannerModel: string;
regions?: Array<[number, number, number, number]>;
experimentalTargets?: string[];
extractions?: ImageLayerExtraction[];
removeFromBackground?: boolean[];
backgroundRemovalModel?: string;
planning?: Pick<ImageLayerPlan, "model" | "taskId">;
independentMaterials?: boolean;
generationConfig?: Partial<Pick<AiConfig, "model" | "imageModel" | "size" | "quality">>;
};
const DEFAULT_PROMPT = "将图片拆分为可独立编辑的图层:识别主要主体、前景、背景和重要物体,每个图层单独输出,保持原图外观和边缘细节,使用透明背景,不要合并不同图层。";
const DEFAULT_PROMPT = "拆出完整不透明背景和必要的独立前景。背景移除已拆内容并修补,保留其他环境与设计结构;框内完整照片或场景保留内部内容,不拆碎。前景保持原始比例、位置和细节,使用真实透明背景;不扩展原图画幅。";
export function CanvasNodeLayerDecompositionDialog({
dataUrl,
open,
config,
sourceSize,
onClose,
onConfirm,
}: {
dataUrl: string;
open: boolean;
config: AiConfig;
sourceSize?: { width: number; height: number };
onClose: () => void;
onConfirm: (payload: CanvasImageLayerDecompositionPayload) => void;
}) {
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number }>();
const [generationConfig, setGenerationConfig] = useState<AiConfig>(config);
const [plannerModel, setPlannerModel] = useState("");
const [regions, setRegions] = useState<Array<[number, number, number, number]>>([]);
const [drawing, setDrawing] = useState<{ x: number; y: number } | null>(null);
const [draft, setDraft] = useState<[number, number, number, number] | null>(null);
const imageFrameRef = useRef<HTMLDivElement>(null);
const configRef = useRef(config);
configRef.current = config;
useEffect(() => {
if (!open) return;
const current = configRef.current;
const models = [current.imageModel, current.model, ...selectableModelsByCapability(current, "image")];
const model = models.find((value) => supportsLayerDecomposition(current, value || "")) || models.find((value) => supportsExperimentalLayerExtraction(current, value || "")) || "";
setGenerationConfig({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) });
setPlannerModel([current.textModel, ...selectableModelsByCapability(current, "text")].find((value) => value && !imageLayerPlannerError(current, value)) || "");
setPrompt(DEFAULT_PROMPT);
setGenerationConfig(config);
setMeasuredSize(undefined);
setRegions([]);
setDrawing(null);
setDraft(null);
}, [config, dataUrl, open]);
}, [dataUrl, open]);
const selectedModel = generationConfig.imageModel || generationConfig.model;
const supported = supportsLayerDecomposition(config, selectedModel) || supportsExperimentalLayerExtraction(config, selectedModel);
const plannerError = imageLayerPlannerError(config, plannerModel);
const outputSize = imageLayerOutputSize(config, selectedModel, sourceSize || measuredSize);
const outputSizeError = supported && !outputSize ? "所选模型没有支持原图比例的尺寸,或源图尺寸尚未读取;请更换模型或重新打开图片" : "";
const point = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = imageFrameRef.current?.getBoundingClientRect();
@@ -51,81 +80,162 @@ export function CanvasNodeLayerDecompositionDialog({
y: Math.max(0, Math.min(1000, ((event.clientY - rect.top) / Math.max(1, rect.height)) * 1000)),
};
};
const startBox = (event: ReactPointerEvent<HTMLDivElement>) => {
const next = point(event);
if (!next) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDrawing(next);
setDraft([next.x, next.y, next.x, next.y]);
};
const moveBox = (event: ReactPointerEvent<HTMLDivElement>) => {
const finishBox = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drawing) return;
const next = point(event);
if (!next) return;
setDraft([Math.min(drawing.x, next.x), Math.min(drawing.y, next.y), Math.max(drawing.x, next.x), Math.max(drawing.y, next.y)]);
};
const finishBox = () => {
if (!draft) return;
const [x1, y1, x2, y2] = draft;
if (x2 - x1 >= 2 && y2 - y1 >= 2) setRegions((current) => [...current, [Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2)]]);
const box = next ? [Math.min(drawing.x, next.x), Math.min(drawing.y, next.y), Math.max(drawing.x, next.x), Math.max(drawing.y, next.y)] : draft;
if (box && box[2] - box[0] >= 2 && box[3] - box[1] >= 2) setRegions((current) => [...current, box.map(Math.round) as [number, number, number, number]]);
setDrawing(null);
setDraft(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const selectedPrompt = regions.length
? `${prompt.trim()}\n\n重点处理用户框选的区域,并分别输出这些区域中的主体为独立透明 PNG 图层。选区坐标(图像 0-1000 坐标系):${regions.map((region, index) => `区域${index + 1} <bbox>${region.join(" ")}</bbox>`).join(";")}`
: prompt.trim();
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={900} title="AI 图层拆分">
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid min-h-[340px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<div ref={imageFrameRef} className="relative inline-block max-h-[60vh] max-w-full select-none" onPointerDown={startBox} onPointerMove={moveBox} onPointerUp={finishBox} onPointerCancel={finishBox}>
<img src={dataUrl} alt="待拆分图片" className="block max-h-[60vh] max-w-full object-contain" draggable={false} />
<AppModal flush open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered width={1120} title="AI 图层拆分">
<div className="grid max-h-[82vh] gap-6 overflow-y-auto p-5 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]" data-canvas-no-zoom>
<div className="grid min-h-[260px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04] md:min-h-[480px]">
<div
ref={imageFrameRef}
aria-label="框选重点区域"
className="relative inline-block max-h-[60vh] max-w-full touch-none select-none"
onPointerDown={(event) => {
if (event.button !== 0) return;
const next = point(event);
if (!next) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDrawing(next);
setDraft([next.x, next.y, next.x, next.y]);
}}
onPointerMove={(event) => {
if (!drawing) return;
const next = point(event);
if (next) setDraft([Math.min(drawing.x, next.x), Math.min(drawing.y, next.y), Math.max(drawing.x, next.x), Math.max(drawing.y, next.y)]);
}}
onPointerUp={finishBox}
onPointerCancel={() => {
setDrawing(null);
setDraft(null);
}}
>
<img
src={dataUrl}
alt="待拆分图片"
className="block max-h-[60vh] max-w-full object-contain"
draggable={false}
onLoad={(event) => setMeasuredSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
/>
<div className="pointer-events-none absolute inset-0">
{regions.map((region, index) => <RegionBox key={`${region.join("-")}-${index}`} region={region} label={index + 1} />)}
{regions.map((region, index) => (
<RegionBox key={`${region.join("-")}-${index}`} region={region} label={index + 1} />
))}
{draft ? <RegionBox region={draft} label={regions.length + 1} draft /> : null}
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex min-w-0 flex-col gap-4">
<div>
<h3 className="text-lg font-semibold">拆分图片图层</h3>
<p className="mt-1 text-sm opacity-60">可在图片上拖拽框选对象,AI 会返回多个透明背景图层,并在画布中自动排列为独立节点。</p>
<p className="mt-2 text-sm opacity-60">可在图片上拖拽框选重点区域,AI 将规划并拆出完整背景与独立图层,在画布中收纳为一组。</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Tag color={regions.length ? "blue" : "default"}>{regions.length ? `已框选 ${regions.length} 个区域` : "未框选,按描述拆分"}</Tag>
{regions.length ? <Button size="small" icon={<RotateCcw className="size-3.5" />} onClick={() => setRegions([])}>清除选区</Button> : <span className="text-xs opacity-55"><Plus className="mr-1 inline size-3" />在左侧图片上拖动添加选区</span>}
{regions.length ? <Tag color="blue">已框选 {regions.length} 个区域</Tag> : <span className="rounded-md bg-black/5 px-2 py-1 text-xs dark:bg-white/5">未框选,按描述拆分</span>}
{regions.length ? (
<Button size="small" icon={<RotateCcw className="size-3.5" />} onClick={() => setRegions([])}>
清除选区
</Button>
) : (
<span className="text-xs opacity-55">
<Plus className="mr-1 inline size-3" />
在图片上拖动添加选区
</span>
)}
</div>
<Input.TextArea aria-label="拆层要求" rows={5} value={prompt} placeholder="描述需要拆分的内容" onChange={(event) => setPrompt(event.target.value)} />
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">视觉规划模型</div>
<ModelPicker
config={config}
value={plannerModel}
capability="text"
fullWidth
showSelectedPrice
placeholder="选择视觉规划模型"
modelFilter={(model) => !imageLayerPlannerError(config, model)}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onChange={setPlannerModel}
/>
{plannerError ? (
<p role="alert" className="text-xs text-red-500">
{plannerError}
</p>
) : null}
</div>
<Input.TextArea rows={7} value={prompt} placeholder="例如:分别提取人物、产品、前景装饰和背景" onChange={(event) => setPrompt(event.target.value)} />
<div className="space-y-2">
<div className="text-sm font-medium opacity-75">图层拆分模型</div>
<ModelPicker
config={generationConfig}
value={generationConfig.imageModel || generationConfig.model}
config={{ ...config, model: selectedModel, imageModel: selectedModel }}
value={selectedModel}
capability="image"
fullWidth
showSelectedPrice={false}
showSelectedPrice
placeholder="选择图层拆分模型"
modelFilter={(model) => supportsLayerDecomposition(config, model) || supportsExperimentalLayerExtraction(config, model)}
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
onChange={(model) => setGenerationConfig((current) => ({ ...current, model, imageModel: model, ...defaultImageParamsForModel(current, model) }))}
/>
{!supported ? (
<p role="alert" className="text-xs text-red-500">
请选择可编辑参考图的图片模型或专用拆层模型
</p>
) : null}
{outputSizeError ? (
<p role="alert" className="text-xs text-red-500">
{outputSizeError}
</p>
) : null}
</div>
<div className="mt-auto flex justify-end gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>取消</Button>
<Button type="primary" icon={<Layers3 className="size-4" />} disabled={!selectedPrompt} onClick={() => onConfirm({ prompt: selectedPrompt, regions, generationConfig: { model: generationConfig.model, imageModel: generationConfig.imageModel, size: generationConfig.size, quality: generationConfig.quality } })}>
<div className="mt-auto flex justify-end gap-2 pt-2">
<Button icon={<X className="size-4" />} onClick={onClose}>
取消
</Button>
<Button
type="primary"
icon={<Layers3 className="size-4" />}
disabled={!prompt.trim() || !supported || !dataUrl || Boolean(drawing) || Boolean(plannerError) || Boolean(outputSizeError)}
onClick={() => {
const scopePrompt = `${prompt.trim()}\n自动拆分:根据实际图像结构选择必要图层,不固定题材或层数;保留完整底图,避免过度拆碎。`;
const selectedPrompt = regions.length
? `${scopePrompt}\n\n重点处理用户框选的区域。选区坐标(图像 0-1000 坐标系):${regions.map((region, index) => `区域${index + 1} <bbox>${region.join(" ")}</bbox>`).join(";")}`
: scopePrompt;
const models = [config.imageModel, config.model, ...selectableModelsByCapability(config, "image")];
onConfirm({
prompt: selectedPrompt,
plannerModel,
regions,
independentMaterials: false,
backgroundRemovalModel: models.find((value) => supportsExperimentalLayerExtraction(config, value || "") && !supportsLayerDecomposition(config, value || "")),
generationConfig: { model: selectedModel, imageModel: selectedModel, size: outputSize, quality: generationConfig.quality },
});
}}
>
开始拆分
</Button>
</div>
</div>
</div>
</Modal>
</AppModal>
);
}
function RegionBox({ region, label, draft = false }: { region: [number, number, number, number]; label: number; draft?: boolean }) {
const [x1, y1, x2, y2] = region;
return <div className={`absolute rounded-sm border-2 ${draft ? "border-dashed border-blue-500 bg-blue-500/10" : "border-solid border-amber-400 bg-amber-400/10"}`} style={{ left: `${x1 / 10}%`, top: `${y1 / 10}%`, width: `${(x2 - x1) / 10}%`, height: `${(y2 - y1) / 10}%` }}><span className="absolute -left-0.5 -top-0.5 grid size-5 -translate-y-1/2 -translate-x-1/2 place-items-center rounded-full bg-amber-400 text-[11px] font-semibold text-black shadow">{label}</span></div>;
return (
<div
className={`absolute rounded-sm border-2 ${draft ? "border-dashed border-blue-500 bg-blue-500/10" : "border-solid border-amber-400 bg-amber-400/10"}`}
style={{ left: `${x1 / 10}%`, top: `${y1 / 10}%`, width: `${(x2 - x1) / 10}%`, height: `${(y2 - y1) / 10}%` }}
>
<span className="absolute -left-0.5 -top-0.5 grid size-5 -translate-y-1/2 -translate-x-1/2 place-items-center rounded-full bg-amber-400 text-[11px] font-semibold text-black shadow">{label}</span>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-pre
import { resolveVideoMediaUrl } from "@/services/file-storage";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { type CanvasTheme } from "@/lib/canvas-theme";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { fitImageMaterialNodeSize, fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { getActiveUserScope } from "@/lib/user-scope";
import { prepareCanvasImage } from "@/services/canvas-image-loader";
import { getResourceAccess, refreshResource, resourceIdFromStorageKey, resolveResourceAccessURL } from "@/services/api/resources";
@@ -475,7 +475,7 @@ export function ImageContent({
}: Pick<CanvasNodeContentProps, "node" | "theme" | "isBatchRoot" | "batchCount" | "batchPreviewNodes" | "batchExpanded" | "batchOpening" | "batchRecovering" | "onToggleBatch">) {
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, "thumbnail");
const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, node.metadata?.imageLayer || node.metadata?.imageLayerGroup ? "original" : "thumbnail");
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
const { updateMediaNode } = useCanvasNodeActions();
const measuredSizeRef = useRef<{ width: number; height: number } | null>(null);
@@ -504,7 +504,9 @@ export function ImageContent({
if (current.metadata?.freeResize || current.metadata?.manualSize) {
return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current;
}
const size = fitNodeSize(naturalWidth, naturalHeight);
// 独立素材使用有界预览框;解码回调不能重新把细长文字放大成超宽节点。
const materialPreview = metadata?.imageLayerMaterial || (metadata?.isBatchRoot && batchPreviewNodes?.some((child) => child.metadata?.imageLayerMaterial));
const size = materialPreview ? fitImageMaterialNodeSize(naturalWidth, naturalHeight) : fitNodeSize(naturalWidth, naturalHeight);
const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1;
if (!needsMetadata && !needsResize) return current;
return {
@@ -530,6 +532,16 @@ export function ImageContent({
loading={loading}
theme={theme}
/>
{node.metadata?.imageLayerGroup?.incomplete ? (
<div role="status" className="absolute bottom-2 left-2 right-2 rounded bg-black/75 px-2 py-1 text-xs text-amber-300">
部分合成:{node.metadata.imageLayerGroup.incomplete.completed}/{node.metadata.imageLayerGroup.incomplete.total} 层已通过{node.metadata.imageLayerGroup.incomplete.missingBackground ? ",缺少完整背景" : ""},展开可查看并重试失败层
</div>
) : null}
{node.metadata?.imageLayerGroup?.compositeStatus === "updating" || node.metadata?.imageLayerGroup?.compositeStatus === "error" ? (
<div role="status" className="absolute inset-0 grid place-items-center bg-black/60 p-4 text-center text-xs text-white">
{node.metadata.imageLayerGroup.compositeStatus === "updating" ? "正在更新合成图…" : node.metadata.imageLayerGroup.compositeError || "合成图更新失败,请在图层管理中重试"}
</div>
) : null}
</div>
</BatchFrame>
);

View File

@@ -22,12 +22,19 @@ export function LoadingContent({ node, theme, onOpenTaskDetails }: Pick<CanvasNo
const showsProgress = Boolean(taskId) && generationTaskShowsProgress(displayTask);
const progress = showsProgress && typeof node.metadata?.taskProgress === "number" ? Math.max(0, Math.min(100, Math.round(node.metadata.taskProgress))) : null;
const statusLabel = taskId ? generationTaskStatusLabel(displayTask) : "等待任务状态";
const stageLabel = taskId ? generationTaskStageLabel(displayTask) : "正在创建任务";
const elapsed = useTaskElapsed(node.metadata?.taskCreatedAt);
const workflow = node.metadata?.imageLayerWorkflow;
const stageLabel = workflow ? (workflow.stage === "planning" ? "拆解规划中" : `拆解中,共 ${workflow.total} 张图`) : node.metadata?.experimentalLayerPlan?.progress || (taskId ? generationTaskStageLabel(displayTask) : "正在创建任务");
const elapsed = useTaskElapsed(workflow?.startedAt || node.metadata?.taskCreatedAt);
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2.5 px-5 text-center" style={{ color: theme.node.activeStroke }}>
{submissionUncertain ? <AlertCircle className="size-10" /> : <div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />}
<span className="text-[var(--fs-tiny)] font-semibold">{stageLabel}</span>
{workflow ? (
<span className="text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>
<Clock3 className="mr-1 inline size-3" />
{elapsed}
</span>
) : null}
{taskId ? (
<div className="flex w-full max-w-[210px] flex-col items-center gap-1.5">
<div className="max-w-full truncate text-[var(--fs-label)] font-medium" style={{ color: theme.node.text }}>
@@ -41,7 +48,7 @@ export function LoadingContent({ node, theme, onOpenTaskDetails }: Pick<CanvasNo
) : null}
<div className="max-w-full truncate text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.muted }}>
<Clock3 className="mr-1 inline size-3" />
{elapsed} · {shortTaskId(taskId)}
{workflow ? shortTaskId(taskId) : `${elapsed} · ${shortTaskId(taskId)}`}
</div>
<div className="mt-0.5 flex items-center gap-1.5">
<button

View File

@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, RefreshCw, ScanSearch, Settings2, Star, Trash2, Type, UserRound, Video, WandSparkles } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { CanvasImageLayerControls } from "./canvas-image-layer-controls";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
@@ -130,7 +131,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
const producedModelStored = data.metadata?.producedModel;
const showProducedModel = showImageInfo && hasMediaContent && Boolean(producedModelStored);
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && (batchCount > 1 || Boolean(data.metadata?.imageLayerGroup));
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
const isActive = isConnectionTarget || isSelected || isFocusRelated;
@@ -364,7 +365,8 @@ export const CanvasNode = React.memo(function CanvasNode({
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
{data.metadata?.imageLayerGroup && !readOnly ? <CanvasImageLayerControls node={data} /> : null}
{isBatchChild && !data.metadata?.imageLayer && !data.metadata?.layerExtraction && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
{data.metadata?.locked ? <NodeLockBadge theme={theme} /> : null}
</div>
) : null}
@@ -378,7 +380,7 @@ export const CanvasNode = React.memo(function CanvasNode({
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] border px-1 py-1 backdrop-blur-xl" style={{ background: `${theme.toolbar.panel}e6`, borderColor: theme.toolbar.border }}>
{hasImageContent ? <BatchChildActionButton theme={theme} label="下载图片" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} /> : null}
{hasImageContent ? <BatchChildActionButton theme={theme} label="创建副本" icon={<Copy className="size-3.5" />} onClick={() => duplicateNode?.(data)} /> : null}
{hasImageContent ? (
{hasImageContent && !data.metadata?.imageLayer && !data.metadata?.layerExtraction ? (
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null}
{data.metadata?.status === "error" && data.metadata.resourceReloadAvailable ? <BatchChildActionButton theme={theme} label="重新加载资源" icon={<Download className="size-3.5" />} onClick={() => onReloadResource?.(data)} /> : null}
@@ -388,9 +390,9 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
) : null}
{/* 批次主图位(折叠根节点封面)常驻下载按钮 */}
{showChrome && isBatchRoot && hasImageContent && !readOnly ? (
{showChrome && isBatchRoot && hasImageContent && !readOnly && (!data.metadata?.imageLayerGroup?.compositeStatus || data.metadata.imageLayerGroup.compositeStatus === "ready") ? (
<div className="absolute bottom-2 right-2 z-[var(--node-z-overlay)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
<BatchChildActionButton theme={theme} label={data.metadata?.imageLayerGroup ? "下载合成图" : "下载主图"} icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
</div>
) : null}
{showChrome && (assetTags.length || showProducedModel || (showImageInfo && hasImageContent)) ? (

View File

@@ -31,6 +31,7 @@ type ModelPickerProps = {
variant?: "default" | "creation";
requirements?: ModelRequirements;
showConfiguredModelName?: boolean;
modelFilter?: (model: string) => boolean;
};
export function ModelPicker({
@@ -48,6 +49,7 @@ export function ModelPicker({
variant = "creation",
requirements,
showConfiguredModelName = false,
modelFilter,
}: ModelPickerProps) {
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
const pickerId = useId();
@@ -59,7 +61,7 @@ export function ModelPicker({
const [activeGroupKey, setActiveGroupKey] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter((model) => Boolean(model) && (!modelFilter || modelFilter(model))))), [capability, config, modelFilter]);
const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]);
const storedCurrent = value?.trim() || "";
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。