diff --git a/backend/services/tool_manager/providers/_canvas_edge_rules.py b/backend/services/tool_manager/providers/_canvas_edge_rules.py index cb78aeb..6df0b29 100644 --- a/backend/services/tool_manager/providers/_canvas_edge_rules.py +++ b/backend/services/tool_manager/providers/_canvas_edge_rules.py @@ -40,7 +40,7 @@ "text": "allow", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "deferred", }, "image": { - "text": "deferred", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "deferred", + "text": "deferred", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "allow", }, "video": { "text": "deferred", "image": "allow", "video": "allow", "audio": "deferred", "storyboard": "allow", "panorama": "deferred", diff --git a/frontend/src/app/theater/[id]/hooks/useQuickAddMenu.ts b/frontend/src/app/theater/[id]/hooks/useQuickAddMenu.ts index 7db76f3..f710406 100644 --- a/frontend/src/app/theater/[id]/hooks/useQuickAddMenu.ts +++ b/frontend/src/app/theater/[id]/hooks/useQuickAddMenu.ts @@ -10,6 +10,7 @@ const nodeDefaultData: Record> = { storyboard: { shotNumber: '001', description: '', duration: 5 }, video: { name: '新视频卡', description: '', videoUrl: '', fitMode: 'cover' }, audio: { name: '新音频卡', description: '', audioUrl: '' }, + panorama: { name: '新全景卡', description: '', panoramaUrl: '' }, }; // Default dimensions by node type @@ -19,6 +20,7 @@ const nodeDefaultDimensions: Record = video: { width: 512, height: 384 }, audio: { width: 360, height: 200 }, storyboard: { width: 398, height: 256 }, + panorama: { width: 512, height: 320 }, }; export interface QuickAddMenuState { diff --git a/frontend/src/components/canvas/AudioGeneratePanel.tsx b/frontend/src/components/canvas/AudioGeneratePanel.tsx index 928e0dc..da699eb 100644 --- a/frontend/src/components/canvas/AudioGeneratePanel.tsx +++ b/frontend/src/components/canvas/AudioGeneratePanel.tsx @@ -14,6 +14,8 @@ import { usePanelResize } from '@/hooks/usePanelResize'; import { onPanelInject } from '@/lib/canvas/panelEvents'; import { mediaUrlsToDataUrls, TEXT_PROMPT_MAX } from '@/lib/canvas/edgePayload'; import type { CanvasNode } from '@/store/useCanvasStore'; +import { useCanvasStore } from '@/store/useCanvasStore'; +import { useShallow } from 'zustand/react/shallow'; import { edgeToast } from '@/lib/canvas/toast'; import { AttachmentPreviews, type ReferenceImage } from './AudioGeneratePanel/AttachmentPreviews'; @@ -50,7 +52,6 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) { const { t } = useTranslation(); const { onSubmit, - onStop, isSubmitting, taskActive, taskDone, @@ -124,12 +125,56 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) { const [showNodePicker, setShowNodePicker] = useState(false); const [showConfig, setShowConfig] = useState(false); + // 订阅 incoming edges 中的图像节点 source ids(仅当前节点的入边,且 source = image)。 + // 切走/刷新后面板重新 mount 时,本 effect 从 edges 回填 references, + // 使「image → audio」拖线建立的「附件参考」关系能随 edges 持久化。 + const incomingImageSourceIds = useCanvasStore( + useShallow((s) => { + if (!nodeId) return [] as string[]; + const incomingEdges = s.edges.filter((e) => e.target === nodeId); + return incomingEdges + .map((e) => e.source) + .filter((sid) => { + const src = s.nodes.find((n) => n.id === sid); + return src?.type === 'image'; + }); + }), + ); + // initialConfig 填充 prompt(从历史拖拽) useEffect(() => { const p = initialConfig?.prompt; p && setPrompt(p); }, [initialConfig?.prompt]); + // 从 incoming edges 自动回填 references: + // - 仅加入「已连线但 references 中还没有」的 source,不删除已有项 + // - 顺序跟随 incoming edges;超出 MAX_REFERENCE_IMAGES 容量丢弃 + // - 避免依赖 references 造成循环:统一走 setReferences 回调形式 + useEffect(() => { + setReferences((prev) => { + const existing = new Set(prev.map((r) => r.sourceNodeId)); + const filled: ReferenceImage[] = []; + incomingImageSourceIds.forEach((sid) => { + const dup = existing.has(sid); + const reached = prev.length + filled.length >= MAX_REFERENCE_IMAGES; + const skip = dup || reached; + skip || (() => { + const src = canvasNodes.find((n) => n.id === sid); + const url = src ? getImageNodeUrl(src) : ''; + const normalized = url ? (normalizeUrl(url) || url) : ''; + normalized && filled.push({ + id: uuidv4(), + url: normalized, + sourceNodeId: sid, + name: ((src?.data as Record)?.name as string) || t('canvas.node.image.refItem', '参考图'), + }); + })(); + }); + return filled.length > 0 ? [...prev, ...filled] : prev; + }); + }, [incomingImageSourceIds, canvasNodes, t]); + // ── 模型切换处理:重置 capability 相关字段 ── const handleModelChange = useCallback((key: string) => { setSelectedModelKey(key); @@ -329,7 +374,6 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) { hasSelectedModel={!!selectedModel} showConfig={showConfig} onToggleConfig={() => setShowConfig((v) => !v)} - onStop={onStop} onSubmit={handleSubmit} /> diff --git a/frontend/src/components/canvas/AudioGeneratePanel/PanelActionButtons.tsx b/frontend/src/components/canvas/AudioGeneratePanel/PanelActionButtons.tsx index e4f8dc6..4faee6a 100644 --- a/frontend/src/components/canvas/AudioGeneratePanel/PanelActionButtons.tsx +++ b/frontend/src/components/canvas/AudioGeneratePanel/PanelActionButtons.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { Settings2, Send, Square, ArrowRight } from 'lucide-react'; +import { Settings2, Send, ArrowRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useCreditsGuard } from '@/hooks/useCreditsGuard'; @@ -12,24 +12,27 @@ interface Props { hasSelectedModel: boolean; showConfig: boolean; onToggleConfig: () => void; - onStop: () => void; onSubmit: () => void; } -/** 面板右侧:设置按钮 + 停止/发送 按钮 */ +/** 面板右侧:设置按钮 + 发送按钮(任务进行中静默禁用) */ export function PanelActionButtons({ taskActive, canSubmit, hasSelectedModel, showConfig, onToggleConfig, - onStop, onSubmit, }: Props) { const { t } = useTranslation(); const { creditsExhausted, tooltipText } = useCreditsGuard(); - const submitDisabled = !canSubmit || creditsExhausted; - const submitTitle = creditsExhausted ? tooltipText : t('canvas.node.audio.submit', '生成音乐'); + // 任务进行中:发送按钮静默禁用(不再提供中断能力) + const submitDisabled = !canSubmit || creditsExhausted || taskActive; + const submitTitle = taskActive + ? t('canvas.node.audio.generating', '生成中') + : creditsExhausted + ? tooltipText + : t('canvas.node.audio.submit', '生成音乐'); return ( <> @@ -48,31 +51,20 @@ export function PanelActionButtons({ - {taskActive ? ( - - ) : ( - - )} + ); } diff --git a/frontend/src/components/canvas/AudioGeneratePanel/types.ts b/frontend/src/components/canvas/AudioGeneratePanel/types.ts index 9d5e24a..e995776 100644 --- a/frontend/src/components/canvas/AudioGeneratePanel/types.ts +++ b/frontend/src/components/canvas/AudioGeneratePanel/types.ts @@ -14,7 +14,6 @@ export interface FlatMusicModelItem { /** 对外 Props */ export interface AudioGeneratePanelProps { onSubmit: (params: MusicCreateParams) => void; - onStop: () => void; isSubmitting: boolean; taskActive: boolean; taskDone: boolean; diff --git a/frontend/src/components/canvas/AudioNode.tsx b/frontend/src/components/canvas/AudioNode.tsx index 1906390..cc71a4e 100644 --- a/frontend/src/components/canvas/AudioNode.tsx +++ b/frontend/src/components/canvas/AudioNode.tsx @@ -47,7 +47,10 @@ const AudioNode = ({ id, data, selected }: NodeProps>) => { const deleteNode = useCanvasStore((s) => s.deleteNode); const addNode = useCanvasStore((s) => s.addNode); const nodes = useCanvasStore((s) => s.nodes); - const { getNode } = useReactFlow(); + const { getNode, screenToFlowPosition } = useReactFlow(); + + // 节点主容器(用于拖拽起点/落点判定) + const nodeRef = useRef(null); // 标题编辑 const [isEditingTitle, setIsEditingTitle] = useState(false); @@ -197,26 +200,30 @@ const AudioNode = ({ id, data, selected }: NodeProps>) => { const handleHistoryDragEnd = useCallback( (e: DragEvent, entry: AudioGenHistoryEntry) => { - void e; - // 拖入空白区域时当成 "从历史克隆一条记录到新节点" - const newId = uuidv4(); - const currentNode = getNode(id); - const posX = (currentNode?.position.x ?? 0) + (currentNode?.measured?.width ?? 300) + 80; - const posY = currentNode?.position.y ?? 0; - addNode({ - id: newId, - type: 'audio', - position: { x: posX, y: posY }, - data: { - name: t('canvas.node.audio.aiGenerated', 'AI 生成'), - description: '', - audioUrl: entry.url, - lyrics: entry.lyrics, - initialGenConfig: entry, - } as AudioNodeData, - }); + // 拖回本节点身上 → 不克隆 + const el = document.elementFromPoint(e.clientX, e.clientY); + const droppedOnSelf = nodeRef.current?.contains(el); + droppedOnSelf || (() => { + // 跟随光标位置创建新节点,并与侧边栏拖拽使用同样的默认尺寸 360×200 + const pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); + const newNode: CanvasNode = { + id: uuidv4(), + type: 'audio', + position: { x: pos.x - 180, y: pos.y - 100 }, + width: 360, + height: 200, + data: { + name: t('canvas.node.audio.aiGenerated', 'AI 生成'), + description: '', + audioUrl: entry.url, + lyrics: entry.lyrics, + initialGenConfig: entry, + } as AudioNodeData, + }; + addNode(newNode); + })(); }, - [addNode, getNode, id, t], + [addNode, screenToFlowPosition, t], ); const handleHistoryClick = (url: string) => { @@ -259,7 +266,7 @@ const AudioNode = ({ id, data, selected }: NodeProps>) => { data-testid="audio-file-upload-input" /> -
+
>) => { {!audioUrl && !isUploading && !upload.uploadError && } {audioUrl && !isUploading && ( - + )} {isUploading && } @@ -342,7 +349,6 @@ const AudioNode = ({ id, data, selected }: NodeProps>) => { canvasNodes={nodes} onTogglePinPanel={handleTogglePinPanel} onSubmit={gen.submit} - onStop={gen.musicTask.reset} onApplyToNode={gen.applyToNode} onApplyToNextNode={gen.applyToNextNode} onLinkNode={linkNode} diff --git a/frontend/src/components/canvas/AudioNode/AudioDisplay.tsx b/frontend/src/components/canvas/AudioNode/AudioDisplay.tsx index e746fe9..53f6191 100644 --- a/frontend/src/components/canvas/AudioNode/AudioDisplay.tsx +++ b/frontend/src/components/canvas/AudioNode/AudioDisplay.tsx @@ -7,12 +7,14 @@ import { useTranslation } from 'react-i18next'; interface Props { audioUrl: string; lyrics?: string; + /** 节点是否选中,决定是否播放频谱动画;未选中时不起 RAF,节省 CPU/GPU。 */ + selected?: boolean; } /** * 音频播放区域:居中播放按钮 + 频谱波形动画(RGB颜色)+ 底部折叠歌词 */ -export function AudioDisplay({ audioUrl, lyrics }: Props) { +export function AudioDisplay({ audioUrl, lyrics, selected = false }: Props) { const { t } = useTranslation(); const audioRef = useRef(null); @@ -263,11 +265,19 @@ export function AudioDisplay({ audioUrl, lyrics }: Props) { animFrameRef.current = requestAnimationFrame(draw); }, []); - // 组件挂载后启动动画循环(静默态也有脉动) + // 选中或正在播放时启动动画循环;均不满足时停止 RAF 并清空画布 useEffect(() => { + if (!selected && !isPlaying) { + // 未选中且未播放:取消上一轮 RAF、清空画布,避免残留最后一帧 + cancelAnimationFrame(animFrameRef.current); + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + canvas && ctx && ctx.clearRect(0, 0, canvas.width, canvas.height); + return; + } animFrameRef.current = requestAnimationFrame(draw); return () => cancelAnimationFrame(animFrameRef.current); - }, [draw]); + }, [draw, selected, isPlaying]); // 播放/暂停 切换 const togglePlay = useCallback(async (e: React.MouseEvent) => { diff --git a/frontend/src/components/canvas/AudioNode/GeneratePanelWrapper.tsx b/frontend/src/components/canvas/AudioNode/GeneratePanelWrapper.tsx index f406b8c..bd46129 100644 --- a/frontend/src/components/canvas/AudioNode/GeneratePanelWrapper.tsx +++ b/frontend/src/components/canvas/AudioNode/GeneratePanelWrapper.tsx @@ -83,7 +83,6 @@ interface PanelProps { canvasNodes: CanvasNode[]; onTogglePinPanel: (e?: React.MouseEvent) => void; onSubmit: (p: MusicCreateParams) => void; - onStop: () => void; onApplyToNode: () => void; onApplyToNextNode: () => void; onLinkNode: (sourceNodeId: string) => void; @@ -108,7 +107,6 @@ export function GeneratePanelWrapper({ canvasNodes, onTogglePinPanel, onSubmit, - onStop, onApplyToNode, onApplyToNextNode, onLinkNode, @@ -138,7 +136,6 @@ export function GeneratePanelWrapper({ setShowConfig((v) => !v)} - onStop={onStop} onSubmit={handleSubmit} />
diff --git a/frontend/src/components/canvas/ImageGeneratePanel/PanelActionButtons.tsx b/frontend/src/components/canvas/ImageGeneratePanel/PanelActionButtons.tsx index 44bc6ca..4378c3b 100644 --- a/frontend/src/components/canvas/ImageGeneratePanel/PanelActionButtons.tsx +++ b/frontend/src/components/canvas/ImageGeneratePanel/PanelActionButtons.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { Settings2, Send, Square, ArrowRight } from 'lucide-react'; +import { Settings2, Send, ArrowRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useCreditsGuard } from '@/hooks/useCreditsGuard'; @@ -12,7 +12,6 @@ interface Props { hasSelectedModel: boolean; showConfig: boolean; onToggleConfig: () => void; - onStop?: () => void; onSubmit: () => void; } @@ -22,13 +21,17 @@ export function PanelActionButtons({ hasSelectedModel, showConfig, onToggleConfig, - onStop, onSubmit, }: Props) { const { t } = useTranslation(); const { creditsExhausted, tooltipText } = useCreditsGuard(); - const submitDisabled = !canSubmit || creditsExhausted; - const submitTitle = creditsExhausted ? tooltipText : t('canvas.node.image.submit', '开始生成'); + // 任务进行中:发送按钮静默禁用(不再提供中断能力) + const submitDisabled = !canSubmit || creditsExhausted || taskActive; + const submitTitle = taskActive + ? t('canvas.node.image.generating', '生成中') + : creditsExhausted + ? tooltipText + : t('canvas.node.image.submit', '开始生成'); return ( <> @@ -47,32 +50,20 @@ export function PanelActionButtons({ - {taskActive ? ( - - ) : ( - - )} + ); } diff --git a/frontend/src/components/canvas/ImageGeneratePanel/types.ts b/frontend/src/components/canvas/ImageGeneratePanel/types.ts index fd7154d..d9ebbc9 100644 --- a/frontend/src/components/canvas/ImageGeneratePanel/types.ts +++ b/frontend/src/components/canvas/ImageGeneratePanel/types.ts @@ -24,7 +24,6 @@ export interface ImagePanelModeRequest { export interface ImageGeneratePanelProps { onSubmit: (params: ImageCreateParams) => void; - onStop?: () => void; isSubmitting: boolean; taskActive: boolean; taskDone: boolean; diff --git a/frontend/src/components/canvas/ImageNode.tsx b/frontend/src/components/canvas/ImageNode.tsx index 1fb209f..0a520a0 100644 --- a/frontend/src/components/canvas/ImageNode.tsx +++ b/frontend/src/components/canvas/ImageNode.tsx @@ -487,7 +487,6 @@ const CharacterNode = ({ id, data, selected }: NodeProps modeRequest={quick.panelModeRequest} onTogglePinPanel={handleTogglePinPanel} onSubmit={gen.submit} - onStop={() => imageTask.reset()} onApplyToNode={gen.applyToNode} onApplyToNextNode={gen.applyToNextNode} onLinkNode={linkNode} diff --git a/frontend/src/components/canvas/ImageNode/GeneratePanelWrapper.tsx b/frontend/src/components/canvas/ImageNode/GeneratePanelWrapper.tsx index ac8ef7b..bd37168 100644 --- a/frontend/src/components/canvas/ImageNode/GeneratePanelWrapper.tsx +++ b/frontend/src/components/canvas/ImageNode/GeneratePanelWrapper.tsx @@ -104,7 +104,6 @@ interface PanelProps { modeRequest: ImagePanelModeRequest | null; onTogglePinPanel: (e?: React.MouseEvent) => void; onSubmit: (p: ImageCreateParams) => void; - onStop: () => void; onApplyToNode: () => void; onApplyToNextNode: () => void; onLinkNode: (sourceNodeId: string) => void; @@ -130,7 +129,6 @@ export function GeneratePanelWrapper({ modeRequest, onTogglePinPanel, onSubmit, - onStop, onApplyToNode, onApplyToNextNode, onLinkNode, @@ -160,7 +158,6 @@ export function GeneratePanelWrapper({ void; } -// Menu items config (avoids repetitive JSX) +// 菜单项配置:图标 / 颜色 / 标签与侧边栏 Sidebar 保持一致 const MENU_ITEMS: Array<{ type: string; icon: typeof ScrollText; iconClass: string; labelKey: string }> = [ - { type: 'text', icon: ScrollText, iconClass: 'text-indigo-500', labelKey: 'canvas.textCard' }, - { type: 'image', icon: User, iconClass: 'text-emerald-500', labelKey: 'canvas.imageCard' }, - { type: 'video', icon: Film, iconClass: 'text-purple-500', labelKey: 'canvas.videoCard' }, - { type: 'audio', icon: Headphones, iconClass: 'text-amber-500', labelKey: 'canvas.audioCard' }, - { type: 'storyboard', icon: Clapperboard, iconClass: 'text-amber-500', labelKey: 'canvas.storyboardCard' }, + { type: 'text', icon: ScrollText, iconClass: 'text-node-blue', labelKey: 'canvas.textCard' }, + { type: 'image', icon: ImageIcon, iconClass: 'text-node-green', labelKey: 'canvas.imageCard' }, + { type: 'video', icon: Video, iconClass: 'text-node-yellow', labelKey: 'canvas.videoCard' }, + { type: 'audio', icon: Headphones, iconClass: 'text-amber-500', labelKey: 'canvas.audioCard' }, + { type: 'storyboard', icon: Table2, iconClass: 'text-node-purple', labelKey: 'canvas.storyboardCard' }, + { type: 'panorama', icon: Globe, iconClass: 'text-cyan-500', labelKey: 'canvas.panoramaCard' }, ]; export function QuickAddMenu({ menuState, onAddNode }: QuickAddMenuProps) { diff --git a/frontend/src/components/canvas/VideoGeneratePanel.tsx b/frontend/src/components/canvas/VideoGeneratePanel.tsx index 05fb6b1..6a2711d 100644 --- a/frontend/src/components/canvas/VideoGeneratePanel.tsx +++ b/frontend/src/components/canvas/VideoGeneratePanel.tsx @@ -30,7 +30,6 @@ export type { VideoGeneratePanelProps } from './VideoGeneratePanel/types'; export default function VideoGeneratePanel(props: VideoGeneratePanelProps) { const { onSubmit, - onStop, isSubmitting, taskActive, taskDone, @@ -332,7 +331,6 @@ export default function VideoGeneratePanel(props: VideoGeneratePanelProps) { hasSelectedModel={!!form.selectedModel} showConfig={showConfig} onToggleConfig={() => setShowConfig((v) => !v)} - onStop={onStop} onSubmit={handleSubmit} />
diff --git a/frontend/src/components/canvas/VideoGeneratePanel/PanelActionButtons.tsx b/frontend/src/components/canvas/VideoGeneratePanel/PanelActionButtons.tsx index 9c85db4..791a69f 100644 --- a/frontend/src/components/canvas/VideoGeneratePanel/PanelActionButtons.tsx +++ b/frontend/src/components/canvas/VideoGeneratePanel/PanelActionButtons.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { Settings2, Send, Square, ArrowRight } from 'lucide-react'; +import { Settings2, Send, ArrowRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { useCreditsGuard } from '@/hooks/useCreditsGuard'; @@ -12,24 +12,27 @@ interface Props { hasSelectedModel: boolean; showConfig: boolean; onToggleConfig: () => void; - onStop: () => void; onSubmit: () => void; } -/** 面板右侧:设置按钮 + 停止/发送 按钮 */ +/** 面板右侧:设置按钮 + 发送按钮(任务进行中静默禁用) */ export function PanelActionButtons({ taskActive, canSubmit, hasSelectedModel, showConfig, onToggleConfig, - onStop, onSubmit, }: Props) { const { t } = useTranslation(); const { creditsExhausted, tooltipText } = useCreditsGuard(); - const submitDisabled = !canSubmit || creditsExhausted; - const submitTitle = creditsExhausted ? tooltipText : t('canvas.node.video.submit'); + // 任务进行中:发送按钮静默禁用(不再提供中断能力) + const submitDisabled = !canSubmit || creditsExhausted || taskActive; + const submitTitle = taskActive + ? t('canvas.node.video.generating', '生成中') + : creditsExhausted + ? tooltipText + : t('canvas.node.video.submit'); return ( <> @@ -48,31 +51,20 @@ export function PanelActionButtons({ - {taskActive ? ( - - ) : ( - - )} + ); } diff --git a/frontend/src/components/canvas/VideoGeneratePanel/types.ts b/frontend/src/components/canvas/VideoGeneratePanel/types.ts index fff2f73..5abacda 100644 --- a/frontend/src/components/canvas/VideoGeneratePanel/types.ts +++ b/frontend/src/components/canvas/VideoGeneratePanel/types.ts @@ -15,7 +15,6 @@ export interface FlatVideoModelItem { /** 对外 Props — 与原实现保持完全一致 */ export interface VideoGeneratePanelProps { onSubmit: (params: VideoCreateParams) => void; - onStop: () => void; isSubmitting: boolean; taskActive: boolean; taskDone: boolean; diff --git a/frontend/src/components/canvas/VideoNode.tsx b/frontend/src/components/canvas/VideoNode.tsx index 4ea0085..5cb470d 100644 --- a/frontend/src/components/canvas/VideoNode.tsx +++ b/frontend/src/components/canvas/VideoNode.tsx @@ -217,6 +217,8 @@ const VideoNode = ({ id, data, selected }: NodeProps>) => { id: uuidv4(), type: 'video', position: { x: pos.x - 128, y: pos.y - 96 }, + width: 512, + height: 384, data: { name: t('canvas.node.video.aiGenerated'), videoUrl: entry.url, @@ -359,7 +361,6 @@ const VideoNode = ({ id, data, selected }: NodeProps>) => { canvasNodes={canvasNodes} onTogglePinPanel={handleTogglePinPanel} onSubmit={gen.submit} - onStop={() => videoTask.reset()} onApplyToNode={gen.applyToNode} onApplyToNextNode={gen.applyToNextNode} onLinkNode={linkNode} diff --git a/frontend/src/components/canvas/VideoNode/GeneratePanelWrapper.tsx b/frontend/src/components/canvas/VideoNode/GeneratePanelWrapper.tsx index 693bc83..7314bee 100644 --- a/frontend/src/components/canvas/VideoNode/GeneratePanelWrapper.tsx +++ b/frontend/src/components/canvas/VideoNode/GeneratePanelWrapper.tsx @@ -92,7 +92,6 @@ interface PanelProps { canvasNodes: CanvasNode[]; onTogglePinPanel: (e?: React.MouseEvent) => void; onSubmit: (p: VideoCreateParams) => void; - onStop: () => void; onApplyToNode: () => void; onApplyToNextNode: () => void; onLinkNode: (sourceNodeId: string) => void; @@ -117,7 +116,6 @@ export function GeneratePanelWrapper({ canvasNodes, onTogglePinPanel, onSubmit, - onStop, onApplyToNode, onApplyToNextNode, onLinkNode, @@ -147,7 +145,6 @@ export function GeneratePanelWrapper({ { }); }); - it('image 行:→text=deferred, →panorama=deferred,其余 allow', () => { + it('image 行:→text=deferred,其余 allow(含 panorama)', () => { expect(EDGE_LEGALITY_MATRIX.image).toEqual({ - text: 'deferred', image: 'allow', video: 'allow', audio: 'allow', storyboard: 'allow', panorama: 'deferred', + text: 'deferred', image: 'allow', video: 'allow', audio: 'allow', storyboard: 'allow', panorama: 'allow', }); }); diff --git a/frontend/src/lib/canvas/edgePayload.ts b/frontend/src/lib/canvas/edgePayload.ts index c3ed430..85ec1f1 100644 --- a/frontend/src/lib/canvas/edgePayload.ts +++ b/frontend/src/lib/canvas/edgePayload.ts @@ -20,6 +20,7 @@ import type { VideoNodeData, AudioNodeData, StoryboardNodeData, + PanoramaNodeData, } from '@/store/useCanvasStore'; import { extractPlainTextFromTiptap } from '@/lib/nodeAttachmentUtils'; import type { PanelInjectEvent } from './panelEvents'; @@ -359,6 +360,24 @@ function injectToStoryboard(targetNode: CanvasNode, payload: EdgePayload): Injec return handler ? handler() : emptyResult; } +/** 下游 panorama:image → 直接写入 panoramaUrl(PSV 渲染单张等距柱状投影图) */ +function injectToPanorama(targetNode: CanvasNode, payload: EdgePayload): InjectionResult { + const data = targetNode.data as PanoramaNodeData; + const handlers: Partial InjectionResult>> = { + image: () => { + const p = payload as Extract; + // 已有全景图 → 保护原资产,不覆盖,给个 warn 提示 + const hasExisting = !!data.panoramaUrl; + if (hasExisting) { + return { warnings: ['全景节点已有图像,未覆盖;如需替换请先删除原图'] }; + } + return { dataPatch: { panoramaUrl: p.url, uploading: false } }; + }, + }; + const handler = handlers[payload.kind]; + return handler ? handler() : emptyResult; +} + // ── 主分发器 ── /** @@ -376,6 +395,7 @@ export function injectPayload( video: () => injectToVideo(targetNode, payload, sourceNodeId), audio: () => injectToAudio(targetNode, payload, sourceNodeId), storyboard: () => injectToStoryboard(targetNode, payload), + panorama: () => injectToPanorama(targetNode, payload), }; const handler = targetNode.type ? dispatch[targetNode.type] : null; return handler ? handler() : emptyResult; diff --git a/frontend/src/lib/canvas/edgeRules.ts b/frontend/src/lib/canvas/edgeRules.ts index f6feec9..0294fca 100644 --- a/frontend/src/lib/canvas/edgeRules.ts +++ b/frontend/src/lib/canvas/edgeRules.ts @@ -53,7 +53,7 @@ export const EDGE_LEGALITY_MATRIX: Record