Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/theater/[id]/hooks/useQuickAddMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const nodeDefaultData: Record<string, Record<string, unknown>> = {
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
Expand All @@ -19,6 +20,7 @@ const nodeDefaultDimensions: Record<string, { width: number; height: number }> =
video: { width: 512, height: 384 },
audio: { width: 360, height: 200 },
storyboard: { width: 398, height: 256 },
panorama: { width: 512, height: 320 },
};

export interface QuickAddMenuState {
Expand Down
48 changes: 46 additions & 2 deletions frontend/src/components/canvas/AudioGeneratePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,7 +52,6 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) {
const { t } = useTranslation();
const {
onSubmit,
onStop,
isSubmitting,
taskActive,
taskDone,
Expand Down Expand Up @@ -124,12 +125,56 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) {
const [showNodePicker, setShowNodePicker] = useState<boolean>(false);
const [showConfig, setShowConfig] = useState<boolean>(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<string, unknown>)?.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);
Expand Down Expand Up @@ -329,7 +374,6 @@ export default function AudioGeneratePanel(props: AudioGeneratePanelProps) {
hasSelectedModel={!!selectedModel}
showConfig={showConfig}
onToggleConfig={() => setShowConfig((v) => !v)}
onStop={onStop}
onSubmit={handleSubmit}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<>
Expand All @@ -48,31 +51,20 @@ export function PanelActionButtons({
<Settings2 className="w-4 h-4" />
</button>

{taskActive ? (
<button
type="button"
onClick={onStop}
className="h-8 w-8 rounded-lg bg-destructive hover:bg-destructive/90 text-destructive-foreground shadow-sm hover:shadow-md transition-all duration-200 flex items-center justify-center"
title={t('canvas.node.audio.stopGenerate', '停止生成')}
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className={cn(
'h-8 w-8 rounded-lg transition-all duration-200 flex items-center justify-center',
submitDisabled
? 'bg-muted text-muted-foreground cursor-not-allowed'
: 'bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md',
)}
title={submitTitle}
>
<Send className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onSubmit}
disabled={submitDisabled}
className={cn(
'h-8 w-8 rounded-lg transition-all duration-200 flex items-center justify-center',
submitDisabled
? 'bg-muted text-muted-foreground cursor-not-allowed'
: 'bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md',
)}
title={submitTitle}
>
<Send className="h-4 w-4" />
</button>
</>
);
}
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/canvas/AudioGeneratePanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export interface FlatMusicModelItem {
/** 对外 Props */
export interface AudioGeneratePanelProps {
onSubmit: (params: MusicCreateParams) => void;
onStop: () => void;
isSubmitting: boolean;
taskActive: boolean;
taskDone: boolean;
Expand Down
52 changes: 29 additions & 23 deletions frontend/src/components/canvas/AudioNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ const AudioNode = ({ id, data, selected }: NodeProps<Node<AudioNodeData>>) => {
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<HTMLDivElement>(null);

// 标题编辑
const [isEditingTitle, setIsEditingTitle] = useState(false);
Expand Down Expand Up @@ -197,26 +200,30 @@ const AudioNode = ({ id, data, selected }: NodeProps<Node<AudioNodeData>>) => {

const handleHistoryDragEnd = useCallback(
(e: DragEvent<HTMLDivElement>, 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) => {
Expand Down Expand Up @@ -259,7 +266,7 @@ const AudioNode = ({ id, data, selected }: NodeProps<Node<AudioNodeData>>) => {
data-testid="audio-file-upload-input"
/>

<div className="audio-node-wrapper w-full h-full flex flex-col group relative">
<div ref={nodeRef} className="audio-node-wrapper w-full h-full flex flex-col group relative">
<NodeEffectOverlay nodeId={id} />

<NodeHeader
Expand Down Expand Up @@ -299,7 +306,7 @@ const AudioNode = ({ id, data, selected }: NodeProps<Node<AudioNodeData>>) => {
{!audioUrl && !isUploading && !upload.uploadError && <EmptyPlaceholder />}

{audioUrl && !isUploading && (
<AudioDisplay audioUrl={audioUrl} lyrics={data.lyrics} />
<AudioDisplay audioUrl={audioUrl} lyrics={data.lyrics} selected={!!selected} />
)}

{isUploading && <UploadingOverlay progress={upload.uploadProgress} />}
Expand Down Expand Up @@ -342,7 +349,6 @@ const AudioNode = ({ id, data, selected }: NodeProps<Node<AudioNodeData>>) => {
canvasNodes={nodes}
onTogglePinPanel={handleTogglePinPanel}
onSubmit={gen.submit}
onStop={gen.musicTask.reset}
onApplyToNode={gen.applyToNode}
onApplyToNextNode={gen.applyToNextNode}
onLinkNode={linkNode}
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/components/canvas/AudioNode/AudioDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAudioElement>(null);
Expand Down Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -108,7 +107,6 @@ export function GeneratePanelWrapper({
canvasNodes,
onTogglePinPanel,
onSubmit,
onStop,
onApplyToNode,
onApplyToNextNode,
onLinkNode,
Expand Down Expand Up @@ -138,7 +136,6 @@ export function GeneratePanelWrapper({
</button>
<AudioGeneratePanel
onSubmit={onSubmit}
onStop={onStop}
isSubmitting={isSubmitting}
taskActive={taskActive}
taskDone={taskDone}
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/components/canvas/ImageGeneratePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export type { ImageRef, ImagePanelModeRequest, ImageGeneratePanelProps } from '.
export default function ImageGeneratePanel(props: ImageGeneratePanelProps) {
const {
onSubmit,
onStop,
isSubmitting,
taskActive,
taskDone,
Expand Down Expand Up @@ -272,7 +271,6 @@ export default function ImageGeneratePanel(props: ImageGeneratePanelProps) {
hasSelectedModel={!!form.selectedModel}
showConfig={showConfig}
onToggleConfig={() => setShowConfig((v) => !v)}
onStop={onStop}
onSubmit={handleSubmit}
/>
</div>
Expand Down
Loading