重新设计页面

This commit is contained in:
北枳 2025-07-29 10:57:06 +08:00
parent 82b29a0283
commit 39a3de215c
28 changed files with 2714 additions and 559 deletions

View File

@ -1 +1 @@
export const BASE_URL = "https://pre.movieflow.api.huiying.video"
export const BASE_URL = "https://77.smartvideo.py.qikongjian.com"

View File

@ -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) {
@ -138,3 +140,9 @@ body {
.bg-muted {
width: 100%;
}
.focus-visible\:outline-none:focus-visible {
outline: none !important;
outline-offset: 0 !important;
box-shadow: none !important;
}

View File

@ -134,3 +134,93 @@ export const TaskStatusMap = {
label: "失败"
}
} 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: '傍晚' },
};

View File

@ -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)}
/>
)}
</>
);
}

View File

@ -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}

View File

@ -746,7 +746,7 @@ export function MediaViewer({
<GlassIconButton
icon={Edit3}
tooltip="Edit sketch"
onClick={() => handleEditClick('1')}
onClick={() => handleEditClick('2')}
/>
</motion.div>
)}

View File

@ -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
View 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
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
</motion.div>
{/* 替换角色弹窗 */}
<ReplaceCharacterModal
isOpen={isReplaceModalOpen}
activeReplaceMethod={activeReplaceMethod}
onClose={() => setIsReplaceModalOpen(false)}
onCharacterSelect={(character) => {
console.log('Selected character:', character);
setIsReplaceModalOpen(false);
// TODO: 处理角色选择逻辑
{/* 右列:角色信息 */}
<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>
</div>
);
}

View 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;

View File

@ -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>

View 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;

View 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;

View File

@ -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

View 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>
);
}

View 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>
);
}

View File

@ -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);
// 确保 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'
// 筛选卡片
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;
});
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="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 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="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}
>
{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,
<SortableContext
items={[...filteredCards.map(card => card.id), 'add-card']}
strategy={rectSortingStrategy}
>
<div
className="grid auto-rows-min gap-6 w-full"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
justifyItems: 'center'
}}
>
<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-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"
<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>
{/* 右列:预览和操作 */}
<div className="space-y-4">
{/* 选中的分镜预览 */}
))}
{/* 添加卡片占位符 */}
<motion.div
className="aspect-video rounded-lg overflow-hidden"
layoutId={`sketch-preview-${currentSketchIndex}`}
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"
>
<img
src={sketches[currentSketchIndex]?.url}
alt={`Sketch ${currentSketchIndex + 1}`}
className="w-full h-full object-cover"
/>
<Plus className="w-8 h-8 text-white/40 group-hover:text-white/60 transition-colors" />
</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>
</AnimatePresence>
</div>
</SortableContext>
</DndContext>
</div>
</motion.div>
</div>
);
}
};
export default ScriptTabContent;

View 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>
);
}

View 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;

View File

@ -0,0 +1,3 @@
.dialogue-item:hover .opt-btn-group {
opacity: 1;
}

View File

@ -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"
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>
{/* 操作按钮 */}
<div className="grid grid-cols-2 gap-2">
<motion.div className='absolute top-4 right-4 flex 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 }}
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 }}
>
<Trash2 className="w-4 h-4" />
<span>Delete sketch</span>
<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="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"
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 }}
>
<RefreshCw className="w-4 h-4" />
<span>Regenerate</span>
<Settings className="w-4 h-4" />
</motion.button>
</motion.div>
</motion.div>
</div>
{/* 基础配置查看 场景/人物/运镜/对话 */}
<div className='space-y-4 col-span-1'>
{/* 场景: */}
</div>
</motion.div>
{/* 替换视频弹窗 */}

View File

@ -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请求方法

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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
{