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
3 changes: 2 additions & 1 deletion backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@


# 画布节点类型常量
NODE_TYPES = {"script", "character", "storyboard", "video"}
# 注:保留 "script"/"character" 旧名以兼容历史数据;新节点统一使用迁移后名称。
NODE_TYPES = {"script", "character", "storyboard", "video", "panorama"}


# ---------------------------------------------------------------------------
Expand Down
23 changes: 15 additions & 8 deletions backend/services/tool_manager/providers/_canvas_edge_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
- 前端拒绝 → 后端放行:Agent 工具绕过校验

设计原则:
1. 矩阵写死为纯常量;5x5 结构与前端逐字母一致。
1. 矩阵写死为纯常量;6x6 结构与前端逐字母一致。
2. validate_edge 只判定合法性;不处理内容注入(后端当前版本仅做建边)。
3. 所有检查走早返回,避免嵌套 if。

Panorama 节点说明:
- panorama 行/列在 MVP 阶段全部为 'deferred'(仅自环 allow),表示与其他节点
类型的连线注入语义尚未在前端 edgePayload.ts 中实现;后续迭代再按需放行。
"""
from __future__ import annotations

Expand All @@ -28,24 +32,27 @@
"unknown_type",
]

NODE_TYPES: tuple[str, ...] = ("text", "image", "video", "audio", "storyboard")
NODE_TYPES: tuple[str, ...] = ("text", "image", "video", "audio", "storyboard", "panorama")

# 5x5 合法性矩阵(Source → Target)——必须与 edgeRules.md 第 4 节逐格对齐
# 6x6 合法性矩阵(Source → Target)——必须与前端 edgeRules.ts 逐格对齐
EDGE_LEGALITY_MATRIX: dict[str, dict[str, EdgeLegality]] = {
"text": {
"text": "allow", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow",
"text": "allow", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "deferred",
},
"image": {
"text": "deferred", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow",
"text": "deferred", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "deferred",
},
"video": {
"text": "deferred", "image": "allow", "video": "allow", "audio": "deferred", "storyboard": "allow",
"text": "deferred", "image": "allow", "video": "allow", "audio": "deferred", "storyboard": "allow", "panorama": "deferred",
},
"audio": {
"text": "deferred", "image": "forbid", "video": "allow", "audio": "deferred", "storyboard": "allow",
"text": "deferred", "image": "forbid", "video": "allow", "audio": "deferred", "storyboard": "allow", "panorama": "deferred",
},
"storyboard": {
"text": "allow", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow",
"text": "allow", "image": "allow", "video": "allow", "audio": "allow", "storyboard": "allow", "panorama": "deferred",
},
"panorama": {
"text": "deferred", "image": "deferred", "video": "deferred", "audio": "deferred", "storyboard": "deferred", "panorama": "allow",
},
}

Expand Down
1 change: 1 addition & 0 deletions backend/services/tool_manager/providers/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _migrate_node_type(node_type: str) -> str:
"video": {"name": str, "description": str, "videoUrl": str, "fitMode": str},
"audio": {"name": str, "description": str, "audioUrl": str, "lyrics": str},
"storyboard": {"shotNumber": str, "description": str, "duration": int, "pivotConfig": Any, "tableData": Any, "tableColumns": Any},
"panorama": {"name": str, "description": str, "panoramaUrl": str},
}

_DEFAULT_NODE_WIDTH = 420
Expand Down
16 changes: 16 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@e965/xlsx": "^0.20.3",
"@floating-ui/react": "^0.27.19",
"@paper-design/shaders-react": "^0.0.76",
"@photo-sphere-viewer/core": "^5.14.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/theater/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import CharacterNode from '@/components/canvas/ImageNode';
import StoryboardNode from '@/components/canvas/StoryboardNode';
import VideoNode from '@/components/canvas/VideoNode';
import AudioNode from '@/components/canvas/AudioNode';
import PanoramaNode from '@/components/canvas/PanoramaNode';
import GhostNode from '@/components/canvas/GhostNode';
import { CustomEdge } from '@/components/canvas/CustomEdge';
import { AIAssistantPanel } from '@/components/canvas/AIAssistantPanel';
Expand All @@ -50,6 +51,7 @@ const nodeTypes = {
storyboard: StoryboardNode,
video: VideoNode,
audio: AudioNode,
panorama: PanoramaNode,
ghost: GhostNode,
} as unknown as NodeTypes;

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/components/canvas/ImageGeneratePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ export default function ImageGeneratePanel(props: ImageGeneratePanelProps) {

const [showConfig, setShowConfig] = useState(false);

// 响应 modeRequest 中的 prompt / aspectRatio overrides(一键场景如“生成全景图”)
// - token 驱动:同一 token 只应用一次,避免覆盖用户后续修改
const appliedOverrideTokenRef = useRef<number | null>(null);
useEffect(() => {
const tok = modeRequest?.token ?? null;
const should = tok !== null && appliedOverrideTokenRef.current !== tok;
should && (() => {
appliedOverrideTokenRef.current = tok;
modeRequest!.promptOverride && form.setPrompt(modeRequest!.promptOverride);
modeRequest!.aspectRatioOverride && form.setAspectRatio(modeRequest!.aspectRatioOverride);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modeRequest]);

// 图像节点连线到图像节点的注入处理:
// 设计原则:连线 == 「源节点加入参考图列表」,**不**主动切换生成模式,由用户手动选择。
// - 源节点可能有多张图,这里只取第一张作为该源对应的参考图(一个源节点 → 一个参考图条目)。
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/canvas/ImageGeneratePanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ export interface ImageRef {
* 外部触发的模式切换请求(用于 ImageNode 工具条快捷按钮)。
* - token 变化视为一次新的请求;相同 token 不重复应用。
* - preselectImages 用于自动预设一或多张参考图(单图编辑传 1 张,多图参考传多张)。
* - promptOverride / aspectRatioOverride 用于一键场景(如“生成全景图”)自动填充提示词与画面比例。
*/
export interface ImagePanelModeRequest {
mode: ImageMode;
token: number;
preselectImages?: ImageRef[];
promptOverride?: string;
aspectRatioOverride?: string;
}

export interface ImageGeneratePanelProps {
Expand Down
62 changes: 60 additions & 2 deletions frontend/src/components/canvas/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CharacterNodeData,
CanvasNode,
ImageGenHistoryEntry,
type PanoramaNodeData,
} from '@/store/useCanvasStore';
import { useAIAssistantStore } from '@/store/useAIAssistantStore';
import { useResourceStore } from '@/store/useResourceStore';
Expand Down Expand Up @@ -65,7 +66,50 @@ const CharacterNode = ({ id, data, selected }: NodeProps<Node<CharacterNodeData>
}, [data.images, data.imageUrl]);

// ── AI 生成 ──
const gen = useImageGenerationApply(id, data);
// panoramaPendingRef:一键“生成全景图”意图标记。点击全景按钮后置 true;
// 生成完成时 onCustomApply 消费该标记并创建全景节点。其他快捷模式点击会重置为 false。
const panoramaPendingRef = useRef(false);

const handleCustomApply = useCallback((urls: string[]): boolean => {
const isPanorama = panoramaPendingRef.current;
panoramaPendingRef.current = false;
if (!isPanorama || urls.length === 0) return false;

// 创建全景节点(右侧偏移)
const currentNode = getNode(id);
const posX = (currentNode?.position.x ?? 0) + (currentNode?.measured?.width ?? 512) + 80;
const posY = currentNode?.position.y ?? 0;
const panoramaNodeId = uuidv4();
const panoramaNode: CanvasNode = {
id: panoramaNodeId,
type: 'panorama',
position: { x: posX, y: posY },
width: 512,
height: 320,
data: {
name: t('canvas.node.image.panoramaGenName', '生成全景图'),
panoramaUrl: urls[0],
uploading: false,
} as PanoramaNodeData,
};
addNode(panoramaNode);

// 程序化连线(绕过 deferred 矩阵校验)
const { edges } = useCanvasStore.getState();
const newEdge = {
id: uuidv4(),
source: id,
target: panoramaNodeId,
sourceHandle: 'right-source',
targetHandle: 'left-target',
type: 'custom' as const,
animated: true,
};
useCanvasStore.setState({ edges: [...edges, newEdge], isDirty: true });
return true;
}, [id, addNode, getNode, t]);

const gen = useImageGenerationApply(id, data, { onCustomApply: handleCustomApply });
const { imageTask, taskActive, taskDone, taskFailed, elapsedMs, prevImagesRef } = gen;

// ── 连线维护 ──
Expand Down Expand Up @@ -94,6 +138,19 @@ const CharacterNode = ({ id, data, selected }: NodeProps<Node<CharacterNodeData>
// ── 快捷模式切换 ──
const quick = useQuickImageMode(id, data, imageList, normalizeImageUrl);

// ── 一键生成全景图:填充面板参数,用户手动提交 ──
const handleGeneratePanoramaClick = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
panoramaPendingRef.current = true;
quick.handleGeneratePanorama(e);
}, [quick]);

// 其他快捷模式点击:重置全景意图标记(避免跨点击变为全景生成)
const handleQuickModeWrapped = useCallback((mode: 'text_to_image' | 'edit' | 'reference_images', e?: React.MouseEvent) => {
panoramaPendingRef.current = false;
quick.handleQuickMode(mode, e);
}, [quick]);

// ── 节点级 UI 状态 ──
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
const [showAddMenu, setShowAddMenu] = useState(false);
Expand Down Expand Up @@ -359,8 +416,9 @@ const CharacterNode = ({ id, data, selected }: NodeProps<Node<CharacterNodeData>
imageList={imageList}
showEditPicker={quick.showEditPicker}
pickerRef={quick.editPickerRef}
onQuickMode={quick.handleQuickMode}
onQuickMode={handleQuickModeWrapped}
onPickEditImage={quick.handlePickEditImage}
onGeneratePanorama={handleGeneratePanoramaClick}
/>
)}

Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/canvas/ImageNode/QuickModeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import React from 'react';
import { Wand2, Images } from 'lucide-react';
import { Wand2, Images, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import type { ImageMode } from '@/hooks/useImageGeneration';
Expand All @@ -13,6 +13,8 @@ interface Props {
pickerRef: React.RefObject<HTMLDivElement | null>;
onQuickMode: (mode: ImageMode, e?: React.MouseEvent) => void;
onPickEditImage: (url: string, e?: React.MouseEvent) => void;
/** 一键生成全景图:填充面板的参数(edit 模式 + 21:9 + 全景提示词) */
onGeneratePanorama?: (e?: React.MouseEvent) => void;
}

/**
Expand All @@ -26,6 +28,7 @@ export function QuickModeSwitcher({
pickerRef,
onQuickMode,
onPickEditImage,
onGeneratePanorama,
}: Props) {
const { t } = useTranslation();
return (
Expand Down Expand Up @@ -93,6 +96,17 @@ export function QuickModeSwitcher({
>
<Images className="h-3.5 w-3.5" />
</button>
{onGeneratePanorama && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onGeneratePanorama(e); }}
onPointerDown={(e) => e.stopPropagation()}
title={t('canvas.node.image.generatePanorama', '生成全景图')}
className="h-7 w-7 rounded-full bg-background/90 backdrop-blur-md border border-border/60 shadow-sm text-muted-foreground hover:text-foreground hover:bg-secondary active:scale-90 transition-all flex items-center justify-center"
>
<Globe className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
Expand Down
Loading