feat(canvas): AI 拆图 - 支持图层规划编辑与素材复用
This commit is contained in:
103
web/src/components/canvas/canvas-image-layer-controls.tsx
Normal file
103
web/src/components/canvas/canvas-image-layer-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>({});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) ? (
|
||||
|
||||
@@ -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() || "";
|
||||
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
|
||||
|
||||
Reference in New Issue
Block a user