Initial commit: Pixel AI comic/video creation platform
- FastAPI backend with SQLModel, Alembic migrations, AgentScope agents - Next.js 15 frontend with React 19, Tailwind, Zustand, React Flow - Multi-provider AI system (DashScope, Kling, MiniMax, Volcengine, OpenAI, etc.) - All HTTP clients migrated from sync requests to async httpx - Admin-managed API keys via environment variables - SSRF vulnerability fixed in ensure_url()
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Layers,
|
||||
Play,
|
||||
X,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { useCanvasStore } from '@/lib/store/canvasStore';
|
||||
import { BatchService } from '@/lib/api';
|
||||
import { AppNodeType, NodeType } from '../types/node';
|
||||
|
||||
interface BatchGenerationToolbarProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BatchGenerationToolbar({ onClose }: BatchGenerationToolbarProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
taskCount?: number;
|
||||
} | null>(null);
|
||||
|
||||
const nodes = useCanvasStore((state) => state.nodes);
|
||||
const selectedNodes = nodes.filter(n => n.selected);
|
||||
|
||||
// 获取可生成的节点(有生成类型的节点)
|
||||
const generatableNodes = selectedNodes.filter(node => {
|
||||
if (node.type !== 'appNode') return false;
|
||||
const nodeData = node.data;
|
||||
return nodeData.type === NodeType.IMAGE_GENERATOR ||
|
||||
nodeData.type === NodeType.VIDEO_GENERATOR ||
|
||||
nodeData.type === NodeType.AUDIO_GENERATOR;
|
||||
});
|
||||
|
||||
const handleBatchGenerate = useCallback(async () => {
|
||||
if (generatableNodes.length === 0) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
// 构建批量生成请求
|
||||
const items = generatableNodes.map(node => {
|
||||
const data = node.data;
|
||||
// 将 NodeType 映射为 API 类型
|
||||
const typeMap: Record<string, string> = {
|
||||
'IMAGE_GENERATOR': 'image',
|
||||
'VIDEO_GENERATOR': 'video',
|
||||
'AUDIO_GENERATOR': 'audio'
|
||||
};
|
||||
const type = data.outputType || typeMap[data.type] || 'image';
|
||||
|
||||
return {
|
||||
type: type,
|
||||
prompt: data.prompt || data.title || '',
|
||||
model: data.model,
|
||||
aspect_ratio: data.aspectRatio || '16:9',
|
||||
resolution: data.resolution || '1K',
|
||||
duration: data.duration,
|
||||
voice: data.voice,
|
||||
image_inputs: data.imageInputs || [],
|
||||
extra_params: data.extraParams || {},
|
||||
source: 'canvas',
|
||||
source_id: node.id
|
||||
};
|
||||
});
|
||||
|
||||
const job = await BatchService.createJob(items);
|
||||
|
||||
setResult({
|
||||
success: job.status !== 'failed',
|
||||
message: `成功创建批量任务 (ID: ${job.id})`,
|
||||
taskCount: job.total
|
||||
});
|
||||
|
||||
// 3秒后自动关闭
|
||||
if (job.status !== 'failed') {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Batch generation failed:', error);
|
||||
setResult({
|
||||
success: false,
|
||||
message: '批量生成失败,请重试'
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [generatableNodes, onClose]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="absolute top-4 left-1/2 -translate-x-1/2 z-[100] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-4 min-w-[400px]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<Layers className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">批量生成</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {generatableNodes.length} 个生成节点
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 节点列表预览 */}
|
||||
{generatableNodes.length > 0 ? (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-2 mb-4">
|
||||
{generatableNodes.map((node, idx) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground w-6">{idx + 1}</span>
|
||||
<span className="text-sm text-white truncate flex-1">
|
||||
{node.data.title || node.data.prompt || '未命名节点'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
node.data.outputType === 'image' || node.data.type === NodeType.IMAGE_GENERATOR
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: node.data.outputType === 'video' || node.data.type === NodeType.VIDEO_GENERATOR
|
||||
? "bg-purple-500/20 text-purple-400"
|
||||
: node.data.outputType === 'audio' || node.data.type === NodeType.AUDIO_GENERATOR
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-yellow-500/20 text-yellow-400"
|
||||
)}>
|
||||
{node.data.outputType ? String(node.data.outputType) : String(node.data.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Layers className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">没有选择可生成的节点</p>
|
||||
<p className="text-xs opacity-60">请选择包含生成配置的节点</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果提示 */}
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={cn(
|
||||
"p-3 rounded-lg mb-4 flex items-center gap-2",
|
||||
result.success
|
||||
? "bg-green-500/10 border border-green-500/30"
|
||||
: "bg-red-500/10 border border-red-500/30"
|
||||
)}
|
||||
>
|
||||
{result.success ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
result.success ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{result.message}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm text-muted-foreground hover:text-white hover:bg-white/10 rounded-xl transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBatchGenerate}
|
||||
disabled={generatableNodes.length === 0 || isGenerating}
|
||||
className={cn(
|
||||
"flex-[2] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-xl transition-all",
|
||||
generatableNodes.length === 0 || isGenerating
|
||||
? "bg-white/5 text-muted-foreground cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90"
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
开始批量生成 ({generatableNodes.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
195
frontend/src/components/canvas/controls/CanvasContextMenu.tsx
Normal file
195
frontend/src/components/canvas/controls/CanvasContextMenu.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2, Copy, RefreshCw, Clipboard, Unplug, Music2, Folder, Ungroup
|
||||
} from 'lucide-react';
|
||||
import { NodeType, AppNodeType, AppNodeData, ContextMenuState, ContextMenuTarget } from '../types/node';
|
||||
import { getNodeIcon, getNodeNameCN } from '../utils';
|
||||
|
||||
interface CanvasContextMenuProps {
|
||||
contextMenu: ContextMenuState;
|
||||
target: ContextMenuTarget;
|
||||
nodes: AppNodeType[];
|
||||
clipboard: AppNodeType | null;
|
||||
|
||||
onClose: () => void;
|
||||
|
||||
// Actions
|
||||
onCreateNode: (type: NodeType, data?: Partial<AppNodeData>) => void;
|
||||
onCreateAndConnect: (type: NodeType, data?: Partial<AppNodeData>) => void;
|
||||
|
||||
// Node Actions
|
||||
onCopyNode: (nodeId: string) => void;
|
||||
onReplaceMedia: (nodeId: string, mediaType: 'image' | 'video') => void;
|
||||
onDeleteNode: (nodeId: string) => void;
|
||||
|
||||
// Global Actions
|
||||
onPaste: () => void;
|
||||
|
||||
// Connection Actions
|
||||
onDeleteEdge: (edgeId: string) => void;
|
||||
|
||||
// Group Actions
|
||||
onCreateGroup?: () => void;
|
||||
onUngroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
}
|
||||
|
||||
export const CanvasContextMenu: React.FC<CanvasContextMenuProps> = ({
|
||||
contextMenu,
|
||||
target,
|
||||
nodes,
|
||||
clipboard,
|
||||
onClose,
|
||||
onCreateNode,
|
||||
onCreateAndConnect,
|
||||
onCopyNode,
|
||||
onReplaceMedia,
|
||||
onDeleteNode,
|
||||
onPaste,
|
||||
onDeleteEdge,
|
||||
onCreateGroup,
|
||||
onUngroup,
|
||||
onDeleteGroup
|
||||
}) => {
|
||||
const createNodeOptions: Array<{
|
||||
key: string;
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
data?: Partial<AppNodeData>;
|
||||
}> = [
|
||||
{ key: NodeType.PROMPT_INPUT, type: NodeType.PROMPT_INPUT, label: getNodeNameCN(NodeType.PROMPT_INPUT), icon: getNodeIcon(NodeType.PROMPT_INPUT) },
|
||||
{ key: NodeType.LYRICS_GENERATOR, type: NodeType.LYRICS_GENERATOR, label: getNodeNameCN(NodeType.LYRICS_GENERATOR), icon: getNodeIcon(NodeType.LYRICS_GENERATOR), data: { title: '歌词生成' } },
|
||||
{ key: NodeType.PROMPT_GENERATOR, type: NodeType.PROMPT_GENERATOR, label: getNodeNameCN(NodeType.PROMPT_GENERATOR), icon: getNodeIcon(NodeType.PROMPT_GENERATOR) },
|
||||
{ key: NodeType.IMAGE_GENERATOR, type: NodeType.IMAGE_GENERATOR, label: getNodeNameCN(NodeType.IMAGE_GENERATOR), icon: getNodeIcon(NodeType.IMAGE_GENERATOR) },
|
||||
{ key: NodeType.VIDEO_GENERATOR, type: NodeType.VIDEO_GENERATOR, label: getNodeNameCN(NodeType.VIDEO_GENERATOR), icon: getNodeIcon(NodeType.VIDEO_GENERATOR) },
|
||||
{ key: NodeType.AUDIO_GENERATOR, type: NodeType.AUDIO_GENERATOR, label: getNodeNameCN(NodeType.AUDIO_GENERATOR), icon: getNodeIcon(NodeType.AUDIO_GENERATOR), data: { generationType: 'audio' } },
|
||||
{ key: `${NodeType.AUDIO_GENERATOR}-music`, type: NodeType.AUDIO_GENERATOR, label: '音乐生成', icon: Music2, data: { generationType: 'music', title: '音乐生成' } },
|
||||
{ key: NodeType.VIDEO_ANALYZER, type: NodeType.VIDEO_ANALYZER, label: getNodeNameCN(NodeType.VIDEO_ANALYZER), icon: getNodeIcon(NodeType.VIDEO_ANALYZER) },
|
||||
{ key: NodeType.IMAGE_EDITOR, type: NodeType.IMAGE_EDITOR, label: getNodeNameCN(NodeType.IMAGE_EDITOR), icon: getNodeIcon(NodeType.IMAGE_EDITOR) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[100] bg-card/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl p-1.5 min-w-[160px] animate-in fade-in zoom-in-95 duration-200 origin-top-left"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{target.type === 'handle-create' && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">添加并连接</div>
|
||||
{createNodeOptions.map((option) => {
|
||||
const ItemIcon = option.icon;
|
||||
return (
|
||||
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => { onCreateAndConnect(option.type, option.data); onClose(); }}>
|
||||
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{target.type === 'node' && target.id && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onCopyNode(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Copy size={12} /> 复制节点
|
||||
</button>
|
||||
{(() => {
|
||||
const targetNode = nodes.find(n => n.id === target.id);
|
||||
if (targetNode && targetNode.type === 'appNode') {
|
||||
const data = targetNode.data as AppNodeData;
|
||||
const isVideo = data.type === NodeType.VIDEO_GENERATOR || data.type === NodeType.VIDEO_ANALYZER;
|
||||
const isImage = data.type === NodeType.IMAGE_GENERATOR || data.type === NodeType.IMAGE_EDITOR;
|
||||
if (isVideo || isImage) {
|
||||
return (
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-purple-500/20 hover:text-purple-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onReplaceMedia(target.id!, isVideo ? 'video' : 'image');
|
||||
onClose();
|
||||
}}>
|
||||
<RefreshCw size={12} /> 替换素材
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
|
||||
onDeleteNode(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Trash2 size={12} /> 删除节点
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(target.type === 'create' || target.type === 'pane') && (
|
||||
<>
|
||||
{clipboard && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
|
||||
onPaste();
|
||||
onClose();
|
||||
}}>
|
||||
<Clipboard size={12} className="text-cyan-400" /> 粘贴节点
|
||||
</button>
|
||||
<div className="w-full h-px bg-white/10 my-1"></div>
|
||||
</>
|
||||
)}
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">创建新节点</div>
|
||||
{createNodeOptions.map((option) => {
|
||||
const ItemIcon = option.icon;
|
||||
return (
|
||||
<button key={option.key} className="w-full text-left px-3 py-2 text-xs font-medium text-foreground hover:bg-white/10 rounded-lg flex items-center gap-2.5 transition-colors" onClick={() => {
|
||||
onCreateNode(option.type, option.data);
|
||||
onClose();
|
||||
}}>
|
||||
<ItemIcon size={12} className="text-cyan-400" /> {option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{target.type === 'connection' && target.id && (
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onDeleteEdge(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Unplug size={12} /> 删除连接线
|
||||
</button>
|
||||
)}
|
||||
|
||||
{target.type === 'group' && target.id && (
|
||||
<>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onUngroup?.(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Ungroup size={12} /> 解散分组
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/20 rounded-lg flex items-center gap-2 transition-colors mt-1" onClick={() => {
|
||||
onDeleteGroup?.(target.id!);
|
||||
onClose();
|
||||
}}>
|
||||
<Trash2 size={12} /> 删除分组
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(target.type === 'create' || target.type === 'pane') && onCreateGroup && (
|
||||
<>
|
||||
<div className="w-full h-px bg-white/10 my-1"></div>
|
||||
<button className="w-full text-left px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-cyan-500/20 hover:text-cyan-400 rounded-lg flex items-center gap-2 transition-colors" onClick={() => {
|
||||
onCreateGroup();
|
||||
onClose();
|
||||
}}>
|
||||
<Folder size={12} /> 创建分组
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
frontend/src/components/canvas/controls/CanvasShortcuts.tsx
Normal file
78
frontend/src/components/canvas/controls/CanvasShortcuts.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Keyboard } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
export function CanvasShortcuts() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const shortcuts = [
|
||||
{ key: '⌘/Ctrl + S', desc: '保存' },
|
||||
{ key: '⌘/Ctrl + Z', desc: '撤销' },
|
||||
{ key: '⌘/Ctrl + ⇧ + Z', desc: '重做' },
|
||||
{ key: '⌘/Ctrl + A', desc: '全选' },
|
||||
{ key: '⌘/Ctrl + D', desc: '复制选中' },
|
||||
{ key: '⌘/Ctrl + G', desc: '创建分组' },
|
||||
{ key: '⌘/Ctrl + Q', desc: '生成队列' },
|
||||
{ key: '⌘/Ctrl + B', desc: '批量生成' },
|
||||
{ key: '⌘/Ctrl + T', desc: '提示词模板库' },
|
||||
{ key: '⌘/Ctrl + C', desc: '复制' },
|
||||
{ key: '⌘/Ctrl + V', desc: '粘贴' },
|
||||
{ key: 'Del / Backspace', desc: '删除' },
|
||||
{ key: 'Esc', desc: '取消选择' },
|
||||
{ key: 'Alt + ← / →', desc: '切换画布' },
|
||||
{ key: '⌘/Ctrl + 0', desc: '适应视图' },
|
||||
{ key: '⌘/Ctrl + +/-', desc: '缩放' },
|
||||
{ key: 'F11', desc: '全屏' },
|
||||
{ key: 'Space + 拖拽', desc: '平移画布' },
|
||||
{ key: 'Scroll', desc: '缩放画布' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"origin-bottom-right transition-all duration-300 ease-spring",
|
||||
isOpen
|
||||
? "opacity-100 scale-100 translate-y-0 mb-2"
|
||||
: "opacity-0 scale-90 translate-y-2 pointer-events-none absolute bottom-full right-0 mb-0"
|
||||
)}
|
||||
>
|
||||
<div className="w-64 p-4 bg-black/40 backdrop-blur-md border border-white/10 rounded-2xl shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-white/10">
|
||||
<span className="text-xs font-medium text-white/90">快捷键列表</span>
|
||||
<span className="text-[10px] text-muted-foreground">Keyboard Shortcuts</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-xs group">
|
||||
<span className="text-muted-foreground group-hover:text-white/80 transition-colors">{item.desc}</span>
|
||||
<kbd className="px-1.5 py-0.5 bg-white/5 group-hover:bg-white/10 rounded border border-white/5 text-white/90 font-mono text-[10px] min-w-[20px] text-center transition-colors">
|
||||
{item.key}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"group relative p-2 rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95",
|
||||
isOpen
|
||||
? "bg-white text-black border-transparent"
|
||||
: "bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 text-muted-foreground hover:text-white"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={18} strokeWidth={2} />
|
||||
|
||||
{/* Tooltip */}
|
||||
{!isOpen && (
|
||||
<div className="absolute right-full mr-3 top-1/2 -translate-y-1/2 px-2 py-1 bg-black/90 backdrop-blur-md rounded-md border border-white/10 text-[10px] text-white font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
快捷键
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useReactFlow, useViewport } from '@xyflow/react';
|
||||
import { Plus, Minus, Scan, Map, List } from 'lucide-react';
|
||||
|
||||
interface CanvasViewportControlsProps {
|
||||
isMiniMapOpen?: boolean;
|
||||
onToggleMiniMap?: () => void;
|
||||
onToggleQueuePanel?: () => void;
|
||||
isQueuePanelOpen?: boolean;
|
||||
}
|
||||
|
||||
export function CanvasViewportControls({
|
||||
isMiniMapOpen = false,
|
||||
onToggleMiniMap,
|
||||
onToggleQueuePanel,
|
||||
isQueuePanelOpen = false
|
||||
}: CanvasViewportControlsProps) {
|
||||
const { zoomIn, zoomOut, zoomTo, fitView } = useReactFlow();
|
||||
const { zoom } = useViewport();
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 left-6 flex items-center justify-between w-[240px] px-3 py-1.5 bg-black/40 backdrop-blur-md border border-white/10 rounded-full shadow-xl z-50 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<button
|
||||
onClick={() => zoomOut({ duration: 300 })}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
>
|
||||
<Minus size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 justify-center px-1">
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="4"
|
||||
step="0.1"
|
||||
value={zoom}
|
||||
onChange={(e) => zoomTo(parseFloat(e.target.value), { duration: 0 })}
|
||||
className="w-16 h-1 bg-white/20 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-lg hover:[&::-webkit-slider-thumb]:scale-125 transition-all shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] font-bold text-muted-foreground w-8 text-center tabular-nums cursor-pointer hover:text-foreground shrink-0"
|
||||
onClick={() => zoomTo(1, { duration: 300 })}
|
||||
title="Reset Zoom"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => zoomIn({ duration: 300 })}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
>
|
||||
<Plus size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
|
||||
<button
|
||||
onClick={() => fitView({
|
||||
duration: 300,
|
||||
padding: {
|
||||
top: 0.1,
|
||||
right: 0.15,
|
||||
bottom: 0.15,
|
||||
left: 0.12
|
||||
},
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.2
|
||||
})}
|
||||
className="p-1 text-muted-foreground hover:text-white transition-colors rounded-full hover:bg-white/10 shrink-0"
|
||||
title="适配视图"
|
||||
>
|
||||
<Scan size={12} strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
{onToggleMiniMap && (
|
||||
<>
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
<button
|
||||
onClick={onToggleMiniMap}
|
||||
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
|
||||
isMiniMapOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
|
||||
}`}
|
||||
title={isMiniMapOpen ? "隐藏小地图" : "显示小地图"}
|
||||
>
|
||||
<Map size={12} strokeWidth={3} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleQueuePanel && (
|
||||
<>
|
||||
<div className="w-px h-2.5 bg-white/10 shrink-0 mx-0.5" />
|
||||
<button
|
||||
onClick={onToggleQueuePanel}
|
||||
className={`p-1 transition-colors rounded-full hover:bg-white/10 shrink-0 ${
|
||||
isQueuePanelOpen ? 'text-white' : 'text-muted-foreground hover:text-white'
|
||||
}`}
|
||||
title={isQueuePanelOpen ? "隐藏生成队列" : "显示生成队列"}
|
||||
>
|
||||
<List size={12} strokeWidth={3} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/canvas/controls/ConnectionMenu.tsx
Normal file
83
frontend/src/components/canvas/controls/ConnectionMenu.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Video, Type, Image as ImageIcon,
|
||||
Edit, Mic2, Info, FileSearch, Sparkles, ScrollText
|
||||
} from 'lucide-react';
|
||||
import { NodeType } from '../types/node';
|
||||
import { cn } from '@/utils';
|
||||
import { VALID_CONNECTIONS } from '../hooks/useSocketInteraction';
|
||||
|
||||
interface ConnectionMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
sourceType?: string;
|
||||
onSelect: (type: NodeType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NODE_OPTIONS = [
|
||||
{ type: NodeType.PROMPT_INPUT, label: 'Prompt Input', icon: Type, color: 'text-amber-400' },
|
||||
{ type: NodeType.LYRICS_GENERATOR, label: 'Lyrics Generator', icon: ScrollText, color: 'text-indigo-400' },
|
||||
{ type: NodeType.PROMPT_GENERATOR, label: 'Prompt Optimizer', icon: Sparkles, color: 'text-fuchsia-400' },
|
||||
{ type: NodeType.IMAGE_GENERATOR, label: 'Image Generator', icon: ImageIcon, color: 'text-cyan-400' },
|
||||
{ type: NodeType.VIDEO_GENERATOR, label: 'Video Generator', icon: Video, color: 'text-purple-400' },
|
||||
{ type: NodeType.AUDIO_GENERATOR, label: 'Audio Generator', icon: Mic2, color: 'text-pink-400' },
|
||||
{ type: NodeType.VIDEO_ANALYZER, label: 'Video Analyzer', icon: FileSearch, color: 'text-emerald-400' },
|
||||
{ type: NodeType.IMAGE_EDITOR, label: 'Image Editor', icon: Edit, color: 'text-rose-400' },
|
||||
{ type: NodeType.INFO_DISPLAY, label: 'Info Display', icon: Info, color: 'text-blue-400' },
|
||||
];
|
||||
|
||||
export const ConnectionMenu = ({ x, y, sourceType, onSelect, onClose }: ConnectionMenuProps) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter options based on source type validity
|
||||
const options = React.useMemo(() => {
|
||||
if (!sourceType) return NODE_OPTIONS;
|
||||
const validTargets = VALID_CONNECTIONS[sourceType] || [];
|
||||
return NODE_OPTIONS.filter(opt => validTargets.includes(opt.type));
|
||||
}, [sourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-black/80 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl p-2 min-w-[200px] animate-in fade-in zoom-in-95 duration-200"
|
||||
style={{
|
||||
left: x,
|
||||
top: y,
|
||||
}}
|
||||
>
|
||||
<div className="text-xs font-bold text-muted-foreground px-2 py-1 mb-1 uppercase tracking-wider">
|
||||
Add Node
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onSelect(option.type)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors text-left group"
|
||||
>
|
||||
<option.icon size={16} className={cn(option.color, "group-hover:scale-110 transition-transform")} />
|
||||
<span className="text-gray-200 font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
434
frontend/src/components/canvas/controls/ImageCropper.tsx
Normal file
434
frontend/src/components/canvas/controls/ImageCropper.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { X, Check, Crop, Move } from 'lucide-react';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { useCanvasEvents } from '../hooks/useCanvasEvents';
|
||||
import { cropImage, type CropRect } from '@/utils/mediaUtils';
|
||||
|
||||
const RATIOS = [
|
||||
{ label: '自由', value: null },
|
||||
{ label: '16:9', value: 16/9 },
|
||||
{ label: '9:16', value: 9/16 },
|
||||
{ label: '4:3', value: 4/3 },
|
||||
{ label: '3:4', value: 3/4 },
|
||||
{ label: '1:1', value: 1 },
|
||||
];
|
||||
|
||||
type InteractionType = 'create' | 'move' | 'resize';
|
||||
type ResizeHandle = 'nw' | 'ne' | 'sw' | 'se';
|
||||
|
||||
export const ImageCropper: React.FC = () => {
|
||||
const { cropRequest, setCropRequest } = useCanvasEvents();
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
const imageSrc = cropRequest?.src || '';
|
||||
|
||||
const onConfirm = (croppedBase64: string) => {
|
||||
if (!cropRequest) return;
|
||||
setNodes((nds) => nds.map(n => n.id === cropRequest.nodeId && n.type === 'appNode' ? { ...n, data: { ...n.data, croppedFrame: croppedBase64 } } : n));
|
||||
setCropRequest(null);
|
||||
};
|
||||
|
||||
const onCancel = () => setCropRequest(null);
|
||||
|
||||
const [crop, setCrop] = useState<CropRect | null>(null);
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null); // null means free
|
||||
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Interaction State
|
||||
const [interaction, setInteraction] = useState<{
|
||||
type: InteractionType;
|
||||
handle?: ResizeHandle;
|
||||
startPos: { x: number; y: number };
|
||||
startCrop: CropRect | null;
|
||||
}>({ type: 'create', startPos: { x: 0, y: 0 }, startCrop: null });
|
||||
|
||||
const getRelativePos = (e: React.MouseEvent | MouseEvent) => {
|
||||
if (!imgRef.current) return { x: 0, y: 0 };
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
rawX: e.clientX,
|
||||
rawY: e.clientY
|
||||
};
|
||||
};
|
||||
|
||||
// Helper: Constrain a rectangle within image bounds (maxW, maxH)
|
||||
// Ensures x, y >= 0 and x+w <= maxW, y+h <= maxH
|
||||
const clampRect = (rect: CropRect, maxW: number, maxH: number): CropRect => {
|
||||
let { x, y, width, height } = rect;
|
||||
|
||||
// Basic clamping
|
||||
if (x < 0) x = 0;
|
||||
if (y < 0) y = 0;
|
||||
if (width > maxW) width = maxW;
|
||||
if (height > maxH) height = maxH;
|
||||
|
||||
if (x + width > maxW) x = maxW - width;
|
||||
if (y + height > maxH) y = maxH - height;
|
||||
|
||||
return { x, y, width, height };
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, type: InteractionType, handle?: ResizeHandle) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const pos = getRelativePos(e);
|
||||
|
||||
// If starting a NEW creation, clear the old crop unless we clicked on handles or existing crop
|
||||
let startCrop = crop;
|
||||
if (type === 'create') {
|
||||
startCrop = { x: pos.x, y: pos.y, width: 0, height: 0 };
|
||||
setCrop(startCrop);
|
||||
}
|
||||
|
||||
setInteraction({
|
||||
type,
|
||||
handle,
|
||||
startPos: { x: pos.x, y: pos.y },
|
||||
startCrop: startCrop ? { ...startCrop } : null
|
||||
});
|
||||
};
|
||||
|
||||
const handleGlobalMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!imgRef.current || !interaction.startCrop) return;
|
||||
|
||||
// Only process if mouse button is down (safety check)
|
||||
if (e.buttons === 0) {
|
||||
setInteraction(prev => ({ ...prev, type: 'create' })); // Reset to default
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper to get relative pos inside callback
|
||||
const getPos = (evt: MouseEvent) => {
|
||||
if (!imgRef.current) return { x: 0, y: 0 };
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
return {
|
||||
x: evt.clientX - rect.left,
|
||||
y: evt.clientY - rect.top
|
||||
};
|
||||
};
|
||||
|
||||
const pos = getPos(e);
|
||||
const maxW = imgRef.current.width;
|
||||
const maxH = imgRef.current.height;
|
||||
const { startPos, startCrop } = interaction;
|
||||
|
||||
if (interaction.type === 'move') {
|
||||
const dx = pos.x - startPos.x;
|
||||
const dy = pos.y - startPos.y;
|
||||
|
||||
const newRect = {
|
||||
...startCrop,
|
||||
x: startCrop.x + dx,
|
||||
y: startCrop.y + dy
|
||||
};
|
||||
|
||||
setCrop(clampRect(newRect, maxW, maxH));
|
||||
}
|
||||
else if (interaction.type === 'create') {
|
||||
let currentX = Math.max(0, Math.min(pos.x, maxW));
|
||||
let currentY = Math.max(0, Math.min(pos.y, maxH));
|
||||
|
||||
// Use startCrop.x/y as anchor (which was set to mouseDown pos)
|
||||
const anchorX = startCrop.x;
|
||||
const anchorY = startCrop.y;
|
||||
|
||||
let width = Math.abs(currentX - anchorX);
|
||||
let height = Math.abs(currentY - anchorY);
|
||||
|
||||
// Apply Aspect Ratio
|
||||
if (aspectRatio) {
|
||||
if (width / height > aspectRatio) {
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
const dirX = currentX >= anchorX ? 1 : -1;
|
||||
const dirY = currentY >= anchorY ? 1 : -1;
|
||||
|
||||
let x = anchorX + (dirX === -1 ? -width : 0);
|
||||
let y = anchorY + (dirY === -1 ? -height : 0);
|
||||
|
||||
// Boundary Check for Create
|
||||
if (x < 0) { x = 0; if (aspectRatio) height = width/aspectRatio; }
|
||||
if (y < 0) { y = 0; if (aspectRatio) width = height*aspectRatio; }
|
||||
if (x + width > maxW) {
|
||||
// Simple clamp by shifting x if possible, or reducing size
|
||||
if (dirX === 1) width = maxW - x;
|
||||
else x = maxW - width;
|
||||
if (aspectRatio) height = width / aspectRatio;
|
||||
}
|
||||
if (y + height > maxH) {
|
||||
if (dirY === 1) height = maxH - y;
|
||||
else y = maxH - height;
|
||||
if (aspectRatio) width = height * aspectRatio;
|
||||
}
|
||||
|
||||
setCrop({ x, y, width, height });
|
||||
}
|
||||
else if (interaction.type === 'resize' && interaction.handle) {
|
||||
// Resizing logic
|
||||
// 1. Determine Anchor Point (Opposite to handle)
|
||||
let anchorX = 0, anchorY = 0;
|
||||
switch (interaction.handle) {
|
||||
case 'nw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y + startCrop.height; break;
|
||||
case 'ne': anchorX = startCrop.x; anchorY = startCrop.y + startCrop.height; break;
|
||||
case 'sw': anchorX = startCrop.x + startCrop.width; anchorY = startCrop.y; break;
|
||||
case 'se': anchorX = startCrop.x; anchorY = startCrop.y; break;
|
||||
}
|
||||
|
||||
// 2. Calculate raw new dimensions based on mouse pos relative to anchor
|
||||
// We do NOT clamp mouse pos here strictly yet, we calculate desired rect then fit.
|
||||
const currentX = Math.max(0, Math.min(pos.x, maxW));
|
||||
const currentY = Math.max(0, Math.min(pos.y, maxH));
|
||||
|
||||
let newW = Math.abs(currentX - anchorX);
|
||||
let newH = Math.abs(currentY - anchorY);
|
||||
|
||||
// 3. Apply Aspect Ratio
|
||||
if (aspectRatio) {
|
||||
// Standard projection: take the larger dimension change or just prefer width?
|
||||
// Let's rely on the handle direction.
|
||||
// For corners, usually we pick the dimension that results in a larger box?
|
||||
// Or typically width drives height for stability.
|
||||
// Let's use width to drive height for consistent feel.
|
||||
newH = newW / aspectRatio;
|
||||
|
||||
// Check if this height causes Y to go out of bounds?
|
||||
// If dragging SE, Y must be <= maxH.
|
||||
// If dragging NE, Y must be >= 0.
|
||||
const isNorth = interaction.handle.includes('n');
|
||||
const projectedY = isNorth ? anchorY - newH : anchorY + newH;
|
||||
|
||||
if (projectedY < 0 || projectedY > maxH) {
|
||||
// Width-based height failed bounds, try Height-based width
|
||||
newH = Math.abs(currentY - anchorY); // Revert to raw Y
|
||||
newW = newH * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reconstruct Rect
|
||||
let newX = interaction.handle.includes('w') ? anchorX - newW : anchorX;
|
||||
let newY = interaction.handle.includes('n') ? anchorY - newH : anchorY;
|
||||
|
||||
// 5. Final Clamp (Double safety)
|
||||
if (newX < 0) newX = 0;
|
||||
if (newY < 0) newY = 0;
|
||||
if (newX + newW > maxW) newW = maxW - newX;
|
||||
if (newY + newH > maxH) newH = maxH - newY;
|
||||
|
||||
// If clamp broke aspect ratio, strict re-calc?
|
||||
// For cropping tool, slight drift is annoying, but hard clamp is better than broken UI.
|
||||
// We'll leave it as is, usually user corrects mouse.
|
||||
|
||||
setCrop({ x: newX, y: newY, width: newW, height: newH });
|
||||
}
|
||||
}, [interaction, aspectRatio]);
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
// Just reset to create/none state
|
||||
setInteraction(prev => ({ ...prev, type: 'create', startCrop: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [interaction, handleGlobalMouseMove]);
|
||||
|
||||
// Adjust existing crop when ratio changes
|
||||
useEffect(() => {
|
||||
if (crop && aspectRatio && crop.width > 0 && crop.height > 0) {
|
||||
// Keep center, adjust size
|
||||
const centerX = crop.x + crop.width / 2;
|
||||
const centerY = crop.y + crop.height / 2;
|
||||
|
||||
let newW = crop.width;
|
||||
let newH = newW / aspectRatio;
|
||||
|
||||
if (newH > (imgRef.current?.height || 0)) {
|
||||
newH = imgRef.current?.height || 0;
|
||||
newW = newH * aspectRatio;
|
||||
}
|
||||
|
||||
let newX = centerX - newW / 2;
|
||||
let newY = centerY - newH / 2;
|
||||
|
||||
if (imgRef.current) {
|
||||
const rect = clampRect({ x: newX, y: newY, width: newW, height: newH }, imgRef.current.width, imgRef.current.height);
|
||||
setCrop(rect);
|
||||
}
|
||||
}
|
||||
}, [aspectRatio, crop]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!imgRef.current || !crop || crop.width === 0) { onConfirm(imageSrc); return; }
|
||||
const canvas = document.createElement('canvas');
|
||||
const sx = imgRef.current.naturalWidth / imgRef.current.width;
|
||||
const sy = imgRef.current.naturalHeight / imgRef.current.height;
|
||||
|
||||
canvas.width = crop.width * sx;
|
||||
canvas.height = crop.height * sy;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(imgRef.current, crop.x * sx, crop.y * sy, crop.width * sx, crop.height * sy, 0, 0, crop.width * sx, crop.height * sy);
|
||||
onConfirm(canvas.toDataURL('image/png'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!cropRequest) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center animate-in fade-in duration-300">
|
||||
|
||||
{/* Top Bar: Title */}
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2">
|
||||
<div className="bg-muted/90 backdrop-blur-md px-6 py-2.5 rounded-full border border-white/10 text-muted-foreground text-xs font-medium flex items-center gap-2 shadow-2xl">
|
||||
<Crop size={14} className="text-cyan-400" />
|
||||
<span>局部分镜截取</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">拖拽四角调整 • 按住中间移动</span>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative max-w-[85vw] max-h-[65vh] border border-white/10 shadow-2xl rounded-lg overflow-hidden select-none bg-black/50 group"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'create')}
|
||||
>
|
||||
<SecureImage ref={imgRef} src={imageSrc} className="max-w-full max-h-[65vh] object-contain block opacity-50" draggable={false} crossOrigin="anonymous" />
|
||||
|
||||
{/* Active Crop Area */}
|
||||
{crop && crop.width > 0 && (
|
||||
<div className="absolute" style={{ left: crop.x, top: crop.y, width: crop.width, height: crop.height }}>
|
||||
{/* 1. Clear Image View Inside */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<SecureImage
|
||||
src={imageSrc}
|
||||
className="absolute max-w-none"
|
||||
style={{
|
||||
width: imgRef.current?.width,
|
||||
height: imgRef.current?.height,
|
||||
left: -crop.x,
|
||||
top: -crop.y,
|
||||
opacity: 1
|
||||
}}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Dark Overlay Outline (Outside shadow trick) */}
|
||||
<div className="absolute inset-0 shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] pointer-events-none" />
|
||||
|
||||
{/* 3. Grid & Border */}
|
||||
<div className="absolute inset-0 border-2 border-cyan-400 z-10 pointer-events-none">
|
||||
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 opacity-40">
|
||||
<div className="border-r border-white/50"/><div className="border-r border-white/50"/><div className="col-span-3 border-b border-white/50 -mt-[33%]"/><div className="col-span-3 border-b border-white/50 mt-[33%]"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Move Handler (Invisible Center) */}
|
||||
<div
|
||||
className="absolute inset-0 z-20 cursor-move group/move"
|
||||
onMouseDown={(e) => handleMouseDown(e, 'move')}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/move:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/50 p-2 rounded-full backdrop-blur-sm">
|
||||
<Move size={16} className="text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Resize Handles (Corners) */}
|
||||
{['nw', 'ne', 'sw', 'se'].map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className={`
|
||||
absolute w-4 h-4 bg-white border-2 border-cyan-500 rounded-full z-30 shadow-sm
|
||||
hover:scale-125 transition-transform
|
||||
`}
|
||||
style={{
|
||||
cursor: `${h}-resize`,
|
||||
left: h.includes('w') ? -8 : 'auto',
|
||||
right: h.includes('e') ? -8 : 'auto',
|
||||
top: h.includes('n') ? -8 : 'auto',
|
||||
bottom: h.includes('s') ? -8 : 'auto',
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'resize', h as ResizeHandle)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Size Label */}
|
||||
<div className="absolute -top-7 left-0 flex gap-2 z-20 pointer-events-none">
|
||||
<div className="bg-cyan-500 text-black text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
|
||||
{Math.round(crop.width)} × {Math.round(crop.height)}
|
||||
</div>
|
||||
{aspectRatio && (
|
||||
<div className="bg-black/60 text-cyan-400 border border-cyan-500/30 text-[9px] font-bold px-1.5 py-0.5 rounded-sm shadow-md">
|
||||
{RATIOS.find(r => r.value === aspectRatio)?.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar: Aspect Ratios & Actions */}
|
||||
<div className="flex flex-col items-center gap-6 mt-8 w-full max-w-2xl px-4">
|
||||
|
||||
{/* Aspect Ratio Selector */}
|
||||
<div className="flex items-center gap-2 p-1 bg-card border border-white/10 rounded-xl shadow-lg overflow-x-auto custom-scrollbar max-w-full">
|
||||
{RATIOS.map(ratio => (
|
||||
<button
|
||||
key={ratio.label}
|
||||
onClick={() => setAspectRatio(ratio.value)}
|
||||
className={`
|
||||
relative px-4 py-2 rounded-lg text-xs font-bold transition-all whitespace-nowrap
|
||||
${aspectRatio === ratio.value
|
||||
? 'bg-cyan-500 text-black shadow-md scale-105 z-10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{ratio.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<button onClick={onCancel} className="px-6 py-2.5 rounded-full bg-white/5 hover:bg-white/10 text-white text-xs font-medium transition-colors border border-white/5">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!crop || crop.width === 0}
|
||||
className={`
|
||||
px-8 py-2.5 rounded-full text-xs font-bold shadow-lg transition-all flex items-center gap-2
|
||||
${(!crop || crop.width === 0)
|
||||
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
|
||||
: 'bg-cyan-500 hover:bg-cyan-400 text-black hover:scale-105 shadow-cyan-500/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Check size={14}/> 确认裁剪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
321
frontend/src/components/canvas/controls/LoadingProgressBar.tsx
Normal file
321
frontend/src/components/canvas/controls/LoadingProgressBar.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Loading Progress Bar Component
|
||||
*
|
||||
* Displays a progress bar during canvas loading with stage indicators.
|
||||
* Shows the current loading stage and progress percentage.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { LoadingStage } from '../managers/useProgressiveLoading';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export interface LoadingProgressBarProps {
|
||||
/** Current loading stage */
|
||||
stage: LoadingStage;
|
||||
/** Loading progress (0-100) */
|
||||
progress: number;
|
||||
/** Whether loading is in progress */
|
||||
isLoading: boolean;
|
||||
/** Error message if loading failed */
|
||||
error?: string;
|
||||
/** Optional className for styling */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for each loading stage
|
||||
*/
|
||||
function getStageMessage(stage: LoadingStage): string {
|
||||
switch (stage) {
|
||||
case 'idle':
|
||||
return '初始化中...';
|
||||
case 'structure':
|
||||
return '加载画布结构...';
|
||||
case 'content':
|
||||
return '加载图片和视频...';
|
||||
case 'complete':
|
||||
return '加载完成!';
|
||||
case 'error':
|
||||
return '加载失败';
|
||||
default:
|
||||
return '加载中...';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stage index for progress indicator
|
||||
*/
|
||||
function getStageIndex(stage: LoadingStage): number {
|
||||
switch (stage) {
|
||||
case 'idle':
|
||||
return 0;
|
||||
case 'structure':
|
||||
return 1;
|
||||
case 'content':
|
||||
return 2;
|
||||
case 'complete':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading Progress Bar Component
|
||||
*/
|
||||
export function LoadingProgressBar({
|
||||
stage,
|
||||
progress,
|
||||
isLoading,
|
||||
error,
|
||||
className = '',
|
||||
}: LoadingProgressBarProps) {
|
||||
// Don't show if not loading and no error
|
||||
if (!isLoading && !error && stage === 'complete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentStageIndex = getStageIndex(stage);
|
||||
const stageMessage = getStageMessage(stage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm ${className}`}
|
||||
>
|
||||
<div className="w-full max-w-md mx-4 p-6 bg-card/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl">
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-red-500">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
加载失败
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{!error && (
|
||||
<>
|
||||
{/* Stage Indicators */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Stage 1: Structure */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 1
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex > 1 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">1</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 1
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
结构
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 2
|
||||
? 'bg-cyan-500'
|
||||
: 'bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Stage 2: Content */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 2
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex > 2 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">2</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 2
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
内容
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 3
|
||||
? 'bg-cyan-500'
|
||||
: 'bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Stage 3: Complete */}
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-300 ${
|
||||
currentStageIndex >= 3
|
||||
? 'bg-cyan-500 text-white shadow-lg shadow-cyan-500/50'
|
||||
: 'bg-white/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{currentStageIndex >= 3 ? (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<span className="text-sm font-bold">3</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium transition-colors ${
|
||||
currentStageIndex >= 3
|
||||
? 'text-white'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
完成
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && (
|
||||
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{stageMessage}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white tabular-nums">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Loading Progress Bar (for in-canvas display)
|
||||
*/
|
||||
export function CompactLoadingProgressBar({
|
||||
stage,
|
||||
progress,
|
||||
isLoading,
|
||||
className = '',
|
||||
}: Omit<LoadingProgressBarProps, 'error'>) {
|
||||
// Don't show if not loading
|
||||
if (!isLoading || stage === 'complete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stageMessage = getStageMessage(stage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 left-1/2 -translate-x-1/2 z-50 ${className}`}
|
||||
>
|
||||
<div className="px-4 py-2 bg-card/95 backdrop-blur-xl border border-white/10 rounded-full shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-4 h-4 text-cyan-500 animate-spin" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stageMessage}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-white tabular-nums">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-24 h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
frontend/src/components/canvas/controls/OnboardingTour.tsx
Normal file
270
frontend/src/components/canvas/controls/OnboardingTour.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronRight, ChevronLeft, Sparkles, MousePointer, Keyboard, Zap, Check } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
target?: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
const onboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: '欢迎使用 Pixel',
|
||||
description: 'Pixel 是一个 AI 驱动的漫画和视频创作平台。让我们花几分钟了解一下核心功能。',
|
||||
icon: <Sparkles className="w-6 h-6 text-cyan-400" />,
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
id: 'canvas',
|
||||
title: '画布工作区',
|
||||
description: '这是你的创作画布。你可以在这里添加节点、连接它们来构建创作流程。拖拽左侧的节点到画布上开始创作。',
|
||||
icon: <MousePointer className="w-6 h-6 text-purple-400" />,
|
||||
target: '.react-flow__pane',
|
||||
position: 'center'
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
title: '左侧工具栏',
|
||||
description: '这里提供了各种 AI 生成节点:图像生成、视频生成、剧本处理等。点击或拖拽到画布上使用。',
|
||||
icon: <Zap className="w-6 h-6 text-yellow-400" />,
|
||||
target: '[data-panel="left-dock"]',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
title: '快捷键',
|
||||
description: '使用快捷键可以提高创作效率。Ctrl+S 保存,Ctrl+Z 撤销,Ctrl+A 全选。点击右下角的键盘图标查看全部快捷键。',
|
||||
icon: <Keyboard className="w-6 h-6 text-green-400" />,
|
||||
target: '[data-shortcut-button]',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: '准备开始创作',
|
||||
description: '你已经了解了基本操作。开始你的第一个 AI 创作项目吧!如有问题,随时查看帮助文档。',
|
||||
icon: <Check className="w-6 h-6 text-emerald-400" />,
|
||||
position: 'center'
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'pixel-onboarding-completed';
|
||||
|
||||
export function OnboardingTour() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [hasCompleted, setHasCompleted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has completed onboarding
|
||||
const completed = localStorage.getItem(STORAGE_KEY);
|
||||
if (!completed) {
|
||||
// Show onboarding after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(true);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setHasCompleted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setHasCompleted(true);
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep < onboardingSteps.length - 1) {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
}, [currentStep, handleComplete]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setHasCompleted(true);
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setHasCompleted(false);
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const step = onboardingSteps[currentStep];
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === onboardingSteps.length - 1;
|
||||
const progress = ((currentStep + 1) / onboardingSteps.length) * 100;
|
||||
|
||||
if (!isOpen) {
|
||||
// Show restart button if user has completed but wants to see it again
|
||||
if (hasCompleted) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="fixed bottom-6 left-6 z-50 p-2 bg-black/40 hover:bg-black/60 backdrop-blur-md border border-white/10 rounded-full text-muted-foreground hover:text-white transition-all"
|
||||
title="重新查看新手引导"
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9998] bg-black/60 backdrop-blur-sm"
|
||||
onClick={handleSkip}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Onboarding Card */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={cn(
|
||||
"fixed z-[9999] w-[400px] bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden",
|
||||
step.position === 'center' && "top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
step.position === 'top' && "bottom-24 right-6",
|
||||
step.position === 'bottom' && "top-24 left-1/2 -translate-x-1/2",
|
||||
step.position === 'left' && "top-1/2 right-6 -translate-y-1/2",
|
||||
step.position === 'right' && "top-1/2 left-6 -translate-y-1/2"
|
||||
)}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-cyan-500 to-purple-500"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-3 right-3 p-1.5 text-muted-foreground hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Icon */}
|
||||
<div className="w-14 h-14 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center mb-4">
|
||||
{step.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-1.5 mb-6">
|
||||
{onboardingSteps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
index === currentStep
|
||||
? "bg-cyan-400"
|
||||
: index < currentStep
|
||||
? "bg-white/40"
|
||||
: "bg-white/10"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={isFirstStep}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-2 text-sm rounded-lg transition-colors",
|
||||
isFirstStep
|
||||
? "text-muted-foreground/50 cursor-not-allowed"
|
||||
: "text-muted-foreground hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
上一步
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isLastStep && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-3 py-2 text-sm text-muted-foreground hover:text-white transition-colors"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
{isLastStep ? '完成' : '下一步'}
|
||||
{!isLastStep && <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetOnboardingButton() {
|
||||
const handleReset = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-muted-foreground hover:text-white underline"
|
||||
>
|
||||
重新显示新手引导
|
||||
</button>
|
||||
);
|
||||
}
|
||||
519
frontend/src/components/canvas/controls/SketchEditor.tsx
Normal file
519
frontend/src/components/canvas/controls/SketchEditor.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
X, Brush, Eraser, Palette, Undo, Trash2,
|
||||
Download, Play, Image as ImageIcon,
|
||||
Activity, Wand2, Loader2, ChevronDown, Layers
|
||||
} from 'lucide-react';
|
||||
import { generateImage, generateVideo } from '../services/canvasService';
|
||||
import { useModelStore } from '@lib/store/modelStore';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { handleError } from '@/lib/services/errorHandler';
|
||||
import { logger } from '@/lib/utils/logger';
|
||||
|
||||
const log = logger.namespace('SketchEditor');
|
||||
|
||||
interface SketchEditorProps {
|
||||
onClose: () => void;
|
||||
onGenerate: (type: 'image' | 'video', result: string, prompt: string) => void;
|
||||
}
|
||||
|
||||
type Tool = 'brush' | 'eraser';
|
||||
type Mode = 'video' | 'image' | 'pose';
|
||||
|
||||
// Colors for the palette
|
||||
export const PRESET_COLORS = [
|
||||
'#000000', '#ffffff', '#ff3b30', '#ff9500',
|
||||
'#ffcc00', '#4cd964', '#5ac8fa', '#007aff',
|
||||
'#5856d6', '#ff2d55', '#8e8e93'
|
||||
];
|
||||
|
||||
export const SketchEditor: React.FC<SketchEditorProps> = ({ onClose, onGenerate }) => {
|
||||
// Models
|
||||
const { imageModels, videoModels } = useModelStore();
|
||||
const [selectedImageModel, setSelectedImageModel] = useState<string>('');
|
||||
const [selectedVideoModel, setSelectedVideoModel] = useState<string>('');
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImageModel && Object.keys(imageModels).length > 0) {
|
||||
setSelectedImageModel(Object.keys(imageModels)[0]);
|
||||
}
|
||||
}, [imageModels, selectedImageModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVideoModel && Object.keys(videoModels).length > 0) {
|
||||
setSelectedVideoModel(Object.keys(videoModels)[0]);
|
||||
}
|
||||
}, [videoModels, selectedVideoModel]);
|
||||
|
||||
// Canvas & Drawing State
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [tool, setTool] = useState<Tool>('brush');
|
||||
const [brushColor, setBrushColor] = useState('#000000');
|
||||
const [brushSize] = useState(5);
|
||||
const [eraserSize] = useState(30);
|
||||
const [canvasHistory, setCanvasHistory] = useState<ImageData[]>([]);
|
||||
|
||||
// Background Image State
|
||||
const [backgroundImage, setBackgroundImage] = useState<HTMLImageElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// UI State
|
||||
const [activeMode, setActiveMode] = useState<Mode>('video');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
|
||||
// Init Canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Handle High DPI
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr);
|
||||
// Initialize transparent
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
saveHistory(); // Save initial blank state
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveHistory = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
setCanvasHistory(prev => [...prev.slice(-10), data]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (canvasHistory.length <= 1) return;
|
||||
const newHistory = [...canvasHistory];
|
||||
newHistory.pop(); // Remove current state
|
||||
const prevState = newHistory[newHistory.length - 1];
|
||||
setCanvasHistory(newHistory);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx && prevState) {
|
||||
ctx.putImageData(prevState, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
saveHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportBackground = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => setBackgroundImage(img);
|
||||
img.src = ev.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Drawing Handlers
|
||||
const getPos = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
setIsDrawing(true);
|
||||
const { x, y } = getPos(e);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
if (tool === 'eraser') {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.lineWidth = eraserSize;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.strokeStyle = brushColor;
|
||||
ctx.lineWidth = brushSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
if (!isDrawing) return;
|
||||
const { x, y } = getPos(e);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (isDrawing) {
|
||||
setIsDrawing(false);
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
ctx?.closePath();
|
||||
// Reset composite operation to default just in case
|
||||
if (ctx) ctx.globalCompositeOperation = 'source-over';
|
||||
saveHistory();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Composite Logic (Merge Background + Sketch) ---
|
||||
const getCompositeDataURL = (): string => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return '';
|
||||
|
||||
// Create an off-screen canvas for composition
|
||||
const osc = document.createElement('canvas');
|
||||
osc.width = canvas.width;
|
||||
osc.height = canvas.height;
|
||||
const ctx = osc.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
// 1. Fill White Background (Base)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, osc.width, osc.height);
|
||||
|
||||
// 2. Draw Background Image (Scaled to Fit/Cover logic matching UI)
|
||||
if (backgroundImage) {
|
||||
// Calculate "contain" aspect ratio to match UI
|
||||
const scale = Math.min(osc.width / backgroundImage.width, osc.height / backgroundImage.height);
|
||||
const w = backgroundImage.width * scale;
|
||||
const h = backgroundImage.height * scale;
|
||||
const x = (osc.width - w) / 2;
|
||||
const y = (osc.height - h) / 2;
|
||||
ctx.drawImage(backgroundImage, x, y, w, h);
|
||||
}
|
||||
|
||||
// 3. Draw User Sketch
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
|
||||
return osc.toDataURL('image/png');
|
||||
};
|
||||
|
||||
// Generation Logic
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
if (activeMode === 'pose') {
|
||||
// --- Pose Generator Mode: Draw TO Canvas ---
|
||||
// 1. Generate Line Art from Gemini 2.5
|
||||
const posePrompt = `
|
||||
Generate a simple, high-contrast black line art sketch on a white background.
|
||||
Subject: ${prompt}.
|
||||
Style: Minimalist stick figure or outline drawing, clear lines, no shading.
|
||||
`;
|
||||
|
||||
const res = await generateImage(posePrompt, selectedImageModel, [], { aspectRatio: '16:9', count: 1 });
|
||||
const imgUrl = res[0];
|
||||
|
||||
// 2. Draw Result onto Canvas
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (canvas && ctx) {
|
||||
// We want to draw this opaque, but keep it editable.
|
||||
// Since current canvas history logic is pixel-based, drawing it is destructive but fine.
|
||||
// We draw it 'source-over'.
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
|
||||
// Scale to fit
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||
const w = img.width * scale;
|
||||
const h = img.height * scale;
|
||||
const x = (canvas.width - w) / 2;
|
||||
const y = (canvas.height - h) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, w, h);
|
||||
saveHistory();
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
throw new Error("Failed to load generated pose image");
|
||||
};
|
||||
img.src = imgUrl;
|
||||
|
||||
} else {
|
||||
// --- Video/Image Mode: Generate FROM Canvas ---
|
||||
const compositeBase64 = getCompositeDataURL();
|
||||
|
||||
if (activeMode === 'video') {
|
||||
const res = await generateVideo(
|
||||
prompt,
|
||||
selectedVideoModel,
|
||||
{ aspectRatio: '16:9' },
|
||||
compositeBase64
|
||||
);
|
||||
onGenerate('video', res.uri, prompt);
|
||||
} else {
|
||||
// Image (Sketch-to-Image)
|
||||
const res = await generateImage(
|
||||
prompt,
|
||||
selectedImageModel,
|
||||
[compositeBase64],
|
||||
{ aspectRatio: '16:9', count: 1 }
|
||||
);
|
||||
onGenerate('image', res[0], prompt);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e, 'SketchGenerate', {
|
||||
customMessage: '生成失败,请重试'
|
||||
});
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeModel = activeMode === 'video'
|
||||
? videoModels[selectedVideoModel]
|
||||
: imageModels[selectedImageModel];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-background flex flex-col animate-in fade-in duration-300">
|
||||
{/* 1. Top Navigation Bar */}
|
||||
<div className="h-14 border-b border-white/10 flex items-center justify-between px-6 bg-card">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute left-6 p-2 rounded-full bg-white/5 hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex justify-center">
|
||||
<div className="flex bg-black/30 p-1 rounded-lg">
|
||||
{[
|
||||
{ id: 'video', label: '涂鸦生视频', icon: Play },
|
||||
{ id: 'image', label: '涂鸦生图', icon: ImageIcon },
|
||||
{ id: 'pose', label: '姿势生成器 (Pose)', icon: Activity }
|
||||
].map(mode => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setActiveMode(mode.id as Mode)}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-1.5 rounded-md text-xs font-bold transition-all
|
||||
${activeMode === mode.id
|
||||
? 'bg-white/10 text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-muted-foreground'}
|
||||
`}
|
||||
>
|
||||
<mode.icon size={12} />
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Main Canvas Area */}
|
||||
<div className="flex-1 relative bg-background flex items-center justify-center p-8 overflow-hidden">
|
||||
|
||||
{/* Floating Toolbar */}
|
||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 p-1.5 bg-muted/90 backdrop-blur-xl border border-white/10 rounded-full shadow-2xl">
|
||||
<button
|
||||
onClick={() => setTool('brush')}
|
||||
className={`p-2.5 rounded-full transition-colors ${tool === 'brush' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
|
||||
title="画笔"
|
||||
>
|
||||
<Brush size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setTool('eraser')}
|
||||
className={`p-2.5 rounded-full transition-colors ${tool === 'eraser' ? 'bg-cyan-500 text-black' : 'text-muted-foreground hover:text-white hover:bg-white/5'}`}
|
||||
title="橡皮擦"
|
||||
>
|
||||
<Eraser size={16} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPalette(!showPalette)}
|
||||
className="p-2.5 rounded-full transition-colors text-muted-foreground hover:text-foreground hover:bg-white/5 relative"
|
||||
title="调色板"
|
||||
>
|
||||
<Palette size={16} style={{ color: tool === 'brush' ? brushColor : undefined }} />
|
||||
<div className="absolute bottom-1 right-1 w-2 h-2 rounded-full border border-border" style={{ backgroundColor: brushColor }} />
|
||||
</button>
|
||||
|
||||
{showPalette && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-3 p-3 bg-card border border-white/10 rounded-xl shadow-xl grid grid-cols-4 gap-2 w-48 z-30">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { setBrushColor(c); setTool('brush'); setShowPalette(false); }}
|
||||
className={`w-8 h-8 rounded-full border-2 ${brushColor === c ? 'border-white' : 'border-transparent hover:scale-110'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button onClick={handleUndo} className="p-2.5 rounded-full text-muted-foreground hover:text-white hover:bg-white/5">
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleClear} className="p-2.5 rounded-full text-red-400 hover:bg-red-500/10">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The Canvas Wrapper */}
|
||||
<div className="relative shadow-2xl rounded-lg overflow-hidden border border-white/5 bg-white select-none" style={{ aspectRatio: '16/9', height: '100%', maxHeight: '800px' }}>
|
||||
{/* Background Image Layer */}
|
||||
{backgroundImage && (
|
||||
<SecureImage
|
||||
src={backgroundImage.src}
|
||||
className="absolute inset-0 w-full h-full object-contain pointer-events-none opacity-50"
|
||||
alt="Reference"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full cursor-crosshair touch-none"
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={startDrawing}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDrawing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Bottom Control Bar */}
|
||||
<div className="h-20 bg-card border-t border-white/10 flex items-center px-8 gap-4">
|
||||
{/* Tools (Left) */}
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
{/* Import Background Button */}
|
||||
<div
|
||||
className="relative p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-foreground border border-white/5 cursor-pointer hover:bg-white/10 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="导入底图"
|
||||
>
|
||||
<Layers size={16} />
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleImportBackground} />
|
||||
</div>
|
||||
<button onClick={() => { if(canvasRef.current){ const a = document.createElement('a'); a.href = getCompositeDataURL(); a.download='sketch.png'; a.click(); } }} className="p-2 rounded-lg bg-white/5 text-muted-foreground hover:text-white border border-white/5" title="下载当前画布">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={activeMode === 'pose' ? "描述姿势 (e.g. A stick figure running fast)..." : "描述画面内容 (e.g. Milk splash around the bottle)..."}
|
||||
className="w-full h-11 bg-black/30 border border-white/10 rounded-xl px-4 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500/50 transition-colors"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings & Generate */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
||||
className="h-11 px-4 flex items-center gap-2 bg-black/30 border border-white/10 rounded-xl text-xs text-muted-foreground font-medium hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{activeModel?.icon && <activeModel.icon size={14} />}
|
||||
<span>{activeModel?.name || 'Select Model'}</span>
|
||||
<ChevronDown size={12} className="text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{showModelDropdown && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-48 bg-card border border-white/10 rounded-xl shadow-xl overflow-hidden z-50">
|
||||
{activeMode === 'video' ? (
|
||||
Object.values(videoModels).map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => { setSelectedVideoModel(m.id); setShowModelDropdown(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedVideoModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{m.icon && <m.icon size={14} />}
|
||||
{m.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
Object.values(imageModels).map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => { setSelectedImageModel(m.id); setShowModelDropdown(false); }}
|
||||
className={`w-full text-left px-4 py-2 text-xs font-bold hover:bg-white/10 flex items-center gap-2 ${selectedImageModel === m.id ? 'text-cyan-400 bg-white/5' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{m.icon && <m.icon size={14} />}
|
||||
{m.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className={`
|
||||
h-11 px-6 rounded-xl flex items-center gap-2 font-bold text-sm transition-all
|
||||
${isGenerating || !prompt.trim()
|
||||
? 'bg-white/5 text-muted-foreground cursor-not-allowed'
|
||||
: activeMode === 'pose'
|
||||
? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-foreground hover:scale-105'
|
||||
: 'bg-gradient-to-r from-cyan-600 to-blue-600 text-foreground hover:scale-105'}
|
||||
`}
|
||||
>
|
||||
{isGenerating ? <Loader2 className="animate-spin" size={16} /> : <Wand2 size={16} />}
|
||||
<span>{activeMode === 'pose' ? '生成姿势' : '生成作品'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Library, Music, Plus, UserCircle } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/utils';
|
||||
import { InputAsset } from '../../types/node';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { SecureVideo } from '../../nodes/components/SecureVideo';
|
||||
|
||||
export const AssetSelector = memo(({ isExpanded, selectedAssets, onToggleAsset, inputAssets = [] }: {
|
||||
isExpanded: boolean,
|
||||
selectedAssets: string[],
|
||||
onToggleAsset: (id: string) => void,
|
||||
inputAssets?: InputAsset[]
|
||||
}) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-3 flex items-center gap-2 pointer-events-auto"
|
||||
>
|
||||
<div className="bg-black/40 backdrop-blur-md rounded-full p-1.5 flex items-center gap-1 border border-white/10 shadow-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<Library className="w-4 h-4 text-foreground/70" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-foreground/70 px-2">素材</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center -space-x-2 overflow-visible hover:space-x-1 transition-all duration-300">
|
||||
{/* Input Assets */}
|
||||
{inputAssets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full border-2 border-cyan-500/50 relative overflow-hidden group bg-black/50 transition-all duration-200 z-20",
|
||||
"hover:scale-110 hover:border-cyan-400 hover:z-30 shadow-[0_0_10px_rgba(6,182,212,0.3)]"
|
||||
)}
|
||||
title={asset.label || undefined}
|
||||
>
|
||||
{asset.type === 'video' ? (
|
||||
<SecureVideo
|
||||
src={asset.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : asset.type === 'audio' ? (
|
||||
<div className={cn(
|
||||
"w-full h-full flex items-center justify-center",
|
||||
asset.gender ? "bg-black/40" : "bg-cyan-500/20"
|
||||
)}>
|
||||
{asset.gender ? (
|
||||
<UserCircle
|
||||
size={20}
|
||||
className={cn(
|
||||
asset.gender === '男' || asset.gender === 'male' ? 'text-blue-400' : 'text-pink-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-5 h-5 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SecureImage
|
||||
src={asset.src}
|
||||
alt="Input Asset"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-10 h-10 rounded-full border-2 border-white/10 border-dashed bg-black/20 hover:bg-white/10 flex items-center justify-center transition-colors p-0"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-foreground/50" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
|
||||
AssetSelector.displayName = 'AssetSelector';
|
||||
@@ -0,0 +1,556 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { InputAsset } from '../../types/node';
|
||||
import { cn } from '@/utils';
|
||||
import { Music, UserCircle } from 'lucide-react';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { SecureVideo } from '../../nodes/components/SecureVideo';
|
||||
import {
|
||||
createMentionChipElement,
|
||||
filterMentionCandidates,
|
||||
parseMentionTokens,
|
||||
} from './mentionUtils';
|
||||
|
||||
interface AutoResizeTextareaProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onEnter: () => void;
|
||||
isExpanded?: boolean;
|
||||
placeholder?: string;
|
||||
/** 可 @ 的素材列表,用于在输入时弹出候选 */
|
||||
inputAssets?: InputAsset[];
|
||||
}
|
||||
|
||||
export const AutoResizeTextarea = ({
|
||||
value,
|
||||
onChange,
|
||||
onEnter,
|
||||
isExpanded,
|
||||
placeholder,
|
||||
inputAssets
|
||||
}: AutoResizeTextareaProps) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
||||
const [mentionPosition, setMentionPosition] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
const closeMention = useCallback(() => {
|
||||
setMentionOpen(false);
|
||||
setMentionQuery('');
|
||||
setMentionStart(null);
|
||||
setMentionPosition(null);
|
||||
}, []);
|
||||
|
||||
// Focus when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded && editorRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
// Move cursor to end
|
||||
const selection = window.getSelection();
|
||||
if (selection && editorRef.current) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// 从 DOM 读取纯文本内容(chip span 的 data-mention-text 就是 @图片1)
|
||||
const getTextFromDOM = useCallback((): string => {
|
||||
if (!editorRef.current) return '';
|
||||
|
||||
let text = '';
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += node.textContent || '';
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
// chip span 的 data-mention-text 就是 @图片1
|
||||
text += el.getAttribute('data-mention-text') || '';
|
||||
} else if (el.tagName === 'BR') {
|
||||
text += '\n';
|
||||
} else {
|
||||
node.childNodes.forEach(walk);
|
||||
}
|
||||
}
|
||||
};
|
||||
editorRef.current.childNodes.forEach(walk);
|
||||
return text;
|
||||
}, []);
|
||||
|
||||
// 光标偏移与恢复(纯 DOM 工具,稳定引用)
|
||||
const getCursorOffset = useCallback((editor: HTMLElement, range: Range): number => {
|
||||
let offset = 0;
|
||||
const walk = (node: Node): boolean => {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
offset += (node.textContent || '').length;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
offset += (el.getAttribute('data-mention-text') || el.innerText || '').length;
|
||||
} else {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
if (walk(node.childNodes[i])) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let i = 0; i < editor.childNodes.length; i++) {
|
||||
if (walk(editor.childNodes[i])) break;
|
||||
}
|
||||
return offset;
|
||||
}, []);
|
||||
|
||||
const restoreCursor = useCallback((editor: HTMLElement, offset: number) => {
|
||||
let currentOffset = 0;
|
||||
let found = false;
|
||||
const walk = (node: Node): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const len = (node.textContent || '').length;
|
||||
if (currentOffset + len >= offset) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, Math.min(offset - currentOffset, len));
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
currentOffset += len;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
const len = (el.getAttribute('data-mention-text') || el.innerText || '').length;
|
||||
if (currentOffset + len >= offset) {
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(el);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
currentOffset += len;
|
||||
} else {
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
if (walk(node.childNodes[i])) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let i = 0; i < editor.childNodes.length; i++) {
|
||||
if (walk(editor.childNodes[i])) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据纯文本 value 渲染 DOM(文本节点 + chip span)
|
||||
// 只在外部 value 变化时调用,用户输入时不调用
|
||||
const renderContent = useCallback((newValue: string, preserveCursor: boolean = true) => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
|
||||
// 保存当前光标位置
|
||||
const selection = window.getSelection();
|
||||
let cursorOffset = 0;
|
||||
if (preserveCursor && selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
cursorOffset = getCursorOffset(editor, range);
|
||||
}
|
||||
|
||||
// 清空并重新渲染
|
||||
editor.innerHTML = '';
|
||||
|
||||
if (!newValue) {
|
||||
if (preserveCursor) {
|
||||
// 确保光标在编辑器内
|
||||
editor.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 value,切分为文本段和 @别名段
|
||||
const tokens = parseMentionTokens(newValue, inputAssets);
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.type === 'text') {
|
||||
editor.appendChild(document.createTextNode(token.text));
|
||||
} else {
|
||||
const chip = createMentionChipElement(token.asset, isExpanded);
|
||||
editor.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复光标位置
|
||||
if (preserveCursor) {
|
||||
restoreCursor(editor, cursorOffset);
|
||||
}
|
||||
}, [inputAssets, isExpanded, getCursorOffset, restoreCursor]);
|
||||
|
||||
// 初始渲染和外部 value 变化时渲染
|
||||
const prevValueRef = useRef<string>(value);
|
||||
const isUserInputRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只在外部 value 变化时重新渲染(不是用户输入导致的变化)
|
||||
if (isUserInputRef.current) {
|
||||
// 用户输入导致的变化,不重新渲染
|
||||
isUserInputRef.current = false;
|
||||
prevValueRef.current = value;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = getTextFromDOM();
|
||||
if (value !== prevValueRef.current && value !== currentText) {
|
||||
renderContent(value, false);
|
||||
prevValueRef.current = value;
|
||||
}
|
||||
}, [value, getTextFromDOM, renderContent]);
|
||||
|
||||
// 更新候选浮层位置
|
||||
const updateMentionPosition = useCallback(() => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
setMentionPosition({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: 200,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 处理输入
|
||||
const handleInput = useCallback(() => {
|
||||
if (isComposingRef.current) return;
|
||||
|
||||
const text = getTextFromDOM();
|
||||
|
||||
// 标记这是用户输入
|
||||
isUserInputRef.current = true;
|
||||
onChange(text);
|
||||
|
||||
// 检查是否输入了 @
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorOffset = getCursorOffset(editor, range);
|
||||
const charBefore = text[cursorOffset - 1];
|
||||
|
||||
if (charBefore === '@') {
|
||||
setMentionOpen(true);
|
||||
setMentionQuery('');
|
||||
setMentionStart(cursorOffset - 1);
|
||||
updateMentionPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mentionOpen && mentionStart !== null) {
|
||||
const query = text.slice(mentionStart + 1, cursorOffset);
|
||||
setMentionQuery(query);
|
||||
if (!query || /\s/.test(query)) {
|
||||
closeMention();
|
||||
} else {
|
||||
updateMentionPosition();
|
||||
}
|
||||
}
|
||||
}, [getTextFromDOM, onChange, mentionOpen, mentionStart, updateMentionPosition, closeMention, getCursorOffset]);
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
// 阻止 Backspace 和 Delete 键冒泡到画布层(避免删除画布节点)
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Enter 提交(折叠模式下不支持换行)
|
||||
if (e.key === 'Enter') {
|
||||
if (!isExpanded || !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
return;
|
||||
}
|
||||
// 展开模式下,Shift+Enter 允许换行(浏览器默认行为)
|
||||
}
|
||||
|
||||
// Backspace 删除 chip
|
||||
if (e.key === 'Backspace' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// 如果有选区,让浏览器默认处理
|
||||
if (!range.collapsed) return;
|
||||
|
||||
// 检查光标前面是否是 chip
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// 如果光标在文本节点开头,检查前一个兄弟节点
|
||||
if (container.nodeType === Node.TEXT_NODE && offset === 0) {
|
||||
const prevSibling = container.previousSibling;
|
||||
if (prevSibling && prevSibling.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = prevSibling as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果光标在编辑器开头,检查第一个子节点
|
||||
if (container === editor && offset === 0) {
|
||||
const firstChild = editor.firstChild;
|
||||
if (firstChild && firstChild.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = firstChild as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果光标在元素节点内,检查前一个子节点
|
||||
if (container.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
||||
const prevChild = container.childNodes[offset - 1];
|
||||
if (prevChild && prevChild.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = prevChild as HTMLElement;
|
||||
if (el.classList.contains('mention-chip')) {
|
||||
e.preventDefault();
|
||||
el.remove();
|
||||
isUserInputRef.current = true;
|
||||
const text = getTextFromDOM();
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [getTextFromDOM, onChange, onEnter, isExpanded]);
|
||||
|
||||
// 候选列表
|
||||
const candidates = useMemo(() => {
|
||||
return filterMentionCandidates(inputAssets, mentionQuery);
|
||||
}, [inputAssets, mentionQuery]);
|
||||
|
||||
// 插入 mention
|
||||
const applyMention = useCallback((asset: InputAsset) => {
|
||||
if (mentionStart === null || !editorRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
const text = getTextFromDOM();
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
|
||||
// 获取当前光标位置
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorOffset = getCursorOffset(editor, range);
|
||||
|
||||
// 构建新文本:删除 @ 和查询,插入 @别名 + 空格
|
||||
const before = text.slice(0, mentionStart);
|
||||
const after = text.slice(cursorOffset);
|
||||
const inserted = `@${alias} `;
|
||||
const newText = before + inserted + after;
|
||||
|
||||
// 标记为用户输入,但需要重新渲染(因为要插入 chip)
|
||||
isUserInputRef.current = false; // 这次需要重新渲染
|
||||
onChange(newText);
|
||||
closeMention();
|
||||
|
||||
// 光标移到插入内容后面
|
||||
const newCursorOffset = before.length + inserted.length;
|
||||
requestAnimationFrame(() => {
|
||||
renderContent(newText, false);
|
||||
requestAnimationFrame(() => {
|
||||
restoreCursor(editor, newCursorOffset);
|
||||
editor.focus();
|
||||
});
|
||||
});
|
||||
}, [mentionStart, getTextFromDOM, onChange, closeMention, getCursorOffset, restoreCursor, renderContent]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex items-center">
|
||||
{/* contentEditable 编辑器 */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => {
|
||||
// 确保点击时聚焦
|
||||
e.stopPropagation();
|
||||
if (editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
// 如果没有内容,将光标移到末尾
|
||||
if (!editorRef.current.textContent) {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
handleInput();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
closeMention();
|
||||
}, 150);
|
||||
}}
|
||||
data-placeholder={placeholder || (isExpanded ? "输入你的创意" : "有什么可以帮您?")}
|
||||
className={cn(
|
||||
"w-full bg-transparent border-none outline-none text-foreground resize-none text-[13px] custom-scrollbar empty:before:content-[attr(data-placeholder)] empty:before:text-white/30 cursor-text",
|
||||
isExpanded
|
||||
? "px-2 min-h-[40px] leading-relaxed"
|
||||
: "px-0 leading-normal"
|
||||
)}
|
||||
style={isExpanded ? {
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
wordWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
} : {
|
||||
overflowY: 'hidden',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
wordWrap: 'normal'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 候选浮层 */}
|
||||
{mentionOpen && candidates.length > 0 && mentionPosition &&
|
||||
typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] rounded-xl bg-black/85 border border-white/10 shadow-xl backdrop-blur-md text-xs text-foreground interaction-model-popover"
|
||||
style={{
|
||||
left: mentionPosition.left,
|
||||
top: mentionPosition.top,
|
||||
transform: 'translateY(-100%) translateY(-8px)',
|
||||
maxWidth: 260,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
{candidates.map(asset => {
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applyMention(asset);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-white/10 flex items-center gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
'w-7 h-7 rounded-full overflow-hidden border border-white/15 flex items-center justify-center bg-black/40 shrink-0'
|
||||
)}>
|
||||
{asset.type === 'video' ? (
|
||||
<SecureVideo
|
||||
src={asset.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : asset.type === 'audio' ? (
|
||||
<div className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
asset.gender ? 'bg-black/40' : 'bg-cyan-500/20'
|
||||
)}>
|
||||
{asset.gender ? (
|
||||
<UserCircle
|
||||
size={18}
|
||||
className={cn(
|
||||
asset.gender === '男' || asset.gender === 'male'
|
||||
? 'text-blue-400'
|
||||
: 'text-pink-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Music className="w-4 h-4 text-cyan-400" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SecureImage
|
||||
src={asset.src}
|
||||
alt={alias}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-[12px]">{alias}</span>
|
||||
<span className="text-[10px] text-foreground/50">
|
||||
{asset.type === 'image' ? '图片素材' : asset.type === 'video' ? '视频素材' : '音频素材'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Loader2, Copy } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Message, renderFormattedMessage } from '../../utils/chatUtils';
|
||||
|
||||
interface ChatDisplayProps {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const ChatDisplay = ({ messages, isLoading }: ChatDisplayProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [copiedIndex, setCopiedIndex] = React.useState<number | null>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
const handleCopy = (text: string, index: number) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (messages.length === 0 && !isLoading) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-full left-0 w-full mb-3 max-h-[400px] overflow-y-auto custom-scrollbar p-4 flex flex-col gap-4 pointer-events-auto"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{messages.map((m, i) => {
|
||||
if (m.role === 'model' && !m.text) return null;
|
||||
|
||||
return (
|
||||
<div key={i} className={`flex w-full ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`flex flex-col max-w-[90%] gap-1 ${m.role === 'user' ? 'items-end' : 'items-start'}`}>
|
||||
{m.role === 'model' && (
|
||||
<div className="flex items-center gap-1.5 px-1 mb-0.5">
|
||||
<span className="w-1 h-1 rounded-full bg-cyan-500/80"></span>
|
||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider">AI Assistant</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="group relative transition-all w-full">
|
||||
<div
|
||||
className={`
|
||||
relative px-4 py-3 rounded-2xl shadow-sm border select-text cursor-text text-xs leading-relaxed
|
||||
${m.role === 'user'
|
||||
? 'bg-white/10 border-white/10 text-foreground rounded-tr-sm ml-4'
|
||||
: 'bg-black/40 border-white/5 text-muted-foreground rounded-tl-sm w-full pr-8'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{m.role === 'model' ? renderFormattedMessage(m.text) : <p className="whitespace-pre-wrap">{m.text}</p>}
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={() => handleCopy(m.text, i)}
|
||||
className={`absolute top-2 right-2 p-1 rounded-full bg-black/50 hover:bg-black/80 border border-white/10 text-muted-foreground opacity-0 group-hover:opacity-100 transition-all z-10`}
|
||||
title="复制"
|
||||
>
|
||||
{copiedIndex === i ? <span className="text-[9px] font-bold text-green-500">✓</span> : <Copy size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start w-full animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex flex-col gap-2 max-w-[85%]">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1 text-cyan-500/80">Thinking</span>
|
||||
<div className="px-5 py-3 bg-black/40 border border-white/5 rounded-2xl rounded-tl-sm flex items-center gap-3 w-fit">
|
||||
<Loader2 size={14} className="animate-spin text-cyan-500" />
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground">正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Image as ImageIcon, Film, MessageSquare, Mic2, Music2, Maximize2, RotateCcw, ArrowRight, Loader2, X } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GenerationType } from './types';
|
||||
import { TabButton } from './TabButton';
|
||||
import type { InputAsset } from '../../types/node';
|
||||
import { AutoResizeTextarea } from './AutoResizeTextarea';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'text', icon: MessageSquare, label: '助手' },
|
||||
{ id: 'image', icon: ImageIcon, label: '图片' },
|
||||
{ id: 'video', icon: Film, label: '视频' },
|
||||
{ id: 'audio', icon: Mic2, label: '音频' },
|
||||
{ id: 'music', icon: Music2, label: '音乐' },
|
||||
] as const;
|
||||
|
||||
const TabList = memo(({ activeTab, onTabChange, disabled }: { activeTab: GenerationType, onTabChange: (tab: GenerationType) => void, disabled?: boolean }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-full border border-white/5">
|
||||
{TABS.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
isActive={activeTab === tab.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabChange(tab.id);
|
||||
}}
|
||||
icon={<tab.icon className="w-3.5 h-3.5" />}
|
||||
label={tab.label}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TabList.displayName = 'TabList';
|
||||
|
||||
export const CollapsedView = memo(({
|
||||
inputText,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onWorkflowSubmit,
|
||||
onExpand,
|
||||
onClearChat,
|
||||
disableTabs = false,
|
||||
inputAssets,
|
||||
inputPlaceholder,
|
||||
isGenerating = false,
|
||||
canGenerate = true,
|
||||
currentTaskId,
|
||||
onCancel,
|
||||
}: {
|
||||
inputText: string;
|
||||
activeTab: GenerationType;
|
||||
onTabChange: (tab: GenerationType) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onWorkflowSubmit?: () => void;
|
||||
onExpand: () => void;
|
||||
onClearChat: () => void;
|
||||
isVisible?: boolean;
|
||||
disableTabs?: boolean;
|
||||
inputAssets?: InputAsset[];
|
||||
inputPlaceholder?: string;
|
||||
/** 与展开态一致:生成中时显示 Loader2,有 taskId 时可点击取消 */
|
||||
isGenerating?: boolean;
|
||||
canGenerate?: boolean;
|
||||
currentTaskId?: string;
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const [isHoveringCancel, setIsHoveringCancel] = useState(false);
|
||||
const showCancelOnHover = isGenerating && currentTaskId;
|
||||
|
||||
const handleExpandClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (activeTab === 'text') {
|
||||
onClearChat();
|
||||
} else {
|
||||
onExpand();
|
||||
}
|
||||
}, [activeTab, onClearChat, onExpand]);
|
||||
|
||||
const handleSubmitClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isGenerating && currentTaskId && onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
onSubmit();
|
||||
}
|
||||
}, [onSubmit, isGenerating, currentTaskId, onCancel]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="collapsed"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
className="w-full h-full flex items-center px-2 gap-3 relative"
|
||||
>
|
||||
<TabList activeTab={activeTab} onTabChange={onTabChange} disabled={disableTabs} />
|
||||
|
||||
<div
|
||||
className="flex-1 relative min-w-0 h-full cursor-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 点击容器时也聚焦到编辑器
|
||||
const editor = e.currentTarget.querySelector('[contenteditable]') as HTMLElement;
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AutoResizeTextarea
|
||||
value={inputText}
|
||||
onChange={onInputChange}
|
||||
onEnter={() => {
|
||||
// 在折叠模式下,Enter 直接提交(不支持 Shift+Enter 换行)
|
||||
onSubmit();
|
||||
}}
|
||||
isExpanded={false}
|
||||
placeholder={inputPlaceholder}
|
||||
inputAssets={inputAssets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleExpandClick}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-foreground/50 hover:bg-white/10 hover:text-foreground transition-colors p-0 shrink-0"
|
||||
title={activeTab === 'text' ? "重新开始对话" : "展开"}
|
||||
>
|
||||
{activeTab === 'text' ? <RotateCcw className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleSubmitClick}
|
||||
disabled={!canGenerate && !(isGenerating && currentTaskId)}
|
||||
onMouseEnter={() => showCancelOnHover && setIsHoveringCancel(true)}
|
||||
onMouseLeave={() => setIsHoveringCancel(false)}
|
||||
className="flex items-center justify-center bg-white/10 w-7 h-7 rounded-full text-foreground hover:bg-white/20 transition-colors p-0 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
title={isGenerating && currentTaskId ? '取消' : '提交'}
|
||||
>
|
||||
{isGenerating && currentTaskId && isHoveringCancel ? (
|
||||
<X className="w-4 h-4" />
|
||||
) : isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsedView.displayName = 'CollapsedView';
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import { Plus, History, BookOpen, Wand2, ArrowRight, Loader2, X } from 'lucide-react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/utils';
|
||||
import { ActionButton, IconButton } from './Selectors';
|
||||
import { GenerationSettings, GenerationType } from './types';
|
||||
import {
|
||||
ModelSelector, ResolutionSelector, ImageResolutionSelector, AspectRatioSelector,
|
||||
DurationSelector, CountSelector, StyleSelector,
|
||||
CameraControlSelector, ShotTypeToggle, AudioToggle, VoiceSelector
|
||||
} from './Selectors';
|
||||
import { useStyles } from '@lib/hooks/useConfig';
|
||||
import { StyleConfig } from '@/types';
|
||||
import type { ModelConfig } from '@/types/model';
|
||||
|
||||
interface InputFooterProps {
|
||||
isGenerating: boolean;
|
||||
canGenerate: boolean;
|
||||
onGenerate: () => void;
|
||||
/** 当前任务 ID(仅编辑态生图/生视频时有),用于取消 */
|
||||
currentTaskId?: string;
|
||||
/** 取消当前任务(仅当 currentTaskId 存在时有效) */
|
||||
onCancel?: () => void;
|
||||
activeTab: GenerationType;
|
||||
settings: GenerationSettings;
|
||||
availableModels: Record<string, any>;
|
||||
lyricsModels?: Record<string, ModelConfig>;
|
||||
musicModels?: Record<string, ModelConfig>;
|
||||
onUpdateSettings: (s: GenerationSettings) => void;
|
||||
isEditMode?: boolean;
|
||||
editNodeType?: string;
|
||||
}
|
||||
|
||||
export const InputFooter = memo(({
|
||||
isGenerating, canGenerate, onGenerate,
|
||||
currentTaskId, onCancel,
|
||||
activeTab, settings, availableModels, onUpdateSettings,
|
||||
lyricsModels = {}, musicModels = {},
|
||||
isEditMode = false, editNodeType
|
||||
}: InputFooterProps) => {
|
||||
const isVideo = activeTab === 'video';
|
||||
const isAudio = activeTab === 'audio';
|
||||
const isMusic = activeTab === 'music';
|
||||
const isImage = activeTab === 'image';
|
||||
const { data: stylesData } = useStyles();
|
||||
const styles = (stylesData?.styles || []) as StyleConfig[];
|
||||
|
||||
// Get current model config for capability-based controls
|
||||
const currentModelConfig = useMemo(() => {
|
||||
if (!settings.model || !availableModels) return null;
|
||||
return availableModels[settings.model];
|
||||
}, [settings.model, availableModels]);
|
||||
|
||||
const capabilities = currentModelConfig?.capabilities;
|
||||
const [isHoveringCancel, setIsHoveringCancel] = useState(false);
|
||||
const showCancelOnHover = isGenerating && currentTaskId;
|
||||
|
||||
// Get voices for audio models
|
||||
const voices = useMemo(() => {
|
||||
if (!isAudio || !currentModelConfig?.voices) return [];
|
||||
return (currentModelConfig.voices as any[]).map((v: any) => ({
|
||||
value: v.value || v.id,
|
||||
label: v.label || v.name || v.value || v.id,
|
||||
gender: v.gender,
|
||||
desc: v.desc
|
||||
}));
|
||||
}, [isAudio, currentModelConfig]);
|
||||
|
||||
return (
|
||||
<div className="mt-auto flex items-center justify-between pt-2">
|
||||
{/* Left: Parameters - Expanded view contains ALL parameters */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isMusic && !isEditMode ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
onClick={() => {
|
||||
const nextLyrics = !(settings.musicGenerateLyrics ?? false);
|
||||
const currentMusic = settings.musicGenerateMusic ?? true;
|
||||
if (!nextLyrics && !currentMusic) return;
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
musicGenerateLyrics: nextLyrics,
|
||||
model: currentMusic
|
||||
? (settings.musicModel || settings.model)
|
||||
: (settings.lyricsModel || settings.model),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
|
||||
(settings.musicGenerateLyrics ?? false)
|
||||
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
|
||||
)}
|
||||
>
|
||||
歌词
|
||||
</span>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.lyricsModel || ''}
|
||||
models={lyricsModels}
|
||||
onSelect={(id) => onUpdateSettings({
|
||||
...settings,
|
||||
lyricsModel: id,
|
||||
musicGenerateLyrics: true,
|
||||
musicGenerateMusic: false,
|
||||
model: id
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
onClick={() => {
|
||||
const currentLyrics = settings.musicGenerateLyrics ?? false;
|
||||
const nextMusic = !(settings.musicGenerateMusic ?? true);
|
||||
if (!currentLyrics && !nextMusic) return;
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
musicGenerateMusic: nextMusic,
|
||||
model: nextMusic
|
||||
? (settings.musicModel || settings.model)
|
||||
: (settings.lyricsModel || settings.model),
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-1 rounded-full cursor-pointer transition-colors",
|
||||
(settings.musicGenerateMusic ?? true)
|
||||
? "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-white/5 border border-transparent"
|
||||
)}
|
||||
>
|
||||
歌曲
|
||||
</span>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.musicModel || ''}
|
||||
models={musicModels}
|
||||
onSelect={(id) => onUpdateSettings({
|
||||
...settings,
|
||||
musicModel: id,
|
||||
musicGenerateMusic: true,
|
||||
musicGenerateLyrics: false,
|
||||
model: id
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModelSelector
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
onSelect={(id) => onUpdateSettings({ ...settings, model: id })}
|
||||
/>
|
||||
<div className="h-4 w-[1px] bg-white/10 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image-specific controls: Resolution, Aspect Ratio, Style */}
|
||||
{isImage && (
|
||||
<>
|
||||
<ImageResolutionSelector
|
||||
resolution={settings.resolution || '1K'}
|
||||
onSelect={(r) => onUpdateSettings({ ...settings, resolution: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<AspectRatioSelector
|
||||
aspectRatio={settings.aspectRatio}
|
||||
isVideo={false}
|
||||
onSelect={(r) => onUpdateSettings({ ...settings, aspectRatio: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
<StyleSelector
|
||||
selectedStyleId={settings.styleId}
|
||||
styles={styles}
|
||||
onSelect={(id) => onUpdateSettings({ ...settings, styleId: id || undefined })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video-specific controls: Resolution, Aspect Ratio, Duration */}
|
||||
{isVideo && (
|
||||
<>
|
||||
<ResolutionSelector
|
||||
resolution={settings.resolution}
|
||||
onSelect={(r) => onUpdateSettings({ ...settings, resolution: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
<AspectRatioSelector
|
||||
aspectRatio={settings.aspectRatio}
|
||||
isVideo={true}
|
||||
onSelect={(r) => onUpdateSettings({ ...settings, aspectRatio: r })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
resolution={settings.resolution}
|
||||
/>
|
||||
<DurationSelector
|
||||
duration={settings.duration}
|
||||
onSelect={(d) => onUpdateSettings({ ...settings, duration: d })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Capability-based dynamic controls (video only) */}
|
||||
{isVideo && capabilities?.supportsCameraControl && (
|
||||
<CameraControlSelector
|
||||
cameraControl={settings.cameraControl}
|
||||
onSelect={(v) => onUpdateSettings({ ...settings, cameraControl: v })}
|
||||
/>
|
||||
)}
|
||||
{isVideo && capabilities?.supportsShotType && (
|
||||
<ShotTypeToggle
|
||||
shotType={settings.shotType}
|
||||
onToggle={(v) => onUpdateSettings({ ...settings, shotType: v })}
|
||||
/>
|
||||
)}
|
||||
{isVideo && capabilities?.supportsAudio && (
|
||||
<AudioToggle
|
||||
enableAudio={settings.enableAudio}
|
||||
onToggle={(v) => onUpdateSettings({ ...settings, enableAudio: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Audio-specific: voice selector */}
|
||||
{isAudio && voices.length > 0 && (
|
||||
<VoiceSelector
|
||||
voice={settings.voice}
|
||||
voices={voices}
|
||||
onSelect={(v) => onUpdateSettings({ ...settings, voice: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMusic && (
|
||||
<span className="text-[10px] text-muted-foreground px-2">
|
||||
当前模式:
|
||||
{(settings.musicGenerateLyrics ?? false) && (settings.musicGenerateMusic ?? true)
|
||||
? '歌词 -> 音乐'
|
||||
: (settings.musicGenerateLyrics ?? false)
|
||||
? '仅歌词'
|
||||
: '仅音乐'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(isImage || isVideo) && (
|
||||
<CountSelector
|
||||
count={settings.count}
|
||||
isVideo={isVideo}
|
||||
onSelect={(c) => onUpdateSettings({ ...settings, count: c })}
|
||||
selectedModelId={settings.model}
|
||||
models={availableModels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 mr-2 text-foreground/40">
|
||||
<IconButton icon={<Wand2 className="w-4 h-4" />} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={isGenerating && currentTaskId ? onCancel : onGenerate}
|
||||
disabled={!canGenerate && !(isGenerating && currentTaskId)}
|
||||
title={isGenerating && currentTaskId ? '取消' : undefined}
|
||||
onMouseEnter={() => showCancelOnHover && setIsHoveringCancel(true)}
|
||||
onMouseLeave={() => setIsHoveringCancel(false)}
|
||||
className="bg-white/10 hover:bg-white/20 text-foreground border-none w-8 h-8 rounded-full p-0 flex items-center justify-center transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating && currentTaskId && isHoveringCancel ? (
|
||||
<X className="w-4 h-4" />
|
||||
) : isGenerating ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InputFooter.displayName = 'InputFooter';
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { Music, UserCircle } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import type { InputAsset } from '../../types/node';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
import { SecureVideo } from '../../nodes/components/SecureVideo';
|
||||
|
||||
export interface ReferencedAssetsChipsProps {
|
||||
/** 当前已引用的素材列表(由父组件根据 prompt 文本解析得到) */
|
||||
assets: InputAsset[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已引用素材 chips:在输入框下方展示当前 prompt 中 @ 引用的素材,
|
||||
* 与普通输入视觉区分,且不参与输入框尺寸计算。
|
||||
*/
|
||||
export const ReferencedAssetsChips = memo(({ assets, className }: ReferencedAssetsChipsProps) => {
|
||||
if (assets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 px-2 flex flex-wrap gap-1 text-[11px] text-foreground/80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{assets.map((asset) => {
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-white/5 border border-white/10"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 h-4 rounded-full overflow-hidden border border-white/20 flex items-center justify-center bg-black/40 shrink-0'
|
||||
)}
|
||||
>
|
||||
{asset.type === 'video' ? (
|
||||
<SecureVideo
|
||||
src={asset.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
) : asset.type === 'audio' ? (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
asset.gender ? 'bg-black/40' : 'bg-cyan-500/20'
|
||||
)}
|
||||
>
|
||||
{asset.gender ? (
|
||||
<UserCircle
|
||||
size={14}
|
||||
className={cn(
|
||||
asset.gender === '男' || asset.gender === 'male'
|
||||
? 'text-blue-400'
|
||||
: 'text-pink-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Music size={14} className="text-cyan-300" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<SecureImage
|
||||
src={asset.src}
|
||||
alt={alias}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate max-w-[80px]">@{alias}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReferencedAssetsChips.displayName = 'ReferencedAssetsChips';
|
||||
@@ -0,0 +1,559 @@
|
||||
import React from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Sparkles, ChevronDown, Check, Scaling, Monitor, Clock, Layers, Aperture, Film, Volume2, VolumeX, Mic2, UserCircle, Image as ImageIcon
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
// Re-export from new modular structure
|
||||
export { ModelSelector } from './selectors/ModelSelector';
|
||||
export { StyleSelector } from './selectors/StyleSelector';
|
||||
export { AssetSelector } from './selectors/AssetSelector';
|
||||
|
||||
const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||
|
||||
import type { ModelConfig } from '@/types/model';
|
||||
import { useVideoGenerationOptions } from '@/lib/hooks/useGenerationConfig';
|
||||
import { getResolutionLevels, getAspectRatiosForResolution } from '@/lib/utils/modelResolutionUtils';
|
||||
import {
|
||||
IMAGE_ASPECT_RATIOS, VIDEO_ASPECT_RATIOS, VIDEO_RESOLUTIONS, IMAGE_RESOLUTIONS,
|
||||
IMAGE_COUNTS, VIDEO_COUNTS
|
||||
} from '../../constants';
|
||||
|
||||
export function TooltipButton({ icon, active, onClick, label }: { icon: React.ReactNode, active: boolean, onClick: () => void, label: string }) {
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"p-2.5 rounded-full transition-all duration-200 w-auto h-auto",
|
||||
active
|
||||
? "bg-white/10 text-foreground shadow-inner hover:bg-white/15"
|
||||
: "text-foreground/40 hover:text-foreground hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<div className="absolute left-14 top-1/2 -translate-y-1/2 bg-black/90 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none border border-white/10">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({ icon, text }: { icon?: React.ReactNode, text: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 bg-black/30 border border-white/5 px-2.5 py-1 rounded-full text-[10px] text-white/60 hover:text-white/90 hover:border-white/10 transition-colors cursor-pointer select-none">
|
||||
{icon}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionButton({ icon, label }: { icon: React.ReactNode, label: string }) {
|
||||
return (
|
||||
<button className="flex items-center gap-1.5 bg-white/5 hover:bg-white/10 border border-white/5 hover:border-white/10 px-3 py-1.5 rounded-full text-[10px] text-white/70 hover:text-white transition-all h-auto">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ icon }: { icon: React.ReactNode }) {
|
||||
return (
|
||||
<button className="p-2 hover:bg-white/10 rounded-full transition-colors text-current w-auto h-auto">
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AspectRatioSelector({
|
||||
aspectRatio,
|
||||
isVideo,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models,
|
||||
resolution
|
||||
}: {
|
||||
aspectRatio: string,
|
||||
isVideo: boolean,
|
||||
onSelect: (ratio: string) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>,
|
||||
resolution?: string
|
||||
}) {
|
||||
const ratios = React.useMemo(() => {
|
||||
if (models && selectedModelId && resolution) {
|
||||
const model = models[selectedModelId];
|
||||
if (model?.resolutions) {
|
||||
return getAspectRatiosForResolution(resolution);
|
||||
}
|
||||
}
|
||||
return isVideo ? VIDEO_ASPECT_RATIOS : IMAGE_ASPECT_RATIOS;
|
||||
}, [isVideo, models, selectedModelId, resolution]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Scaling size={10} />
|
||||
<span>{aspectRatio}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{ratios.map((ratio) => (
|
||||
<div
|
||||
key={ratio}
|
||||
onClick={() => onSelect(ratio)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
aspectRatio === ratio
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{ratio}</span>
|
||||
{aspectRatio === ratio && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountSelector({
|
||||
count,
|
||||
isVideo,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models
|
||||
}: {
|
||||
count: number,
|
||||
isVideo: boolean,
|
||||
onSelect: (count: number) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>
|
||||
}) {
|
||||
const counts = React.useMemo(() => {
|
||||
if (models && selectedModelId) {
|
||||
const model = models[selectedModelId];
|
||||
if (model?.counts) {
|
||||
const { min, max } = model.counts;
|
||||
if (min != null && max != null) {
|
||||
const items: number[] = [];
|
||||
for (let i = min; i <= max; i++) items.push(i);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
}
|
||||
return isVideo ? VIDEO_COUNTS : IMAGE_COUNTS;
|
||||
}, [models, selectedModelId, isVideo]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Layers size={10} />
|
||||
<span>{count}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-20 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{counts.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
onClick={() => onSelect(c)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
count === c
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{c}</span>
|
||||
{count === c && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResolutionSelector({
|
||||
resolution,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models
|
||||
}: {
|
||||
resolution: string,
|
||||
onSelect: (res: string) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>
|
||||
}) {
|
||||
const resolutions = React.useMemo(() => {
|
||||
if (models && selectedModelId) {
|
||||
const model = models[selectedModelId];
|
||||
if (model?.resolutions) {
|
||||
return getResolutionLevels(selectedModelId);
|
||||
}
|
||||
}
|
||||
return VIDEO_RESOLUTIONS;
|
||||
}, [models, selectedModelId]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Monitor size={10} />
|
||||
<span>{resolution}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{resolutions.map((res: string) => (
|
||||
<div
|
||||
key={res}
|
||||
onClick={() => onSelect(res)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
resolution === res
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{res}</span>
|
||||
{resolution === res && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageResolutionSelector({
|
||||
resolution,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models
|
||||
}: {
|
||||
resolution: string,
|
||||
onSelect: (res: string) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>
|
||||
}) {
|
||||
const resolutions = React.useMemo(() => {
|
||||
if (models && selectedModelId) {
|
||||
const model = models[selectedModelId];
|
||||
if (model?.resolutions) {
|
||||
return getResolutionLevels(selectedModelId);
|
||||
}
|
||||
}
|
||||
return IMAGE_RESOLUTIONS;
|
||||
}, [models, selectedModelId]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<ImageIcon size={10} aria-hidden />
|
||||
<span>{resolution}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-24 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{resolutions.map((res: string) => (
|
||||
<div
|
||||
key={res}
|
||||
onClick={() => onSelect(res)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
resolution === res
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{res}</span>
|
||||
{resolution === res && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function DurationSelector({
|
||||
duration,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models
|
||||
}: {
|
||||
duration: string,
|
||||
onSelect: (dur: string) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>
|
||||
}) {
|
||||
const { config: videoConfig } = useVideoGenerationOptions();
|
||||
|
||||
const durations = React.useMemo(() => {
|
||||
if (models && selectedModelId) {
|
||||
const model = models[selectedModelId];
|
||||
if (model?.durations) {
|
||||
const cfg = model.durations;
|
||||
|
||||
if (cfg.values && Array.isArray(cfg.values)) {
|
||||
return cfg.values.map((d: number) => `${d}s`);
|
||||
}
|
||||
|
||||
if (cfg.min != null && cfg.max != null) {
|
||||
const items: string[] = [];
|
||||
for (let i = cfg.min; i <= cfg.max; i++) {
|
||||
items.push(`${i}s`);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
if (Array.isArray(cfg)) {
|
||||
return cfg.map((d: string | number) => String(d).endsWith('s') ? String(d) : `${d}s`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值
|
||||
return ['5s', '10s', '15s'];
|
||||
}, [models, selectedModelId, videoConfig]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Clock size={10} />
|
||||
<span>{duration}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-20 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 max-h-48 overflow-y-auto custom-scrollbar">
|
||||
{durations.map((dur: string) => (
|
||||
<div
|
||||
key={dur}
|
||||
onClick={() => onSelect(dur)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
duration === dur
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{dur}</span>
|
||||
{duration === dur && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const CAMERA_CONTROLS = [
|
||||
{ value: 'none', label: '自动' },
|
||||
{ value: 'down_back', label: '下压后退' },
|
||||
{ value: 'forward_up', label: '推进上仰' },
|
||||
{ value: 'right_turn_forward', label: '右旋推进' },
|
||||
{ value: 'left_turn_forward', label: '左旋推进' },
|
||||
];
|
||||
|
||||
export function CameraControlSelector({
|
||||
cameraControl,
|
||||
onSelect
|
||||
}: {
|
||||
cameraControl?: string,
|
||||
onSelect: (value: string | undefined) => void
|
||||
}) {
|
||||
const currentLabel = CAMERA_CONTROLS.find(c => c.value === cameraControl)?.label || '运镜';
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Aperture size={10} />
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-28 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{CAMERA_CONTROLS.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => onSelect(opt.value === 'none' ? undefined : opt.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
(cameraControl || 'none') === opt.value
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{(cameraControl || 'none') === opt.value && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShotTypeToggle({
|
||||
shotType,
|
||||
onToggle
|
||||
}: {
|
||||
shotType?: 'single' | 'multi',
|
||||
onToggle: (value: 'single' | 'multi') => void
|
||||
}) {
|
||||
const isMulti = shotType === 'multi';
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(isMulti ? 'single' : 'multi')}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400"
|
||||
title={isMulti ? '多镜头叙事' : '单镜头'}
|
||||
>
|
||||
<Film size={10} />
|
||||
<span>{isMulti ? '多镜头' : '单镜头'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AudioToggle({
|
||||
enableAudio,
|
||||
onToggle
|
||||
}: {
|
||||
enableAudio?: boolean,
|
||||
onToggle: (value: boolean) => void
|
||||
}) {
|
||||
const isEnabled = enableAudio !== false;
|
||||
return (
|
||||
<div
|
||||
onClick={() => onToggle(!isEnabled)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400"
|
||||
title={isEnabled ? '有声视频' : '无声视频'}
|
||||
>
|
||||
{isEnabled ? <Volume2 size={10} /> : <VolumeX size={10} />}
|
||||
<span>{isEnabled ? '有声' : '静音'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceSelector({
|
||||
voice,
|
||||
voices,
|
||||
onSelect
|
||||
}: {
|
||||
voice?: string,
|
||||
voices: { value: string; label: string; gender?: string; desc?: string }[],
|
||||
onSelect: (value: string) => void
|
||||
}) {
|
||||
const currentVoice = voices.find(v => v.value === voice);
|
||||
const displayName = currentVoice?.label || '默认音色';
|
||||
|
||||
if (!voices.length) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Mic2 size={10} />
|
||||
<span className="truncate max-w-[80px]">{displayName}</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-44 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
|
||||
{voices.map((v) => (
|
||||
<div
|
||||
key={v.value}
|
||||
onClick={() => onSelect(v.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] cursor-pointer transition-all group",
|
||||
voice === v.value
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
{v.gender && (
|
||||
<span className="shrink-0 flex items-center justify-center" title={v.gender === '男' || v.gender === 'male' ? '男' : '女'}>
|
||||
<UserCircle
|
||||
size={16}
|
||||
className={cn(
|
||||
v.gender === '男' || v.gender === 'male'
|
||||
? "text-blue-400"
|
||||
: "text-pink-400"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium truncate">{v.label}</span>
|
||||
</div>
|
||||
{v.desc && (
|
||||
<p className="text-[9px] text-muted-foreground/60 line-clamp-2 leading-relaxed">
|
||||
{v.desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{voice === v.value && (
|
||||
<Check size={12} className="text-blue-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/utils';
|
||||
import { styles } from '@/styles/constants';
|
||||
|
||||
export const TabButton = memo(({
|
||||
isActive,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
disabled
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={disabled && !isActive}
|
||||
className={cn(
|
||||
`h-7 ${styles.roundedFull} ${styles.buttonGhost}`,
|
||||
isActive ? `${styles.buttonActive} px-3` : "w-7 px-0",
|
||||
disabled && !isActive && "opacity-30 pointer-events-none"
|
||||
)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
{icon}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
initial={{ width: 0, opacity: 0, marginLeft: 0 }}
|
||||
animate={{ width: "auto", opacity: 1, marginLeft: 6 }}
|
||||
exit={{ width: 0, opacity: 0, marginLeft: 0 }}
|
||||
className="overflow-hidden whitespace-nowrap text-[10px] font-medium"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
));
|
||||
|
||||
TabButton.displayName = 'TabButton';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GenerationSettings, Role } from './types';
|
||||
|
||||
export const MOCK_ROLES: Role[] = [
|
||||
{ id: '1', name: 'Girl', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix' },
|
||||
{ id: '2', name: 'Cat', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mimi' },
|
||||
{ id: '3', name: 'Dog', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Buddy' },
|
||||
{ id: '4', name: 'Boy', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack' },
|
||||
];
|
||||
|
||||
export const DEFAULT_SETTINGS: GenerationSettings = {
|
||||
resolution: '1K',
|
||||
aspectRatio: '16:9',
|
||||
duration: '5s',
|
||||
count: 1,
|
||||
model: '',
|
||||
inspiration: false,
|
||||
styleId: undefined,
|
||||
lyricsModel: '',
|
||||
musicModel: '',
|
||||
musicTarget: 'music',
|
||||
musicGenerateLyrics: false,
|
||||
musicGenerateMusic: true
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { InputAsset } from '../../types/node';
|
||||
import { DefaultService } from '@lib/api';
|
||||
|
||||
export type MentionToken =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'chip'; asset: InputAsset };
|
||||
|
||||
const signedUrlCache = new Map<string, string>();
|
||||
|
||||
async function getSignedUrl(url: string): Promise<string | null> {
|
||||
if (!url || !url.startsWith('http')) return null;
|
||||
if (signedUrlCache.has(url)) return signedUrlCache.get(url)!;
|
||||
try {
|
||||
const response = await DefaultService.signUrlEndpoint({ url });
|
||||
const next = (response as any)?.data?.url;
|
||||
if (next && typeof next === 'string' && next !== url) {
|
||||
signedUrlCache.set(url, next);
|
||||
return next;
|
||||
}
|
||||
} catch {
|
||||
// ignore signing failures and keep original source
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseMentionTokens(text: string, inputAssets?: InputAsset[]): MentionToken[] {
|
||||
if (!inputAssets || !text) {
|
||||
return text ? [{ type: 'text', text }] : [];
|
||||
}
|
||||
|
||||
const candidates = inputAssets.filter((asset) => Boolean(asset.mentionAlias || asset.label));
|
||||
if (candidates.length === 0) {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
const result: MentionToken[] = [];
|
||||
let i = 0;
|
||||
let buffer = '';
|
||||
|
||||
while (i < text.length) {
|
||||
let matched: { asset: InputAsset; length: number } | null = null;
|
||||
|
||||
if (text[i] === '@') {
|
||||
for (const asset of candidates) {
|
||||
const alias = asset.mentionAlias || asset.label!;
|
||||
const mentionText = `@${alias}`;
|
||||
if (text.slice(i, i + mentionText.length) === mentionText) {
|
||||
const nextChar = text[i + mentionText.length];
|
||||
const isValidEnd = !nextChar || !/[a-zA-Z0-9]/.test(nextChar);
|
||||
if (isValidEnd) {
|
||||
matched = { asset, length: mentionText.length };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
if (buffer) {
|
||||
result.push({ type: 'text', text: buffer });
|
||||
buffer = '';
|
||||
}
|
||||
result.push({ type: 'chip', asset: matched.asset });
|
||||
i += matched.length;
|
||||
} else {
|
||||
buffer += text[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
result.push({ type: 'text', text: buffer });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createMentionChipElement(asset: InputAsset, isExpanded?: boolean): HTMLSpanElement {
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
const chip = document.createElement('span');
|
||||
chip.contentEditable = 'false';
|
||||
chip.className = 'mention-chip';
|
||||
chip.setAttribute('data-asset-id', asset.id);
|
||||
chip.setAttribute('data-asset-type', asset.type);
|
||||
|
||||
chip.style.cssText = `
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
margin: 0 1px;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
vertical-align: middle;
|
||||
${!isExpanded ? 'transform: scale(0.9);' : ''}
|
||||
`;
|
||||
|
||||
const thumbnail = document.createElement('span');
|
||||
thumbnail.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
if (asset.type === 'image' || asset.type === 'video') {
|
||||
const isVideo = asset.type === 'video';
|
||||
const mediaElement = document.createElement(isVideo ? 'video' : 'img');
|
||||
const isLocal = asset.src.startsWith('/files/')
|
||||
|| asset.src.startsWith('http://localhost')
|
||||
|| asset.src.startsWith('https://localhost')
|
||||
|| asset.src.startsWith('data:');
|
||||
|
||||
if (isVideo) {
|
||||
(mediaElement as HTMLVideoElement).muted = true;
|
||||
(mediaElement as HTMLVideoElement).loop = true;
|
||||
(mediaElement as HTMLVideoElement).autoplay = true;
|
||||
(mediaElement as HTMLVideoElement).playsInline = true;
|
||||
}
|
||||
|
||||
// For remote URLs, retry once with freshly signed URL on load failure.
|
||||
if (!isLocal) {
|
||||
let retried = false;
|
||||
mediaElement.addEventListener('error', async () => {
|
||||
if (retried) return;
|
||||
retried = true;
|
||||
const signed = await getSignedUrl(asset.src);
|
||||
if (signed) {
|
||||
if (isVideo) {
|
||||
const video = mediaElement as HTMLVideoElement;
|
||||
video.src = signed;
|
||||
video.load();
|
||||
} else {
|
||||
(mediaElement as HTMLImageElement).src = signed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
(mediaElement as HTMLVideoElement).src = asset.src;
|
||||
} else {
|
||||
(mediaElement as HTMLImageElement).src = asset.src;
|
||||
}
|
||||
mediaElement.style.cssText = 'width: 100%; height: 100%; object-fit: cover; display: block;';
|
||||
thumbnail.appendChild(mediaElement);
|
||||
} else if (asset.type === 'audio') {
|
||||
const iconWrapper = document.createElement('span');
|
||||
iconWrapper.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
${asset.gender ? 'background-color: rgba(0, 0, 0, 0.4);' : 'background-color: rgba(6, 182, 212, 0.2);'}
|
||||
`;
|
||||
iconWrapper.innerHTML = asset.gender
|
||||
? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="${asset.gender === '男' || asset.gender === 'male' ? 'text-blue-400' : 'text-pink-400'}"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`
|
||||
: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: rgb(103, 232, 249);"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>';
|
||||
thumbnail.appendChild(iconWrapper);
|
||||
}
|
||||
|
||||
chip.appendChild(thumbnail);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.style.cssText = `
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 80px;
|
||||
font-size: 11px;
|
||||
color: currentColor;
|
||||
`;
|
||||
label.textContent = alias;
|
||||
chip.appendChild(label);
|
||||
chip.setAttribute('data-mention-text', `@${alias}`);
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
export function filterMentionCandidates(inputAssets: InputAsset[] | undefined, mentionQuery: string): InputAsset[] {
|
||||
return (inputAssets || []).filter((asset) => {
|
||||
const alias = asset.mentionAlias || asset.label || asset.id;
|
||||
if (!alias) return false;
|
||||
if (!mentionQuery) return true;
|
||||
return alias.includes(mentionQuery);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Image as ImageIcon, ChevronDown, Check, X } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { InputAsset } from '../../../types/node';
|
||||
|
||||
interface AssetSelectorProps {
|
||||
isExpanded: boolean;
|
||||
selectedAssets: string[];
|
||||
onToggleAsset: (id: string) => void;
|
||||
inputAssets: InputAsset[];
|
||||
}
|
||||
|
||||
export function AssetSelector({
|
||||
isExpanded,
|
||||
selectedAssets,
|
||||
onToggleAsset,
|
||||
inputAssets
|
||||
}: AssetSelectorProps) {
|
||||
if (!isExpanded || inputAssets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group pointer-events-auto">
|
||||
<ImageIcon className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
|
||||
<span className="truncate max-w-[80px]">
|
||||
素材 ({inputAssets.length})
|
||||
</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-2 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-1">
|
||||
{inputAssets.map((asset) => {
|
||||
const isSelected = selectedAssets.includes(asset.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => onToggleAsset(asset.id)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
isSelected
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
{asset.thumbnail ? (
|
||||
<img
|
||||
src={asset.thumbnail}
|
||||
alt={asset.name}
|
||||
className="w-8 h-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-white/5">
|
||||
<ImageIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="truncate">{asset.name}</span>
|
||||
<span className="text-[9px] text-foreground/30 font-normal">
|
||||
{asset.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Sparkles, ChevronDown, Check } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import type { ModelConfig } from '@/types/model';
|
||||
|
||||
const capitalize = (s: string) => s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
|
||||
|
||||
export function ModelSelector({
|
||||
selectedModelId,
|
||||
models,
|
||||
onSelect
|
||||
}: {
|
||||
selectedModelId: string,
|
||||
models: Record<string, ModelConfig>,
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
console.log('[ModelSelector] models:', models, 'count:', Object.keys(models).length);
|
||||
}, [models]);
|
||||
|
||||
const selectedModel = models[selectedModelId];
|
||||
const SelectedIcon = selectedModel?.icon || Sparkles;
|
||||
|
||||
const sortedModels = React.useMemo(() => {
|
||||
const values = Object.values(models);
|
||||
console.log('[ModelSelector] sortedModels values:', values.length);
|
||||
return values.sort((a, b) => {
|
||||
if (a.is_default && !b.is_default) return -1;
|
||||
if (!a.is_default && b.is_default) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [models]);
|
||||
|
||||
if (!sortedModels.length) {
|
||||
console.log('[ModelSelector] No models available, rendering placeholder');
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-bold text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<SelectedIcon className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
|
||||
<span className="truncate max-w-[100px]">
|
||||
{selectedModel?.name || "Select Model"}
|
||||
</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-56 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="max-h-[280px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
|
||||
{sortedModels.map((m: ModelConfig) => {
|
||||
const isSelected = selectedModelId === m.id;
|
||||
const Icon = m.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => onSelect(m.id)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
isSelected
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded-md flex items-center justify-center transition-colors",
|
||||
isSelected ? "bg-blue-500/20" : "bg-white/5 group-hover:bg-white/10"
|
||||
)}>
|
||||
{Icon ? <Icon size={12} /> : <Sparkles size={12} />}
|
||||
</div>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="truncate">{m.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-foreground/30 font-normal truncate">
|
||||
{m.provider_name || capitalize(m.provider)}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-white/10 text-foreground/50 uppercase">
|
||||
{m.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Palette, ChevronDown, Check } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { StyleConfig } from '@/types';
|
||||
import type { ModelConfig } from '@/types/model';
|
||||
import { SecureImage } from '@/components/common/SecureImage';
|
||||
|
||||
export function StyleSelector({
|
||||
selectedStyleId,
|
||||
styles,
|
||||
onSelect,
|
||||
selectedModelId,
|
||||
models
|
||||
}: {
|
||||
selectedStyleId?: string,
|
||||
styles: StyleConfig[],
|
||||
onSelect: (id: string) => void,
|
||||
selectedModelId?: string,
|
||||
models?: Record<string, ModelConfig>
|
||||
}) {
|
||||
const selectedStyle = styles.find(s => s.id === selectedStyleId);
|
||||
|
||||
const availableStyles = React.useMemo(() => {
|
||||
if (!models || !selectedModelId) {
|
||||
return styles.filter(s => s.type !== 'lora');
|
||||
}
|
||||
|
||||
const currentModelConfig = models[selectedModelId];
|
||||
|
||||
if (!currentModelConfig) {
|
||||
return styles.filter(s => s.type !== 'lora');
|
||||
}
|
||||
|
||||
return styles.filter(style => {
|
||||
if (style.type === 'lora') {
|
||||
if (!currentModelConfig.capabilities?.supportsLora) return false;
|
||||
return style.lora?.base_model === currentModelConfig.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [styles, selectedModelId, models]);
|
||||
|
||||
if (availableStyles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full hover:bg-white/5 cursor-pointer transition-colors text-[10px] font-bold text-muted-foreground hover:text-cyan-400 group">
|
||||
<Palette className="w-3 h-3 group-hover:text-cyan-400 transition-colors" />
|
||||
<span className="truncate max-w-[80px]">
|
||||
{selectedStyle?.name || "风格"}
|
||||
</span>
|
||||
<ChevronDown size={10} className="opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-48 p-1 bg-black/40 backdrop-blur-xl border-white/10 z-[1000] shadow-2xl rounded-xl interaction-model-popover"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="max-h-[200px] overflow-y-auto custom-scrollbar flex flex-col gap-0.5">
|
||||
<div
|
||||
onClick={() => onSelect('')}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
!selectedStyleId
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md flex items-center justify-center bg-white/5">
|
||||
<Palette size={12} />
|
||||
</div>
|
||||
<span>不使用</span>
|
||||
</div>
|
||||
{!selectedStyleId && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
|
||||
{availableStyles.map((style) => (
|
||||
<div
|
||||
key={style.id}
|
||||
onClick={() => onSelect(style.id)}
|
||||
className={cn(
|
||||
"px-3 py-2 rounded-lg text-[11px] font-medium cursor-pointer transition-all flex items-center justify-between group",
|
||||
selectedStyleId === style.id
|
||||
? "bg-blue-500/10 text-blue-400"
|
||||
: "text-foreground/70 hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{style.imageUrl && (
|
||||
<SecureImage
|
||||
src={style.imageUrl}
|
||||
alt={style.name}
|
||||
className="w-5 h-5 rounded-md overflow-hidden object-cover"
|
||||
/>
|
||||
)}
|
||||
{!style.imageUrl && (
|
||||
<div className="w-5 h-5 rounded-md flex items-center justify-center bg-white/5">
|
||||
<Palette size={12} />
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{style.name}</span>
|
||||
</div>
|
||||
{selectedStyleId === style.id && <Check size={12} className="text-blue-400 shrink-0 ml-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
export { StyleSelector } from './StyleSelector';
|
||||
export { AssetSelector } from './AssetSelector';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { InputAsset } from '../../types/node';
|
||||
import type { AssetMentionMapping } from '../../utils';
|
||||
|
||||
export interface GenerationSettings {
|
||||
resolution: string;
|
||||
aspectRatio: string;
|
||||
duration: string;
|
||||
count: number;
|
||||
model: string;
|
||||
inspiration: boolean;
|
||||
styleId?: string;
|
||||
shotType?: 'single' | 'multi';
|
||||
enableAudio?: boolean;
|
||||
cameraControl?: string;
|
||||
voice?: string;
|
||||
musicPrompt?: string;
|
||||
lyricsModel?: string;
|
||||
musicModel?: string;
|
||||
musicTarget?: 'lyrics' | 'music';
|
||||
musicGenerateLyrics?: boolean;
|
||||
musicGenerateMusic?: boolean;
|
||||
}
|
||||
|
||||
export type GenerationType = 'video' | 'image' | 'text' | 'audio' | 'music';
|
||||
|
||||
export interface InteractionInputProps {
|
||||
onGenerate?: (data: {
|
||||
type: GenerationType;
|
||||
prompt: string;
|
||||
settings: GenerationSettings;
|
||||
inputAssets?: InputAsset[];
|
||||
/** prompt 中使用 @别名 引用到的素材映射信息 */
|
||||
assetMappings?: AssetMentionMapping[];
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
Reference in New Issue
Block a user