diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 59a932c4d..dceed2e82 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -495,16 +495,6 @@ type ImageToolbarAction = | "preview"; type StoryToolbarAction = "regenerate" | "create-storyboard" | "batch-video"; -type PresetMediaToolbarAction = - | "first-frame-hd" - | "first-frame-outpaint" - | "first-frame-background" - | "first-frame-download" - | "audio-beat" - | "audio-camera" - | "audio-sfx" - | "audio-download"; - type ComposerSubmitPayload = { prompt: string; references: string[]; @@ -1028,17 +1018,6 @@ const VIDEO_EFFECT_PRESETS = [ { title: "饰品特写", author: "算法织梦者", count: 74 }, ] as const; -const PRESET_MEDIA_TOOLBAR_ITEMS: Record = { - "first-frame-hd": ["高清", "首帧高清接口待接入。"], - "first-frame-outpaint": ["扩图", "首帧扩图接口待接入。"], - "first-frame-background": ["替换背景", "背景替换接口待接入。"], - "first-frame-download": ["下载", "图片下载接口待接入。"], - "audio-beat": ["节奏匹配", "音频节奏解析接口待接入。"], - "audio-camera": ["镜头语言", "镜头语言配置接口待接入。"], - "audio-sfx": ["生成音效", "音效生成接口待接入。"], - "audio-download": ["下载", "素材下载接口待接入。"], -}; - const TOOLBAR_ITEMS: Array<{ id: Exclude; label: string; icon: LucideIcon }> = [ { id: "add", label: "添加节点", icon: Plus }, { id: "workflow", label: "工具箱", icon: GitBranch }, @@ -1145,16 +1124,6 @@ const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [ const TARGET_AVATAR = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/target-avatar.jpg"; -const CHARACTER_IMAGE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png"; -const CHARACTER_THREE_VIEW_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png"; -const FIRST_FRAME_IMAGE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/first-frame-image.png"; -const AUDIO_SOURCE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-source.mp3"; -const AUDIO_REFERENCE_IMAGE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/audio-reference-image.png"; const PUBLISH_MODAL_BG_URL = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/publish-modal-bg.png"; const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本"; @@ -1166,23 +1135,9 @@ const TEXT_TO_VIDEO_GENERATE_PROMPT = "根据文字描述生成视频。"; const TEXT_TO_MUSIC_PROMPT = "生成一首现代品牌电子音乐,干净有力的低频贝斯,清晰电子鼓点,整体风格高级、未来感强。"; const IMAGE_TO_PROMPT_PROMPT = "根据图片生成结构化中文提示词,包括主体描述、环境、光影、镜头语言、风格关键词。"; -const HD_IMAGE_SOURCE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/hd-image-source.png"; -const VIDEO_FIRST_FRAME_SOURCE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-frame-source.png"; const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。"; -const VIDEO_FIRST_LAST_FIRST_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-last-first.png"; -const VIDEO_FIRST_LAST_LAST_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/video-first-last-last.png"; const VIDEO_FIRST_LAST_PROMPT = "手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然"; -const SCRIPT_VIDEO_REFERENCE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-video-reference.mp4"; -const SCRIPT_CHARACTER_ONE_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-character-one.png"; -const SCRIPT_CHARACTER_TWO_URL = - "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/script-character-two.png"; const SCRIPT_CHARACTER_PROMPT = "根据角色参考图生成分镜脚本,围绕旧城区、机械修复、暴雨停电和被重新点亮的街道展开,突出人物关系、镜头画面和声音设计。"; const SLASH_IMAGE_ITEMS = [ @@ -1829,7 +1784,7 @@ function createMaterialStyleNode({ kind: "material-style", title: `素材-风格-${title}`, subtitle: "风格素材", - inputType: "image", + inputType: undefined, x, y, width: MATERIAL_STYLE_NODE_SIZE, @@ -1925,70 +1880,6 @@ function createMaterialRoleNode({ }; } -function createHistoryDemoAssetNode({ - id, - mediaType, - mediaUrl, - title, -}: { - id: string; - mediaType: NonNullable; - mediaUrl: string; - title: string; -}): CloneNode { - const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片"; - return { - id, - kind: "media", - title, - subtitle: `${label}资源`, - inputType: mediaType, - x: 0, - y: 0, - width: mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420, - height: mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280, - accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff", - draftValues: { - action: `${mediaType.toUpperCase()}_RESOURCE`, - alt: title, - generated: true, - historyDemo: true, - mediaType, - mediaUrl, - nodeType: mediaType, - url: [mediaUrl], - }, - }; -} - -const DEFAULT_IMAGE_HISTORY_NODES: CloneNode[] = [ - ["history-demo-image-1", "角色图", CHARACTER_IMAGE_URL], - ["history-demo-image-2", "角色三视图", CHARACTER_THREE_VIEW_URL], - ["history-demo-image-3", "首帧", FIRST_FRAME_IMAGE_URL], - ["history-demo-image-4", "音频参考图", AUDIO_REFERENCE_IMAGE_URL], - ["history-demo-image-5", "高清图片", HD_IMAGE_SOURCE_URL], - ["history-demo-image-6", "视频首帧", VIDEO_FIRST_FRAME_SOURCE_URL], - ["history-demo-image-7", "首尾帧-首帧", VIDEO_FIRST_LAST_FIRST_URL], - ["history-demo-image-8", "首尾帧-尾帧", VIDEO_FIRST_LAST_LAST_URL], -].map(([id, title, mediaUrl]) => createHistoryDemoAssetNode({ id, mediaType: "image", mediaUrl, title })); - -const DEFAULT_VIDEO_HISTORY_NODES: CloneNode[] = Array.from({ length: 10 }, (_, index) => - createHistoryDemoAssetNode({ - id: `history-demo-video-${index + 1}`, - mediaType: "video", - mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, - title: `分镜视频-#${index + 1}`, - }) -); - -const DEFAULT_AUDIO_HISTORY_NODES: CloneNode[] = Array.from({ length: 6 }, (_, index) => - createHistoryDemoAssetNode({ - id: `history-demo-audio-${index + 1}`, - mediaType: "audio", - mediaUrl: AUDIO_SOURCE_URL, - title: `示例音频-#${index + 1}`, - }) -); const HISTORY_ASSET_ZOOM_LEVELS = [ { cardSize: 130, label: "75%" }, @@ -2180,7 +2071,7 @@ function readyCanvasInputs(nodes: CloneNode[], edges: CloneEdge[], target: strin const targetNode = nodes.find(node => node.id === target); if (targetNode) for (const source of incoming) { const error = connectionTypeError(source, targetNode); - if (error) throw Error(error); + if (error) throw new Error(error); } assertConnectedInputsReady(incoming); return incoming; @@ -2417,7 +2308,7 @@ function createVideoCropResultNode({ existingIndex: number; sourceNode: CloneNode; }): CloneNode { - const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL; + const sourceUrl = getNodeReferenceMediaUrl(sourceNode); const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const width = sourceNode.width || 560; const height = sourceNode.height || 315; @@ -2464,7 +2355,7 @@ function createVideoSubtitleEraseNode({ regions?: SubtitleEraseRegion[]; sourceNode: CloneNode; }): CloneNode { - const sourceUrl = getNodeReferenceMediaUrl(sourceNode) || SCRIPT_VIDEO_REFERENCE_URL; + const sourceUrl = getNodeReferenceMediaUrl(sourceNode); const previewUrl = getVideoNodePreviewUrl(sourceNode) || sourceUrl; const outputSizeLabel = getDraftString(sourceNode, "outputSizeLabel") || "1280 × 720"; const normalizedRegions = regions.map(({ height, width, x, y }) => ({ @@ -2746,7 +2637,7 @@ function StoryScriptWorkflowDialog({ setBusy("prompts"); setError(""); try { - if (!onSubmitTask) throw Error("任务服务尚未就绪"); + if (!onSubmitTask) throw new Error("任务服务尚未就绪"); await onSubmitTask("prompts", { assets, rows: source, style }); } catch (error) { setError(error instanceof Error ? error.message : "提示词提交失败"); @@ -2759,7 +2650,7 @@ function StoryScriptWorkflowDialog({ setBusy("asset"); setError(""); try { - if (!onSubmitTask) throw Error("任务服务尚未就绪"); + if (!onSubmitTask) throw new Error("任务服务尚未就绪"); await onSubmitTask("asset", { assets, rows, style, assetIndex: index }); } catch (error) { setError(error instanceof Error ? error.message : "资产提交失败"); @@ -3096,7 +2987,7 @@ function StoryScriptWorkflowDialog({ setError(""); try { const result = await imageGenerationService.uploadImage(file); - if (!result.success || !result.data?.url) throw Error(result.error || "上传失败"); + if (!result.success || !result.data?.url) throw new Error(result.error || "上传失败"); updateAsset(uploadIndex.current, { imageUrl: result.data.url }); } catch (e) { setError(e instanceof Error ? e.message : "上传失败"); @@ -3338,7 +3229,7 @@ function getToolReferenceModuleTemplates( function getToolReferenceMediaSpec( template: { kind: AddModuleKind; title?: string }, definition: VideoCanvasFunctionDefinition, - index: number, + _index: number, ): { action: string; height: number; @@ -3347,13 +3238,13 @@ function getToolReferenceMediaSpec( title: string; width: number; } { - const title = `${template.title || "素材参考"}(示例)`; + const title = template.title || "素材参考"; if (template.kind === "audio") { return { action: "AUDIO_RESOURCE", height: 190, mediaType: "audio", - mediaUrl: AUDIO_SOURCE_URL, + mediaUrl: "", title, width: 720, }; @@ -3363,26 +3254,17 @@ function getToolReferenceMediaSpec( action: "VIDEO_RESOURCE", height: 315, mediaType: "video", - mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, + mediaUrl: "", title, width: 560, }; } - let mediaUrl = CHARACTER_IMAGE_URL; - if (definition.key === "first-frame-video") mediaUrl = VIDEO_FIRST_FRAME_SOURCE_URL; - if (definition.key === "first-last-frame-video") { - mediaUrl = index === 0 ? VIDEO_FIRST_LAST_FIRST_URL : VIDEO_FIRST_LAST_LAST_URL; - } - if (definition.key === "audio-to-video") mediaUrl = AUDIO_REFERENCE_IMAGE_URL; - if (definition.key === "image-to-prompt") mediaUrl = FIRST_FRAME_IMAGE_URL; - if (definition.key === "hd-upscale") mediaUrl = HD_IMAGE_SOURCE_URL; - return { action: "IMAGE_RESOURCE", height: definition.key === "audio-to-video" ? 400 : 280, mediaType: "image", - mediaUrl, + mediaUrl: "", title, width: definition.key === "audio-to-video" ? 720 : 420, }; @@ -3512,7 +3394,7 @@ function getVideoFunctionKeyForMode(mode: VideoGenerationMode) { function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode) { const error = videoInputLimitError(mode, images.length); - if (error) throw Error(error); + if (error) throw new Error(error); if (mode === "text-to-video") return []; if (mode === "first-frame") return images.slice(0, 1); if (mode === "first-last-frame") return images.slice(0, 2); @@ -4470,19 +4352,16 @@ function createStarterWorkflow( action: "IMAGE_RESOURCE", height: 520, mediaType: "image", - mediaUrl: CHARACTER_IMAGE_URL, preset, - title: "角色图", + title: "上传角色图", width: 874, x: Math.round(center.x - 874 - distance / 2), y: Math.round(center.y - 320), }); const rightBase = createPresetMediaNode({ action: "IMAGE_GENERATE", - generated: true, height: 520, mediaType: "image", - mediaUrl: CHARACTER_THREE_VIEW_URL, preset, prompt, title: "角色三视图生成", @@ -4512,9 +4391,8 @@ function createStarterWorkflow( action: "IMAGE_RESOURCE", height: 430, mediaType: "image", - mediaUrl: FIRST_FRAME_IMAGE_URL, preset, - title: "首帧", + title: "上传首帧", width: 620, x: Math.round(center.x - 675), y: Math.round(center.y - 310), @@ -4541,9 +4419,8 @@ function createStarterWorkflow( action: "AUDIO_RESOURCE", height: 190, mediaType: "audio", - mediaUrl: AUDIO_SOURCE_URL, preset, - title: "音频", + title: "上传音频", width: 720, x: leftX, y: leftY, @@ -4552,9 +4429,8 @@ function createStarterWorkflow( action: "IMAGE_RESOURCE", height: 400, mediaType: "image", - mediaUrl: AUDIO_REFERENCE_IMAGE_URL, preset, - title: "图片", + title: "上传画面参考", width: 720, x: leftX, y: leftY + 300, @@ -4649,6 +4525,8 @@ export function LiblibVideoCanvasReplica({ useState(null); const [effectLibraryTargetNodeId, setEffectLibraryTargetNodeId] = useState(null); + const [styleLibraryTargetNodeId, setStyleLibraryTargetNodeId] = + useState(null); const [roleLibraryTargetNodeId, setRoleLibraryTargetNodeId] = useState(null); const [videoRoleAgreementOpen, setVideoRoleAgreementOpen] = useState(false); @@ -4973,7 +4851,7 @@ export function LiblibVideoCanvasReplica({ action: "IMAGE_RESOURCE", height: 525, mediaType: "image", - mediaUrl: workflow.cover || FIRST_FRAME_IMAGE_URL, + mediaUrl: workflow.cover || "", title: "参考图", width: 350, x: TOOLBOX_TEMPLATE_GROUP_FRAME.x + 40, @@ -4993,7 +4871,7 @@ export function LiblibVideoCanvasReplica({ action: "VIDEO_GENERATE", height: 525, mediaType: "video", - mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, + mediaUrl: "", prompt: workflow.description || `${title} 预设工作流`, title: "成品", width: 350, @@ -5005,7 +4883,7 @@ export function LiblibVideoCanvasReplica({ subtitle: "工具箱工作流", draftValues: { ...(result.draftValues || {}), - generated: true, + generated: false, params: { prompt: workflow.description || `${title} 预设工作流`, presetId: workflow.uuid || workflow.id || "", @@ -5470,11 +5348,20 @@ export function LiblibVideoCanvasReplica({ } const form = new FormData(); form.append("file", file); - const response = await fetch("/api/canvas/media/upload", { - method: "POST", - body: form, - signal: AbortSignal.timeout(180_000), - }); + let response: Response; + try { + response = await fetch("/api/canvas/media/upload", { + method: "POST", + body: form, + signal: AbortSignal.timeout(600_000), + }); + } catch (error) { + throw new Error( + error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError") + ? "上传超时,请检查网络后重试,或改用更小的素材" + : "上传失败,请检查网络后重试", + ); + } const result = await response.json(); if (!response.ok || !result.success || !result.data?.url) throw new Error(result.error || "素材上传失败"); @@ -5592,14 +5479,20 @@ export function LiblibVideoCanvasReplica({ x: anchor.x + sameKindCount * 28, y: anchor.y + sameKindCount * 28, }); - addNodes([node], []); - setSelectedNodeId(null); + addNodes( + [node], + styleLibraryTargetNodeId + ? [{ id: makeId("edge"), source: node.id, target: styleLibraryTargetNodeId }] + : [], + ); + setSelectedNodeId(styleLibraryTargetNodeId); setSelectedEdgeId(null); setActivePanel(null); + setStyleLibraryTargetNodeId(null); setCanvasCommand(null); setConnectionMenu(null); }, - [addNodes, nodes, toCanvasPoint] + [addNodes, nodes, styleLibraryTargetNodeId, toCanvasPoint] ); const applyVideoEffectFromMarket = useCallback( @@ -6086,19 +5979,16 @@ export function LiblibVideoCanvasReplica({ break; } case "图片反推提示词": { - const image = createMediaResourceNode({ - mediaType: "image", - mediaUrl: FIRST_FRAME_IMAGE_URL, - title: "图片", - width: 420, - height: 280, - x: leftOfAnchor(420), - y: centerY(280), - }); + const image = createInputNode("image", leftOfAnchor(158), centerY(158)); + image.title = "上传反推图片"; draftUpdates = { action: "TEXT_GENERATE", content: [], - params: { ...(anchor.draftValues?.params as Record | undefined), prompt: IMAGE_TO_PROMPT_PROMPT }, + params: { + ...(anchor.draftValues?.params as Record | undefined), + functionKey: "image-to-prompt", + prompt: IMAGE_TO_PROMPT_PROMPT, + }, }; connectToAnchor(image); break; @@ -6109,52 +5999,89 @@ export function LiblibVideoCanvasReplica({ break; } case "图生图": { - anchorUpdates = { - kind: "media", - title: "图片", - subtitle: "图片资源", - width: 420, - height: 280, - inputType: "image", - }; + const imageInput = createInputNode( + "image", + leftOfAnchor(158), + centerY(158), + ); + imageInput.title = "图片参考"; draftUpdates = { - action: "IMAGE_RESOURCE", + action: "IMAGE_GENERATE", generated: false, - mediaType: "image", - mediaUrl: CHARACTER_IMAGE_URL, + generatorType: "DEFAULT", nodeType: "image", - url: [CHARACTER_IMAGE_URL], + params: { + ...getNodeParams(anchor), + functionKey: "image-repaint", + }, }; - connectFromAnchor(makeGenerated("image", "图片生成", rightX)); + connectToAnchor(imageInput); + selectedId = imageInput.id; break; } case "图片高清": { anchorUpdates = { - kind: "media", - title: "图片", - subtitle: "图片资源", - width: 420, - height: 280, - inputType: "image", + title: "图片高清", }; + const imageInput = createInputNode( + "image", + leftOfAnchor(158), + centerY(158), + ); + imageInput.title = "高清输入"; draftUpdates = { - action: "IMAGE_RESOURCE", + action: "IMAGE_GENERATE", generated: false, - mediaType: "image", - mediaUrl: HD_IMAGE_SOURCE_URL, + generatorType: "ENHANCE", nodeType: "image", - url: [HD_IMAGE_SOURCE_URL], + params: { + ...getNodeParams(anchor), + functionKey: "hd-upscale", + }, }; - connectFromAnchor(makeGenerated("image", "高清", rightX, { generatorType: "ENHANCE" })); + connectToAnchor(imageInput); + selectedId = imageInput.id; break; } - case "参考": - case "标记": - case "风格": { + case "参考": { + setMarkSelectionTargetId(null); + setReferenceSelectionTargetId((current) => + current === nodeId ? null : nodeId, + ); setSelectedNodeId(nodeId); + setSelectedEdgeId(null); setNodeContextMenu(null); setConnectionMenu(null); setCanvasCommand(null); + setActivePanel(null); + setLineConnectState(null); + return; + } + case "标记": { + setReferenceSelectionTargetId(null); + setMarkSelectionTargetId((current) => + current === nodeId ? null : nodeId, + ); + setSelectedNodeId(nodeId); + setSelectedEdgeId(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setCanvasCommand(null); + setActivePanel(null); + setLineConnectState(null); + return; + } + case "风格": { + setReferenceSelectionTargetId(null); + setMarkSelectionTargetId(null); + setStyleLibraryTargetNodeId(nodeId); + setSelectedNodeId(nodeId); + setSelectedEdgeId(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setCanvasCommand(null); + setActivePanel("styleLibrary"); + setLineConnectState(null); return; } case "自己编写分镜脚本": { @@ -6183,37 +6110,33 @@ export function LiblibVideoCanvasReplica({ return; } case "音频生视频": { - anchorUpdates = { - kind: "media", - title: "音频", - subtitle: "音频资源", - width: 720, - height: 190, - inputType: "audio", - }; draftUpdates = { - action: "AUDIO_RESOURCE", - generated: false, - mediaType: "audio", - mediaUrl: AUDIO_SOURCE_URL, + action: "AUDIO_GENERATE", nodeType: "audio", - url: [AUDIO_SOURCE_URL], }; + const videoBase = createPresetMediaNode({ + action: "VIDEO_GENERATE", + height: 430, + mediaType: "video", + preset: "audio-to-video", + prompt: AUDIO_TO_VIDEO_PROMPT, + title: "音频生视频", + width: 760, + x: rightX, + y: Math.round(anchor.y + 70), + }); const video = { - ...makeGenerated("video", "视频", Math.round(anchor.x + 720 + OFFICIAL_NODE_GAP), { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }), + ...videoBase, width: 760, height: 430, y: Math.round(anchor.y + 70), }; - const image = createMediaResourceNode({ - mediaType: "image", - mediaUrl: AUDIO_REFERENCE_IMAGE_URL, - title: "图片", - width: 720, - height: 400, - x: anchor.x, - y: Math.round(anchor.y + 300), - }); + const image = createInputNode( + "image", + leftOfAnchor(158), + Math.round(anchor.y + anchor.height + 40), + ); + image.title = "上传画面参考"; nextNodes.push(video, image); nextEdges.push( { id: makeId("edge"), source: anchor.id, target: video.id }, @@ -6252,42 +6175,18 @@ export function LiblibVideoCanvasReplica({ } case "视频参考生成分镜脚本": { draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: "解析视频内容生成分镜脚本。" } }; - connectToAnchor( - createMediaResourceNode({ - action: "VIDEO_RESOURCE", - mediaType: "video", - mediaUrl: SCRIPT_VIDEO_REFERENCE_URL, - prompt: "解析视频内容生成分镜脚本。", - title: "视频", - width: 560, - height: 315, - x: leftOfAnchor(560), - y: centerY(315), - }) - ); + const videoInput = createInputNode("video", leftOfAnchor(158), centerY(158)); + videoInput.title = "上传参考视频"; + connectToAnchor(videoInput); selectedId = anchor.id; break; } case "角色生成分镜脚本": { draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: SCRIPT_CHARACTER_PROMPT } }; - const first = createMediaResourceNode({ - mediaType: "image", - mediaUrl: SCRIPT_CHARACTER_ONE_URL, - title: "角色 1", - width: 300, - height: 300, - x: leftOfAnchor(300), - y: Math.round(anchor.y + 10), - }); - const second = createMediaResourceNode({ - mediaType: "image", - mediaUrl: SCRIPT_CHARACTER_TWO_URL, - title: "角色 2", - width: 300, - height: 300, - x: leftOfAnchor(300), - y: Math.round(anchor.y + 330), - }); + const first = createInputNode("image", leftOfAnchor(158), Math.round(anchor.y + 10)); + first.title = "上传角色 1"; + const second = createInputNode("image", leftOfAnchor(158), Math.round(anchor.y + 190)); + second.title = "上传角色 2"; connectToAnchor(first); connectToAnchor(second); selectedId = anchor.id; @@ -6322,6 +6221,8 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); if ( actionLabel !== "自己编写内容" && + actionLabel !== "图生图" && + actionLabel !== "图片高清" && actionLabel !== "首帧生成视频" && actionLabel !== "首尾帧生成视频" ) { @@ -6335,7 +6236,7 @@ export function LiblibVideoCanvasReplica({ async (nodeId: string, action: ImageToolbarAction, option?: string) => { const anchor = nodes.find((node) => node.id === nodeId); if (!anchor) return; - const anchorSourceUrl = getNodeReferenceMediaUrl(anchor) || CHARACTER_IMAGE_URL; + const anchorSourceUrl = getNodeReferenceMediaUrl(anchor); if (action === "download") { if (!anchorSourceUrl) { @@ -6359,22 +6260,17 @@ export function LiblibVideoCanvasReplica({ } if (action === "annotate") { - setNodes((current) => - current.map((node) => - node.id === anchor.id - ? { - ...node, - draftValues: { - ...(node.draftValues || {}), - annotationMode: true, - annotations: node.draftValues?.annotations || [{ label: "主体", x: 42, y: 46 }], - }, - } - : node, - ), + setReferenceSelectionTargetId(null); + setMarkSelectionTargetId((current) => + current === anchor.id ? null : anchor.id, ); setSelectedNodeId(anchor.id); - toast.success("已进入标注模式"); + setSelectedEdgeId(null); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + setLineConnectState(null); return; } @@ -6499,6 +6395,10 @@ export function LiblibVideoCanvasReplica({ const resolvedReferenceImages = await resolveNodeReferenceImages(anchor); const sourceUrl = resolvedReferenceImages[0] || anchorSourceUrl; + if (!sourceUrl) { + toast.error("请先上传图片或连接上游图片节点,再使用该功能"); + return; + } const extraReferenceImages = resolvedReferenceImages.slice(1); const labels: Record< @@ -6693,9 +6593,11 @@ export function LiblibVideoCanvasReplica({ action === "lighting" ? "lighting" : action === "panorama" - ? "panorama-720" - : /扩/.test(option || "") + ? "panorama-720" + : /扩/.test(option || "") ? "image-outpaint" + : /背景/.test(option || "") + ? "image-repaint" : /重绘/.test(option || "") ? "image-repaint" : /擦/.test(option || "") @@ -6794,8 +6696,6 @@ export function LiblibVideoCanvasReplica({ ); return; } - - { setNodes((current) => current.map((node) => node.id === pendingNode.id && getDraftString(node, "generationStatus") === "pending" @@ -6810,7 +6710,6 @@ export function LiblibVideoCanvasReplica({ : node, ), ); - } return; } @@ -7515,7 +7414,7 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null); toast.success("已提交图片生成"); - if (!videoCanvasFunctionConfig.enabled || !definition.enabled) throw Error("该图片功能尚未启用"); + if (!videoCanvasFunctionConfig.enabled || !definition.enabled) throw new Error("该图片功能尚未启用"); await generationTasks.submit(resultNode,"image",{ ...canvasImageRequest({...payload.settings,model:getFunctionModel(videoCanvasFunctionConfig,definition,payload.settings.model)},[sourceUrl,...extraReferenceImages],isWideGeneration), allowFailover:false,billingFeature:getImageGenerationBillingFeature(functionKey),prompt:finalPrompt,promptExtend:!isWideGeneration, @@ -7614,7 +7513,7 @@ export function LiblibVideoCanvasReplica({ new Set(magicMarks.map((mark) => mark.imageUrl).filter(Boolean)), ).slice(0, 4); const limitError = videoInputLimitError(requestedMode, imageUrls.length); - if (limitError) throw Error(limitError); + if (limitError) throw new Error(limitError); const referenceImages = requestedMode === "text-to-video" && markedImageUrls.length > 0 ? markedImageUrls @@ -7984,7 +7883,7 @@ export function LiblibVideoCanvasReplica({ const previewUrl = referenceImage || row.keyframeUrl || ""; const functionKey=referenceImage?"first-frame-video":"text-to-video"; const definition=getConfiguredFunction(videoCanvasFunctionConfig,functionKey); - if(!videoCanvasFunctionConfig.enabled||!definition.enabled) throw Error("该视频生成功能尚未启用"); + if(!videoCanvasFunctionConfig.enabled||!definition.enabled) throw new Error("该视频生成功能尚未启用"); const selected=chooseStoryVideoSettings(canvasModelOptions,Boolean(referenceImage),Number.parseFloat(row.duration)||5,getFunctionModel(videoCanvasFunctionConfig,definition)); const {duration,model}=selected; const settings: ComposerSettings = { @@ -8150,7 +8049,7 @@ export function LiblibVideoCanvasReplica({ }); const result = await response.json(); if (!response.ok || !result.success || !result.data?.url) - throw Error(result.error || "截帧失败"); + throw new Error(result.error || "截帧失败"); const frame = createMediaResourceNode({ action: "VIDEO_FRAME_EXTRACT", mediaType: "image", @@ -8192,7 +8091,7 @@ export function LiblibVideoCanvasReplica({ }); const result = await response.json(); if (!response.ok || !result.success || !result.data?.url) - throw Error(result.error || "慢放处理失败"); + throw new Error(result.error || "慢放处理失败"); const video = createMediaResourceNode({ action: "VIDEO_RESOURCE", mediaType: "video", @@ -8253,8 +8152,8 @@ export function LiblibVideoCanvasReplica({ try { const incoming=connectedInputNodes(nodes,edges,nodeId); assertConnectedInputsReady(incoming); const videos=incoming.filter(n=>n.inputType==="video"); - if(videos.length!==1)throw Error("请连接一个已就绪的视频作为高清处理输入"); - const url=getNodeMediaUrl(videos[0]);if(!url)throw Error("上游视频尚未生成"); + if(videos.length!==1)throw new Error("请连接一个已就绪的视频作为高清处理输入"); + const url=getNodeMediaUrl(videos[0]);if(!url)throw new Error("上游视频尚未生成"); await generationTasks.submit(node,"video-upscale",{url,resolution:getDraftString(node,"hdResolution")||"1080P"},edges.filter(e=>e.target===nodeId)); }catch(error){toast.error(error instanceof Error?error.message:"视频高清提交失败");} }, [nodes,edges,generationTasks.submit]); @@ -8383,7 +8282,12 @@ export function LiblibVideoCanvasReplica({ const copy = splitCopy[mode]; const baseTitle = videoNode.title || "视频"; const durationSeconds = getVideoNodeDurationSeconds(videoNode); - const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode) || SCRIPT_VIDEO_REFERENCE_URL; + const sourceUrl = getNodeReferenceMediaUrl(videoNode) || getNodeMediaUrl(videoNode); + if (!sourceUrl) { + toast.error("请先上传视频或连接上游视频节点"); + setActiveVideoToolbarMode(null); + return; + } const outputSizeLabel = getDraftString(videoNode, "outputSizeLabel") || "1280 × 720"; const existingTrackCount = nodes.filter( (item) => item.inputType === "audio" && item.draftValues?.sourceVideoNodeId === videoNode.id @@ -8911,6 +8815,37 @@ export function LiblibVideoCanvasReplica({ } try { if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) { + const magicMarks = getVideoCanvasMagicMarks(node); + const markedImageUrls = magicMarks + .map((mark) => mark.imageUrl) + .filter(Boolean); + const resolvedImageUrls = Array.from( + new Set([...imageUrls, ...markedImageUrls]), + ); + const markLabels = magicMarks.map( + (mark) => mark.labelOptions[mark.selectedIndex] || mark.labelOptions[0], + ); + const styleTitles = incomingNodes + .filter( + (item) => + item.kind === "material-style" || + getDraftString(item, "action") === "MATERIAL_STYLE", + ) + .map( + (item) => + getDraftString(item, "styleTitle") || + item.title.replace(/^素材-风格-/, ""), + ) + .filter(Boolean); + const imagePromptInput = [ + rawPrompt, + markLabels.length > 0 + ? `重点遵循已标记元素:${markLabels.map((label) => `@${label}`).join("、")}` + : "", + ...styleTitles.map((title) => `应用图片风格「${title}」。`), + ] + .filter(Boolean) + .join(" "); const savedFunctionKey = String( params.functionKey || node.draftValues?.functionKey || "", ).trim(); @@ -8932,19 +8867,22 @@ export function LiblibVideoCanvasReplica({ : "image-repaint"; const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey); if (!videoCanvasFunctionConfig.enabled || !definition.enabled) - throw Error("该图片功能尚未启用"); - const sourceUrl = imageUrls[0] || ""; - if (!sourceUrl && functionRequiresMedia(definition)) throw Error("该图片功能需要有效的上游图片,请重新连接素材"); - const extraReferenceImages = imageUrls.slice(1); + throw new Error("该图片功能尚未启用"); + const sourceUrl = resolvedImageUrls[0] || ""; + if (!sourceUrl && functionRequiresMedia(definition)) throw new Error("该图片功能需要有效的上游图片,请重新连接素材"); + const extraReferenceImages = resolvedImageUrls.slice(1); const prompt = functionKey === MULTI_VIEW_NINE_GRID_FUNCTION_KEY ? composeMultiViewNineGridPrompt(definition, String(params.option || node.title)) : isWideImageGenerationFunction(functionKey) ? composeConfiguredImagePresetPrompt( definition, - rawPrompt || String(params.option || node.title), + imagePromptInput || String(params.option || node.title), ) - : composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title)); + : composeFunctionPrompt( + definition, + imagePromptInput || String(params.option || node.title), + ); if ( definition.route === "angle-edit" && !anglePresets[String((node.draftValues?.angleSettings as any)?.preset || "")]?.prompt @@ -9028,7 +8966,7 @@ export function LiblibVideoCanvasReplica({ : savedKey || (imageUrls.length || videoUrls.length ? "image-to-prompt" : "story-script"); const definition = getConfiguredFunction(videoCanvasFunctionConfig, key); if (!videoCanvasFunctionConfig.enabled || !definition.enabled) - throw Error("该分析功能尚未启用"); + throw new Error("该分析功能尚未启用"); const prompt = composeFunctionPrompt( definition, rawPrompt || STORY_SCRIPT_PROMPT, @@ -9059,7 +8997,7 @@ export function LiblibVideoCanvasReplica({ return; } - throw Error(`${node.title} 暂无可执行的后端动作`); + throw new Error(`${node.title} 暂无可执行的后端动作`); } catch (error) { setNodes((current) => current.map((item) => @@ -9474,7 +9412,7 @@ export function LiblibVideoCanvasReplica({ } const key=String(p.functionKey||d.functionKey||(node.inputType==="video"?"text-to-video":node.inputType==="image"?"":"story-script")); const definition=key?getConfiguredFunction(videoCanvasFunctionConfig,key):{key:"image-generate",kind:"image-edit" as const,route:"canvas-generate-image" as const,enabled:true,label:"图片生成",prompt:"",description:""}; - if(!definition?.enabled||!videoCanvasFunctionConfig.enabled)throw Error(`${node.title}:功能未启用或未配置`); + if(!definition?.enabled||!videoCanvasFunctionConfig.enabled)throw new Error(`${node.title}:功能未启用或未配置`); const model=getFunctionModel(videoCanvasFunctionConfig,definition,normalizeModelRef(settings.model||p.model)); const prompt=composeFunctionPrompt(definition,String(p.prompt||d.prompt||"")); if(node.inputType==="video")return {kind:"video",params:{prompt,preferredModelRef:model,mode:p.mode||settings.mode||(edges.some(e=>e.target===node.id&&nodes.find(n=>n.id===e.source)?.inputType==="image")?"first-frame":"text-to-video"),duration:Number(String(settings.duration||p.duration||5).replace(/s$/,"")),ratio:settings.ratio||p.ratio||"16:9",resolution:settings.resolution||p.resolution||"720p",generateAudio:settings.audio!=="关闭"}}; @@ -9483,7 +9421,7 @@ export function LiblibVideoCanvasReplica({ return {kind:"image",params:{prompt,model,allowFailover:false,imageCount:1,imageSize:settings.resolution||p.imageSize||"1K",aspectRatio:settings.ratio||p.ratio||"auto",billingFeature:getImageGenerationBillingFeature(key),promptExtend:true}}; } if(node.inputType==="text"||node.kind==="script"||node.kind==="text")return {kind:"analysis",params:{prompt,preferredModelRef:model,mode:key==="story-script"?"story-script":key==="video-analysis"?"video-analysis":"agent-plan",nodeTitle:node.title}}; - throw Error(`${node.title}:尚未配置可执行动作`); + throw new Error(`${node.title}:尚未配置可执行动作`); },()=>crypto.randomUUID()); await groupRuns.submit(requests); generationTasks.refresh();toast.success(`已提交 ${requests.length} 个节点,按连线依赖执行`); @@ -9508,7 +9446,6 @@ export function LiblibVideoCanvasReplica({ const mediaType = getDraftString(node, "mediaType") || node.inputType; const imageUrl = getNodeReferenceMediaUrl(node); const eligible = - node.id !== markSelectionTargetId && node.kind === "media" && mediaType === "image" && Boolean(imageUrl) && @@ -10142,8 +10079,11 @@ export function LiblibVideoCanvasReplica({ event.preventDefault(); event.stopPropagation(); resetFileDrag(); - if (isFileDropBlocked(event) || isBusy) return; - addUploadedFilesAtPoint(Array.from(event.dataTransfer.files), toCanvasPoint(event.clientX, event.clientY)); + if (isFileDropBlocked(event)) return; + if (isBusy) { toast.error("画布正在加载,请稍候再拖入素材"); return; } + const files = Array.from(event.dataTransfer.files); + if (!files.length) { toast.error("未能读取拖入的文件,请从桌面直接拖拽素材后松开"); return; } + addUploadedFilesAtPoint(files, toCanvasPoint(event.clientX, event.clientY)); }} >
@@ -10329,7 +10269,6 @@ export function LiblibVideoCanvasReplica({ .filter((node) => { const mediaType = getDraftString(node, "mediaType") || node.inputType; return ( - node.id !== markSelectionTargetId && node.kind === "media" && mediaType === "image" && Boolean(getNodeReferenceMediaUrl(node)) @@ -10557,6 +10496,7 @@ export function LiblibVideoCanvasReplica({ title={selectedVideoMediaNode.title} onApply={() => applyVideoClipSelection(selectedVideoMediaNode.id)} onChange={(range) => updateVideoClipRange(selectedVideoMediaNode.id, range)} + onExtractAudio={() => splitVideoAudioTrack(selectedVideoMediaNode.id, "audio-video")} onReset={() => updateVideoClipRange(selectedVideoMediaNode.id, { start: 0.18, end: 0.52 })} /> ) : null} @@ -10798,7 +10738,7 @@ export function LiblibVideoCanvasReplica({ const form = new FormData(); form.append("file", file); const response = await fetch("/api/canvas/media/upload", {method:"POST",body:form,signal:AbortSignal.timeout(120000)}); const result = await response.json(); const url = result.url || result.data?.url; - if(!response.ok || !url) throw Error(result.error || "上传失败"); + if(!response.ok || !url) throw new Error(result.error || "上传失败"); setNodes(current=>current.map(n=>n.id===selectedNode.id?{...n,title:file.name,draftValues:{...n.draftValues,action:"AUDIO_RESOURCE",mediaUrl:url,url:[url],mediaType:"audio",durationSeconds:result.data?.duration,generated:false}}:n)); }} /> ) : null} @@ -10817,7 +10757,7 @@ export function LiblibVideoCanvasReplica({ videoCanvasFunctionConfig, kind === "asset" ? "image-repaint" : "story-script", ); - if (!videoCanvasFunctionConfig.enabled || !definition.enabled) throw Error("该生成功能尚未启用"); + if (!videoCanvasFunctionConfig.enabled || !definition.enabled) throw new Error("该生成功能尚未启用"); const asset = assets[data.assetIndex ?? 0]; const result = kind === "asset" @@ -11022,7 +10962,10 @@ export function LiblibVideoCanvasReplica({ setActivePanel(null)} onOpenEffectLibrary={() => setActivePanel("effectLibrary")} - onOpenStyleLibrary={() => setActivePanel("styleLibrary")} + onOpenStyleLibrary={() => { + setStyleLibraryTargetNodeId(null); + setActivePanel("styleLibrary"); + }} /> ) : null} @@ -11041,6 +10984,7 @@ export function LiblibVideoCanvasReplica({ onClose={() => { setActivePanel(null); setEffectLibraryTargetNodeId(null); + setStyleLibraryTargetNodeId(null); }} onApplyStyle={addStyleAssetFromMarket} selectedEffectTitle={getDraftString( @@ -11299,8 +11243,8 @@ export function LiblibVideoCanvasReplica({ onApply={async (settings) => { const definition = getConfiguredFunction(videoCanvasFunctionConfig, "angle-edit"); const sourceUrl = getNodeMediaUrl(angleEditorNode) || getNodeSourceMediaUrl(angleEditorNode); - if (!sourceUrl) throw Error("请先上传参考图片"); - if (!videoCanvasFunctionConfig.enabled || !definition?.enabled) throw Error("多角度功能尚未启用"); + if (!sourceUrl) throw new Error("请先上传参考图片"); + if (!videoCanvasFunctionConfig.enabled || !definition?.enabled) throw new Error("多角度功能尚未启用"); const presetPrompt = anglePresets[settings.preset || ""]?.prompt; const resultNode = createMediaResourceNode({ action: "IMAGE_GENERATE", @@ -13305,6 +13249,7 @@ function VideoClipEditorBar({ durationSeconds, onApply, onChange, + onExtractAudio, onReset, previewUrl, title, @@ -13315,6 +13260,7 @@ function VideoClipEditorBar({ durationSeconds: number; onApply: () => void; onChange: (range: VideoClipRange) => void; + onExtractAudio: () => void; onReset: () => void; previewUrl: string; title: string; @@ -13421,7 +13367,10 @@ function VideoClipEditorBar({
))} -
- 图片参考 - 1 -
+ {referenceImageUrl ? ( +
+ 图片参考 + 1 +
+ ) : null}
2 @@ -15090,11 +15042,7 @@ function AudioToVideoComposerPanel({
- ); -} - -function FirstFrameVideoScene() { - return ( -
- -
- - - -
- -
- ); -} - -function AudioToVideoScene() { - return ( -
- -
-
- - -
- - -
- -
- ); -} - type PresetFloatingToolbarItem = | [string, string] | { @@ -15538,157 +15404,6 @@ function PresetFloatingToolbar({ ); } -function SceneConnector({ className }: { className?: string }) { - return ( -
-
- -
- ); -} - -function StoryTextPanel() { - return ( -
-
- - 剧本 -
-
-
- 输入内容 - -
-
- {STORY_SCRIPT_TEXT} -
-
-
- ); -} - -function ScriptGeneratorPanel() { - const rows = [ - ["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"], - ["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"], - ["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"], - ]; - return ( -
-
- - 脚本生成器 -
-
-
- 故事脚本 - 待生成 -
-
- {["镜号", "场景", "画面", "声音"].map((item) => ( -
{item}
- ))} -
- {rows.map((row) => ( -
- {row.map((cell) => ( -
{cell}
- ))} -
- ))} - -
-
- ); -} - -function PresetMediaPanel({ - imageUrl, - label, - small = false, - wide = false, -}: { - imageUrl: string; - label: string; - small?: boolean; - wide?: boolean; -}) { - return ( -
-
- - {label} -
-
- {label} - -
-
- ); -} - -function VideoResultPanel({ label }: { label: string }) { - return ( -
-
-
-
-
-
-
-
-
待生成视频
-
生成后将在这里预览
-
- -
-
- ); -} - -function AudioResourcePanel() { - return ( -
-
- - 音频 -
-
- -
-
- ); -} - function PresetComposerPanel({ anchor, billingDefinition, @@ -15698,7 +15413,6 @@ function PresetComposerPanel({ modelOptions = [], onSubmit, prompt, - submitMessage, thumbnailUrl, title, }: { @@ -15708,9 +15422,8 @@ function PresetComposerPanel({ membershipSummary?: MembershipSummaryResponse; modelKind?: CanvasModelKind; modelOptions?: CanvasModelOption[]; - onSubmit?: (prompt: string, settings?: ComposerSettings) => void; + onSubmit: (prompt: string, settings?: ComposerSettings) => void; prompt: string; - submitMessage: string; thumbnailUrl?: string; title: string; }) { @@ -15826,11 +15539,7 @@ function PresetComposerPanel({ - ))} -
- {toolbarMenu ? setToolbarMenu(null)} /> : null} - -
- -
-
- -
- -
- - -
- ); -} - function SceneTool({ active, badge, @@ -16285,36 +15939,6 @@ function SceneToolIcon({ kind }: { kind: "panorama" | "orbit" | "lighting" | "gr ); } -function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: string; label: string; wide?: boolean }) { - return ( -
-
- - {label} -
-
- {label} - {wide ? ( - - ) : null} -
- -
- ); -} - function PromptComposer({ anchor, billingFeature = null, @@ -16325,7 +15949,7 @@ function PromptComposer({ modelOptions = [], nodes = [], onSubmit, - referenceThumbnailUrl = CHARACTER_IMAGE_URL, + referenceThumbnailUrl, }: { anchor?: PresetControlAnchor; billingFeature?: ImageToolCreditFeature | null; @@ -16335,7 +15959,7 @@ function PromptComposer({ modelKind?: CanvasModelKind; modelOptions?: CanvasModelOption[]; nodes?: CloneNode[]; - onSubmit?: (payload: ComposerSubmitPayload) => void; + onSubmit: (payload: ComposerSubmitPayload) => void; referenceThumbnailUrl?: string; }) { const [menu, setMenu] = useState(null); @@ -16400,7 +16024,6 @@ function PromptComposer({ setPrompt((current) => `${current.trimEnd()} ${option}`.trimStart()); } setMenu(null); - toast.info(`${option} 已应用,生成接口待接入。`); }, []); const onPromptChange = useCallback((event: ReactChangeEvent) => { @@ -16457,10 +16080,12 @@ function PromptComposer({ onClick={() => setMenu((current) => current === type ? null : type as ComposerMenu)} /> ))} -
- 角色图缩略图 - 1 -
+ {referenceThumbnailUrl ? ( +
+ 角色图缩略图 + 1 +
+ ) : null}
{references.length > 0 || slashScene ? (
@@ -16536,14 +16161,12 @@ function PromptComposer({ - ))} -
-
-
- - {directorHelpMenuOpen ? ( -
-
-
- - {directorHelpShortcutOpen ? ( -
-
-
- 快捷键 -
-
- {DIRECTOR_HELP_SHORTCUTS.map(([label, shortcut]) => ( -
- {label} - {shortcut ? {shortcut} : null} -
- ))} -
-
-
- ) : null} -
- -
-
- ) : null} -
- -
- - {tutorialActive ? ( - <> -
-
-
- -
- onTutorialActiveChange(false)} /> - - ) : null} - -
-
- {directorFullscreen ? null : ( - - )} - -
-
-
-
-
- {roleInspectorOpen ? ( - <> -
-
-
- - ) : null} -
- 机位1 -
-
-
- {node.title} · {viewMode === "director" ? "导演视角" : "机位视角"} · {ratio} -
-
{displayedRoleName}
-
- -
-
- -
- - {directorFullscreen ? null : ( - - )} -
-
- -
- {directorToolbarItems.map((item) => { - const Icon = item.icon; - return ( -
- - {item.menu === "transform" && transformMenuOpen ? ( -
- {DIRECTOR_TRANSFORM_MODE_OPTIONS.map((option) => { - const OptionIcon = option.icon; - const selected = directorTransformMode === option.key; - return ( - - ); - })} -
- ) : item.label === "添加角色" && roleMenuOpen ? ( -
- {DIRECTOR_ROLE_MENU_OPTIONS.map((option) => ( - - - {option.separatorAfter ? ( - - ) : option.key !== "geometry" ? ( - - ) : null} - - ))} -
- ) : item.label === "全景图" && panoramaMenuOpen ? ( -
- {DIRECTOR_PANORAMA_MENU_OPTIONS.map((option, index) => { - const OptionIcon = option.icon; - return ( - - - {index < DIRECTOR_PANORAMA_MENU_OPTIONS.length - 1 ? : null} - - ); - })} -
- ) : item.label === "添加机位" && cameraPresetMenuOpen ? ( -
-
选择机位视角
-
-
- {DIRECTOR_CAMERA_PRESET_OPTIONS.map((option) => ( - - ))} -
-
-
- ) : ( - - {item.label} - - )} -
- ); - })} -
- - {ratioMenuOpen ? ( -
-
比例
-
- {DIRECTOR_RATIO_OPTIONS.map((option) => ( - - ))} -
-
- ) : ( - - 选择画幅比例 - - )} -
- {directorTrailingToolbarItems.map((item) => { - const Icon = item.icon; - return ( -
- - - {item.label} - -
- ); - })} -
- - { - event.currentTarget.value = ""; - }} - ref={panoramaUploadInputRef} - type="file" - /> - - {aiImportOpen ? ( - setAiImportOpen(false)} - onModeChange={setAiImportMode} - onSelectImage={(imageUrl) => { - setAiImportSelectedImage(imageUrl); - setAiImportTab("history"); - }} - onStart={handleStartAiImport} - onTabChange={setAiImportTab} - running={aiImportRunning} - selectedImage={aiImportSelectedImage} - tab={aiImportTab} - /> - ) : null} - {panoramaHistoryOpen ? setPanoramaHistoryOpen(false)} /> : null} - {panoramaAiOpen ? setPanoramaAiOpen(false)} /> : null} -
- ); -} - -function DirectorPanoramaHistoryDialog({ onClose }: { onClose: () => void }) { - const [activeTab, setActiveTab] = useState<"image" | "video" | "audio">("image"); - const tabs = [ - { count: 8, key: "image" as const, label: "图片历史" }, - { count: 10, key: "video" as const, label: "视频历史" }, - { count: 6, key: "audio" as const, label: "音频历史" }, - ]; - const assets = Array.from({ length: activeTab === "video" ? 10 : activeTab === "audio" ? 6 : 8 }, (_, index) => ({ - id: `${activeTab}-${index}`, - image: DIRECTOR_AI_HISTORY_IMAGES[index % DIRECTOR_AI_HISTORY_IMAGES.length], - title: activeTab === "image" ? `全景参考 ${index + 1}` : activeTab === "video" ? `视频资产 ${index + 1}` : `音频资产 ${index + 1}`, - })); - - return ( -
-
-
-
历史资产
- 100% - -
- -
-
- {tabs.map((tabItem) => ( - - ))} -
-
- - -
-
- -
-
2026-07-09
-
- {assets.slice(0, 8).map((asset) => ( -
-
- {activeTab === "image" ? ( - - ) : ( -
- {activeTab === "video" ?
- )} - 历史 -
-
-
{asset.title}
-
- - - -
-
-
- ))} -
-
-
-
- ); -} - -function DirectorPanoramaAiDialog({ onClose }: { onClose: () => void }) { - const uploadInputRef = useRef(null); - - return ( -
-
-
-
AI生成
- -
- -
- -
- -
-
退出不会中断生成,可在历史记录中查看结果
-
- 0 - -
-
- - { - event.currentTarget.value = ""; - }} - ref={uploadInputRef} - type="file" - /> -
-
- ); -} - -function DirectorRoleInspectorPanel({ selectedObject }: { selectedObject: string }) { - return ( -
-
-
- - -
-
-
-
- - - - -
-
统一缩放
-
- - -
-
- -
-
-
- ); -} - -function DirectorAiImportDialog({ - mode, - onClose, - onModeChange, - onSelectImage, - onStart, - onTabChange, - running, - selectedImage, - tab, -}: { - mode: "insert" | "overwrite"; - onClose: () => void; - onModeChange: (mode: "insert" | "overwrite") => void; - onSelectImage: (imageUrl: string) => void; - onStart: () => void; - onTabChange: (tab: "upload" | "history") => void; - running: boolean; - selectedImage: string | null; - tab: "upload" | "history"; -}) { - const showHistory = tab === "history" && !selectedImage; - - return ( -
-
-
-
AI 识图导入
- -
- -
-
- {[ - { key: "upload" as const, label: "本地上传" }, - { key: "history" as const, label: "历史记录" }, - ].map((item) => ( - - ))} -
- - {selectedImage ? ( - - ) : showHistory ? ( -
-
2026-07-09
-
- {DIRECTOR_AI_HISTORY_IMAGES.map((imageUrl, index) => ( -
- - -
- -
-
- ))} -
-
- ) : ( - - )} - -
-
选择是否覆盖场景
-
- {[ - { - key: "insert" as const, - title: "插入当前导演台", - desc: "作为站位参考层插入,不覆盖当前全景、角色和机位", - }, - { - key: "overwrite" as const, - title: "覆盖当前导演台", - desc: "作为站位参考层插入,覆盖当前全景、角色和机位", - }, - ].map((item) => ( - - ))} -
-
-
- -
-
退出不会中断生成过程,全景图生成成功后,会自动加载到背景
-
- 6 - -
-
-
-
- ); -} - -function DirectorAxisInputs({ - label, - values, -}: { - label: string; - values: [string, string, string] | string[]; -}) { - return ( -
-
{label}
-
- {["X", "Y", "Z"].map((axis, index) => ( - - ))} -
-
- ); -} - function ModuleActionIcon({ icon }: { icon: LucideIcon | "hd" }) { if (icon === "hd") { return ( @@ -21238,6 +19272,7 @@ function CanvasNodeShell({ } function PresetMediaCanvasNode({ + onChooseFile, onClearGridSplit, onClearGridSplitSelection, onClearLocalEditRegions, @@ -21250,6 +19285,7 @@ function PresetMediaCanvasNode({ onUpdateLocalEditRegions, selected, }: { + onChooseFile: () => void; onClearGridSplit: () => void; onClearGridSplitSelection: () => void; onClearLocalEditRegions: () => void; @@ -21298,7 +19334,21 @@ function PresetMediaCanvasNode({ {getDraftString(node, "uploadStatus") === "uploading" ? <>正在上传素材… : {getDraftString(node, "uploadError") || "上传失败,请重新上传"}}
) : mediaType === "audio" ? ( - +
+ + {!generated ? ( + + ) : null} +
) : mediaType === "video" ? (
event.stopPropagation()} + title={mediaUrl ? "替换视频" : "上传视频"} type="button" > ↗ @@ -21410,7 +19463,10 @@ function PresetMediaCanvasNode({ {!generated && !isGenerating && !isGenerationFailed ? ( - ))} -
- -
- {assets.map((asset) => ( - - ))} -
- - ); -} function SkillsAccessModal({ onClose, diff --git a/components/canvas/smart-edit/SmartEditDialog.tsx b/components/canvas/smart-edit/SmartEditDialog.tsx index ff70beb80..a42a657d7 100644 --- a/components/canvas/smart-edit/SmartEditDialog.tsx +++ b/components/canvas/smart-edit/SmartEditDialog.tsx @@ -90,6 +90,23 @@ export function SmartEditDialog({ [zoom, setZoom] = useState(1), [snap, setSnap] = useState(true); const [configure, setConfigure] = useState(false); + const [videoDragActive, setVideoDragActive] = useState(false); + const videoDragDepthRef = useRef(0); + const uploadVideoFiles = async (files: File[]) => { + if (!files.length) return; + if (files.length + draft.assets.length > 12) { + setError("最多 12 个素材"); + return; + } + setBusy(true); + try { + for (const file of files) await upload(file, "video"); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; const applied = useRef(planTaskId); useEffect(() => { if (planTaskId && planTaskId !== applied.current) { @@ -112,7 +129,7 @@ export function SmartEditDialog({ fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal }) .then(async (r) => { const data = await r.json(); - if (!r.ok) throw Error(data.error); + if (!r.ok) throw new Error(data.error); setCap(data); }) .catch((e) => { @@ -185,17 +202,26 @@ export function SmartEditDialog({ setTimeout(() => URL.revokeObjectURL(url), 1000); }; const upload = async (file: File, kind: "video" | "audio") => { - if (file.size > 125 * 1024 * 1024) throw Error("单个素材不得超过 125 MiB"); + if (file.size > 125 * 1024 * 1024) throw new Error("单个素材不得超过 125 MiB"); const form = new FormData(); form.append("file", file); - const response = await fetch("/api/canvas/media/upload", { - method: "POST", - body: form, - signal: AbortSignal.timeout(120000), - }); + let response: Response; + try { + response = await fetch("/api/canvas/media/upload", { + method: "POST", + body: form, + signal: AbortSignal.timeout(600000), + }); + } catch (e) { + throw new Error( + e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError") + ? "上传超时,请检查网络后重试,或改用更小的素材" + : "上传失败,请检查网络后重试", + ); + } const result = await response.json(); const url = result.url || result.data?.url; - if (!response.ok || !url) throw Error(result.error || "上传失败"); + if (!response.ok || !url) throw new Error(result.error || "上传失败"); if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } }); else patch({ @@ -308,7 +334,43 @@ export function SmartEditDialog({ aria-modal="true" aria-label="智能剪辑" className="fixed inset-0 z-[160] flex flex-col bg-[#171717] text-white" + onDragEnter={(e) => { + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + videoDragDepthRef.current += 1; + setVideoDragActive(true); + }} + onDragOver={(e) => { + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(e) => { + videoDragDepthRef.current = Math.max(0, videoDragDepthRef.current - 1); + if (!videoDragDepthRef.current) setVideoDragActive(false); + }} + onDrop={(e) => { + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + videoDragDepthRef.current = 0; + setVideoDragActive(false); + const files = Array.from(e.dataTransfer.files).filter((file) => + file.type.startsWith("video/"), + ); + if (!files.length) { + setError("请拖入 MP4、MOV 或 WebM 视频素材"); + return; + } + void uploadVideoFiles(files); + }} > + {videoDragActive ? ( +
+ + 松开以上传视频素材 + +
+ ) : null}

智能剪辑 · {plan?.title || "从素材到成片"}

{plan && ( @@ -371,18 +433,7 @@ export function SmartEditDialog({ onChange={async (e) => { const files = Array.from(e.target.files || []); e.target.value = ""; - if (files.length + draft.assets.length > 12) { - setError("最多 12 个素材"); - return; - } - setBusy(true); - try { - for (const file of files) await upload(file, "video"); - } catch (e) { - setError((e as Error).message); - } finally { - setBusy(false); - } + await uploadVideoFiles(files); }} /> diff --git a/components/canvas/smart-edit/SmartEditNode.tsx b/components/canvas/smart-edit/SmartEditNode.tsx index 431cc8b58..0b3c05a75 100644 --- a/components/canvas/smart-edit/SmartEditNode.tsx +++ b/components/canvas/smart-edit/SmartEditNode.tsx @@ -236,14 +236,23 @@ export function SmartEditNodeComposer({ try { const body = new FormData(); body.append("file", file); - const response = await fetch("/api/canvas/media/upload", { - method: "POST", - body, - signal: AbortSignal.timeout(120000), - }); + let response: Response; + try { + response = await fetch("/api/canvas/media/upload", { + method: "POST", + body, + signal: AbortSignal.timeout(600000), + }); + } catch (e) { + throw new Error( + e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError") + ? "上传超时,请检查网络后重试,或改用更小的素材" + : "上传失败,请检查网络后重试", + ); + } const data = await response.json(), url = data.url || data.data?.url; - if (!response.ok || !url) throw Error(data.error || "音乐上传失败"); + if (!response.ok || !url) throw new Error(data.error || "音乐上传失败"); onDraft({ music: { url, gain: 0.2, start: 0 } }); } catch (e) { setError(e instanceof Error ? e.message : "音乐上传失败");