forked from 77media/video-flow
重新设计页面
This commit is contained in:
parent
82b29a0283
commit
39a3de215c
@ -1 +1 @@
|
||||
export const BASE_URL = "https://pre.movieflow.api.huiying.video"
|
||||
export const BASE_URL = "https://77.smartvideo.py.qikongjian.com"
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
/* 3dwave */
|
||||
--index: calc(1vh + 1vw);
|
||||
--transition: cubic-bezier(0.1, 0.7, 0, 1);
|
||||
|
||||
--gradient-color: linear-gradient(120deg, rgb(79, 222, 255, 0.75) -1%, rgb(230, 117, 255, 0.75) 100%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@ -137,4 +139,10 @@ body {
|
||||
|
||||
.bg-muted {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.focus-visible\:outline-none:focus-visible {
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@ -133,4 +133,94 @@ export const TaskStatusMap = {
|
||||
value: "failed",
|
||||
label: "失败"
|
||||
}
|
||||
} as const;
|
||||
} as const;
|
||||
|
||||
// 分镜脚本编辑器类型定义
|
||||
export interface StoryboardCard {
|
||||
id: string;
|
||||
shotId: string; // 分镜ID
|
||||
scene?: SceneOption; // 场景
|
||||
characters: CharacterOption[]; // 出现人物
|
||||
description: string; // 分镜描述
|
||||
shotType: string; // 分镜类型
|
||||
cameraMove: string; // 相机运动
|
||||
dialogues: DialogueItem[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CharacterRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DialogueItem {
|
||||
id: string;
|
||||
speaker: string;
|
||||
text: string; // 对话内容,支持关键词标记,如 #角色# [场景]
|
||||
}
|
||||
|
||||
export interface CharacterInfo {
|
||||
avatar: string;
|
||||
gender: string;
|
||||
age: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SceneInfo {
|
||||
image: string;
|
||||
location: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface SceneOption {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
location: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface CharacterOption {
|
||||
characterId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
gender: string;
|
||||
age: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Mock 数据
|
||||
export const mockSceneOptions: SceneOption[] = [
|
||||
{ sceneId: '1', name: '暮色森林', image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' }
|
||||
];
|
||||
|
||||
export const mockCharacterOptions: CharacterOption[] = [
|
||||
{ characterId: '1', name: '艾琳', image: 'https://c.huiying.video/images/32f6b07c-bceb-4b63-8a13-4749703ab08d.jpg', gender: '女', age: '24', description: '银发女剑士' },
|
||||
{ characterId: '2', name: '影子猎手', image: 'https://c.huiying.video/images/97c6c59a-50cc-4159-aacd-94ab9d208150.jpg', gender: '男', age: '35', description: '神秘追踪者' },
|
||||
];
|
||||
|
||||
export const mockStoryboards: StoryboardCard[] = [
|
||||
{
|
||||
id: '1',
|
||||
shotId: 'SC-01',
|
||||
scene: mockSceneOptions[0],
|
||||
characters: [mockCharacterOptions[0], mockCharacterOptions[1]],
|
||||
description: '艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现',
|
||||
shotType: '中景',
|
||||
cameraMove: '缓慢推进',
|
||||
dialogues: [
|
||||
{ id: 'd1', speaker: '艾琳', text: '我们必须在 #影子猎手# 到来前穿过 [暮色森林]。' },
|
||||
{ id: 'd2', speaker: '影子猎手', text: '你以为能逃出这里?' },
|
||||
],
|
||||
notes: '镜头缓慢推进至人物背影',
|
||||
},
|
||||
];
|
||||
|
||||
export const characterInfoMap: Record<string, CharacterInfo> = {
|
||||
'艾琳': { avatar: 'https://c.huiying.video/images/32f6b07c-bceb-4b63-8a13-4749703ab08d.jpg', gender: '女', age: '24', description: '银发女剑士' },
|
||||
'影子猎手': { avatar: 'https://c.huiying.video/images/97c6c59a-50cc-4159-aacd-94ab9d208150.jpg', gender: '男', age: '35', description: '神秘追踪者' },
|
||||
};
|
||||
|
||||
export const sceneInfoMap: Record<string, SceneInfo> = {
|
||||
'暮色森林': { image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' },
|
||||
};
|
||||
@ -14,6 +14,7 @@ import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||
import { ScriptEditDialog } from '@/components/script-edit-dialog';
|
||||
|
||||
|
||||
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
||||
@ -52,7 +53,7 @@ export function CreateToVideo2() {
|
||||
const [isSmartAssistantExpanded, setIsSmartAssistantExpanded] = useState(false);
|
||||
const [userId, setUserId] = useState<number>(0);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
|
||||
const [isScriptEditDialogOpen, setIsScriptEditDialogOpen] = useState(false);
|
||||
// 在客户端挂载后读取localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -134,6 +135,8 @@ export function CreateToVideo2() {
|
||||
}
|
||||
|
||||
const handleCreateVideo = async () => {
|
||||
setIsScriptEditDialogOpen(true);
|
||||
return;
|
||||
setIsCreating(true);
|
||||
// 创建剧集数据
|
||||
let episodeData: any = {
|
||||
@ -695,6 +698,13 @@ export function CreateToVideo2() {
|
||||
<EmptyStateAnimation className='' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScriptEditDialogOpen && (
|
||||
<ScriptEditDialog
|
||||
isOpen={isScriptEditDialogOpen}
|
||||
onClose={() => setIsScriptEditDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,8 @@ export default function WorkFlow() {
|
||||
const {
|
||||
taskObject,
|
||||
taskSketch,
|
||||
taskScenes,
|
||||
taskShotSketch,
|
||||
taskVideos,
|
||||
sketchCount,
|
||||
isLoading,
|
||||
@ -224,6 +226,7 @@ export default function WorkFlow() {
|
||||
taskStatus={taskObject?.taskStatus || '1'}
|
||||
taskSketch={taskSketch}
|
||||
sketchVideo={taskVideos}
|
||||
taskScenes={taskScenes}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={setCurrentSketchIndex}
|
||||
roles={roles}
|
||||
|
||||
@ -746,7 +746,7 @@ export function MediaViewer({
|
||||
<GlassIconButton
|
||||
icon={Edit3}
|
||||
tooltip="Edit sketch"
|
||||
onClick={() => handleEditClick('1')}
|
||||
onClick={() => handleEditClick('2')}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@ -58,6 +58,7 @@ export function useWorkflowData() {
|
||||
// 更新 taskObject 的类型
|
||||
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
|
||||
const [taskSketch, setTaskSketch] = useState<any[]>([]);
|
||||
const [taskScenes, setTaskScenes] = useState<any[]>([]);
|
||||
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
|
||||
const [taskVideos, setTaskVideos] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -176,6 +177,7 @@ export function useWorkflowData() {
|
||||
});
|
||||
}
|
||||
setTaskSketch(sketchList);
|
||||
setTaskScenes(sketchList);
|
||||
updateSketchCount(sketchList.length);
|
||||
setIsGeneratingSketch(true);
|
||||
loadingText = LOADING_TEXT_MAP.sketch(sketchList.length, task.task_result.total_count);
|
||||
@ -393,6 +395,7 @@ export function useWorkflowData() {
|
||||
});
|
||||
}
|
||||
setTaskSketch(sketchList);
|
||||
setTaskScenes(sketchList);
|
||||
updateSketchCount(sketchList.length);
|
||||
// 设置为最后一个草图
|
||||
if (data.sketch.total_count > realSketchResultData.length) {
|
||||
@ -528,6 +531,7 @@ export function useWorkflowData() {
|
||||
setDataLoadError(null);
|
||||
// 重置所有状态
|
||||
setTaskSketch([]);
|
||||
setTaskScenes([]);
|
||||
setTaskVideos([]);
|
||||
updateSketchCount(0);
|
||||
updateVideoCount(0);
|
||||
@ -548,6 +552,8 @@ export function useWorkflowData() {
|
||||
return {
|
||||
taskObject,
|
||||
taskSketch,
|
||||
taskScenes,
|
||||
taskShotSketch,
|
||||
taskVideos,
|
||||
sketchCount,
|
||||
isLoading,
|
||||
|
||||
22
components/portal.tsx
Normal file
22
components/portal.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Portal({ children }: PortalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
children,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
227
components/script-edit-dialog.tsx
Normal file
227
components/script-edit-dialog.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
interface ScriptEditDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptEditDialog({ isOpen, onClose, onConfirm }: ScriptEditDialogProps) {
|
||||
const [suggestion, setSuggestion] = useState('');
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!suggestion.trim()) return;
|
||||
setIsUpdating(true);
|
||||
// 模拟更新延迟
|
||||
setTimeout(() => {
|
||||
setIsUpdating(false);
|
||||
setSuggestion('');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSuggestion('');
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 弹窗内容 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-7/12 h-[90vh] bg-white/80 dark:bg-[#5b75ac4d] backdrop-blur-xl rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
initial={{ scale: 0.95, y: 10, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
duration: 0.3,
|
||||
bounce: 0.15,
|
||||
stiffness: 300,
|
||||
damping: 25
|
||||
}
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.95,
|
||||
y: 10,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
type: "tween",
|
||||
duration: 0.1,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<motion.button
|
||||
className="absolute z-50 top-4 right-4 p-2 rounded-full bg-gray-100/80 dark:bg-gray-800/80 hover:bg-gray-200/80 dark:hover:bg-gray-700/80 transition-colors"
|
||||
onClick={onClose}
|
||||
whileHover={{ rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</motion.button>
|
||||
|
||||
{/* 标题 */}
|
||||
<motion.div
|
||||
className="flex-none px-6 py-4"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit Script
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<motion.div
|
||||
className="flex-1 overflow-auto p-6 pt-0 pb-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
>
|
||||
{/* TypingEditor */}
|
||||
<motion.div
|
||||
style={{
|
||||
height: 'calc(100% - 88px)'
|
||||
}}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
</motion.div>
|
||||
|
||||
{/* 修改建议输入区域 */}
|
||||
<motion.div
|
||||
className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent dark:from-[#5b75ac4d] dark:via-[#5b75ac4d] dark:to-transparent pt-8 pb-4 rounded-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center space-x-2 px-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Enter your modification suggestion and press Enter to send..."
|
||||
value={suggestion}
|
||||
onChange={(e) => setSuggestion(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUpdate();
|
||||
}
|
||||
}}
|
||||
className="outline-none box-shadow-none bg-white/50 dark:bg-[#5b75ac20] border-0 focus:ring-2 focus:ring-blue-500/20 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: suggestion.trim() ? 1 : 0.8,
|
||||
opacity: suggestion.trim() ? 1 : 0.5,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 30
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleUpdate}
|
||||
disabled={!suggestion.trim() || isUpdating}
|
||||
className="relative w-9 h-9 rounded-full bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isUpdating ? 0 : 1,
|
||||
scale: isUpdating ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 transform rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
{isUpdating && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
>
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<motion.div
|
||||
className="flex-none px-6 py-4 flex justify-end space-x-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleReset}
|
||||
className="min-w-[80px] bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onConfirm?.('')}
|
||||
className="min-w-[80px] bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
213
components/ui/character-editor.tsx
Normal file
213
components/ui/character-editor.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, X, Plus } from 'lucide-react';
|
||||
import { cn } from "@/public/lib/utils";
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
|
||||
interface CharacterAttribute {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
type: 'text' | 'number' | 'select';
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface CharacterEditorProps {
|
||||
initialDescription?: string;
|
||||
onDescriptionChange?: (description: string) => void;
|
||||
onAttributesChange?: (attributes: CharacterAttribute[]) => void;
|
||||
}
|
||||
|
||||
const mockParse = (text: string): CharacterAttribute[] => {
|
||||
// 模拟结构化解析结果
|
||||
return [
|
||||
{ key: "age", label: "年龄", value: "20", type: "number" },
|
||||
{ key: "gender", label: "性别", value: "女性", type: "select", options: ["男性", "女性", "其他"] },
|
||||
{ key: "hair", label: "发型", value: "银白短发", type: "text" },
|
||||
{ key: "race", label: "种族", value: "精灵", type: "text" },
|
||||
{ key: "skin", label: "肤色", value: "白皙", type: "text" },
|
||||
{ key: "build", label: "体型", value: "高挑", type: "text" },
|
||||
{ key: "costume", label: "服装", value: "白色连衣裙", type: "text" },
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
export default function CharacterEditor({
|
||||
initialDescription = "一个银白短发的精灵女性,大约20岁,肤色白皙,身材高挑,身着白色连衣裙",
|
||||
onDescriptionChange,
|
||||
onAttributesChange,
|
||||
}: CharacterEditorProps) {
|
||||
const [inputText, setInputText] = useState(initialDescription);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const attributesRef = useRef<CharacterAttribute[]>(mockParse(initialDescription));
|
||||
const contentEditableRef = useRef<HTMLElement>(null);
|
||||
|
||||
const handleTextChange = (e: { target: { value: string } }) => {
|
||||
// 移除 HTML 标签,保留换行
|
||||
const value = e.target.value;
|
||||
setInputText(value);
|
||||
onDescriptionChange?.(value);
|
||||
};
|
||||
|
||||
// 格式化文本为 HTML
|
||||
const formatTextToHtml = (text: string) => {
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line || '<br>')
|
||||
.join('<div>');
|
||||
};
|
||||
|
||||
const handleSmartPolish = async () => {
|
||||
setIsOptimizing(true);
|
||||
try {
|
||||
const polishedText = "一位拥有银白短发、白皙肌肤的高挑精灵少女,年龄约二十岁,气质神秘优雅。举手投足间散发着独特的精灵族气质,眼神中透露出智慧与沧桑。";
|
||||
setInputText(polishedText);
|
||||
attributesRef.current = mockParse(polishedText);
|
||||
onDescriptionChange?.(polishedText);
|
||||
onAttributesChange?.(attributesRef.current);
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttributeChange = (attr: CharacterAttribute, newValue: string) => {
|
||||
// 移除 HTML 标签
|
||||
newValue = newValue.replace(/<[^>]*>/g, '');
|
||||
|
||||
// 更新描述文本
|
||||
let newText = inputText;
|
||||
if (attr.type === "number" && attr.key === "age") {
|
||||
newText = newText.replace(/\d+岁/, `${newValue}岁`);
|
||||
} else {
|
||||
newText = newText.replace(new RegExp(attr.value, 'g'), newValue);
|
||||
}
|
||||
|
||||
// 更新属性值
|
||||
const newAttr = { ...attr, value: newValue };
|
||||
attributesRef.current = attributesRef.current.map(a =>
|
||||
a.key === attr.key ? newAttr : a
|
||||
);
|
||||
|
||||
setInputText(newText);
|
||||
onDescriptionChange?.(newText);
|
||||
onAttributesChange?.(attributesRef.current);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 自由输入区域 */}
|
||||
<div className="relative">
|
||||
<ContentEditable
|
||||
innerRef={contentEditableRef}
|
||||
html={formatTextToHtml(inputText)}
|
||||
onChange={handleTextChange}
|
||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||
rounded-lg border border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
whitespace-pre-wrap break-words"
|
||||
placeholder="用自然语言描述角色,比如:一个身穿红袍的精灵女性..."
|
||||
/>
|
||||
|
||||
{/* 智能润色按钮 */}
|
||||
<motion.button
|
||||
onClick={handleSmartPolish}
|
||||
disabled={isOptimizing}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
|
||||
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
|
||||
transition-colors text-xs disabled:opacity-50"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 结构化属性标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attributesRef.current.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full
|
||||
bg-white/5 hover:bg-white/10 transition-colors
|
||||
border border-white/20 group"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<span className="text-sm text-white/90">{attr.label}:</span>
|
||||
<ContentEditable
|
||||
html={attr.value}
|
||||
onChange={(e) => handleAttributeChange(attr, e.target.value)}
|
||||
className="text-sm text-white/90 min-w-[1em] focus:outline-none
|
||||
border-b border-transparent focus:border-white/30
|
||||
hover:border-white/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义标签区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customTags.map((tag) => (
|
||||
<motion.div
|
||||
key={tag}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||||
rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-white/90">{tag}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users } from 'lucide-react';
|
||||
import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users, Check, Sparkles, Plus, X } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { ReplaceCharacterModal } from './replace-character-modal';
|
||||
import { Slider } from './slider';
|
||||
import CharacterEditor from './character-editor';
|
||||
|
||||
interface Appearance {
|
||||
hairStyle: string;
|
||||
skinTone: string;
|
||||
facialFeatures: string;
|
||||
bodyType: string;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
name: string;
|
||||
@ -11,8 +20,33 @@ interface Role {
|
||||
sound: string;
|
||||
soundDescription: string;
|
||||
roleDescription: string;
|
||||
age: number;
|
||||
gender: 'male' | 'female' | 'other';
|
||||
ethnicity: string;
|
||||
appearance: Appearance;
|
||||
// 新增标签数组
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// Mock 数据
|
||||
const mockRole: Role = {
|
||||
name: "青春女学生",
|
||||
url: "/assets/3dr_chihiro.png",
|
||||
sound: "",
|
||||
soundDescription: "",
|
||||
roleDescription: "一位充满活力和梦想的高中女生,蓝色长发随风飘扬,眼神中透露着对未来的憧憬。她身着整洁的校服,举止优雅而不失活力。",
|
||||
age: 16,
|
||||
gender: 'female',
|
||||
ethnicity: '亚洲人',
|
||||
appearance: {
|
||||
hairStyle: "鲜艳蓝色长发",
|
||||
skinTone: "白皙",
|
||||
facialFeatures: "大眼睛,清秀五官",
|
||||
bodyType: "苗条"
|
||||
},
|
||||
tags: ['高中生', '校服', '蓝色长发', '大眼睛', '清秀五官', '苗条']
|
||||
};
|
||||
|
||||
interface CharacterTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentRoleIndex: number;
|
||||
@ -24,17 +58,74 @@ export function CharacterTabContent({
|
||||
taskSketch,
|
||||
currentRoleIndex,
|
||||
onSketchSelect,
|
||||
roles = []
|
||||
roles = [mockRole]
|
||||
}: CharacterTabContentProps) {
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
const [activeReplaceMethod, setActiveReplaceMethod] = useState('upload');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [editingField, setEditingField] = useState<{
|
||||
type: 'name' | 'voiceDescription' | 'characterDescription' | null;
|
||||
value: string;
|
||||
}>({ type: null, value: '' });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [localRole, setLocalRole] = useState(mockRole);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 处理标签添加
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !localRole.tags.includes(newTag.trim())) {
|
||||
const newTagText = newTag.trim();
|
||||
// 更新标签数组
|
||||
const updatedTags = [...localRole.tags, newTagText];
|
||||
// 更新角色描述文本
|
||||
const updatedDescription = localRole.roleDescription + (localRole.roleDescription ? ',' : '') + newTagText;
|
||||
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
tags: updatedTags,
|
||||
roleDescription: updatedDescription
|
||||
});
|
||||
setNewTag('');
|
||||
|
||||
// 自动调整文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理标签删除
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
tags: localRole.tags.filter(tag => tag !== tagToRemove)
|
||||
});
|
||||
};
|
||||
|
||||
// 处理年龄滑块变化
|
||||
const handleAgeChange = (value: number[]) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
age: value[0]
|
||||
});
|
||||
};
|
||||
|
||||
// 处理描述更新
|
||||
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
roleDescription: e.target.value
|
||||
});
|
||||
|
||||
// 自动调整文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
// 新增智能优化处理函数
|
||||
const handleSmartOptimize = () => {
|
||||
console.log('Optimizing character description...');
|
||||
// TODO: 调用 AI 优化接口
|
||||
};
|
||||
|
||||
// 如果没有角色数据,显示占位内容
|
||||
if (!roles || roles.length === 0) {
|
||||
@ -46,38 +137,6 @@ export function CharacterTabContent({
|
||||
);
|
||||
}
|
||||
|
||||
// 处理音频播放进度
|
||||
const handleTimeUpdate = () => {
|
||||
if (audioRef.current) {
|
||||
const progress = (audioRef.current.currentTime / audioRef.current.duration) * 100;
|
||||
setProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理进度条点击
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (audioRef.current) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = (x / rect.width) * 100;
|
||||
const time = (percentage / 100) * audioRef.current.duration;
|
||||
audioRef.current.currentTime = time;
|
||||
setProgress(percentage);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前选中的角色
|
||||
const currentRole = roles[currentRoleIndex];
|
||||
|
||||
@ -117,163 +176,16 @@ export function CharacterTabContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 中间部分:替换角色 */}
|
||||
<motion.div
|
||||
className="p-4 rounded-lg bg-white/5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-2">Replace character</h3>
|
||||
<div className="flex gap-4">
|
||||
<motion.button
|
||||
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('upload');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>Upload character</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('library');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>Character library</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors',
|
||||
activeReplaceMethod === 'generate' && isReplaceModalOpen
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-white/10 hover:border-white/20'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('generate');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Wand2 className="w-6 h-6" />
|
||||
<span>Generate character</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 下部分:角色详情 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6"
|
||||
className="grid grid-cols-2 gap-6 p-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{/* 左列:角色信息 */}
|
||||
{/* 左列:角色预览 */}
|
||||
<div className="space-y-4">
|
||||
{/* 角色姓名 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">Character name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentRole.name}
|
||||
onChange={(e) => console.log('name changed:', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg
|
||||
focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{/* 声音描述 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">Voice description</label>
|
||||
<textarea
|
||||
value={currentRole.soundDescription}
|
||||
onChange={(e) => console.log('voice description changed:', e.target.value)}
|
||||
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
|
||||
focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 声音预览 */}
|
||||
<div className="p-4 rounded-lg bg-white/5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white/70">Voice preview</span>
|
||||
<GlassIconButton
|
||||
icon={RefreshCw}
|
||||
tooltip="Regenerate voice"
|
||||
onClick={() => console.log('regenerate voice')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={currentRole.sound}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
className="w-full h-1 bg-white/10 rounded-full cursor-pointer overflow-hidden"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-blue-500 rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 播放控制 */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<motion.button
|
||||
className="p-2 rounded-full hover:bg-white/10"
|
||||
onClick={togglePlay}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
<div className="text-xs text-white/50">
|
||||
{audioRef.current ? (
|
||||
`${Math.floor(audioRef.current.currentTime)}s / ${Math.floor(audioRef.current.duration)}s`
|
||||
) : '0:00 / 0:00'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 右列:角色信息 */}
|
||||
<div className="space-y-4">
|
||||
{/* 角色描述 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">Character description</label>
|
||||
<textarea
|
||||
value={currentRole.roleDescription}
|
||||
onChange={(e) => console.log('character description changed:', e.target.value)}
|
||||
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
|
||||
focus:outline-none focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 角色预览 */}
|
||||
{/* 角色预览图 */}
|
||||
<div className="w-full mx-auto rounded-lg overflow-hidden relative group">
|
||||
<img
|
||||
src={currentRole.url}
|
||||
@ -281,32 +193,81 @@ export function CharacterTabContent({
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent
|
||||
opacity-100 transition-opacity">
|
||||
opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<GlassIconButton
|
||||
icon={Wand2}
|
||||
tooltip="Regenerate character"
|
||||
onClick={() => console.log('regenerate character')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => console.log('Apply changes')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-500/10 hover:bg-green-500/20
|
||||
text-green-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>应用</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>重新生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列:角色信息 */}
|
||||
<div className="space-y-4">
|
||||
<CharacterEditor
|
||||
initialDescription={localRole.roleDescription}
|
||||
onDescriptionChange={(description) => {
|
||||
setLocalRole({
|
||||
...localRole,
|
||||
roleDescription: description
|
||||
});
|
||||
}}
|
||||
onAttributesChange={(attributes) => {
|
||||
const newRole = { ...localRole };
|
||||
|
||||
attributes.forEach(attr => {
|
||||
switch (attr.key) {
|
||||
case 'age':
|
||||
newRole.age = parseInt(attr.value);
|
||||
break;
|
||||
case 'gender':
|
||||
if (attr.value === '男性') {
|
||||
newRole.gender = 'male';
|
||||
} else if (attr.value === '女性') {
|
||||
newRole.gender = 'female';
|
||||
} else {
|
||||
newRole.gender = 'other';
|
||||
}
|
||||
break;
|
||||
case 'hair':
|
||||
newRole.appearance.hairStyle = attr.value;
|
||||
break;
|
||||
case 'skin':
|
||||
newRole.appearance.skinTone = attr.value;
|
||||
break;
|
||||
case 'build':
|
||||
newRole.appearance.bodyType = attr.value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
setLocalRole(newRole);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 替换角色弹窗 */}
|
||||
<ReplaceCharacterModal
|
||||
isOpen={isReplaceModalOpen}
|
||||
activeReplaceMethod={activeReplaceMethod}
|
||||
onClose={() => setIsReplaceModalOpen(false)}
|
||||
onCharacterSelect={(character) => {
|
||||
console.log('Selected character:', character);
|
||||
setIsReplaceModalOpen(false);
|
||||
// TODO: 处理角色选择逻辑
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
components/ui/dialogue-item.tsx
Normal file
167
components/ui/dialogue-item.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVertical, Trash2, Edit2, ChevronDown } from 'lucide-react';
|
||||
import { DialogueItem as DialogueItemType, CharacterOption } from '@/app/model/enums';
|
||||
import KeywordText from './keyword-text';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { mockCharacterOptions } from '@/app/model/enums';
|
||||
import './style/dialogue-item.css';
|
||||
|
||||
interface DialogueItemProps {
|
||||
dialogue: DialogueItemType;
|
||||
onUpdate: (updates: Partial<DialogueItemType>) => void;
|
||||
onDelete: () => void;
|
||||
onCharacterChange?: (character: CharacterOption) => void;
|
||||
}
|
||||
|
||||
const DialogueItem: React.FC<DialogueItemProps> = ({
|
||||
dialogue,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onCharacterChange,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(dialogue.text);
|
||||
const [isCharacterSelectOpen, setIsCharacterSelectOpen] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: dialogue.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate({ text: editText });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: CharacterOption) => {
|
||||
onUpdate({ speaker: character.name });
|
||||
onCharacterChange?.(character);
|
||||
setIsCharacterSelectOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`
|
||||
group relative flex items-start gap-3 p-3 rounded-lg
|
||||
${isDragging ? 'bg-white/10' : 'hover:bg-white/5'}
|
||||
transition-colors duration-200 dialogue-item
|
||||
`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-1 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-grow">
|
||||
{/* Character Selector */}
|
||||
<Popover.Root open={isCharacterSelectOpen} onOpenChange={setIsCharacterSelectOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="mb-1 px-2 py-0.5 text-sm font-medium text-gray-400 rounded
|
||||
hover:bg-white/5 flex items-center gap-1 transition-colors group/select"
|
||||
>
|
||||
{dialogue.speaker}
|
||||
<ChevronDown className="w-3 h-3 opacity-50 group-hover/select:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="w-[200px] bg-black/90 backdrop-blur-xl rounded-lg border border-white/10
|
||||
shadow-xl animate-in fade-in-80 z-50"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="p-1">
|
||||
{mockCharacterOptions.map((char) => (
|
||||
<button
|
||||
key={char.characterId}
|
||||
onClick={() => handleCharacterSelect(char)}
|
||||
className="w-full px-3 py-2 text-sm text-left text-gray-200 rounded-md
|
||||
hover:bg-white/5 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src={char.image}
|
||||
alt={char.name}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
{char.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="h-[1px] bg-white/10 my-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpdate({ speaker: '旁白' });
|
||||
setIsCharacterSelectOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm text-left text-gray-400 rounded-md
|
||||
hover:bg-white/5 transition-colors"
|
||||
>
|
||||
旁白
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className="w-full bg-black/20 rounded-lg p-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-500/50"
|
||||
rows={3}
|
||||
onBlur={() => {
|
||||
handleSave();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-200" onClick={() => setIsEditing(true)}>
|
||||
<KeywordText text={dialogue.text} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 opacity-0 transition-opacity opt-btn-group">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogueItem;
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Image, Users, Video, Music, Settings } from 'lucide-react';
|
||||
import { X, Image, Users, Video, Music, Settings, FileText, Maximize, Minimize } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { ScriptTabContent } from './script-tab-content';
|
||||
import ScriptTabContent from './script-tab-content';
|
||||
import { SceneTabContent } from './scene-tab-content';
|
||||
import { VideoTabContent } from './video-tab-content';
|
||||
import { SettingsTabContent } from './settings-tab-content';
|
||||
import { CharacterTabContent } from './character-tab-content';
|
||||
@ -17,6 +18,7 @@ interface EditModalProps {
|
||||
taskStatus: string;
|
||||
taskSketch: any[];
|
||||
sketchVideo: any[];
|
||||
taskScenes: any[];
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
roles?: any[];
|
||||
@ -24,9 +26,10 @@ interface EditModalProps {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: '1', label: 'Shot Sketch', icon: Image },
|
||||
{ id: '2', label: 'Character', icon: Users },
|
||||
{ id: '3', label: 'Shot video', icon: Video },
|
||||
{ id: '0', label: 'Script', icon: FileText },
|
||||
{ id: '1', label: 'Character', icon: Users },
|
||||
{ id: '2', label: 'Scene', icon: Image },
|
||||
{ id: '3', label: 'Shot', icon: Video },
|
||||
{ id: '4', label: 'Music', icon: Music },
|
||||
// { id: '5', label: '剪辑', icon: Scissors },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
@ -39,6 +42,7 @@ export function EditModal({
|
||||
taskStatus,
|
||||
taskSketch,
|
||||
sketchVideo,
|
||||
taskScenes,
|
||||
currentSketchIndex,
|
||||
onSketchSelect,
|
||||
roles = [],
|
||||
@ -60,15 +64,15 @@ export function EditModal({
|
||||
const isTabDisabled = (tabId: string) => {
|
||||
if (tabId === 'settings') return false;
|
||||
// 换成 如果对应标签下 数据存在 就不禁用
|
||||
if (tabId === '1') return taskSketch.length === 0;
|
||||
if (tabId === '2') return roles.length === 0;
|
||||
if (tabId === '1') return roles.length === 0;
|
||||
if (tabId === '2') return taskScenes.length === 0;
|
||||
if (tabId === '3') return sketchVideo.length === 0;
|
||||
if (tabId === '4') return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const hanldeChangeSelect = (index: number) => {
|
||||
if (activeTab === '2') {
|
||||
if (activeTab === '1') {
|
||||
setCurrentRoleIndex(index);
|
||||
} else {
|
||||
setCurrentIndex(index);
|
||||
@ -83,15 +87,11 @@ export function EditModal({
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case '1':
|
||||
case '0':
|
||||
return (
|
||||
<ScriptTabContent
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={hanldeChangeSelect}
|
||||
/>
|
||||
<ScriptTabContent />
|
||||
);
|
||||
case '2':
|
||||
case '1':
|
||||
return (
|
||||
<CharacterTabContent
|
||||
taskSketch={taskSketch}
|
||||
@ -100,6 +100,14 @@ export function EditModal({
|
||||
roles={roles}
|
||||
/>
|
||||
);
|
||||
case '2':
|
||||
return (
|
||||
<SceneTabContent
|
||||
taskSketch={taskScenes}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={hanldeChangeSelect}
|
||||
/>
|
||||
);
|
||||
case '3':
|
||||
return (
|
||||
<VideoTabContent
|
||||
@ -152,7 +160,9 @@ export function EditModal({
|
||||
{/* 弹窗内容 */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 flex justify-center">
|
||||
<motion.div
|
||||
className="w-[66%] min-w-[800px] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
|
||||
className={cn(
|
||||
"w-[88%] min-w-[888px] h-[95vh] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
|
||||
)}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
@ -194,8 +204,9 @@ export function EditModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{/* 弹窗操作按钮 */}
|
||||
<div className="pl-4 border-l border-white/10">
|
||||
{/* 关闭按钮 */}
|
||||
<motion.button
|
||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
onClick={onClose}
|
||||
@ -208,7 +219,7 @@ export function EditModal({
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="h-[80vh] overflow-y-auto p-4">
|
||||
<div className="overflow-y-auto p-4" style={{ height: 'calc(100% - 8rem)' }}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
@ -216,6 +227,7 @@ export function EditModal({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
|
||||
176
components/ui/filter-bar.tsx
Normal file
176
components/ui/filter-bar.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Check, ChevronDown } from 'lucide-react';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { mockSceneOptions, mockCharacterOptions } from '@/app/model/enums';
|
||||
|
||||
interface FilterBarProps {
|
||||
selectedScenes: string[];
|
||||
selectedCharacters: string[];
|
||||
onScenesChange: (scenes: string[]) => void;
|
||||
onCharactersChange: (characters: string[]) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const MultiSelect: React.FC<{
|
||||
label: string;
|
||||
options: { id: string; name: string }[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
}> = ({ label, options, selected, onChange }) => {
|
||||
return (
|
||||
<div className="flex items-center min-w-[200px]">
|
||||
<label className="block text-sm text-gray-400 mr-2 flex-shrink-0 w-[2rem]">{label}</label>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
className="flex-1 h-10 px-3 rounded-lg bg-black/30 border border-white/10
|
||||
flex items-center justify-between text-sm text-gray-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/30"
|
||||
>
|
||||
<span>
|
||||
{selected.length
|
||||
? `已选择 ${selected.length} 个${label}`
|
||||
: `选择${label}`}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="w-[var(--radix-popover-trigger-width)] overflow-hidden bg-black/90 backdrop-blur-xl
|
||||
rounded-lg border border-white/10 shadow-xl animate-in fade-in-80 z-50"
|
||||
align="start"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="p-2">
|
||||
{options.map((option) => (
|
||||
<motion.button
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
const newSelected = selected.includes(option.id)
|
||||
? selected.filter(id => id !== option.id)
|
||||
: [...selected, option.id];
|
||||
onChange(newSelected);
|
||||
}}
|
||||
className="relative w-full flex items-center px-8 py-2 rounded-md text-sm text-gray-200
|
||||
hover:bg-white/5 cursor-pointer outline-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<span className="absolute left-2">
|
||||
{selected.includes(option.id) && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-4 h-4 text-blue-400"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</motion.div>
|
||||
)}
|
||||
</span>
|
||||
{option.name}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterBar: React.FC<FilterBarProps> = ({
|
||||
selectedScenes,
|
||||
selectedCharacters,
|
||||
onScenesChange,
|
||||
onCharactersChange,
|
||||
onReset,
|
||||
}) => {
|
||||
const sceneOptions = mockSceneOptions.map(scene => ({
|
||||
id: scene.sceneId,
|
||||
name: scene.name,
|
||||
}));
|
||||
|
||||
const characterOptions = mockCharacterOptions.map(char => ({
|
||||
id: char.characterId,
|
||||
name: char.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full bg-black/20 backdrop-blur-sm rounded-xl p-4 border border-white/10"
|
||||
>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<MultiSelect
|
||||
label="场景"
|
||||
options={sceneOptions}
|
||||
selected={selectedScenes}
|
||||
onChange={onScenesChange}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="角色"
|
||||
options={characterOptions}
|
||||
selected={selectedCharacters}
|
||||
onChange={onCharactersChange}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="h-10 px-4 rounded-lg bg-white/5 hover:bg-white/10
|
||||
text-sm text-gray-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前筛选条件 */}
|
||||
<AnimatePresence>
|
||||
{(selectedScenes.length > 0 || selectedCharacters.length > 0) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-4 flex flex-wrap gap-2"
|
||||
>
|
||||
{selectedScenes.map((sceneId) => {
|
||||
const scene = mockSceneOptions.find(s => s.sceneId === sceneId);
|
||||
return scene && (
|
||||
<motion.span
|
||||
key={sceneId}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
className="px-2 py-1 rounded-md bg-blue-500/20 text-blue-400 text-sm"
|
||||
>
|
||||
场景:{scene.name}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
{selectedCharacters.map((charId) => {
|
||||
const char = mockCharacterOptions.find(c => c.characterId === charId);
|
||||
return char && (
|
||||
<motion.span
|
||||
key={charId}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
className="px-2 py-1 rounded-md bg-yellow-500/20 text-yellow-400 text-sm"
|
||||
>
|
||||
角色:{char.name}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterBar;
|
||||
137
components/ui/keyword-text.tsx
Normal file
137
components/ui/keyword-text.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { characterInfoMap, sceneInfoMap, mockCharacterOptions, mockSceneOptions } from '@/app/model/enums';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface KeywordTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const KeywordText: React.FC<KeywordTextProps> = ({ text, className = '', id }) => {
|
||||
// 解析文本中的关键词
|
||||
const parseText = (text: string) => {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
// 匹配 #角色# 和 [场景]
|
||||
const regex = /#([^#]+)#|\[([^\]]+)\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// 添加普通文本
|
||||
if (match.index > currentIndex) {
|
||||
parts.push(text.slice(currentIndex, match.index));
|
||||
}
|
||||
|
||||
const [fullMatch, character, scene] = match;
|
||||
if (character) {
|
||||
// 角色关键词
|
||||
const info = mockCharacterOptions.find(option => option.characterId === id);
|
||||
if (info) {
|
||||
parts.push(
|
||||
<Tooltip.Provider key={match.index}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<motion.span
|
||||
className="text-yellow-400 font-medium cursor-help"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
#{character}#
|
||||
</motion.span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="z-[9999] bg-black/60 backdrop-blur-lg p-3 rounded-lg shadow-xl border border-white/10"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={info.image}
|
||||
alt={character}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-white">{character}</div>
|
||||
<div className="text-sm text-gray-300">
|
||||
{info.gender} · {info.age}岁
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{info.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-black/90" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
} else {
|
||||
parts.push(<span key={match.index} className="text-yellow-400">#{character}#</span>);
|
||||
}
|
||||
} else if (scene) {
|
||||
// 场景关键词
|
||||
const info = mockSceneOptions.find(option => option.sceneId === id);
|
||||
console.log('info', info);
|
||||
if (info) {
|
||||
parts.push(
|
||||
<Tooltip.Provider key={match.index}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<motion.span
|
||||
className="text-blue-400 font-medium cursor-help"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
[{scene}]
|
||||
</motion.span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="z-[9999] bg-black/60 backdrop-blur-lg p-3 rounded-lg shadow-xl border border-white/10"
|
||||
sideOffset={5}
|
||||
>
|
||||
<div className="w-48">
|
||||
<img
|
||||
src={info.image}
|
||||
alt={scene}
|
||||
className="w-full h-24 object-cover rounded-lg mb-2"
|
||||
/>
|
||||
<div className="font-medium text-white">{scene}</div>
|
||||
<div className="text-sm text-gray-300 mt-1">
|
||||
{info.location} · {info.time}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip.Arrow className="fill-black/90" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
} else {
|
||||
parts.push(<span key={match.index} className="text-blue-400">[{scene}]</span>);
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (currentIndex < text.length) {
|
||||
parts.push(text.slice(currentIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{parseText(text)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordText;
|
||||
@ -119,7 +119,7 @@ export function MediaPropertiesModal({
|
||||
{/* 弹窗内容 */}
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 flex justify-center">
|
||||
<motion.div
|
||||
className="w-[66%] min-w-[800px] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
|
||||
className="w-[88%] min-w-[888px] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
@ -138,7 +138,7 @@ export function MediaPropertiesModal({
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium">Media Properties</h2>
|
||||
<h2 className="text-lg font-medium">More Properties</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -147,7 +147,7 @@ export function MediaPropertiesModal({
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-4 overflow-x-auto pb-2 pt-2 hide-scrollbar"
|
||||
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
||||
>
|
||||
{sketches.map((sketch, index) => (
|
||||
<motion.div
|
||||
|
||||
308
components/ui/scene-editor.tsx
Normal file
308
components/ui/scene-editor.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, X, Plus, Clock, MapPin, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Palette } from 'lucide-react';
|
||||
import { cn } from "@/public/lib/utils";
|
||||
import ContentEditable from 'react-contenteditable';
|
||||
|
||||
interface SceneAttribute {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
type: 'text' | 'number' | 'select';
|
||||
options?: string[];
|
||||
icon?: any;
|
||||
}
|
||||
|
||||
interface SceneEnvironment {
|
||||
time: {
|
||||
period: '清晨' | '上午' | '中午' | '下午' | '傍晚' | '夜晚' | '深夜';
|
||||
specific?: string;
|
||||
};
|
||||
location: {
|
||||
main: string;
|
||||
detail?: string;
|
||||
};
|
||||
weather: {
|
||||
type: '晴天' | '多云' | '雨天' | '雪天' | '雷暴' | '阴天';
|
||||
description?: string;
|
||||
};
|
||||
atmosphere: {
|
||||
lighting: string;
|
||||
mood: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SceneEditorProps {
|
||||
initialDescription?: string;
|
||||
onDescriptionChange?: (description: string) => void;
|
||||
onAttributesChange?: (attributes: SceneAttribute[]) => void;
|
||||
onEnvironmentChange?: (environment: SceneEnvironment) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 天气图标映射
|
||||
const weatherIcons = {
|
||||
'晴天': Sun,
|
||||
'多云': Cloud,
|
||||
'雨天': CloudRain,
|
||||
'雪天': CloudSnow,
|
||||
'雷暴': CloudLightning,
|
||||
'阴天': Cloud
|
||||
};
|
||||
|
||||
const mockParse = (text: string): { attributes: SceneAttribute[], environment: SceneEnvironment } => {
|
||||
// 模拟结构化解析结果
|
||||
const attributes: SceneAttribute[] = [];
|
||||
|
||||
const environment: SceneEnvironment = {
|
||||
time: {
|
||||
period: "下午",
|
||||
specific: "午后2点左右"
|
||||
},
|
||||
location: {
|
||||
main: "教室",
|
||||
detail: "高中教室,靠窗的位置"
|
||||
},
|
||||
weather: {
|
||||
type: "晴天",
|
||||
description: "阳光明媚,微风轻拂"
|
||||
},
|
||||
atmosphere: {
|
||||
lighting: "自然光线充足,温暖的阳光从窗户斜射入室内",
|
||||
mood: "安静祥和,充满生机与活力"
|
||||
}
|
||||
};
|
||||
|
||||
// 添加环境属性到结构化标签
|
||||
const environmentAttributes: SceneAttribute[] = [
|
||||
{
|
||||
key: "time",
|
||||
label: "时间",
|
||||
value: `${environment.time.period}${environment.time.specific ? `,${environment.time.specific}` : ''}`,
|
||||
type: "text",
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
label: "地点",
|
||||
value: `${environment.location.main}${environment.location.detail ? `,${environment.location.detail}` : ''}`,
|
||||
type: "text",
|
||||
icon: MapPin
|
||||
},
|
||||
{
|
||||
key: "weather",
|
||||
label: "天气",
|
||||
value: `${environment.weather.type}${environment.weather.description ? `,${environment.weather.description}` : ''}`,
|
||||
type: "text",
|
||||
icon: weatherIcons[environment.weather.type as keyof typeof weatherIcons]
|
||||
},
|
||||
{
|
||||
key: "atmosphere",
|
||||
label: "氛围",
|
||||
value: `${environment.atmosphere.lighting},${environment.atmosphere.mood}`,
|
||||
type: "text",
|
||||
icon: Palette
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
attributes: [...attributes, ...environmentAttributes],
|
||||
environment
|
||||
};
|
||||
};
|
||||
|
||||
export default function SceneEditor({
|
||||
initialDescription = "一个银白短发的精灵女性,大约20岁,肤色白皙,身材高挑,身着白色连衣裙。在教室里,阳光透过窗户洒在地板上,营造出温暖而安静的氛围。",
|
||||
onDescriptionChange,
|
||||
onAttributesChange,
|
||||
onEnvironmentChange,
|
||||
className
|
||||
}: SceneEditorProps) {
|
||||
const [inputText, setInputText] = useState(initialDescription);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [customTags, setCustomTags] = useState<string[]>([]);
|
||||
const [newTag, setNewTag] = useState("");
|
||||
const parseResult = useRef(mockParse(initialDescription));
|
||||
const contentEditableRef = useRef<HTMLElement>(null);
|
||||
|
||||
const handleTextChange = (e: { target: { value: string } }) => {
|
||||
// 移除 HTML 标签,保留换行
|
||||
const value = e.target.value;
|
||||
setInputText(value);
|
||||
onDescriptionChange?.(value);
|
||||
|
||||
// 重新解析文本
|
||||
const newParseResult = mockParse(value);
|
||||
parseResult.current = newParseResult;
|
||||
onAttributesChange?.(newParseResult.attributes);
|
||||
onEnvironmentChange?.(newParseResult.environment);
|
||||
};
|
||||
|
||||
// 格式化文本为 HTML
|
||||
const formatTextToHtml = (text: string) => {
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line || '<br>')
|
||||
.join('<div>');
|
||||
};
|
||||
|
||||
const handleSmartPolish = async () => {
|
||||
setIsOptimizing(true);
|
||||
try {
|
||||
const polishedText = "一位拥有银白短发、白皙肌肤的高挑精灵少女,年龄约二十岁,气质神秘优雅。在阳光明媚的午后,她站在教室靠窗的位置,温暖的阳光从窗户斜射入室内,为这个安静祥和的空间注入了生机与活力。";
|
||||
setInputText(polishedText);
|
||||
const newParseResult = mockParse(polishedText);
|
||||
parseResult.current = newParseResult;
|
||||
onDescriptionChange?.(polishedText);
|
||||
onAttributesChange?.(newParseResult.attributes);
|
||||
onEnvironmentChange?.(newParseResult.environment);
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttributeChange = (attr: SceneAttribute, newValue: string) => {
|
||||
// 移除 HTML 标签
|
||||
newValue = newValue.replace(/<[^>]*>/g, '');
|
||||
|
||||
// 更新描述文本
|
||||
let newText = inputText;
|
||||
if (attr.type === "number" && attr.key === "age") {
|
||||
newText = newText.replace(/\d+岁/, `${newValue}岁`);
|
||||
} else {
|
||||
newText = newText.replace(new RegExp(attr.value, 'g'), newValue);
|
||||
}
|
||||
|
||||
// 更新属性值
|
||||
const newAttr = { ...attr, value: newValue };
|
||||
const newAttributes = parseResult.current.attributes.map(a =>
|
||||
a.key === attr.key ? newAttr : a
|
||||
);
|
||||
|
||||
// 重新解析文本以更新环境信息
|
||||
const newParseResult = mockParse(newText);
|
||||
parseResult.current = newParseResult;
|
||||
|
||||
setInputText(newText);
|
||||
onDescriptionChange?.(newText);
|
||||
onAttributesChange?.(newAttributes);
|
||||
onEnvironmentChange?.(newParseResult.environment);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* 自由输入区域 */}
|
||||
<div className="relative">
|
||||
<ContentEditable
|
||||
innerRef={contentEditableRef}
|
||||
html={formatTextToHtml(inputText)}
|
||||
onChange={handleTextChange}
|
||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||
rounded-lg border border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
whitespace-pre-wrap break-words"
|
||||
placeholder="用自然语言描述场景,比如:阳光透过窗户洒在教室的地板上..."
|
||||
/>
|
||||
|
||||
{/* 智能润色按钮 */}
|
||||
<motion.button
|
||||
onClick={handleSmartPolish}
|
||||
disabled={isOptimizing}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1.5 px-3 py-1.5
|
||||
bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 rounded-full
|
||||
transition-colors text-xs disabled:opacity-50"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>{isOptimizing ? "优化中..." : "智能优化"}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 结构化属性标签 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseResult.current.attributes.map((attr) => (
|
||||
<motion.div
|
||||
key={attr.key}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full
|
||||
bg-white/5 hover:bg-white/10 transition-colors
|
||||
border border-white/20 group"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{attr.icon && <attr.icon className="w-3.5 h-3.5 text-white/70" />}
|
||||
<span className="text-sm text-white/90 flex-shrink-0">{attr.label}:</span>
|
||||
<ContentEditable
|
||||
html={attr.value}
|
||||
onChange={(e) => handleAttributeChange(attr, e.target.value)}
|
||||
className="text-sm text-white/90 min-w-[1em] focus:outline-none
|
||||
border-b border-transparent focus:border-white/30
|
||||
hover:border-white/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 自定义标签区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customTags.map((tag) => (
|
||||
<motion.div
|
||||
key={tag}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="group flex items-center gap-1 px-3 py-1.5 bg-white/5
|
||||
rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-white/90">{tag}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCustomTags(tags => tags.filter(t => t !== tag));
|
||||
setInputText((text: string) => text.replace(new RegExp(`[,。]?${tag}[,。]?`), ""));
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3 text-white/70" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
placeholder="添加自定义标签..."
|
||||
className="flex-1 px-3 py-2 bg-white/5 border-none rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-white/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newTag.trim()) {
|
||||
setCustomTags(tags => [...tags, newTag.trim()]);
|
||||
setInputText((text: string) => text + (text.endsWith("。") ? "" : ",") + newTag.trim());
|
||||
setNewTag("");
|
||||
}
|
||||
}}
|
||||
className="p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5 text-white/70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
components/ui/scene-tab-content.tsx
Normal file
314
components/ui/scene-tab-content.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw, Sun, Moon, Cloud, CloudRain, CloudSnow, CloudLightning, Sparkles, Clock, MapPin, Palette, Check, Plus } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import SceneEditor from './scene-editor';
|
||||
|
||||
interface SceneEnvironment {
|
||||
time: {
|
||||
period: '清晨' | '上午' | '中午' | '下午' | '傍晚' | '夜晚' | '深夜';
|
||||
specific?: string;
|
||||
};
|
||||
location: {
|
||||
main: string;
|
||||
detail?: string;
|
||||
};
|
||||
weather: {
|
||||
type: '晴天' | '多云' | '雨天' | '雪天' | '雷暴' | '阴天';
|
||||
description?: string;
|
||||
};
|
||||
atmosphere: {
|
||||
lighting: string;
|
||||
mood: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SceneTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
interface SceneSketch {
|
||||
id: string;
|
||||
script: string;
|
||||
environment: SceneEnvironment;
|
||||
}
|
||||
|
||||
// Mock 数据
|
||||
const mockSketch: SceneSketch = {
|
||||
id: '1',
|
||||
script: '教室里洒满了温暖的阳光,透过窗户的光线在地板上画出了长长的影子。黑板上还留着上节课的板书,几个学生正在整理自己的书包。教室后排的绿植在微风中轻轻摇曳,为这个平静的午后增添了一丝生机。',
|
||||
environment: {
|
||||
time: {
|
||||
period: '下午',
|
||||
specific: '午后2点左右'
|
||||
},
|
||||
location: {
|
||||
main: '教室',
|
||||
detail: '高中教室,靠窗的位置'
|
||||
},
|
||||
weather: {
|
||||
type: '晴天',
|
||||
description: '阳光明媚,微风轻拂'
|
||||
},
|
||||
atmosphere: {
|
||||
lighting: '自然光线充足,温暖的阳光从窗户斜射入室内',
|
||||
mood: '安静祥和,充满生机与活力'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function SceneTabContent({
|
||||
taskSketch = [],
|
||||
currentSketchIndex = 0,
|
||||
onSketchSelect
|
||||
}: SceneTabContentProps) {
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 确保 taskSketch 是数组
|
||||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||||
|
||||
const [localSketch, setLocalSketch] = useState(mockSketch);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// 天气图标映射
|
||||
const weatherIcons = {
|
||||
'晴天': Sun,
|
||||
'多云': Cloud,
|
||||
'雨天': CloudRain,
|
||||
'雪天': CloudSnow,
|
||||
'雷暴': CloudLightning,
|
||||
'阴天': Cloud
|
||||
};
|
||||
|
||||
// 时间选项
|
||||
const timeOptions = ['清晨', '上午', '中午', '下午', '傍晚', '夜晚', '深夜'];
|
||||
|
||||
// 处理脚本更新
|
||||
const handleScriptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setLocalSketch({
|
||||
...localSketch,
|
||||
script: e.target.value
|
||||
});
|
||||
|
||||
// 自动调整文本框高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理智能优化
|
||||
const handleSmartOptimize = () => {
|
||||
console.log('Optimizing scene description...');
|
||||
// TODO: 调用 AI 优化接口
|
||||
};
|
||||
|
||||
// 自动滚动到选中项
|
||||
useEffect(() => {
|
||||
if (thumbnailsRef.current && scriptsRef.current) {
|
||||
const thumbnailContainer = thumbnailsRef.current;
|
||||
const scriptContainer = scriptsRef.current;
|
||||
|
||||
// 计算缩略图滚动位置
|
||||
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
|
||||
const thumbnailGap = 16; // gap-4 = 16px
|
||||
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * currentSketchIndex;
|
||||
|
||||
// 计算脚本文字滚动位置
|
||||
const scriptElement = scriptContainer.children[currentSketchIndex] as HTMLElement;
|
||||
const scriptScrollPosition = scriptElement?.offsetLeft ?? 0;
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
thumbnailContainer.scrollTo({
|
||||
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
scriptContainer.scrollTo({
|
||||
left: scriptScrollPosition - scriptContainer.clientWidth / 2 + scriptElement?.clientWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [currentSketchIndex]);
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>No scene data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 上部分 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* 分镜缩略图行 */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="relative flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
||||
>
|
||||
<div className="flex gap-4 min-w-fit">
|
||||
{sketches.map((sketch, index) => (
|
||||
<motion.div
|
||||
key={sketch.id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<img
|
||||
src={sketch.url}
|
||||
alt={`Sketch ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
|
||||
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('Delete sketch');
|
||||
}}
|
||||
className="text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{/* 新增占位符 */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg cursor-pointer',
|
||||
'bg-white/5 hover:bg-white/10 transition-colors',
|
||||
'flex items-center justify-center',
|
||||
'border-2 border-dashed border-white/20 hover:border-white/30'
|
||||
)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => console.log('Add new sketch')}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-white/50">
|
||||
<Plus className="w-6 h-6" />
|
||||
<span className="text-xs">添加场景</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 脚本预览行 - 单行滚动 */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
ref={scriptsRef}
|
||||
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
||||
>
|
||||
{sketches.map((script, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.02 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
{script.script}
|
||||
</span>
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 渐变遮罩 */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 下部分 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{/* 左列:脚本编辑器 */}
|
||||
<div className="space-y-4">
|
||||
{/* 选中的分镜预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden"
|
||||
layoutId={`sketch-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<img
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => console.log('Apply changes')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-500/10 hover:bg-green-500/20
|
||||
text-green-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>应用</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Regenerate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列:环境设置 */}
|
||||
<div className="space-y-4">
|
||||
{/* 使用新的场景编辑器组件 */}
|
||||
<SceneEditor
|
||||
initialDescription={localSketch.script}
|
||||
onDescriptionChange={(description) => {
|
||||
setLocalSketch({
|
||||
...localSketch,
|
||||
script: description
|
||||
});
|
||||
}}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,195 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DndContext, closestCenter, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { StoryboardCard as StoryboardCardType, mockStoryboards, mockSceneOptions } from '@/app/model/enums';
|
||||
import FilterBar from './filter-bar';
|
||||
import StoryboardCard from './storyboard-card';
|
||||
|
||||
interface ScriptTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
}
|
||||
const ScriptTabContent: React.FC = () => {
|
||||
const [cards, setCards] = useState<StoryboardCardType[]>(mockStoryboards);
|
||||
const [selectedScenes, setSelectedScenes] = useState<string[]>([]);
|
||||
const [selectedCharacters, setSelectedCharacters] = useState<string[]>([]);
|
||||
|
||||
export function ScriptTabContent({
|
||||
taskSketch = [],
|
||||
currentSketchIndex = 0,
|
||||
onSketchSelect
|
||||
}: ScriptTabContentProps) {
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
const scriptsRef = useRef<HTMLDivElement>(null);
|
||||
// 筛选卡片
|
||||
const filteredCards = cards.filter(card => {
|
||||
const matchesScene = selectedScenes.length === 0 ||
|
||||
selectedScenes.includes(card.scene?.sceneId || '');
|
||||
const matchesCharacter = selectedCharacters.length === 0 ||
|
||||
card.characters.some(char => selectedCharacters.includes(char.characterId));
|
||||
return matchesScene && matchesCharacter;
|
||||
});
|
||||
|
||||
// 确保 taskSketch 是数组
|
||||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||||
|
||||
// 自动滚动到选中项
|
||||
useEffect(() => {
|
||||
if (thumbnailsRef.current && scriptsRef.current) {
|
||||
const thumbnailContainer = thumbnailsRef.current;
|
||||
const scriptContainer = scriptsRef.current;
|
||||
|
||||
// 计算缩略图滚动位置
|
||||
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
|
||||
const thumbnailGap = 16; // gap-4 = 16px
|
||||
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * currentSketchIndex;
|
||||
|
||||
// 计算脚本文字滚动位置
|
||||
const scriptElement = scriptContainer.children[currentSketchIndex] as HTMLElement;
|
||||
const scriptScrollPosition = scriptElement?.offsetLeft ?? 0;
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
thumbnailContainer.scrollTo({
|
||||
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
scriptContainer.scrollTo({
|
||||
left: scriptScrollPosition - scriptContainer.clientWidth / 2 + scriptElement?.clientWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
// 处理卡片拖拽
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = cards.findIndex(c => c.id === active.id);
|
||||
const newIndex = cards.findIndex(c => c.id === over?.id);
|
||||
const newCards = [...cards];
|
||||
const [removed] = newCards.splice(oldIndex, 1);
|
||||
newCards.splice(newIndex, 0, removed);
|
||||
setCards(newCards);
|
||||
}
|
||||
}, [currentSketchIndex]);
|
||||
};
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>No sketch data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 处理卡片更新
|
||||
const handleCardUpdate = useCallback((cardId: string, updates: Partial<StoryboardCardType>) => {
|
||||
setCards(cards => cards.map(card =>
|
||||
card.id === cardId ? { ...card, ...updates } : card
|
||||
));
|
||||
}, []);
|
||||
|
||||
// 处理卡片删除
|
||||
const handleCardDelete = useCallback((cardId: string) => {
|
||||
setCards(cards => cards.filter(card => card.id !== cardId));
|
||||
}, []);
|
||||
|
||||
// 处理卡片复制
|
||||
const handleCardDuplicate = useCallback((cardId: string) => {
|
||||
const card = cards.find(c => c.id === cardId);
|
||||
if (card) {
|
||||
const newCard: StoryboardCardType = {
|
||||
...card,
|
||||
id: `card-${Date.now()}`,
|
||||
shotId: `SC-${cards.length + 1}`,
|
||||
dialogues: card.dialogues.map(d => ({ ...d, id: `d${Date.now()}-${d.id}` })),
|
||||
};
|
||||
setCards(cards => [...cards, newCard]);
|
||||
}
|
||||
}, [cards]);
|
||||
|
||||
// 添加新卡片
|
||||
const handleAddCard = () => {
|
||||
const newCard: StoryboardCardType = {
|
||||
id: `card-${Date.now()}`,
|
||||
shotId: `SC-${cards.length + 1}`,
|
||||
scene: undefined,
|
||||
characters: [],
|
||||
dialogues: [],
|
||||
description: '',
|
||||
shotType: '',
|
||||
cameraMove: '',
|
||||
};
|
||||
setCards(cards => [...cards, newCard]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 上部分 */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* 分镜缩略图行 */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
||||
>
|
||||
{sketches.map((sketch, index) => (
|
||||
<motion.div
|
||||
key={sketch.id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<img
|
||||
src={sketch.url}
|
||||
alt={`Sketch ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 筛选栏 - 固定在顶部 */}
|
||||
<div className="flex-shrink-0 bg-black/20 backdrop-blur-sm z-10">
|
||||
<FilterBar
|
||||
selectedScenes={selectedScenes}
|
||||
selectedCharacters={selectedCharacters}
|
||||
onScenesChange={setSelectedScenes}
|
||||
onCharactersChange={setSelectedCharacters}
|
||||
onReset={() => {
|
||||
setSelectedScenes([]);
|
||||
setSelectedCharacters([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 脚本预览行 - 单行滚动 */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
ref={scriptsRef}
|
||||
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
||||
{/* 卡片网格 - 可滚动区域 */}
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={[...filteredCards.map(card => card.id), 'add-card']}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{sketches.map((script, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.02 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
{script.script}
|
||||
</span>
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
<div
|
||||
className="grid auto-rows-min gap-6 w-full"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
justifyItems: 'center'
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{filteredCards.map((card) => (
|
||||
<div key={card.id} className="w-full max-w-[480px]">
|
||||
<StoryboardCard
|
||||
card={card}
|
||||
onUpdate={(updates) => handleCardUpdate(card.id, updates)}
|
||||
onDelete={() => handleCardDelete(card.id)}
|
||||
onDuplicate={() => handleCardDuplicate(card.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 添加卡片占位符 */}
|
||||
<motion.div
|
||||
key="add-card"
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={handleAddCard}
|
||||
className="w-full max-w-[480px] h-[480px] bg-black/20 backdrop-blur-sm rounded-xl
|
||||
border border-dashed border-white/10 cursor-pointer
|
||||
flex items-center justify-center
|
||||
hover:bg-black/30 hover:border-white/20 transition-all duration-200
|
||||
group"
|
||||
>
|
||||
<Plus className="w-8 h-8 text-white/40 group-hover:text-white/60 transition-colors" />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 渐变遮罩 */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 下部分 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{/* 左列:脚本编辑器 */}
|
||||
<div className="space-y-2">
|
||||
<motion.textarea
|
||||
className="w-full h-full p-4 rounded-lg bg-white/5 backdrop-blur-sm border border-white/10
|
||||
text-white/90 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={sketches[currentSketchIndex]?.script}
|
||||
onChange={() => {}}
|
||||
layoutId="script-editor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右列:预览和操作 */}
|
||||
<div className="space-y-4">
|
||||
{/* 选中的分镜预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden"
|
||||
layoutId={`sketch-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<img
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => console.log('Delete sketch')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20
|
||||
text-red-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete sketch</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Regenerate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ScriptTabContent;
|
||||
|
||||
305
components/ui/shot-tab-content.tsx
Normal file
305
components/ui/shot-tab-content.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings } from 'lucide-react';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { ReplaceVideoModal } from './replace-video-modal';
|
||||
import { MediaPropertiesModal } from './media-properties-modal';
|
||||
import { DramaLineChart } from './drama-line-chart';
|
||||
|
||||
interface ShotTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export function ShotTabContent({
|
||||
taskSketch = [],
|
||||
currentSketchIndex = 0,
|
||||
onSketchSelect,
|
||||
isPlaying: externalIsPlaying = true
|
||||
}: ShotTabContentProps) {
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
const videosRef = useRef<HTMLDivElement>(null);
|
||||
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
|
||||
const [isMuted, setIsMuted] = React.useState(false);
|
||||
const [progress, setProgress] = React.useState(0);
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = React.useState(false);
|
||||
const [activeReplaceMethod, setActiveReplaceMethod] = React.useState<'upload' | 'library' | 'generate'>('upload');
|
||||
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
|
||||
|
||||
// 监听外部播放状态变化
|
||||
useEffect(() => {
|
||||
setIsPlaying(externalIsPlaying);
|
||||
}, [externalIsPlaying]);
|
||||
|
||||
// 确保 taskSketch 是数组
|
||||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||||
|
||||
// 自动滚动到选中项
|
||||
useEffect(() => {
|
||||
if (thumbnailsRef.current && videosRef.current) {
|
||||
const thumbnailContainer = thumbnailsRef.current;
|
||||
const videoContainer = videosRef.current;
|
||||
|
||||
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
|
||||
const thumbnailGap = 16; // gap-4 = 16px
|
||||
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * currentSketchIndex;
|
||||
|
||||
const videoElement = videoContainer.children[currentSketchIndex] as HTMLElement;
|
||||
const videoScrollPosition = videoElement?.offsetLeft ?? 0;
|
||||
|
||||
thumbnailContainer.scrollTo({
|
||||
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
videoContainer.scrollTo({
|
||||
left: videoScrollPosition - videoContainer.clientWidth / 2 + videoElement?.clientWidth / 2,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [currentSketchIndex]);
|
||||
|
||||
// 视频播放控制
|
||||
useEffect(() => {
|
||||
if (videoPlayerRef.current) {
|
||||
if (isPlaying) {
|
||||
videoPlayerRef.current.play().catch(() => {
|
||||
// 处理自动播放策略限制
|
||||
setIsPlaying(false);
|
||||
});
|
||||
} else {
|
||||
// videoPlayerRef.current.pause();
|
||||
}
|
||||
}
|
||||
}, [isPlaying, currentSketchIndex]);
|
||||
|
||||
// 更新进度条
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoPlayerRef.current) {
|
||||
const progress = (videoPlayerRef.current.currentTime / videoPlayerRef.current.duration) * 100;
|
||||
setProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>No sketch data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 上部分 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* 分镜缩略图行 */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"
|
||||
>
|
||||
{sketches.map((sketch, index) => (
|
||||
<motion.div
|
||||
key={sketch.id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<video
|
||||
src={sketch.url}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onMouseEnter={(e) => e.currentTarget.play()}
|
||||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<span className="text-xs text-white/90">Shot {index + 1}</span>
|
||||
</div>
|
||||
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
|
||||
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={() => console.log('Delete sketch')}
|
||||
className="text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视频描述行 - 单行滚动 */}
|
||||
<div className="relative group">
|
||||
<div
|
||||
ref={videosRef}
|
||||
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
||||
>
|
||||
{sketches.map((video, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
key={video.id || index}
|
||||
className={cn(
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.02 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
Shot {index + 1}
|
||||
</span>
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 渐变遮罩 */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* 下部分 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-3 gap-4 w-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{/* 视频预览和操作 */}
|
||||
<div className="space-y-4 col-span-2">
|
||||
{/* 选中的视频预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden relative group"
|
||||
layoutId={`video-preview-${currentSketchIndex}`}
|
||||
>
|
||||
<video
|
||||
ref={videoPlayerRef}
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
className="w-full h-full object-cover"
|
||||
loop
|
||||
autoPlay={false}
|
||||
playsInline
|
||||
controls
|
||||
muted={isMuted}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/>
|
||||
|
||||
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
||||
{/* 人物替换按钮 */}
|
||||
<motion.button
|
||||
onClick={() => console.log('Replace character')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</motion.button>
|
||||
{/* 场景替换按钮 */}
|
||||
<motion.button
|
||||
onClick={() => console.log('Replace scene')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</motion.button>
|
||||
{/* Regenerate 按钮 */}
|
||||
<motion.button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</motion.button>
|
||||
{/* 运镜按钮 */}
|
||||
{/* <motion.button
|
||||
onClick={() => console.log('Replace shot')}
|
||||
disabled={true}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
|
||||
{/* 更多设置 点击打开 More properties 弹窗 */}
|
||||
<motion.button
|
||||
className='p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
||||
style={{textDecorationLine: 'underline'}}
|
||||
onClick={() => setIsMediaPropertiesModalOpen(true)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 基础配置查看 场景/人物/运镜/对话 */}
|
||||
<div className='space-y-4 col-span-1'>
|
||||
{/* 场景: */}
|
||||
</div>
|
||||
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* 替换视频弹窗 */}
|
||||
<ReplaceVideoModal
|
||||
isOpen={isReplaceModalOpen}
|
||||
activeReplaceMethod={activeReplaceMethod}
|
||||
onClose={() => setIsReplaceModalOpen(false)}
|
||||
onVideoSelect={(video) => {
|
||||
console.log('Selected video:', video);
|
||||
setIsReplaceModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Media Properties 弹窗 */}
|
||||
<MediaPropertiesModal
|
||||
isOpen={isMediaPropertiesModalOpen}
|
||||
onClose={() => setIsMediaPropertiesModalOpen(false)}
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
components/ui/storyboard-card.tsx
Normal file
284
components/ui/storyboard-card.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { GripVertical, Trash2, Copy, Plus } from 'lucide-react';
|
||||
import { StoryboardCard as StoryboardCardType, DialogueItem as DialogueItemType, CharacterOption, mockCharacterOptions } from '@/app/model/enums';
|
||||
import DialogueItem from './dialogue-item';
|
||||
import KeywordText from './keyword-text';
|
||||
import { DndContext, closestCenter } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
|
||||
interface StoryboardCardProps {
|
||||
card: StoryboardCardType;
|
||||
onUpdate: (updates: Partial<StoryboardCardType>) => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
const EditableField: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}> = ({ label, value, onChange, className = '' }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(editValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 自动调整文本框高度
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [isEditing, editValue]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-gray-400 mb-1">{label}</div>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => {
|
||||
setEditValue(e.target.value);
|
||||
// 调整高度
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onBlur={handleSave}
|
||||
autoFocus
|
||||
className="w-full bg-black/20 rounded-lg p-2 text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500/50
|
||||
resize-none overflow-hidden min-h-[2.5rem]"
|
||||
style={{
|
||||
maxHeight: '20rem', // 防止内容过多时超出视口
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setEditValue(value); // 重置编辑值
|
||||
}}
|
||||
className="text-sm text-gray-200 cursor-text
|
||||
hover:bg-white/5 rounded-lg p-2 transition-colors
|
||||
line-clamp-3"
|
||||
>
|
||||
{value || `点击添加${label}...`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
card,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editNotes, setEditNotes] = useState(card.notes || '');
|
||||
console.log('card', card);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: card.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleDialogueReorder = (event: any) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = card.dialogues.findIndex(d => d.id === active.id);
|
||||
const newIndex = card.dialogues.findIndex(d => d.id === over?.id);
|
||||
const newDialogues = [...card.dialogues];
|
||||
const [removed] = newDialogues.splice(oldIndex, 1);
|
||||
newDialogues.splice(newIndex, 0, removed);
|
||||
onUpdate({ dialogues: newDialogues });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogueUpdate = (dialogueId: string, updates: Partial<DialogueItemType>) => {
|
||||
const newDialogues = card.dialogues.map(d =>
|
||||
d.id === dialogueId ? { ...d, ...updates } : d
|
||||
);
|
||||
onUpdate({ dialogues: newDialogues });
|
||||
};
|
||||
|
||||
const handleCharacterChange = (dialogueId: string, character: CharacterOption) => {
|
||||
// 更新对话中的角色
|
||||
handleDialogueUpdate(dialogueId, { speaker: character.name });
|
||||
|
||||
// 更新卡片的角色列表
|
||||
const allCharacters = new Map(card.characters.map(char => [char.characterId, char]));
|
||||
allCharacters.set(character.characterId, character);
|
||||
|
||||
// 获取所有对话中出现的角色
|
||||
card.dialogues.forEach(dialogue => {
|
||||
const char = mockCharacterOptions.find(c => c.name === dialogue.speaker);
|
||||
if (char) {
|
||||
allCharacters.set(char.characterId, char);
|
||||
}
|
||||
});
|
||||
|
||||
onUpdate({ characters: Array.from(allCharacters.values()) });
|
||||
};
|
||||
|
||||
const handleAddDialogue = () => {
|
||||
const newDialogue: DialogueItemType = {
|
||||
id: `d${Date.now()}`,
|
||||
speaker: '新角色',
|
||||
text: '在此输入对话内容',
|
||||
};
|
||||
onUpdate({ dialogues: [...card.dialogues, newDialogue] });
|
||||
};
|
||||
|
||||
const handleSaveNotes = () => {
|
||||
onUpdate({ notes: editNotes });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
x: isDragging ? 5 : 0,
|
||||
y: isDragging ? 5 : 0,
|
||||
rotate: isDragging ? 2 : 0
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className={`
|
||||
group relative w-full bg-black/30 backdrop-blur-lg rounded-xl overflow-hidden
|
||||
border border-white/10 shadow-xl h-[480px] flex flex-col
|
||||
${isDragging ? 'z-50 shadow-2xl ring-2 ring-blue-500/50' : 'hover:ring-1 hover:ring-white/20'}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-3 right-3 p-2 rounded-lg bg-black/20 opacity-0 group-hover:opacity-100
|
||||
transition-opacity cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-white/60" />
|
||||
</div>
|
||||
|
||||
{/* Card Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="text-lg font-medium text-white mb-2">
|
||||
{card.shotId}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{card.scene && <KeywordText text={`[${card.scene.name}]`} id={card.scene.sceneId} />}
|
||||
{card.characters.map((char) => (
|
||||
<span key={char.characterId} className="text-sm">
|
||||
<KeywordText text={`#${char.name}#`} id={char.characterId} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogues */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col bg-black/10">
|
||||
<div className="p-4 pb-2 flex-shrink-0 flex justify-between items-center">
|
||||
<div className="text-sm text-gray-400">对话列表</div>
|
||||
<button
|
||||
onClick={handleAddDialogue}
|
||||
className="px-2 py-1 rounded-md border border-dashed border-white/20
|
||||
text-xs text-gray-400 hover:bg-white/5 transition-colors
|
||||
flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加对话
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 pb-4">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDialogueReorder}
|
||||
>
|
||||
<SortableContext
|
||||
items={card.dialogues.map(d => d.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{card.dialogues.map((dialogue) => (
|
||||
<DialogueItem
|
||||
key={dialogue.id}
|
||||
dialogue={dialogue}
|
||||
onUpdate={(updates) => handleDialogueUpdate(dialogue.id, updates)}
|
||||
onDelete={() => {
|
||||
const newDialogues = card.dialogues.filter(d => d.id !== dialogue.id);
|
||||
onUpdate({ dialogues: newDialogues });
|
||||
|
||||
const remainingCharacters = new Map<string, CharacterOption>();
|
||||
newDialogues.forEach(d => {
|
||||
const char = mockCharacterOptions.find(c => c.name === d.speaker);
|
||||
if (char) {
|
||||
remainingCharacters.set(char.characterId, char);
|
||||
}
|
||||
});
|
||||
onUpdate({ characters: Array.from(remainingCharacters.values()) });
|
||||
}}
|
||||
onCharacterChange={(character) => handleCharacterChange(dialogue.id, character)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shot Description */}
|
||||
<div className="p-4 border-t border-white/10 bg-black/5">
|
||||
<EditableField
|
||||
label="分镜描述"
|
||||
value={card.description}
|
||||
onChange={(value) => onUpdate({ description: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card Actions */}
|
||||
<div className="absolute top-3 right-[4rem] flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={onDuplicate}
|
||||
className="p-2 rounded-lg bg-black/20 hover:bg-black/30 transition-colors"
|
||||
title="复制"
|
||||
>
|
||||
<Copy className="w-4 h-4 text-blue-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 rounded-lg bg-black/20 hover:bg-black/30 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryboardCard;
|
||||
3
components/ui/style/dialogue-item.css
Normal file
3
components/ui/style/dialogue-item.css
Normal file
@ -0,0 +1,3 @@
|
||||
.dialogue-item:hover .opt-btn-group {
|
||||
opacity: 1;
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Wand2 } from 'lucide-react';
|
||||
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings } from 'lucide-react';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { ReplaceVideoModal } from './replace-video-modal';
|
||||
@ -97,10 +97,9 @@ export function VideoTabContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 上部分 */}
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
@ -114,7 +113,7 @@ export function VideoTabContent({
|
||||
<motion.div
|
||||
key={sketch.id || index}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer',
|
||||
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
|
||||
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
|
||||
)}
|
||||
onClick={() => onSketchSelect(index)}
|
||||
@ -131,7 +130,16 @@ export function VideoTabContent({
|
||||
onMouseLeave={(e) => e.currentTarget.pause()}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
<span className="text-xs text-white/90">Shot {index + 1}</span>
|
||||
</div>
|
||||
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
|
||||
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
onClick={() => console.log('Delete sketch')}
|
||||
className="text-red-500"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -161,7 +169,7 @@ export function VideoTabContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
{video.script}
|
||||
Shot {index + 1}
|
||||
</span>
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
@ -178,119 +186,16 @@ export function VideoTabContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 中间部分 替换视频 可上传、素材库选择、生成视频 */}
|
||||
<motion.div
|
||||
className="p-4 rounded-lg bg-white/5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h3 className="text-sm font-medium mb-2">Replace video</h3>
|
||||
<div className="flex gap-4">
|
||||
<motion.button
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors',
|
||||
activeReplaceMethod === 'upload' && isReplaceModalOpen
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-white/10 hover:border-white/20'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('upload');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>Upload video</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors',
|
||||
activeReplaceMethod === 'library' && isReplaceModalOpen
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-white/10 hover:border-white/20'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('library');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>Library</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors',
|
||||
activeReplaceMethod === 'generate' && isReplaceModalOpen
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-white/10 hover:border-white/20'
|
||||
)}
|
||||
onClick={() => {
|
||||
setActiveReplaceMethod('generate');
|
||||
setIsReplaceModalOpen(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Wand2 className="w-6 h-6" />
|
||||
<span>Generate video</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 下部分 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6"
|
||||
className="grid grid-cols-3 gap-4 w-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{/* 左列:戏剧张力线、调节音量 */}
|
||||
<div className="space-y-4">
|
||||
{/* 戏剧张力线 */}
|
||||
<div className="p-4 rounded-lg bg-white/5">
|
||||
<DramaLineChart
|
||||
title="Drama line"
|
||||
height={135}
|
||||
showToggleButton={false}
|
||||
className="w-full"
|
||||
onDataChange={(data) => {
|
||||
console.log('视频编辑戏剧线数据更新:', data);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音量调节 */}
|
||||
<div className="p-4 rounded-lg bg-white/5">
|
||||
<h3 className="text-sm font-medium mb-2">Volume</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full"
|
||||
onChange={(e) => console.log('Volume:', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 更多设置 点击打开 Media properties 弹窗 */}
|
||||
<motion.button
|
||||
className='bg-[transparent] m-4 p-0 border-none rounded-lg transition-colors'
|
||||
style={{textDecorationLine: 'underline'}}
|
||||
onClick={() => setIsMediaPropertiesModalOpen(true)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className='text-sm font-medium mb-2'>More properties</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 右列:视频预览和操作 */}
|
||||
<div className="space-y-4">
|
||||
{/* 视频预览和操作 */}
|
||||
<div className="space-y-4 col-span-2">
|
||||
{/* 选中的视频预览 */}
|
||||
<motion.div
|
||||
className="aspect-video rounded-lg overflow-hidden relative group"
|
||||
@ -308,33 +213,70 @@ export function VideoTabContent({
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
/>
|
||||
|
||||
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
||||
{/* 人物替换按钮 */}
|
||||
<motion.button
|
||||
onClick={() => console.log('Replace character')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</motion.button>
|
||||
{/* 场景替换按钮 */}
|
||||
{/* <motion.button
|
||||
onClick={() => console.log('Replace scene')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
{/* Regenerate 按钮 */}
|
||||
<motion.button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</motion.button>
|
||||
{/* 运镜按钮 */}
|
||||
{/* <motion.button
|
||||
onClick={() => console.log('Replace shot')}
|
||||
disabled={true}
|
||||
className="p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</motion.button> */}
|
||||
|
||||
{/* 更多设置 点击打开 More properties 弹窗 */}
|
||||
<motion.button
|
||||
className='p-2 bg-black/50 hover:bg-black/70
|
||||
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
||||
style={{textDecorationLine: 'underline'}}
|
||||
onClick={() => setIsMediaPropertiesModalOpen(true)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<motion.button
|
||||
onClick={() => console.log('Delete sketch')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20
|
||||
text-red-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete sketch</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => console.log('Regenerate')}
|
||||
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Regenerate</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础配置查看 场景/人物/运镜/对话 */}
|
||||
<div className='space-y-4 col-span-1'>
|
||||
{/* 场景: */}
|
||||
</div>
|
||||
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* 替换视频弹窗 */}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getToken, clearAuthData } from './auth';
|
||||
|
||||
// API基础URL
|
||||
const API_BASE_URL = 'https://pre.movieflow.api.huiying.video';
|
||||
const API_BASE_URL = 'https://77.smartvideo.py.qikongjian.com';
|
||||
|
||||
/**
|
||||
* 统一的API请求方法
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const BASE_URL = 'https://pre.movieflow.api.huiying.video'
|
||||
const BASE_URL = 'https://77.smartvideo.py.qikongjian.com'
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -79,6 +79,7 @@
|
||||
"postcss": "8.4.30",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
@ -12587,6 +12588,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-contenteditable": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-contenteditable/-/react-contenteditable-3.3.7.tgz",
|
||||
"integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"prop-types": "^15.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
"postcss": "8.4.30",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@host = https://pre.movieflow.api.huiying.video
|
||||
@host = https://77.smartvideo.py.qikongjian.com
|
||||
### Create a movie project
|
||||
POST http://localhost:8000/movie/create_movie_project
|
||||
Content-Type: application/json
|
||||
@ -19,7 +19,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Get movie project detail
|
||||
POST https://pre.movieflow.api.huiying.video/movie/get_movie_project_detail
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/get_movie_project_detail
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -43,7 +43,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Get status
|
||||
POST https://pre.movieflow.api.huiying.video/movie/get_status
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/get_status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -51,7 +51,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Text to script tags
|
||||
POST https://pre.movieflow.api.huiying.video/movie/text_to_script_tags
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/text_to_script_tags
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -59,7 +59,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Analyze movie script stream
|
||||
POST https://pre.movieflow.api.huiying.video/movie/analyze_movie_script_stream
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/analyze_movie_script_stream
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -67,7 +67,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### test scene json
|
||||
POST https://pre.movieflow.api.huiying.video/movie/scene_json
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/scene_json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -75,7 +75,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### test shot sketch json
|
||||
POST https://pre.movieflow.api.huiying.video/movie/shot_sketch_json
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/shot_sketch_json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@ -83,7 +83,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### test video json
|
||||
POST https://pre.movieflow.api.huiying.video/movie/video_json
|
||||
POST https://77.smartvideo.py.qikongjian.com/movie/video_json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user