feat(canvas): 媒体产出模型与选用模型分离 (#597)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,9 @@ import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { canvasNodeVideoPreviewReference } from "@/lib/canvas/canvas-media-preview";
|
||||
import { producedModelLabel } from "@/lib/canvas/produced-model";
|
||||
import { useConfigStore } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
|
||||
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
@@ -1000,6 +1003,15 @@ export function CanvasNodeImageInfo({ node }: { node: CanvasNodeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasNodeProducedModel({ stored }: { stored: string }) {
|
||||
// Keep catalog subscriptions off the CanvasNode shell and avoid normalizing config per node.
|
||||
const channels = useConfigStore((state) => state.config.channels);
|
||||
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
|
||||
const visibleChannels = useMemo(() => customChannelsEnabled ? channels : channels.filter((channel) => channel.scope === "system"), [channels, customChannelsEnabled]);
|
||||
const label = producedModelLabel({ channels: visibleChannels }, stored);
|
||||
return <span className="max-w-full min-w-0 truncate rounded-[var(--r-sm)] bg-black/55 px-2 py-1 text-[var(--fs-label)] font-medium leading-none text-white backdrop-blur-sm">{label}</span>;
|
||||
}
|
||||
|
||||
function BatchPreviewImage({ node }: { node: CanvasNodeData }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(ref);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Vide
|
||||
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -13,7 +14,8 @@ const RESULT_LIST_ID = "canvas-node-search-results";
|
||||
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const results = useMemo(() => searchCanvasNodes(nodes, query), [nodes, query]);
|
||||
const config = useEffectiveConfig();
|
||||
const results = useMemo(() => searchCanvasNodes(nodes, query, 80, config), [config, nodes, query]);
|
||||
|
||||
useEffect(() => setActiveIndex(0), [query, open]);
|
||||
useEffect(() => setActiveIndex((current) => Math.min(current, Math.max(0, results.length - 1))), [results.length]);
|
||||
@@ -69,6 +71,7 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
|
||||
<CanvasNodeSearchResult
|
||||
key={node.id}
|
||||
node={node}
|
||||
config={config}
|
||||
active={index === activeIndex}
|
||||
onActivate={() => setActiveIndex(index)}
|
||||
onSelect={() => focusNode(node)}
|
||||
@@ -79,9 +82,9 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
|
||||
);
|
||||
}
|
||||
|
||||
const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, active, onActivate, onSelect }: { node: CanvasNodeData; active: boolean; onActivate: () => void; onSelect: () => void }) {
|
||||
const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, config, active, onActivate, onSelect }: { node: CanvasNodeData; config: AiConfig; active: boolean; onActivate: () => void; onSelect: () => void }) {
|
||||
const times = canvasNodeSearchTimes(node);
|
||||
const materialSummary = canvasNodeMaterialSummary(node);
|
||||
const materialSummary = canvasNodeMaterialSummary(node, config);
|
||||
const context = canvasNodeSearchContext(node);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { producedModelLabel } from "@/lib/canvas/produced-model";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
|
||||
import { buildImageToolbarTools } from "./canvas-image-toolbar-tools";
|
||||
import { CanvasGridSplitPicker } from "./canvas-grid-split-picker";
|
||||
@@ -499,6 +501,7 @@ function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChan
|
||||
|
||||
export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, readOnly = false, onUnauthorized }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; onMetadataChange?: (nodeId: string, metadata: Partial<CanvasNodeMetadata>) => void; readOnly?: boolean; onUnauthorized?: () => void }) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const [assetTags, setAssetTags] = useState<string[]>([]);
|
||||
const [assetTagInput, setAssetTagInput] = useState("");
|
||||
const [assetCategory, setAssetCategory] = useState<CanvasAssetCategory>("other");
|
||||
@@ -592,11 +595,11 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{nodeGenerationRows(node).length ? (
|
||||
{nodeGenerationRows(node, producedModelLabel(config, node.metadata?.producedModel)).length ? (
|
||||
<section className="canvas-node-inspector-section">
|
||||
<div className="canvas-node-inspector-section-heading"><span>生成信息</span></div>
|
||||
<div className="canvas-node-inspector-facts">
|
||||
{nodeGenerationRows(node).map((item) => <InfoRow key={item.label} label={item.label} value={item.value} />)}
|
||||
{nodeGenerationRows(node, producedModelLabel(config, node.metadata?.producedModel)).map((item) => <InfoRow key={item.label} label={item.label} value={item.value} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -668,7 +671,7 @@ function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function nodeGenerationRows(node: CanvasNodeData) {
|
||||
function nodeGenerationRows(node: CanvasNodeData, producedModelText: string) {
|
||||
const metadata = node.metadata;
|
||||
if (!metadata) return [] as Array<{ label: string; value: string }>;
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
@@ -689,7 +692,7 @@ function nodeGenerationRows(node: CanvasNodeData) {
|
||||
add("耗时", minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`);
|
||||
};
|
||||
|
||||
add("模型", metadata.model);
|
||||
add("产出模型", producedModelText);
|
||||
add("生成尺寸", metadata.size);
|
||||
add("分辨率", metadata.vquality || metadata.quality);
|
||||
add("秒数", metadata.seconds ? `${metadata.seconds} 秒` : undefined);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Positi
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { ART_CRITIQUE_NODE_TYPE } from "@/lib/art-critique/contracts";
|
||||
import { getNodeDefinition, getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
|
||||
import { CanvasNodeContent, CanvasNodeImageInfo, CanvasNodeProducedModel } from "./canvas-node-content";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
@@ -127,6 +127,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent);
|
||||
const isComposerNode = data.type === CanvasNodeType.Config;
|
||||
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 isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
|
||||
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
|
||||
@@ -472,9 +474,12 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
|
||||
</div>
|
||||
) : null}
|
||||
{showChrome && (assetTags.length || (showImageInfo && hasImageContent)) ? (
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
|
||||
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
|
||||
{showChrome && (assetTags.length || showProducedModel || (showImageInfo && hasImageContent)) ? (
|
||||
<div className={`pointer-events-none absolute inset-x-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2 ${mediaActive && hasVideoContent ? "bottom-16" : "bottom-3"}`}>
|
||||
<div className="flex min-w-0 items-end gap-1">
|
||||
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
|
||||
{showProducedModel ? <CanvasNodeProducedModel stored={producedModelStored!} /> : null}
|
||||
</div>
|
||||
{showImageInfo && hasImageContent ? <CanvasNodeImageInfo node={data} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -215,7 +215,7 @@ export function CanvasToolbar({
|
||||
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
|
||||
<CanvasAppearanceControls appearance={appearance} backgroundMode={backgroundMode} colorTheme={colorTheme} theme={theme} onAppearanceChange={onAppearanceChange} onSaveAppearanceDefault={onSaveAppearanceDefault} onBackgroundModeChange={onBackgroundModeChange} />
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 rounded-[var(--dock-item-radius-labeled)] border px-2.5 py-2" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />图片信息</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] font-semibold"><Info className="size-3" />媒体信息</span>
|
||||
<Switch size="sm" checked={showImageInfo} onChange={onShowImageInfoChange} />
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
|
||||
@@ -2,17 +2,21 @@ import { Button, Modal } from "antd";
|
||||
import { Check, Star } from "lucide-react";
|
||||
|
||||
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
|
||||
import { producedModelLabel } from "@/lib/canvas/produced-model";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimary, onFocus }: { open: boolean; versions: CanvasNodeData[]; onClose: () => void; onSetPrimary: (nodeId: string) => void; onFocus: (nodeId: string) => void }) {
|
||||
const theme = canvasThemes[useActiveTheme()];
|
||||
const config = useEffectiveConfig();
|
||||
const modalWidth = Math.min(1180, Math.max(440, 112 + versions.length * 340));
|
||||
return (
|
||||
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
|
||||
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
|
||||
{versions.map((node) => {
|
||||
const producedModelText = producedModelLabel(config, node.metadata?.producedModel);
|
||||
return (
|
||||
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
@@ -23,7 +27,7 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
|
||||
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video ? <CanvasVideoPreviewImage node={node} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" fallback={<span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>暂无视频首帧</span>} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
|
||||
</button>
|
||||
<div className="space-y-2 p-3 text-[var(--fs-label)]">
|
||||
<Info label="模型" value={node.metadata?.model || "默认模型"} />
|
||||
{producedModelText ? <Info label="产出模型" value={producedModelText} /> : null}
|
||||
<Info label="尺寸" value={node.metadata?.size || "默认尺寸"} />
|
||||
<div><div className="whitespace-nowrap opacity-45">提示词</div><div className="mt-1 line-clamp-5 whitespace-pre-wrap break-words leading-5">{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}</div></div>
|
||||
<Button block size="small" className="whitespace-nowrap" type={node.metadata?.versionPrimary ? "default" : "primary"} disabled={node.metadata?.versionPrimary} icon={<Star className="size-3.5" />} onClick={() => onSetPrimary(node.id)}>{node.metadata?.versionPrimary ? "当前主版本" : "设为主版本"}</Button>
|
||||
@@ -37,5 +41,5 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
|
||||
}
|
||||
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex min-w-0 items-center gap-3"><span className="w-9 shrink-0 whitespace-nowrap opacity-45">{label}</span><span className="min-w-0 flex-1 truncate text-right font-medium" title={value}>{value}</span></div>;
|
||||
return <div className="flex min-w-0 items-center gap-3"><span className="w-16 shrink-0 whitespace-nowrap opacity-45">{label}</span><span className="min-w-0 flex-1 truncate text-right font-medium" title={value}>{value}</span></div>;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { AudioLines, Clock3, FileText, Image, Layers, Pencil, Search, X } from "
|
||||
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes } from "@/lib/canvas/canvas-node-search";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { Video as CanvasVideoIcon } from "lucide-react";
|
||||
|
||||
export function CanvasWorkspaceNodeListPanel({
|
||||
nodes,
|
||||
config,
|
||||
results,
|
||||
query,
|
||||
deferredQuery,
|
||||
@@ -18,6 +20,7 @@ export function CanvasWorkspaceNodeListPanel({
|
||||
onFocus,
|
||||
}: {
|
||||
nodes: CanvasNodeData[];
|
||||
config: AiConfig;
|
||||
results: CanvasNodeData[];
|
||||
query: string;
|
||||
deferredQuery: string;
|
||||
@@ -52,7 +55,7 @@ export function CanvasWorkspaceNodeListPanel({
|
||||
</div>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain py-1" role="listbox" aria-label="画布节点列表">
|
||||
{results.length ? (
|
||||
results.map((node) => <CanvasNodeListItem key={node.id} node={node} active={selectedNodeIds.has(node.id)} onSelect={() => onFocus(node.id)} />)
|
||||
results.map((node) => <CanvasNodeListItem key={node.id} node={node} config={config} active={selectedNodeIds.has(node.id)} onSelect={() => onFocus(node.id)} />)
|
||||
) : (
|
||||
<WorkspaceState icon="canvas" compact title="没有匹配节点" description="换一个关键词继续搜索。" />
|
||||
)}
|
||||
@@ -61,9 +64,9 @@ export function CanvasWorkspaceNodeListPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, active, onSelect }: { node: CanvasNodeData; active: boolean; onSelect: () => void }) {
|
||||
const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, config, active, onSelect }: { node: CanvasNodeData; config: AiConfig; active: boolean; onSelect: () => void }) {
|
||||
const times = canvasNodeSearchTimes(node);
|
||||
const materialSummary = canvasNodeMaterialSummary(node);
|
||||
const materialSummary = canvasNodeMaterialSummary(node, config);
|
||||
const context = canvasNodeSearchContext(node);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button, Grid } from "antd";
|
||||
import { Home, PanelLeftClose, PanelLeftOpen, Layers, Images, ListChecks, History, X } from "lucide-react";
|
||||
import { AppDrawer } from "@/components/ui/product/app-drawer";
|
||||
import { searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
@@ -47,6 +48,7 @@ export function CanvasWorkspacePanel({
|
||||
const [query, setQuery] = useState("");
|
||||
const history = tab === "history";
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const config = useEffectiveConfig();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const tasks = useCanvasWorkspaceTasks(projectId, open && (tab === "tasks" || history));
|
||||
const content = (
|
||||
@@ -58,7 +60,7 @@ export function CanvasWorkspacePanel({
|
||||
</button>
|
||||
</header>
|
||||
{tab === "nodes" && (
|
||||
<CanvasWorkspaceNodeListPanel nodes={nodes} results={searchCanvasNodes(nodes, deferredQuery, nodes.length)} query={query} deferredQuery={deferredQuery} selectedNodeIds={selectedNodeIds} onQueryChange={setQuery} onFocus={onFocus} />
|
||||
<CanvasWorkspaceNodeListPanel nodes={nodes} config={config} results={searchCanvasNodes(nodes, deferredQuery, nodes.length, config)} query={query} deferredQuery={deferredQuery} selectedNodeIds={selectedNodeIds} onQueryChange={setQuery} onFocus={onFocus} />
|
||||
)}
|
||||
{tab === "assets" && <CanvasWorkspaceAssetPanel onInsert={onInsertAssets} onManage={onAssets} onProjectAssets={onProjectAssets} />}
|
||||
{(tab === "tasks" || history) && (
|
||||
|
||||
Reference in New Issue
Block a user