diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..044143398 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,17 @@ +# 影策 + +面向 AI 影视与短剧创作的工作台。画布上的媒体节点要能分清「这份内容是谁写成的」和「下一次准备用谁」。 + +## Language + +**产出模型**: +写出当前图片、视频或音频内容的模型。在该内容生成成功并写入节点时确定。 +_Avoid_: 出图模型, 选用模型, 模型 + +**选用模型**: +提示词面板中,下一次生成将使用的模型。 +_Avoid_: 产出模型, 模型 + +**媒体信息**: +画布外观中的开关。打开后,在有内容的图片、视频、音频节点上显示产出模型;图片同时显示尺寸和体积。 +_Avoid_: 图片信息 diff --git a/docs/adr/0009-produced-model-separate-from-selection.md b/docs/adr/0009-produced-model-separate-from-selection.md new file mode 100644 index 000000000..0a690405a --- /dev/null +++ b/docs/adr/0009-produced-model-separate-from-selection.md @@ -0,0 +1,11 @@ +# ADR-0009: 媒体产出模型与选用模型分开记录 + +状态:已接受。日期:2026-09-22。 + +画布节点上的模型字段同时承担两件事:提示词面板下一次要用的选用模型,以及这份图片、视频或音频实际由哪个模型写成。改下拉并不重新生成时,后一件事会被覆盖,过一段时间无法从节点上认出资产的产出模型。 + +决定:产出模型在生成成功并写入节点时冻结,选用模型继续随下拉变化。角标、节点详情和版本对比只展示产出模型;没有产出模型时留空。展示名按当前模型目录解析,目录中没有则显示模型 ID。本地加工、上传和本次改动之前的旧节点不回填。 + +## 考虑过的方案 + +继续复用同一个模型字段。改动更少,但选用模型一变,产出事实就丢了。否决。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 320504bfc..e678c8822 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -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` | diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index d101136d6..3c75f618c 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,10 @@ description: 当前项目已实现的核心工作台能力与使用入口 # 功能清单 +## 画布媒体产出模型 + +图片、视频、音频在生成成功并写入节点后,会冻结当时提交的产出模型。提示词面板里的模型下拉仍是下一次要用的选用模型,改下拉不会改掉已经生成的内容。画布外观中的「媒体信息」打开后,有内容的媒体节点在左下角显示产出模型;图片右下角继续显示尺寸和体积。上传、粘贴、截取、合并、提取音频和本地加工不记录产出模型。本次改动之前的节点不回填。分享页和版本预览画布不显示这块信息。 + ## 画布 Agent 名称、文案与形象 「站点及外观 → 画布配置」提供独立助手名称、入口文字、面板标题和欢迎/输入文案。支持导入授权的 Live2D Cubism 3/4 运行时 ZIP,预览后保存启用、调整高度、切回默认动态球。加载失败保留可点击的默认入口,上传和资源访问具有管理员校验和引用保护。Cubism Core 由部署者提供,真实播放待验收;不含图片自动建模或 AI 自我介绍同步。详见 [画布 Agent 外观与 Live2D](/docs/backend/canvas-agent-appearance)。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index fdbb3d140..b3895e3c3 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -15,6 +15,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线草稿、登录切换和错误重试行为。尚未做真实浏览器/CDN 验收。 - [ ] 使用 React Profiler 与长时间运行的大画布记录任务更新、节点数/可见数、主线程长任务和页面隐藏行为。当前只做结构性降热点,不推断整画布必然全量重渲染或已达到某个帧率。 +## 画布媒体产出模型 + +- 产出模型与选用模型分开保存。生成成功才冻结产出模型;生成中、失败和只改模型下拉不改已有产出模型。 +- [ ] 打开画布外观「媒体信息」后,新生成的图片、视频、音频左下角显示模型展示名或模型 ID。图片右下角仍显示尺寸和体积。关闭开关后这两处都不显示。视频标题旁的宽高保持可见;视频播放时角标不盖住控制条。 +- [ ] 生成成功后把下拉改成另一个模型且不重新生成,角标仍是原来的产出模型。重新生成成功后角标换成新模型。 +- [ ] 上传、粘贴、宫格切分、截取、合并、提取音频不显示产出模型。节点详情和版本对比在没有产出模型时留空,不显示「默认模型」。搜索能按产出模型名称和选用模型找到节点。 +- [ ] 分享页和版本预览画布不显示产出模型。旧画布节点在重新生成成功之前不出现角标。 +- [ ] 真实浏览器验收尚未执行。 + ## 原任务 ID 媒体保存恢复 - [x] 故障注入:下载 503 后服务实例重建继续原任务、OSS 上传失败保留暂存文件及资源 ID、素材登记异常使任务成功事务回滚,登记恢复无需再次下载。 diff --git a/docs/index.md b/docs/index.md index 8ac032613..17655d62c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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):生成成功时冻结产出模型,选用模型继续随下拉变化;角标按当前目录解析展示名。 ## 本地协作文档(不随仓库分发) diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 9e6806f3c..853a161d7 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -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 {label}; +} + function BatchPreviewImage({ node }: { node: CanvasNodeData }) { const ref = useRef(null); const nearViewport = useNearViewport(ref); diff --git a/web/src/components/canvas/canvas-node-search-modal.tsx b/web/src/components/canvas/canvas-node-search-modal.tsx index 280fddc46..0c4a011c8 100644 --- a/web/src/components/canvas/canvas-node-search-modal.tsx +++ b/web/src/components/canvas/canvas-node-search-modal.tsx @@ -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: 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 (
- + {producedModelText ? : null}
提示词
{node.metadata?.composerContent || node.metadata?.prompt || "未填写"}
@@ -37,5 +41,5 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar } function Info({ label, value }: { label: string; value: string }) { - return
{label}{value}
; + return
{label}{value}
; } diff --git a/web/src/components/canvas/canvas-workspace-node-list-panel.tsx b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx index 22bb96308..ddf455beb 100644 --- a/web/src/components/canvas/canvas-workspace-node-list-panel.tsx +++ b/web/src/components/canvas/canvas-workspace-node-list-panel.tsx @@ -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({
{results.length ? ( - results.map((node) => onFocus(node.id)} />) + results.map((node) => onFocus(node.id)} />) ) : ( )} @@ -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 (