feat(canvas): 媒体产出模型与选用模型分离 (#597)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
17
CONTEXT.md
Normal file
17
CONTEXT.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 影策
|
||||
|
||||
面向 AI 影视与短剧创作的工作台。画布上的媒体节点要能分清「这份内容是谁写成的」和「下一次准备用谁」。
|
||||
|
||||
## Language
|
||||
|
||||
**产出模型**:
|
||||
写出当前图片、视频或音频内容的模型。在该内容生成成功并写入节点时确定。
|
||||
_Avoid_: 出图模型, 选用模型, 模型
|
||||
|
||||
**选用模型**:
|
||||
提示词面板中,下一次生成将使用的模型。
|
||||
_Avoid_: 产出模型, 模型
|
||||
|
||||
**媒体信息**:
|
||||
画布外观中的开关。打开后,在有内容的图片、视频、音频节点上显示产出模型;图片同时显示尺寸和体积。
|
||||
_Avoid_: 图片信息
|
||||
11
docs/adr/0009-produced-model-separate-from-selection.md
Normal file
11
docs/adr/0009-produced-model-separate-from-selection.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# ADR-0009: 媒体产出模型与选用模型分开记录
|
||||
|
||||
状态:已接受。日期:2026-09-22。
|
||||
|
||||
画布节点上的模型字段同时承担两件事:提示词面板下一次要用的选用模型,以及这份图片、视频或音频实际由哪个模型写成。改下拉并不重新生成时,后一件事会被覆盖,过一段时间无法从节点上认出资产的产出模型。
|
||||
|
||||
决定:产出模型在生成成功并写入节点时冻结,选用模型继续随下拉变化。角标、节点详情和版本对比只展示产出模型;没有产出模型时留空。展示名按当前模型目录解析,目录中没有则显示模型 ID。本地加工、上传和本次改动之前的旧节点不回填。
|
||||
|
||||
## 考虑过的方案
|
||||
|
||||
继续复用同一个模型字段。改动更少,但选用模型一变,产出事实就丢了。否决。
|
||||
@@ -43,7 +43,7 @@ description: 从产品功能快速定位主要代码入口
|
||||
| 职责 | 代码位置 |
|
||||
| --- | --- |
|
||||
| 页面装配和跨域动作协调 | `web/src/pages/canvas/project.tsx` |
|
||||
| 画布领域规则和生成协议 | `web/src/lib/canvas/canvas-project-domain.ts`、`canvas-project-generation.ts` |
|
||||
| 画布领域规则和生成协议 | `web/src/lib/canvas/canvas-project-domain.ts`、`canvas-project-generation.ts`、`produced-model.ts` |
|
||||
| 项目恢复、持久化和项目级动作 | `web/src/pages/canvas/use-canvas-project-lifecycle.ts` |
|
||||
| 云端版本校验与冲突草稿 | `web/src/services/user-data-sync.ts`、`canvas-sync-drafts.ts`、`web/src/lib/canvas/canvas-content.ts`、`web/src/pages/canvas/canvas-sync-status.tsx` |
|
||||
| 历史树与独立只读预览 | `web/src/pages/canvas/canvas-version-history.tsx`、`canvas-version-preview.tsx`;后端 `internal/canvas/canvas_history.go` 与 `repository/canvas_history.go`,Agent 写入桥接在 `app/canvas_bridge.go` |
|
||||
|
||||
@@ -5,6 +5,10 @@ description: 当前项目已实现的核心工作台能力与使用入口
|
||||
|
||||
# 功能清单
|
||||
|
||||
## 画布媒体产出模型
|
||||
|
||||
图片、视频、音频在生成成功并写入节点后,会冻结当时提交的产出模型。提示词面板里的模型下拉仍是下一次要用的选用模型,改下拉不会改掉已经生成的内容。画布外观中的「媒体信息」打开后,有内容的媒体节点在左下角显示产出模型;图片右下角继续显示尺寸和体积。上传、粘贴、截取、合并、提取音频和本地加工不记录产出模型。本次改动之前的节点不回填。分享页和版本预览画布不显示这块信息。
|
||||
|
||||
## 画布 Agent 名称、文案与形象
|
||||
|
||||
「站点及外观 → 画布配置」提供独立助手名称、入口文字、面板标题和欢迎/输入文案。支持导入授权的 Live2D Cubism 3/4 运行时 ZIP,预览后保存启用、调整高度、切回默认动态球。加载失败保留可点击的默认入口,上传和资源访问具有管理员校验和引用保护。Cubism Core 由部署者提供,真实播放待验收;不含图片自动建模或 AI 自我介绍同步。详见 [画布 Agent 外观与 Live2D](/docs/backend/canvas-agent-appearance)。
|
||||
|
||||
@@ -15,6 +15,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
- [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线草稿、登录切换和错误重试行为。尚未做真实浏览器/CDN 验收。
|
||||
- [ ] 使用 React Profiler 与长时间运行的大画布记录任务更新、节点数/可见数、主线程长任务和页面隐藏行为。当前只做结构性降热点,不推断整画布必然全量重渲染或已达到某个帧率。
|
||||
|
||||
## 画布媒体产出模型
|
||||
|
||||
- 产出模型与选用模型分开保存。生成成功才冻结产出模型;生成中、失败和只改模型下拉不改已有产出模型。
|
||||
- [ ] 打开画布外观「媒体信息」后,新生成的图片、视频、音频左下角显示模型展示名或模型 ID。图片右下角仍显示尺寸和体积。关闭开关后这两处都不显示。视频标题旁的宽高保持可见;视频播放时角标不盖住控制条。
|
||||
- [ ] 生成成功后把下拉改成另一个模型且不重新生成,角标仍是原来的产出模型。重新生成成功后角标换成新模型。
|
||||
- [ ] 上传、粘贴、宫格切分、截取、合并、提取音频不显示产出模型。节点详情和版本对比在没有产出模型时留空,不显示「默认模型」。搜索能按产出模型名称和选用模型找到节点。
|
||||
- [ ] 分享页和版本预览画布不显示产出模型。旧画布节点在重新生成成功之前不出现角标。
|
||||
- [ ] 真实浏览器验收尚未执行。
|
||||
|
||||
## 原任务 ID 媒体保存恢复
|
||||
|
||||
- [x] 故障注入:下载 503 后服务实例重建继续原任务、OSS 上传失败保留暂存文件及资源 ID、素材登记异常使任务成功事务回滚,登记恢复无需再次下载。
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
- [ADR-0006:语音滤镜与项目模板](adr/0006-voice-filters-and-templates.md):补充评估 Concat 剩余两个缺口——语音滤镜走 Web Audio 预览近似 + ffmpeg 音频滤镜图导出,模板走项目快照服务化;均为预设插件且服从单一滤镜图计划。
|
||||
- [ADR-0007:AI 编辑交互](adr/0007-ai-editing-interaction.md):对话式剪辑作为预设插件——AI 输出受约束命令 JSON(schema 校验 fail-closed),与手势命令同队列同撤销栈;≤3 条直接执行、批量改动 diff 预览待确认。
|
||||
- [ADR-0008:自研 UI 组件层与 AntD 替换](adr/0008-ui-component-layer-antd-replacement.md):`components/ui` 重建为自研组件层(Celadon UI),吸收 dbx 契约形态(族目录+barrel)与 BoardUI 设计深度(语义状态矩阵/复合排版/RAC 原语/动效纪律/agentic 产品块),分阶段替换 AntD(依赖面 180 文件/41 导入)最终脱离;试点画布工具条/节点徽章,唯一实施计划见 [plans/ui-kit-rollout-plan.mdx](plans/ui-kit-rollout-plan.mdx),活规范见 [plans/ui-design-system.mdx](plans/ui-design-system.mdx)。
|
||||
- [ADR-0009:媒体产出模型与选用模型分开记录](adr/0009-produced-model-separate-from-selection.md):生成成功时冻结产出模型,选用模型继续随下拉变化;角标按当前目录解析展示名。
|
||||
|
||||
## 本地协作文档(不随仓库分发)
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getCachedResourceBlob } from "@/services/resource-blob-cache";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
|
||||
import { commitProducedModel } from "@/lib/canvas/produced-model";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
export function generationTaskInput(task: GenerationTask) {
|
||||
@@ -110,15 +111,15 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
|
||||
|
||||
// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。
|
||||
// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。
|
||||
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}): CanvasNodeMetadata {
|
||||
return {
|
||||
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}, fallbackModel?: string): CanvasNodeMetadata {
|
||||
return commitProducedModel({
|
||||
...node.metadata,
|
||||
...workflowMetadataForResultNode(),
|
||||
...media,
|
||||
...extra,
|
||||
errorDetails: undefined,
|
||||
assetId: undefined,
|
||||
};
|
||||
}, fallbackModel);
|
||||
}
|
||||
|
||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
|
||||
@@ -158,7 +159,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
|
||||
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }),
|
||||
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }, task.model),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,7 +180,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
...node,
|
||||
type: CanvasNodeType.Video,
|
||||
...geometry,
|
||||
metadata: applyGeneratedMediaResultMetadata(node, videoMetadata(video), { prompt, ...completedTaskMetadata(task) }),
|
||||
metadata: applyGeneratedMediaResultMetadata(node, videoMetadata(video), { prompt, ...completedTaskMetadata(task) }, task.model),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +189,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
const audio = result.audio.storageKey
|
||||
? { url: await resolveMediaUrl(result.audio.storageKey, result.audio.dataUrl), storageKey: result.audio.storageKey, durationMs: result.audio.durationMs, bytes: result.audio.bytes || 0, mimeType: result.audio.mimeType || "audio/mpeg" }
|
||||
: await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), result.audio.format || "mp3");
|
||||
return { ...node, type: CanvasNodeType.Audio, metadata: applyGeneratedMediaResultMetadata(node, audioMetadata(audio), { prompt, ...completedTaskMetadata(task) }) };
|
||||
return { ...node, type: CanvasNodeType.Audio, metadata: applyGeneratedMediaResultMetadata(node, audioMetadata(audio), { prompt, ...completedTaskMetadata(task) }, task.model) };
|
||||
}
|
||||
|
||||
if (!result.text) throw new Error("后端任务没有返回文本");
|
||||
|
||||
@@ -171,12 +171,15 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
|
||||
metadata.naturalWidth = primary.metadata?.naturalWidth;
|
||||
metadata.naturalHeight = primary.metadata?.naturalHeight;
|
||||
metadata.primaryImageId = primary.id;
|
||||
if (primary.metadata?.producedModel) metadata.producedModel = primary.metadata.producedModel;
|
||||
else delete metadata.producedModel;
|
||||
metadata.status = "success";
|
||||
delete metadata.errorDetails;
|
||||
delete metadata.generationErrorCode;
|
||||
delete metadata.failedPromptFingerprint;
|
||||
} else {
|
||||
delete metadata.content;
|
||||
delete metadata.producedModel;
|
||||
delete metadata.storageKey;
|
||||
delete metadata.assetId;
|
||||
delete metadata.mimeType;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getNodeListLabel } from "@/lib/canvas/node-registry";
|
||||
import { producedModelLabel, producedModelSearchTerms } from "@/lib/canvas/produced-model";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasNodeCreatedAt, canvasNodeUpdatedAt } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -10,10 +12,10 @@ const searchTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80) {
|
||||
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80, config?: AiConfig) {
|
||||
const keyword = query.trim().toLocaleLowerCase();
|
||||
return nodes
|
||||
.filter((node) => !keyword || canvasNodeSearchTerms(node).some((value) => value.toLocaleLowerCase().includes(keyword)))
|
||||
.filter((node) => !keyword || canvasNodeSearchTerms(node, config).some((value) => value.toLocaleLowerCase().includes(keyword)))
|
||||
.toSorted((left, right) => timestampValue(canvasNodeUpdatedAt(right)) - timestampValue(canvasNodeUpdatedAt(left)))
|
||||
.slice(0, keyword ? limit : Math.min(limit, 40));
|
||||
}
|
||||
@@ -26,14 +28,15 @@ export function canvasNodeSearchContext(node: CanvasNodeData) {
|
||||
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type);
|
||||
}
|
||||
|
||||
export function canvasNodeMaterialSummary(node: CanvasNodeData) {
|
||||
export function canvasNodeMaterialSummary(node: CanvasNodeData, config?: AiConfig) {
|
||||
const details = [getNodeListLabel(node.type)];
|
||||
const width = node.metadata?.naturalWidth;
|
||||
const height = node.metadata?.naturalHeight;
|
||||
if (width && height) details.push(`${width}×${height}`);
|
||||
if (node.metadata?.durationMs) details.push(formatDuration(node.metadata.durationMs));
|
||||
if (node.metadata?.bytes) details.push(formatBytes(node.metadata.bytes));
|
||||
if (node.metadata?.model) details.push(node.metadata.model);
|
||||
const producedModel = node.metadata?.producedModel;
|
||||
if (producedModel) details.splice(1, 0, config ? producedModelLabel(config, producedModel) : producedModel);
|
||||
return details.slice(0, 3).join(" · ");
|
||||
}
|
||||
|
||||
@@ -48,7 +51,7 @@ export function canvasNodeSearchTimes(node: CanvasNodeData) {
|
||||
};
|
||||
}
|
||||
|
||||
function canvasNodeSearchTerms(node: CanvasNodeData) {
|
||||
function canvasNodeSearchTerms(node: CanvasNodeData, config?: AiConfig) {
|
||||
return [
|
||||
node.title,
|
||||
node.type,
|
||||
@@ -56,6 +59,7 @@ function canvasNodeSearchTerms(node: CanvasNodeData) {
|
||||
node.metadata?.prompt,
|
||||
node.metadata?.composerContent,
|
||||
node.metadata?.model,
|
||||
...producedModelSearchTerms(node.metadata, config),
|
||||
node.metadata?.chapterTitle,
|
||||
node.metadata?.workflowTitle,
|
||||
node.metadata?.workflowDescription,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isSeedanceVideoConfig } from "@/lib/seedance-video";
|
||||
import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowOutputSizeValue, workflowVideoFieldsFromJson } from "@/lib/model-capabilities";
|
||||
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { interruptFileUpload } from "@/lib/canvas/canvas-file-upload";
|
||||
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
@@ -199,6 +200,7 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
|
||||
...generationWorkflowMetadata(config),
|
||||
generationType: type,
|
||||
model: config.model,
|
||||
producedModelCandidate: producedModelCandidateForGeneration(config),
|
||||
size: config.size,
|
||||
quality: config.quality,
|
||||
transparentBackground: config.transparentBackground,
|
||||
@@ -235,6 +237,7 @@ export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetada
|
||||
return {
|
||||
...generationWorkflowMetadata(config),
|
||||
model: config.model,
|
||||
producedModelCandidate: producedModelCandidateForGeneration(config),
|
||||
audioVoice: config.audioVoice,
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
|
||||
49
web/src/lib/canvas/produced-model.ts
Normal file
49
web/src/lib/canvas/produced-model.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { decodeChannelModel, modelOptionName, type AiConfig } from "@/stores/use-config-store";
|
||||
import type { CanvasNodeMetadata } from "@/types/canvas";
|
||||
|
||||
/** 生成提交时记下的模型身份。RunningHub 任务提交的就是「RunningHub · 工作流名」。 */
|
||||
export function submittedProducedModel(model: string | undefined): string | undefined {
|
||||
const value = model?.trim();
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
export function producedModelCandidateForGeneration(config: { model?: string; taskWorkflowProvider?: string }): string | undefined {
|
||||
if (config.taskWorkflowProvider && config.taskWorkflowProvider !== "model") return undefined;
|
||||
return submittedProducedModel(config.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功写入新媒体时冻结产出模型。优先用本次提交记下的候选,避免下拉框里的选用模型覆盖它。
|
||||
* 没有可用模型时清掉旧值,避免新内容继续显示上一次的模型。
|
||||
*/
|
||||
export function commitProducedModel(metadata: CanvasNodeMetadata, fallbackModel?: string): CanvasNodeMetadata {
|
||||
const producedModel = submittedProducedModel(metadata.producedModelCandidate) || submittedProducedModel(fallbackModel);
|
||||
const { producedModelCandidate: _candidate, producedModel: _previous, ...rest } = metadata;
|
||||
return producedModel ? { ...rest, producedModel } : rest;
|
||||
}
|
||||
|
||||
/** 按当前模型目录解析角标。有展示名用展示名,否则用模型 ID,不把系统渠道收成「系统模型」。 */
|
||||
export function producedModelLabel(config: Pick<AiConfig, "channels">, stored: string | undefined): string {
|
||||
const value = stored?.trim();
|
||||
if (!value) return "";
|
||||
const decoded = decodeChannelModel(value);
|
||||
const modelId = decoded?.model || value;
|
||||
if (decoded) {
|
||||
const channel = config.channels.find((item) => item.id === decoded.channelId);
|
||||
if (!channel) return modelId;
|
||||
return channel.modelCosts?.find((item) => item.model === modelId)?.displayName?.trim() || modelId;
|
||||
}
|
||||
for (const item of config.channels) {
|
||||
const named = item.modelCosts?.find((entry) => entry.model === modelId || entry.logicalModelId === modelId)?.displayName?.trim();
|
||||
if (named) return named;
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
export function producedModelSearchTerms(metadata: CanvasNodeMetadata | undefined, config?: AiConfig): string[] {
|
||||
const stored = metadata?.producedModel?.trim();
|
||||
if (!stored) return [];
|
||||
const modelId = modelOptionName(stored);
|
||||
const label = config ? producedModelLabel(config, stored) : modelId;
|
||||
return [stored, modelId, label].filter((value, index, all) => Boolean(value) && all.indexOf(value) === index);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, resetGenerationTaskMetadata, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { commitProducedModel } from "@/lib/canvas/produced-model";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -227,18 +228,21 @@ export async function executeImageGeneration({
|
||||
? {
|
||||
...node,
|
||||
...geometry,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
content: child.metadata?.content,
|
||||
storageKey: child.metadata?.storageKey,
|
||||
mimeType: child.metadata?.mimeType,
|
||||
bytes: child.metadata?.bytes,
|
||||
naturalWidth: child.metadata?.naturalWidth,
|
||||
naturalHeight: child.metadata?.naturalHeight,
|
||||
assetId: child.metadata?.assetId,
|
||||
primaryImageId: targetId,
|
||||
status: NODE_STATUS_SUCCESS,
|
||||
},
|
||||
metadata: commitProducedModel(
|
||||
{
|
||||
...node.metadata,
|
||||
content: child.metadata?.content,
|
||||
storageKey: child.metadata?.storageKey,
|
||||
mimeType: child.metadata?.mimeType,
|
||||
bytes: child.metadata?.bytes,
|
||||
naturalWidth: child.metadata?.naturalWidth,
|
||||
naturalHeight: child.metadata?.naturalHeight,
|
||||
assetId: child.metadata?.assetId,
|
||||
primaryImageId: targetId,
|
||||
status: NODE_STATUS_SUCCESS,
|
||||
},
|
||||
child.metadata?.producedModel,
|
||||
),
|
||||
}
|
||||
: node,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
|
||||
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import type { CanvasGenerationExecution } from "./canvas-generation-executor-types";
|
||||
@@ -58,6 +59,7 @@ export async function executeVideoGeneration({
|
||||
resourceReloadAvailable: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
model: generationConfig.model,
|
||||
producedModelCandidate: producedModelCandidateForGeneration(generationConfig),
|
||||
size: generationConfig.size,
|
||||
seconds: generationConfig.videoSeconds,
|
||||
vquality: generationConfig.vquality,
|
||||
|
||||
@@ -152,7 +152,7 @@ export function useCanvasDirector({
|
||||
position: existingPreview?.position || { x: sourceNode.position.x - previewSize.width - 36, y: sourceNode.position.y },
|
||||
width: previewSize.width,
|
||||
height: previewSize.height,
|
||||
metadata: { ...existingPreview?.metadata, ...imageMetadata(image), prompt: output.prompt, workflowKind: "reference_set", assetTags: ["导演台构图", `镜头:${sourceNode.title}`] },
|
||||
metadata: { ...existingPreview?.metadata, ...imageMetadata(image), prompt: output.prompt, workflowKind: "reference_set", assetTags: ["导演台构图", `镜头:${sourceNode.title}`], producedModel: undefined, producedModelCandidate: undefined },
|
||||
};
|
||||
if (previewIndex >= 0) nextNodes[previewIndex] = previewNode;
|
||||
else nextNodes.push(previewNode);
|
||||
@@ -170,7 +170,7 @@ export function useCanvasDirector({
|
||||
position: existingVideo?.position || { x: sourceNode.position.x, y: sourceNode.position.y + sourceNode.height + 48 },
|
||||
width: existingVideo?.width || 360,
|
||||
height: existingVideo?.height || 220,
|
||||
metadata: { ...existingVideo?.metadata, ...videoMetadata(videoUpload), prompt: output.prompt, workflowKind: "reference_video", assetTags: ["导演台白膜", `镜头:${sourceNode.title}`] },
|
||||
metadata: { ...existingVideo?.metadata, ...videoMetadata(videoUpload), prompt: output.prompt, workflowKind: "reference_video", assetTags: ["导演台白膜", `镜头:${sourceNode.title}`], producedModel: undefined, producedModelCandidate: undefined },
|
||||
};
|
||||
if (videoIndex >= 0) nextNodes[videoIndex] = videoNode;
|
||||
else nextNodes.push(videoNode);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
|
||||
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
|
||||
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
|
||||
import {
|
||||
@@ -289,6 +290,7 @@ export function useCanvasGenerationRetry({
|
||||
...item.metadata,
|
||||
prompt: mediaPrompt,
|
||||
model: generationConfig.model,
|
||||
producedModelCandidate: producedModelCandidateForGeneration(generationConfig),
|
||||
size: generationConfig.size,
|
||||
seconds: generationConfig.videoSeconds,
|
||||
vquality: generationConfig.vquality,
|
||||
@@ -388,6 +390,7 @@ export function useCanvasGenerationRetry({
|
||||
? {
|
||||
generationType: savedImageMetadata.generationType,
|
||||
model: generationConfig.model,
|
||||
producedModelCandidate: producedModelCandidateForGeneration(generationConfig),
|
||||
size: generationConfig.size,
|
||||
quality: generationConfig.quality,
|
||||
transparentBackground: generationConfig.transparentBackground,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import { isValidGridSplit, layoutGridSplitCells } from "@/lib/canvas/canvas-grid-split";
|
||||
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitProducedModel } from "@/lib/canvas/produced-model";
|
||||
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { cancelIncompleteImageBatch } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
@@ -775,11 +776,11 @@ export function useCanvasMediaTools({
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === targetId);
|
||||
if (!currentNode) throw new Error("局部编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: commitProducedModel({ ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata }) };
|
||||
setNodes((current) => current.map((item) => {
|
||||
if (item.id === targetId) return finalizedNode;
|
||||
if (item.id !== rootId || requestedCount <= 1 || item.metadata?.primaryImageId) return item;
|
||||
return { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), primaryImageId: targetId, status: NODE_STATUS_SUCCESS } };
|
||||
return { ...item, width: size.width, height: size.height, metadata: commitProducedModel({ ...item.metadata, ...imageMetadata(uploaded), primaryImageId: targetId, status: NODE_STATUS_SUCCESS }) };
|
||||
}));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
hasSuccess = true;
|
||||
@@ -872,7 +873,7 @@ export function useCanvasMediaTools({
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("图片编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, ...generationMetadata } };
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: commitProducedModel({ ...currentNode.metadata, ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, ...generationMetadata }) };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
@@ -965,7 +966,7 @@ export function useCanvasMediaTools({
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("标注编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, ...generationMetadata } };
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: commitProducedModel({ ...currentNode.metadata, ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, ...generationMetadata }) };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
@@ -1034,7 +1035,7 @@ export function useCanvasMediaTools({
|
||||
position: { x: position.x + (index % 2) * (size.width + 48), y: position.y + Math.floor(index / 2) * (size.height + 48) },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: { ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, pluginId: "image-tools", pluginNodeId: "layer-decomposition", pluginData: { layerIndex: index + 1, sourceNodeId: node.id }, ...generationMetadata },
|
||||
metadata: commitProducedModel({ ...imageMetadata(uploaded), prompt, status: NODE_STATUS_SUCCESS, pluginId: "image-tools", pluginNodeId: "layer-decomposition", pluginData: { layerIndex: index + 1, sourceNodeId: node.id }, ...generationMetadata }),
|
||||
});
|
||||
}
|
||||
setNodes((current) => [...current.filter((item) => item.id !== taskNodeId), ...layerNodes]);
|
||||
@@ -1109,7 +1110,7 @@ export function useCanvasMediaTools({
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, imageSpec.width, imageSpec.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("视角生成节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: commitProducedModel({ ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata }) };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
@@ -1219,7 +1220,7 @@ export function useCanvasMediaTools({
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("表情编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit } };
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: commitProducedModel({ ...currentNode.metadata, ...imageMetadata(uploaded), prompt: providerPrompt, ...generationMetadata, emotionEdit }) };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
|
||||
@@ -194,6 +194,7 @@ export function useCanvasUpload({
|
||||
metadata: {
|
||||
...currentNode.metadata, ...metadata,
|
||||
fileUpload: undefined, fileUploadProgress: undefined, errorDetails: undefined,
|
||||
producedModel: undefined, producedModelCandidate: undefined,
|
||||
...(replaceId ? {
|
||||
assetId: undefined, taskId: undefined, freeResize: false,
|
||||
isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { applyCreativeAnswers, creativeScenarioPrompt, normalizeCreativeQuestion
|
||||
import { assertCreativeBriefSpecifications, creativeNodeId, creativeProposalOps, initialCreativeState, mergeCreativeBrief, normalizeCreativeProposal, readCreativeState, type CreativeAgentState, type CreativeMediaState } from "@/lib/creation/creative-agent-state";
|
||||
import { CREATIVE_AGENT_SYSTEM_PROMPT, CREATIVE_AGENT_TOOLS, parseCreativeToolArguments } from "@/lib/creation/creative-agent-tools";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { submittedProducedModel } from "@/lib/canvas/produced-model";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { logicalModelIDForConfig, modelDisplayName, resolveModelRequestConfig, selectableModelsByCapability, type AiConfig } from "@/stores/use-config-store";
|
||||
import { creationRuns, type CreationGuard, type CreationRun, type CreationRunDetail, type CreationStatus, type CreationSubmission } from "./api/creation-runs";
|
||||
@@ -460,7 +461,9 @@ export class CreativeAgentController {
|
||||
const adapter = this.options.canvas();
|
||||
const node = adapter?.read().nodes.find((node) => node.id === media.nodeId);
|
||||
if (!node) throw new Error("目标节点已删除,产物仍保留在任务中心和素材库");
|
||||
const metadata = { content: resourceFileUrl(resourceIdFromStorageKey(output.storageKey)!), storageKey: output.storageKey, status: "success" as const, naturalWidth: output.width, naturalHeight: output.height, bytes: output.bytes, mimeType: output.mimeType, ...(result.video ? { durationMs: result.video.durationMs } : {}), generationTaskId: task.id };
|
||||
const generationItem = this.state.proposal?.generationItems.find((item) => item.ref === media.ref);
|
||||
const producedModel = submittedProducedModel(task.model) || submittedProducedModel(generationItem?.model);
|
||||
const metadata = { content: resourceFileUrl(resourceIdFromStorageKey(output.storageKey)!), storageKey: output.storageKey, status: "success" as const, naturalWidth: output.width, naturalHeight: output.height, bytes: output.bytes, mimeType: output.mimeType, ...(result.video ? { durationMs: result.video.durationMs } : {}), generationTaskId: task.id, producedModel, producedModelCandidate: undefined };
|
||||
const asset = await this.getAsset()({ canvasId: this.run!.canvasId!, node: { ...node, metadata: { ...node.metadata, ...metadata } }, source: "canvas-generation", taskId: task.id, signal: this.abort.signal });
|
||||
this.guard();
|
||||
await this.commitOps([{ type: "update_node", id: media.nodeId, metadata: { ...metadata, assetId: asset.assetId } }]);
|
||||
|
||||
@@ -272,7 +272,12 @@ export type CanvasNodeMetadata = {
|
||||
fontSize?: number;
|
||||
generationMode?: CanvasGenerationMode;
|
||||
generationType?: CanvasImageGenerationType;
|
||||
/** 选用模型:下一次生成将使用的模型,随提示词面板下拉变化。 */
|
||||
model?: string;
|
||||
/** 产出模型:当前图片、视频或音频内容生成成功时冻结的模型身份。 */
|
||||
producedModel?: string;
|
||||
/** 本次生成已提交、尚未成功落盘的模型身份。失败或改下拉都不改产出模型。 */
|
||||
producedModelCandidate?: string;
|
||||
workflowProvider?: "model" | "runninghub";
|
||||
runningHubWorkflowId?: string;
|
||||
runningHubWorkflowKind?: "workflow" | "app";
|
||||
|
||||
162
web/test/produced-model.test.tsx
Normal file
162
web/test/produced-model.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { applyGeneratedMediaResultMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitProducedModel, producedModelCandidateForGeneration, producedModelLabel, submittedProducedModel } from "@/lib/canvas/produced-model";
|
||||
import { searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { effectiveConfigForCustomChannels, type AiConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const config = {
|
||||
channels: [
|
||||
{
|
||||
id: "system-1",
|
||||
name: "系统渠道",
|
||||
scope: "system",
|
||||
models: ["gpt-image-1"],
|
||||
modelCosts: [{ model: "gpt-image-1", displayName: "图像 Pro", logicalModelId: "logical-image" }],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
name: "我的渠道",
|
||||
scope: "user",
|
||||
models: ["flux-dev"],
|
||||
modelCosts: [{ model: "flux-dev" }],
|
||||
},
|
||||
],
|
||||
} as AiConfig;
|
||||
|
||||
function node(metadata: CanvasNodeData["metadata"]): CanvasNodeData {
|
||||
return {
|
||||
id: "node-1",
|
||||
type: CanvasNodeType.Video,
|
||||
title: "镜头",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe("produced model", () => {
|
||||
test("freezes the submitted model instead of the dropdown selection", () => {
|
||||
const next = applyGeneratedMediaResultMetadata(
|
||||
node({
|
||||
model: "user-1::changed-later",
|
||||
producedModel: "user-1::old",
|
||||
producedModelCandidate: "system-1::gpt-image-1",
|
||||
content: "https://example.test/old.mp4",
|
||||
}),
|
||||
videoMetadata({
|
||||
url: "https://example.test/new.mp4",
|
||||
storageKey: "video:new",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
bytes: 12,
|
||||
mimeType: "video/mp4",
|
||||
}),
|
||||
{},
|
||||
"logical-image",
|
||||
);
|
||||
|
||||
expect(next.producedModel).toBe("system-1::gpt-image-1");
|
||||
expect(next.producedModelCandidate).toBeUndefined();
|
||||
expect(next.model).toBe("user-1::changed-later");
|
||||
});
|
||||
|
||||
test("uses the task model when the node never recorded a candidate", () => {
|
||||
const next = commitProducedModel({ model: "user-1::draft" }, "logical-image");
|
||||
expect(next.producedModel).toBe("logical-image");
|
||||
expect(next.model).toBe("user-1::draft");
|
||||
});
|
||||
|
||||
test("workflow success records the submitted workflow name instead of the unused catalog model", () => {
|
||||
expect(producedModelCandidateForGeneration({ model: "system-1::gpt-image-1", taskWorkflowProvider: "runninghub" })).toBeUndefined();
|
||||
expect(submittedProducedModel("RunningHub · 分镜")).toBe("RunningHub · 分镜");
|
||||
const next = commitProducedModel({ producedModel: "system-1::gpt-image-1", producedModelCandidate: undefined }, "RunningHub · 分镜");
|
||||
expect(next.producedModel).toBe("RunningHub · 分镜");
|
||||
expect(producedModelLabel(config, next.producedModel)).toBe("RunningHub · 分镜");
|
||||
});
|
||||
|
||||
test("batch root projects the primary result model and clears it when no result remains", () => {
|
||||
const root = { ...node({ isBatchRoot: true, batchChildIds: ["child"], producedModel: "old" }), type: CanvasNodeType.Image };
|
||||
const child = { ...node({ batchRootId: root.id, status: "success", content: "https://example.test/image", producedModel: "new" }), id: "child", type: CanvasNodeType.Image };
|
||||
expect(reconcileImageBatchRoot(root, [root, child]).metadata?.producedModel).toBe("new");
|
||||
const failed = { ...child, metadata: { ...child.metadata, content: undefined, status: "error" as const } };
|
||||
expect(reconcileImageBatchRoot(root, [root, failed]).metadata?.producedModel).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves a display name and otherwise shows the model id", () => {
|
||||
const resolved = effectiveConfigForCustomChannels(config, true);
|
||||
expect(producedModelLabel(resolved, "system-1::gpt-image-1")).toBe("图像 Pro");
|
||||
expect(producedModelLabel(resolved, "user-1::flux-dev")).toBe("flux-dev");
|
||||
expect(producedModelLabel(resolved, "logical-image")).toBe("图像 Pro");
|
||||
expect(producedModelLabel(resolved, "deleted::gpt-image-1")).toBe("gpt-image-1");
|
||||
expect(producedModelLabel(resolved, "missing-model")).toBe("missing-model");
|
||||
expect(producedModelLabel(resolved, "system-1::gpt-image-1")).not.toBe("系统模型");
|
||||
});
|
||||
|
||||
test("search matches the produced model label and the selected model", () => {
|
||||
const image = node({ model: "wan-video", producedModel: "system-1::gpt-image-1" });
|
||||
image.type = CanvasNodeType.Image;
|
||||
expect(searchCanvasNodes([image], "图像 Pro", 80, config).map((item) => item.id)).toEqual(["node-1"]);
|
||||
expect(searchCanvasNodes([image], "gpt-image-1", 80, config).map((item) => item.id)).toEqual(["node-1"]);
|
||||
expect(searchCanvasNodes([image], "wan-video", 80, config).map((item) => item.id)).toEqual(["node-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("produced model badge", () => {
|
||||
const noop = () => {};
|
||||
function renderMedia(type: CanvasNodeType, showImageInfo: boolean, mediaActive = false, renderLOD: "full" | "compact" = "full") {
|
||||
return renderToStaticMarkup(
|
||||
<CanvasNode
|
||||
data={{
|
||||
id: "media",
|
||||
type,
|
||||
title: "结果",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: {
|
||||
content: "https://example.test/media",
|
||||
storageKey: "resource:1",
|
||||
producedModel: "user-1::flux-dev",
|
||||
naturalWidth: 1280,
|
||||
naturalHeight: 720,
|
||||
bytes: 2048,
|
||||
},
|
||||
}}
|
||||
scale={1}
|
||||
isSelected={false}
|
||||
mediaActive={mediaActive}
|
||||
isRelated={false}
|
||||
isFocusRelated={false}
|
||||
isConnectionTarget={false}
|
||||
showImageInfo={showImageInfo}
|
||||
renderLOD={renderLOD}
|
||||
onMouseDown={noop}
|
||||
onHoverStart={noop}
|
||||
onHoverEnd={noop}
|
||||
onConnectStart={noop}
|
||||
onResize={noop}
|
||||
onContentChange={noop}
|
||||
onContextMenu={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test("shows the model at the lower left only while media info is enabled", () => {
|
||||
expect(renderMedia(CanvasNodeType.Image, false)).not.toContain("flux-dev");
|
||||
const image = renderMedia(CanvasNodeType.Image, true);
|
||||
expect(image).toContain("flux-dev");
|
||||
expect(image).toContain("1280 x 720");
|
||||
const video = renderMedia(CanvasNodeType.Video, true, true);
|
||||
expect(video).toContain("bottom-16");
|
||||
expect(video).toContain("flux-dev");
|
||||
expect(video).not.toContain("1280 x 720");
|
||||
expect(renderMedia(CanvasNodeType.Audio, true)).toContain("flux-dev");
|
||||
expect(renderMedia(CanvasNodeType.Image, true, false, "compact")).not.toContain("flux-dev");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user