diff --git a/CHANGELOG.md b/CHANGELOG.md index c1df0ba..ac6bef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ ## Unreleased ++ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。 + [新增] 配置与用户偏好支持通过配置文件导入和导出。 + [新增] 模型渠道新增火山方舟协议。 + [优化] 画布左侧元素列表新增图片放大预览操作。 + [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。 + [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。 ++ [修复] 修复画布重试时组装提示词重复以及切换类型调用模型不一致的问题。 ## v0.10.0 - 2026-07-25 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index dad36df..f0ddf46 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,9 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。 +- 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。 +- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。 - 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。 - 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。 - 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。 diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 319c77e..c06b372 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -3,7 +3,7 @@ import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings import { Button, Segmented } from "antd"; import { ModelPicker } from "@/components/model-picker"; -import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; @@ -143,17 +143,9 @@ function InputChip({ label, value, style }: { label: string; value: string; styl } function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig { - const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; - const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; - const currentModel = node.metadata?.model; - const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode) - ? currentModel - : defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode) - ? defaultModel - : fallbackModel; return { ...globalConfig, - model, + model: resolveModelForCapability(globalConfig, node.metadata?.model, mode), quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, size: node.metadata?.size || globalConfig.size || defaultConfig.size, background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background, diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 3bc55dc..5bc0d4b 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -3,7 +3,7 @@ import { ArrowUp, LoaderCircle, Square } from "lucide-react"; import { Button } from "antd"; import { ModelPicker } from "@/components/model-picker"; -import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; @@ -133,17 +133,9 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode { } function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig { - const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel; - const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; - const currentModel = node.metadata?.model; - const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode) - ? currentModel - : defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode) - ? defaultModel - : fallbackModel; return { ...globalConfig, - model, + model: resolveModelForCapability(globalConfig, node.metadata?.model, mode), quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality, size: node.metadata?.size || globalConfig.size || defaultConfig.size, background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background, diff --git a/web/src/lib/canvas/canvas-generation-helpers.ts b/web/src/lib/canvas/canvas-generation-helpers.ts index 462b4bb..26b86b7 100644 --- a/web/src/lib/canvas/canvas-generation-helpers.ts +++ b/web/src/lib/canvas/canvas-generation-helpers.ts @@ -1,4 +1,4 @@ -import { defaultConfig, type AiConfig } from "@/stores/use-config-store"; +import { defaultConfig, resolveModelForCapability, type AiConfig } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveMediaUrl } from "@/services/file-storage"; import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory"; @@ -90,10 +90,9 @@ export function getInputSummary(inputs: NodeGenerationInput[]) { } export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig { - const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel; return { ...config, - model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model), + model: resolveModelForCapability(config, node?.metadata?.model, mode), quality: node?.metadata?.quality || config.quality || defaultConfig.quality, size: node?.metadata?.size || config.size || defaultConfig.size, background: node?.metadata?.background ?? config.background ?? defaultConfig.background, diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 524741f..63baf05 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2087,14 +2087,13 @@ function InfiniteCanvasPage() { return; } const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; - const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; if (!effectivePrompt && (mode === "text" || mode === "audio")) { finishGenerationRequest(nodeId, runController); setRunningNodeId(null); return; } let pendingChildIds: string[] = []; - if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); + if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...(node.type === CanvasNodeType.Config ? {} : { prompt }), status: NODE_STATUS_LOADING, errorDetails: undefined } } : node))); try { if (mode === "image") { @@ -2158,7 +2157,7 @@ function InfiniteCanvasPage() { ? isConfigNode ? { ...node, - metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined }, + metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined }, } : isEmptyImageNode ? { @@ -2386,7 +2385,7 @@ function InfiniteCanvasPage() { height: textConfig.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 }, })); - setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); + setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]); setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]); } diff --git a/web/src/pages/image/index.tsx b/web/src/pages/image/index.tsx index 7d3b642..cf9a570 100644 --- a/web/src/pages/image/index.tsx +++ b/web/src/pages/image/index.tsx @@ -69,6 +69,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName export default function ImagePage() { const { message } = App.useApp(); const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); @@ -89,6 +90,7 @@ export default function ImagePage() { const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [isReferenceDragActive, setIsReferenceDragActive] = useState(false); const [autoRunToken, setAutoRunToken] = useState(0); const imageCommand = useWorkbenchAgentStore((state) => state.imageCommand); const clearImageCommand = useWorkbenchAgentStore((state) => state.clearImageCommand); @@ -427,7 +429,27 @@ export default function ImagePage() {
{ + event.preventDefault(); + dragDepthRef.current += 1; + if (event.dataTransfer.types.includes("Files")) setIsReferenceDragActive(true); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(event) => { + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (!dragDepthRef.current) setIsReferenceDragActive(false); + }} + onDrop={(event) => { + event.preventDefault(); + dragDepthRef.current = 0; + setIsReferenceDragActive(false); + void addReferences(event.dataTransfer.files); + }} onWheel={(event) => { if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth) return; event.preventDefault(); @@ -449,7 +471,7 @@ export default function ImagePage() {
))} - {!references.length ?
暂无参考图
: null} + {!references.length ?
{isReferenceDragActive ? "松开即可添加参考图" : "暂无参考图,可将图片拖到这里"}
: null} diff --git a/web/src/pages/video/index.tsx b/web/src/pages/video/index.tsx index 7dad358..e69826c 100644 --- a/web/src/pages/video/index.tsx +++ b/web/src/pages/video/index.tsx @@ -1,5 +1,5 @@ import { ArrowLeft, ArrowRight, BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, LoaderCircle, Music2, Plus, SlidersHorizontal, Sparkles, Trash2, Upload, VideoIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type DragEvent } from "react"; import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd"; import localforage from "localforage"; import { nanoid } from "nanoid"; @@ -71,6 +71,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName export default function VideoPage() { const { message } = App.useApp(); const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); const activeLogIdsRef = useRef>(new Set()); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); @@ -94,6 +95,7 @@ export default function VideoPage() { const [selectedLogIds, setSelectedLogIds] = useState([]); const [previewLog, setPreviewLog] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [referenceDragTarget, setReferenceDragTarget] = useState<"image" | "video" | "audio" | null>(null); const [autoRunToken, setAutoRunToken] = useState(0); const videoCommand = useWorkbenchAgentStore((state) => state.videoCommand); const clearVideoCommand = useWorkbenchAgentStore((state) => state.clearVideoCommand); @@ -151,6 +153,25 @@ export default function VideoPage() { setAudioReferences((value) => [...value, ...nextAudioReferences].slice(0, SEEDANCE_REFERENCE_LIMITS.audios)); }; + const handleReferenceDragEnter = (event: DragEvent, target: "image" | "video" | "audio") => { + event.preventDefault(); + dragDepthRef.current += 1; + if (event.dataTransfer.types.includes("Files")) setReferenceDragTarget(target); + }; + + const handleReferenceDragLeave = (event: DragEvent) => { + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (!dragDepthRef.current) setReferenceDragTarget(null); + }; + + const handleReferenceDrop = (event: DragEvent) => { + event.preventDefault(); + dragDepthRef.current = 0; + setReferenceDragTarget(null); + void addReferences(event.dataTransfer.files); + }; + const addReferencesFromClipboard = async () => { try { const items = await navigator.clipboard.read(); @@ -431,7 +452,16 @@ export default function VideoPage() { -
+
handleReferenceDragEnter(event, "image")} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={handleReferenceDragLeave} + onDrop={handleReferenceDrop} + > {references.map((item, index) => (
{item.name} @@ -442,7 +472,7 @@ export default function VideoPage() {
))} - {!references.length ?
暂无参考图,最多 9 张
: null} + {!references.length ?
{referenceDragTarget === "image" ? "松开即可上传参考资产" : "暂无参考图,可拖入文件,最多 9 张"}
: null}
@@ -453,7 +483,16 @@ export default function VideoPage() { 上传 -
+
handleReferenceDragEnter(event, "video")} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={handleReferenceDragLeave} + onDrop={handleReferenceDrop} + > {videoReferences.map((item, index) => (
))} - {!videoReferences.length ?
暂无参考视频,最多 3 个
: null} + {!videoReferences.length ?
{referenceDragTarget === "video" ? "松开即可上传参考资产" : "暂无参考视频,可拖入文件,最多 3 个"}
: null}
@@ -475,7 +514,16 @@ export default function VideoPage() { 上传 -
+
handleReferenceDragEnter(event, "audio")} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={handleReferenceDragLeave} + onDrop={handleReferenceDrop} + > {audioReferences.map((item, index) => (
@@ -490,7 +538,7 @@ export default function VideoPage() {
))} - {!audioReferences.length ?
暂无参考音频,最多 3 个,mp3/wav,单个 15MB 内
: null} + {!audioReferences.length ?
{referenceDragTarget === "audio" ? "松开即可上传参考资产" : "暂无参考音频,可拖入文件,最多 3 个,mp3/wav,单个 15MB 内"}
: null}
diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index fc30cea..f4ea685 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -158,6 +158,14 @@ export function modelMatchesCapability(config: AiConfig, value: string, capabili return modelCapabilityOf(config, value) === capability; } +export function resolveModelForCapability(config: AiConfig, currentModel: string | undefined, capability: ModelCapability) { + const defaultModel = capability === "image" ? config.imageModel : capability === "video" ? config.videoModel : capability === "audio" ? config.audioModel : config.textModel; + const fallbackModel = capability === "image" ? defaultConfig.imageModel : capability === "video" ? defaultConfig.videoModel : capability === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; + if (currentModel && modelMatchesCapability(config, currentModel, capability)) return currentModel; + if (defaultModel && modelMatchesCapability(config, defaultModel, capability)) return defaultModel; + return fallbackModel; +} + export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) { if (!capability) return config.models; return config.channels.flatMap((channel) => channel.models.filter((model) => model.capability === capability).map((model) => encodeChannelModel(channel.id, model.name)));