loading剧本真实化数据

This commit is contained in:
北枳 2025-07-25 11:41:12 +08:00
parent debc584dd9
commit c434dd71da
27 changed files with 2043 additions and 1449 deletions

View File

@ -84,6 +84,95 @@ export const del = <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
return request.delete(url, config);
};
// utils/streamJsonPost.ts
export async function streamJsonPost<T = any>(
url: string,
body: any,
onJson: (json: T) => void
) {
try {
const token = localStorage.getItem('token') || 'mock-token';
const response = await fetch(`${BASE_URL}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('Stream not supported');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// Process any remaining data in the buffer
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim());
onJson(parsed);
} catch (err) {
console.warn('Final JSON parse error:', err, buffer);
}
}
break;
}
// Decode the current chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete JSON objects
let boundary = buffer.indexOf('\n');
while (boundary !== -1) {
const chunk = buffer.slice(0, boundary).trim();
buffer = buffer.slice(boundary + 1);
if (chunk) {
try {
const parsed = JSON.parse(chunk);
onJson(parsed);
} catch (err) {
// Only log if it's not an empty line
if (chunk !== '') {
console.warn('JSON parse error:', err, chunk);
}
}
}
boundary = buffer.indexOf('\n');
}
}
} catch (error) {
console.error('Stream processing error:', error);
throw error;
} finally {
// Ensure reader is released
reader.releaseLock();
}
} catch (error) {
console.error('Stream request error:', error);
// Handle specific error types
if (error instanceof TypeError) {
console.error('Network error - check your connection');
}
if (error instanceof SyntaxError) {
console.error('Invalid JSON in the stream');
}
throw error;
}
}
// 封装流式数据请求
export const stream = async <T>({
url,

View File

@ -1,6 +1,7 @@
import { post } from './request';
import { ProjectTypeEnum } from '@/app/model/enums';
import { ApiResponse } from '@/api/common';
import { BASE_URL } from './constants'
// API 响应类型
interface BaseApiResponse<T> {
@ -198,4 +199,19 @@ export const getRunningStreamData = async (data: { project_id: string }): Promis
// 获取 脚本 接口
export const getScriptTags = async (data: { project_id: string }): Promise<any> => {
return post<any>('/movie/text_to_script_tags', data);
};
// 获取 loading-场景 接口
export const getSceneJson = async (data: { project_id: string }): Promise<ApiResponse<any>> => {
return post<any>('/movie/scene_json', data);
};
// 获取 loading-分镜 接口
export const getShotSketchJson = async (data: { project_id: string }): Promise<ApiResponse<any>> => {
return post<any>('/movie/shot_sketch_json', data);
};
// 获取 loading-视频 接口
export const getVideoJson = async (data: { project_id: string }): Promise<ApiResponse<any>> => {
return post<any>('/movie/video_json', data);
};

View File

@ -1,3 +1,5 @@
'use client';
import WorkFlow from '@/components/pages/work-flow';
export default function ScriptWorkFlowPage() {

View File

@ -1,26 +1,6 @@
import './globals.css';
import type { Metadata } from 'next';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';
import dynamic from 'next/dynamic';
// Import the OAuthCallbackHandler dynamically to ensure it only runs on the client
const OAuthCallbackHandler = dynamic(
() => import('@/components/ui/oauth-callback-handler'),
{ ssr: false }
);
// Import AuthGuard dynamically for client-side only
const AuthGuard = dynamic(
() => import('@/components/auth/auth-guard'),
{ ssr: false }
);
// Import dev helper in development environment only
const DevHelper = dynamic(
() => import('@/utils/dev-helper').then(() => ({ default: () => null })),
{ ssr: false }
);
import { Providers } from '@/components/providers';
export const metadata: Metadata = {
title: 'AI Movie Flow - Create Amazing Videos with AI',
@ -30,24 +10,14 @@ export const metadata: Metadata = {
export default function RootLayout({
children,
}: {
children: React.ReactNode;
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased">
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<AuthGuard>
{children}
</AuthGuard>
<Toaster />
<OAuthCallbackHandler />
{process.env.NODE_ENV === 'development' && <DevHelper />}
</ThemeProvider>
<Providers>
{children}
</Providers>
</body>
</html>
);

View File

@ -3,15 +3,8 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ScriptModal } from '@/components/ui/script-modal';
import {
Image,
Video,
import {
CheckCircle,
Music,
Loader2,
User,
Tv,
Airplay,
Heart,
Camera,
Film,
@ -56,12 +49,6 @@ const StageIcons = ({ currentStage, isExpanded }: { currentStage: number, isExpa
...data
}));
// 将当前阶段移到第一位
// return stages.sort((a, b) => {
// if (a.stage === currentStage) return -1;
// if (b.stage === currentStage) return 1;
// return a.stage - b.stage;
// });
return stages;
}, [currentStage]);
@ -214,21 +201,21 @@ export function TaskInfo({
setIsScriptModalOpen(true);
setCurrentStage(1);
}
if (currentLoadingText.includes('character')) {
console.log('isScriptModalOpen-character', currentLoadingText, isScriptModalOpen);
if (isScriptModalOpen) {
setIsScriptModalOpen(false);
// if (currentLoadingText.includes('character')) {
// console.log('isScriptModalOpen-character', currentLoadingText, isScriptModalOpen);
// if (isScriptModalOpen) {
// setIsScriptModalOpen(false);
// 延迟8s 再次打开
timerRef.current = setTimeout(() => {
setIsScriptModalOpen(true);
setCurrentStage(0);
}, 8000);
} else {
setIsScriptModalOpen(true);
setCurrentStage(0);
}
}
// // 延迟8s 再次打开
// timerRef.current = setTimeout(() => {
// setIsScriptModalOpen(true);
// setCurrentStage(0);
// }, 8000);
// } else {
// setIsScriptModalOpen(true);
// setCurrentStage(0);
// }
// }
if (currentLoadingText.includes('initializing')) {
console.log('isScriptModalOpen-initializing', currentLoadingText, isScriptModalOpen);
setIsScriptModalOpen(true);
@ -251,15 +238,6 @@ export function TaskInfo({
}, {});
}, [taskObject?.tags]); // 只在 tags 改变时重新计算
// 自动触发打开 剧本 弹窗 延迟5秒
// useEffect(() => {
// if (taskObject?.title && currentLoadingText !== 'Task completed') {
// setTimeout(() => {
// setIsScriptModalOpen(true);
// }, 5000);
// }
// }, [taskObject?.title]);
return (
<>
<div className="title-JtMejk flex items-center justify-center gap-2">
@ -293,6 +271,7 @@ export function TaskInfo({
}}
currentStage={currentStage}
roles={roles}
currentLoadingText={currentLoadingText}
/>
{currentLoadingText === 'Task completed' ? (

View File

@ -3,6 +3,8 @@
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData } from '@/api/video_flow';
import { useAppDispatch, useAppSelector } from '@/lib/store/hooks';
import { setSketchCount, setVideoCount } from '@/lib/store/workflowSlice';
// 步骤映射
const STEP_MAP = {
@ -16,6 +18,7 @@ const STEP_MAP = {
// 执行loading文字映射
const LOADING_TEXT_MAP = {
initializing: 'initializing...',
getSketchStatus: 'Getting sketch status...',
sketch: (count: number, total: number) => `Generating sketch ${count + 1 > total ? total : count + 1}/${total}...`,
sketchComplete: 'Sketch generation complete',
character: 'Drawing characters...',
@ -57,14 +60,12 @@ export function useWorkflowData() {
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
const [taskVideos, setTaskVideos] = useState<any[]>([]);
const [sketchCount, setSketchCount] = useState(0);
const [videoCount, setVideoCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [currentStep, setCurrentStep] = useState('0');
const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [currentLoadingText, setCurrentLoadingText] = useState('正在加载项目数据...');
const [currentLoadingText, setCurrentLoadingText] = useState('loading project info...');
const [totalSketchCount, setTotalSketchCount] = useState(0);
const [roles, setRoles] = useState<any[]>([]);
const [music, setMusic] = useState<any[]>([]);
@ -72,6 +73,9 @@ export function useWorkflowData() {
const [dataLoadError, setDataLoadError] = useState<string | null>(null);
const [needStreamData, setNeedStreamData] = useState(false);
const dispatch = useAppDispatch();
const { sketchCount, videoCount } = useAppSelector((state) => state.workflow);
// 自动开始播放一轮
const autoPlaySketch = useCallback(() => {
return new Promise<void>((resolve) => {
@ -108,6 +112,17 @@ export function useWorkflowData() {
handleAutoPlay();
}, [sketchCount, totalSketchCount, isGeneratingSketch, autoPlaySketch]);
// 更新 setSketchCount
const updateSketchCount = useCallback((count: number) => {
dispatch(setSketchCount(count));
}, [dispatch]);
// 更新 setVideoCount
const updateVideoCount = useCallback((count: number) => {
dispatch(setVideoCount(count));
}, [dispatch]);
// 替换原有的 setSketchCount 和 setVideoCount 调用
useEffect(() => {
console.log('sketchCount 已更新:', sketchCount);
setCurrentSketchIndex(sketchCount - 1);
@ -118,6 +133,8 @@ export function useWorkflowData() {
setCurrentSketchIndex(videoCount - 1);
}, [videoCount]);
// 将 sketchCount 和 videoCount 放到 redux 中 每一次变化也要更新
// 添加手动播放控制
const handleManualPlay = useCallback(async () => {
if (!isGeneratingSketch && taskSketch.length > 0) {
@ -148,17 +165,18 @@ export function useWorkflowData() {
// 如果有已完成的数据,同步到状态
if (task.task_name === 'generate_sketch' && task.task_result) {
if (task.task_result.data.length >= 0 && taskSketch.length < task.task_result.data.length) {
const realSketchResultData = task.task_result.data.filter((item: any) => item.image_path);
if (realSketchResultData.length >= 0) {
// 正在生成草图中 替换 sketch 数据
const sketchList = [];
for (const sketch of task.task_result.data) {
for (const sketch of realSketchResultData) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
updateSketchCount(sketchList.length);
setIsGeneratingSketch(true);
loadingText = LOADING_TEXT_MAP.sketch(sketchList.length, task.task_result.total_count);
}
@ -197,11 +215,13 @@ export function useWorkflowData() {
}
}
if (task.task_name === 'generate_shot_sketch' && task.task_result) {
if (task.task_result.data.length >= 0 && taskShotSketch.length < task.task_result.data.length) {
console.log('----------正在生成草图中 替换 sketch 数据', taskShotSketch.length, task.task_result.data.length);
const realShotResultData = task.task_result.data.filter((item: any) => item.url);
if (realShotResultData.length >= 0) {
finalStep = '1';
console.log('----------正在生成草图中 替换 sketch 数据', taskShotSketch.length, realShotResultData.length);
// 正在生成草图中 替换 sketch 数据
const sketchList = [];
for (const sketch of task.task_result.data) {
for (const sketch of realShotResultData) {
sketchList.push({
url: sketch.url,
script: sketch.description
@ -209,7 +229,7 @@ export function useWorkflowData() {
}
setTaskSketch(sketchList);
setTaskShotSketch(sketchList);
setSketchCount(sketchList.length);
updateSketchCount(sketchList.length);
setIsGeneratingSketch(true);
loadingText = LOADING_TEXT_MAP.shotSketch(sketchList.length, task.task_result.total_count);
}
@ -221,15 +241,13 @@ export function useWorkflowData() {
console.log('----------草图生成完成', sketchCount);
loadingText = LOADING_TEXT_MAP.getVideoStatus;
finalStep = '3';
} else {
}
setTotalSketchCount(task.task_result.total_count);
}
if (task.task_name === 'generate_videos' && task.task_result) {
const realTaskResultData = task.task_result.data.filter((item: any) => item.urls && item.urls.length > 0);
if (realTaskResultData.length >= 0 && taskVideos.length !== realTaskResultData.length) {
console.log('----------正在生成视频中-发生变化才触发', taskVideos.length);
if (realTaskResultData.length >= 0) {
console.log('----------正在生成视频中', realTaskResultData.length);
// 正在生成视频中 替换视频数据
const videoList = [];
for (const video of realTaskResultData) {
@ -241,7 +259,7 @@ export function useWorkflowData() {
});
}
setTaskVideos(videoList);
setVideoCount(videoList.length);
updateVideoCount(videoList.length);
setIsGeneratingVideo(true);
loadingText = LOADING_TEXT_MAP.video(videoList.length, task.task_result.total_count);
}
@ -365,21 +383,21 @@ export function useWorkflowData() {
// 如果有已完成的数据,同步到状态
let finalStep = '1';
if (data) {
if (data.sketch && data.sketch.data && data.sketch.data.length > 0) {
if (data.sketch && data.sketch.data) {
const realSketchResultData = data.sketch.data.filter((item: any) => item.image_path);
const sketchList = [];
for (const sketch of data.sketch.data) {
for (const sketch of realSketchResultData) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name,
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
setTotalSketchCount(data.sketch.total_count);
updateSketchCount(sketchList.length);
// 设置为最后一个草图
if (data.sketch.total_count > data.sketch.data.length) {
if (data.sketch.total_count > realSketchResultData.length) {
setIsGeneratingSketch(true);
loadingText = LOADING_TEXT_MAP.sketch(data.sketch.data.length, data.sketch.total_count);
loadingText = LOADING_TEXT_MAP.sketch(realSketchResultData.length, data.sketch.total_count);
} else {
finalStep = '2';
if (!data.character || !data.character.data || !data.character.data.length) {
@ -408,9 +426,10 @@ export function useWorkflowData() {
}
}
}
if (data.shot_sketch && data.shot_sketch.data && data.shot_sketch.data.length > 0) {
if (data.shot_sketch && data.shot_sketch.data) {
const realShotResultData = data.shot_sketch.data.filter((item: any) => item.url);
const sketchList = [];
for (const sketch of data.shot_sketch.data) {
for (const sketch of realShotResultData) {
sketchList.push({
url: sketch.url,
script: sketch.description,
@ -418,12 +437,11 @@ export function useWorkflowData() {
}
setTaskSketch(sketchList);
setTaskShotSketch(sketchList);
setSketchCount(sketchList.length);
setTotalSketchCount(data.shot_sketch.total_count);
updateSketchCount(sketchList.length);
// 设置为最后一个草图
if (data.shot_sketch.total_count > data.shot_sketch.data.length) {
if (data.shot_sketch.total_count > realShotResultData.length) {
setIsGeneratingSketch(true);
loadingText = LOADING_TEXT_MAP.shotSketch(data.shot_sketch.data.length, data.shot_sketch.total_count);
loadingText = LOADING_TEXT_MAP.shotSketch(realShotResultData.length, data.shot_sketch.total_count);
} else {
finalStep = '3';
setIsGeneratingVideo(true);
@ -434,6 +452,9 @@ export function useWorkflowData() {
}
if (data.video.data) {
const realDataVideoData = data.video.data.filter((item: any) => item.urls && item.urls.length > 0);
if (realDataVideoData.length === 0 && finalStep === '3') {
loadingText = LOADING_TEXT_MAP.video(0, data.video.total_count);
}
if (realDataVideoData.length > 0) {
const videoList = [];
console.log('----------data.video.data', data.video.data);
@ -446,7 +467,7 @@ export function useWorkflowData() {
});
}
setTaskVideos(videoList);
setVideoCount(videoList.length);
updateVideoCount(videoList.length);
// 如果在视频步骤,设置为最后一个视频
if (data.video.total_count > realDataVideoData.length) {
setIsGeneratingVideo(true);
@ -508,8 +529,8 @@ export function useWorkflowData() {
// 重置所有状态
setTaskSketch([]);
setTaskVideos([]);
setSketchCount(0);
setTotalSketchCount(0);
updateSketchCount(0);
updateVideoCount(0);
setRoles([]);
setMusic([]);
setFinal(null);

View File

@ -0,0 +1,363 @@
'use client';
import { useEffect, useState, useRef, useCallback } from "react";
import { streamJsonPost } from "@/api/request";
import { useSearchParams } from "next/navigation";
import { Heart, Camera, Film, Scissors } from "lucide-react";
import { getSceneJson, getShotSketchJson, getVideoJson } from "@/api/video_flow";
import { useAppSelector } from "@/lib/store/hooks";
interface ScriptContent {
acts?: Array<{
id: string;
stableId: string;
title: string;
desc: string;
beats: string[];
}>;
characters?: Array<{
id: string;
stableId: string;
name: string;
role: string;
arc: string;
desc: string;
color: string;
}>;
dialogue?: {
stableId: string;
rhythm: string;
style: string;
};
themes?: Array<{
id: string;
stableId: string;
theme: string;
desc: string;
depth: string;
}>;
dramaticLine?: {
stableId: string;
points: Array<{
id: string;
stableId: string;
title: string;
desc: string;
intensity: number; // 0-100 情感强度
}>;
};
}
interface Stage {
id: string;
title: string;
icon: React.ElementType;
color: string;
profession: string;
}
const stages: Stage[] = [
{
id: 'script',
title: 'Scriptwriter',
icon: Heart,
color: '#8b5cf6',
profession: 'Scriptwriter'
},
{
id: 'storyboard',
title: 'Storyboard artist',
icon: Camera,
color: '#06b6d4',
profession: 'Storyboard artist'
},
{
id: 'production',
title: 'Visual director',
icon: Film,
color: '#10b981',
profession: 'Visual director'
},
{
id: 'editing',
title: 'Editor',
icon: Scissors,
color: '#f59e0b',
profession: 'Editor'
}
];
const detailThinkingText = [
{
default: 'is thinking...',
acts: 'is thinking about the story structure...',
characters: 'is thinking about the characters...',
dialogue: 'is thinking about the dialogue...',
themes: 'is thinking about the themes...',
dramaticLine: 'is thinking about the dramatic line...',
another: 'is thinking about the another...'
}, {
default: 'is thinking...',
}, {
default: 'is thinking...',
}, {
default: 'is thinking...',
}
]
export function useWorkofficeData(currentStage: number, isOpen: boolean, currentLoadingText: string) {
const [scriptwriterData, setScriptwriterData] = useState<ScriptContent>({
acts: [],
characters: [],
dialogue: undefined,
themes: [],
dramaticLine: undefined
});
const [thinkingColor, setThinkingColor] = useState<string>('');
const [thinkingText, setThinkingText] = useState<string>('');
const searchParams = useSearchParams();
const project_id = searchParams.get('episodeId') || '';
const [sceneData, setSceneData] = useState<any>([]);
const [shotSketchData, setShotSketchData] = useState<any>([]);
const [videoData, setVideoData] = useState<any>([]);
const [storyboardData, setStoryboardData] = useState<any>({});
const [sketchType, setSketchType] = useState<string>('');
const { sketchCount, videoCount } = useAppSelector((state) => state.workflow);
// 使用 ref 存储临时数据,避免重复创建对象
const tempDataRef = useRef<ScriptContent>({
acts: [],
characters: [],
dialogue: undefined,
themes: [],
dramaticLine: undefined
});
// 深度比较两个对象是否相等
const isEqual = (obj1: any, obj2: any): boolean => {
if (obj1 === obj2) return true;
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return obj1 === obj2;
if (obj1 === null || obj2 === null) return obj1 === obj2;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!isEqual(obj1[key], obj2[key])) return false;
}
return true;
};
// 更新思考文本的函数
const updateThinkingText = useCallback((data: ScriptContent) => {
console.log('updateThinkingText', currentStage, isOpen);
if (currentStage === 0) {
if (!data.acts || data.acts.length === 0) {
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].acts}`;
}
if (!data.characters || data.characters.length === 0) {
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].characters}`;
}
if (!data.dialogue) {
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].dialogue}`;
}
if (!data.themes || data.themes.length === 0) {
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].themes}`;
}
if (!data.dramaticLine) {
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].dramaticLine}`;
}
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].another}`;
}
return `${stages[currentStage].profession} ${detailThinkingText[currentStage].default}`;
}, [currentStage]);
useEffect(() => {
console.log('currentStage---changed', currentStage);
if (!isOpen) return;
setThinkingColor(stages[currentStage].color);
if (currentStage !== 0) return; // 暂时仅支持剧本创作阶段获取真实数据
// 剧本创作阶段
if (currentStage === 0) {
// 重置临时数据
tempDataRef.current = {
acts: [],
characters: [],
dialogue: undefined,
themes: [],
dramaticLine: undefined
};
handleStreamData();
} else if (currentStage === 1) {
// 不需要每次isOpen为true都请求只需要请求一次所以这里需要判断是否已经请求过
// 场景设计、分镜设计
if (!sceneData.length) {
let tempSceneData: any[] = [];
// 场景设计
getSceneJson({ project_id: project_id }).then((res: any) => {
console.log('sceneJson', res);
if (res.successful) {
for (const [index, scene] of res.data.sketches.entries()) {
tempSceneData.push({
id: `SC-${index + 1}`,
location: scene.sketch_name,
description: scene.sketch_description,
core_atmosphere: scene.core_atmosphere
});
}
setSceneData(tempSceneData);
}
});
}
if (!shotSketchData.length) {
let tempShotSketchData: any[] = [];
// 分镜设计
getShotSketchJson({ project_id: project_id }).then((res: any) => {
console.log('shotSketchJson', res);
if (res.successful) {
for (const [index, shot] of res.data.shot_sketches.entries()) {
tempShotSketchData.push({
id: index + 1,
shotLanguage: shot.shot_type.split(', '),
frame_description: shot.frame_description,
atmosphere: shot.atmosphere.split(', '),
camera_motion: shot.cinematography_blueprint_camera_motion.split(', '),
composition: shot.cinematography_blueprint_composition,
key_action: shot.key_action,
dialogue_performance: {
speaker: shot.dialogue_performance_speaker,
language: shot.dialogue_performance_language,
delivery: shot.dialogue_performance_delivery,
line: shot.dialogue_performance_line
}
});
}
setShotSketchData(tempShotSketchData);
}
});
}
} else if (currentStage === 2) {
// 视觉导演
getVideoJson({ project_id: project_id }).then((res: any) => {
console.log('videoJson', res);
});
}
}, [currentStage, isOpen]);
useEffect(() => {
const newThinkingText = updateThinkingText(scriptwriterData);
if (newThinkingText !== thinkingText) {
setThinkingText(newThinkingText);
}
}, [scriptwriterData, updateThinkingText]);
useEffect(() => {
console.log('------sketchCount, sceneData, shotSketchData', sketchCount, sceneData, shotSketchData);
if (!isOpen) return;
if (!currentLoadingText.includes('shot sketch')) {
if (!sceneData.length || sketchCount > sceneData.length) return;
setSketchType('scene');
setStoryboardData(sceneData[sketchCount ? sketchCount - 1 : 0]);
setThinkingText(`Drawing scene sketch: ${sceneData[sketchCount ? sketchCount - 1 : 0].id}...`);
} else {
if (!shotSketchData.length || sketchCount > shotSketchData.length) return;
setSketchType('shot');
setStoryboardData(shotSketchData[sketchCount ? sketchCount - 1 : 0]);
setThinkingText(`Drawing shot sketch: ${shotSketchData[sketchCount ? sketchCount - 1 : 0].id}...`);
}
}, [sceneData, shotSketchData, currentLoadingText, sketchCount, isOpen]);
const updateStateIfChanged = useCallback((newData: Partial<ScriptContent>) => {
const hasChanges = Object.entries(newData).some(([key, value]) => {
return !isEqual(value, tempDataRef.current[key as keyof ScriptContent]);
});
if (hasChanges) {
const updatedData = {
...tempDataRef.current,
...newData
};
// 只有在数据真正变化时才更新状态
if (!isEqual(updatedData, tempDataRef.current)) {
tempDataRef.current = updatedData;
setScriptwriterData(updatedData);
}
}
}, []);
async function handleStreamData() {
await streamJsonPost('/movie/analyze_movie_script_stream', { project_id: project_id }, (data: any) => {
console.log('workoffice---chunk', data);
const newData: Partial<ScriptContent> = {};
// 三幕结构
if (data.name === 'Three-act Structure') {
newData.acts = data.acts.map((act: any, index: number) => ({
id: String(index),
stableId: `act${index}`,
title: act.name,
desc: act.description,
beats: act.tags
}));
}
// 人物设定
if (data.name === 'Character Arc Design') {
newData.characters = data.characters.map((protagonist: any, index: number) => ({
id: String(index),
stableId: `protagonist${index}`,
name: protagonist.name,
role: protagonist.role,
arc: 'Growth and transformation',
desc: protagonist.description,
color: protagonist.gender === 'male' ? '#8b5cf6' : '#ec4899'
}));
}
// 对话节奏
if (data.name === 'Dialogue Rhythm') {
newData.dialogue = {
stableId: 'dialogue1',
rhythm: data.rhythm_and_pacing,
style: data.style_and_voice
};
}
// 主题深化过程
if (data.name === 'Thematic Development') {
newData.themes = data.themes.map((theme: any, index: number) => ({
id: String(index),
stableId: `theme${index}`,
theme: theme.name,
desc: theme.desc,
depth: theme.depth
}));
}
// 戏演线
if (data.name === 'Dramatic Line') {
newData.dramaticLine = {
stableId: 'dramaticLine1',
points: data.stages.map((point: any, index: number) => ({
id: String(index),
stableId: `point${index}`,
title: point.stage,
desc: point.desc,
intensity: point.score
}))
};
}
// 只在数据真正变化时更新状态
if (Object.keys(newData).length > 0) {
updateStateIfChanged(newData);
}
});
}
return {
scriptwriterData,
thinkingText,
thinkingColor,
storyboardData,
sketchType
};
}

39
components/providers.tsx Normal file
View File

@ -0,0 +1,39 @@
'use client';
import { Provider } from 'react-redux';
import { store } from '@/lib/store/store';
import { ThemeProvider } from 'next-themes';
import { Toaster } from '@/components/ui/sonner';
import AuthGuard from './auth/auth-guard';
import dynamic from 'next/dynamic';
// 动态导入 OAuthCallbackHandler 和 DevHelper
const OAuthCallbackHandler = dynamic(
() => import('./ui/oauth-callback-handler').then(mod => mod.default),
{ ssr: false }
);
const DevHelper = dynamic(
() => import('@/utils/dev-helper').then(mod => mod.default),
{ ssr: false }
);
export function Providers({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<AuthGuard>
{children}
</AuthGuard>
<Toaster />
<OAuthCallbackHandler />
{process.env.NODE_ENV === 'development' && <DevHelper />}
</ThemeProvider>
</Provider>
);
}

View File

@ -151,6 +151,24 @@ export function CharacterTabContent({
<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>

View File

@ -0,0 +1,401 @@
'use client';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { TrendingUp, Eye, EyeOff } from 'lucide-react';
interface DataPoint {
x: number;
y: number;
label?: string;
}
interface DramaLineChartProps {
data?: DataPoint[];
width?: number;
height?: number;
onDataChange?: (data: DataPoint[]) => void;
className?: string;
showLabels?: boolean;
title?: string;
showToggleButton?: boolean;
}
export function DramaLineChart({
data: initialData,
width,
height = 120,
onDataChange,
className = '',
showLabels = true,
title = '戏剧张力线',
showToggleButton = true
}: DramaLineChartProps) {
// Mock 数据 - 模拟一个经典的戏剧结构x轴表示时间进度
const mockData: DataPoint[] = [
{ x: 0, y: 20, label: '0s' },
{ x: 15, y: 35, label: '1' },
{ x: 30, y: 45, label: '2s' },
{ x: 45, y: 65, label: '3s' },
{ x: 60, y: 85, label: '4s' },
{ x: 75, y: 70, label: '5s' },
{ x: 90, y: 40, label: '6s' },
{ x: 100, y: 25, label: '7s' },
{ x: 100, y: 25, label: '8s' },
];
const [data, setData] = useState<DataPoint[]>(initialData || mockData);
const [isDragging, setIsDragging] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [isVisible, setIsVisible] = useState(!showToggleButton);
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
const [containerWidth, setContainerWidth] = useState(width || 320);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 动态计算容器宽度
useEffect(() => {
const updateWidth = () => {
if (containerRef.current && !width) {
setContainerWidth(containerRef.current.offsetWidth - 8); // 减去padding
}
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}, [width]);
// 计算SVG坐标
const padding = 20;
let chartWidth = (width || containerWidth) - padding * 4;
chartWidth = showToggleButton ? chartWidth : (width || containerWidth) - padding * 2;
const chartHeight = height - padding * 2;
const getPointPosition = useCallback((point: DataPoint) => {
return {
x: padding + (point.x / 100) * chartWidth,
y: padding + (1 - point.y / 100) * chartHeight
};
}, [chartWidth, chartHeight, padding]);
// 根据鼠标位置计算数据点
const getDataPointFromMouse = useCallback((clientX: number, clientY: number) => {
if (!svgRef.current) return null;
const rect = svgRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const dataX = Math.max(0, Math.min(100, ((x - padding) / chartWidth) * 100));
const dataY = Math.max(0, Math.min(100, (1 - (y - padding) / chartHeight) * 100));
return { x: dataX, y: dataY };
}, [chartWidth, chartHeight, padding]);
// 处理鼠标按下
const handleMouseDown = useCallback((e: React.MouseEvent, index: number) => {
e.preventDefault();
setIsDragging(true);
setDragIndex(index);
}, []);
// 处理鼠标移动
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging || dragIndex === null) return;
const newPoint = getDataPointFromMouse(e.clientX, e.clientY);
if (!newPoint) return;
const newData = [...data];
// 保持x坐标不变只允许改变y坐标
newData[dragIndex] = { ...newData[dragIndex], y: newPoint.y };
setData(newData);
onDataChange?.(newData);
}, [isDragging, dragIndex, data, getDataPointFromMouse, onDataChange]);
// 处理鼠标抬起
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setDragIndex(null);
}, []);
// 添加全局事件监听
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, handleMouseMove, handleMouseUp]);
// 生成路径字符串
const pathData = data.map((point, index) => {
const pos = getPointPosition(point);
return index === 0 ? `M ${pos.x} ${pos.y}` : `L ${pos.x} ${pos.y}`;
}).join(' ');
// 生成平滑曲线路径
const smoothPathData = React.useMemo(() => {
if (data.length < 2) return pathData;
const points = data.map(getPointPosition);
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
if (i === 1) {
// 第二个点
const cp1x = prev.x + (curr.x - prev.x) * 0.3;
const cp1y = prev.y;
const cp2x = curr.x - (curr.x - prev.x) * 0.3;
const cp2y = curr.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
} else if (i === points.length - 1) {
// 最后一个点
const cp1x = prev.x + (curr.x - prev.x) * 0.3;
const cp1y = prev.y;
const cp2x = curr.x - (curr.x - prev.x) * 0.3;
const cp2y = curr.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
} else {
// 中间的点
const prevPrev = points[i - 2];
const cp1x = prev.x + (curr.x - prevPrev.x) * 0.15;
const cp1y = prev.y + (curr.y - prevPrev.y) * 0.15;
const cp2x = curr.x - (next.x - prev.x) * 0.15;
const cp2y = curr.y - (next.y - prev.y) * 0.15;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${curr.x} ${curr.y}`;
}
}
return path;
}, [data, getPointPosition, pathData]);
// 切换显示状态
const toggleVisibility = () => {
setIsVisible(!isVisible);
};
return (
<div className={`${className}`} ref={containerRef}>
{/* 切换按钮 */}
{showToggleButton && (
<motion.button
onClick={toggleVisibility}
className="absolute top-2 left-2 z-20 p-2 rounded-lg bg-black/20 backdrop-blur-sm
border border-white/20 text-white/80 hover:text-white hover:bg-black/30
transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
title={isVisible ? '隐藏戏剧线' : '显示戏剧线'}
>
<motion.div
animate={{ rotate: isVisible ? 0 : 180 }}
transition={{ duration: 0.3 }}
>
{isVisible ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</motion.div>
</motion.button>
)}
{/* 折线图容器 */}
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={showToggleButton ? "absolute bottom-0 left-0 z-10 pointer-events-auto" : "relative w-full pointer-events-auto"}
>
<div className={showToggleButton ? "w-full rounded-lg bg-black/20 backdrop-blur-sm p-3" : "w-full p-2"}>
{/* 标题 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="flex items-center gap-2 mb-2"
>
<TrendingUp className="w-4 h-4 text-blue-400" />
<span className="text-sm font-medium text-white/90">{title}</span>
<span className="text-xs text-white/50">Drag to adjust the tension value</span>
</motion.div>
{/* SVG 图表 */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.4, ease: "easeOut" }}
className="relative"
>
<svg
ref={svgRef}
width={width || containerWidth}
height={height}
className="overflow-visible cursor-crosshair"
style={{ maxWidth: '100%', height: 'auto' }}
>
{/* 网格线 */}
<defs>
<pattern
id="grid"
width={chartWidth / 10}
height={chartHeight / 5}
patternUnits="userSpaceOnUse"
>
<path
d={`M 0 0 L ${chartWidth / 10} 0 M 0 0 L 0 ${chartHeight / 5}`}
fill="none"
stroke="rgba(255,255,255,0.1)"
strokeWidth="0.5"
/>
</pattern>
{/* 渐变 */}
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#fff" stopOpacity="0.4" />
<stop offset="50%" stopColor="#fff" stopOpacity="0.4" />
<stop offset="100%" stopColor="#fff" stopOpacity="0.4" />
</linearGradient>
{/* 区域渐变 */}
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.3" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.05" />
</linearGradient>
</defs>
{/* 主线条 */}
<motion.path
d={smoothPathData}
fill="none"
stroke="url(#lineGradient)"
strokeWidth="2"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.2, delay: 0.4, ease: "easeInOut" }}
/>
{/* 数据点 */}
{data.map((point, index) => {
const pos = getPointPosition(point);
const isHovered = hoveredPoint === index;
const isDraggingThis = dragIndex === index;
return (
<g key={index}>
{/* 点的光环效果 */}
{(isHovered || isDraggingThis) && (
<motion.circle
cx={pos.x}
cy={pos.y}
r="8"
fill="none"
stroke="#3b82f6"
strokeWidth="1"
opacity="0.5"
initial={{ scale: 0 }}
animate={{ scale: [1, 1.5, 1] }}
transition={{ duration: 1, repeat: Infinity }}
/>
)}
{/* 主要的点 */}
<motion.circle
cx={pos.x}
cy={pos.y}
r={isHovered || isDraggingThis ? "5" : "3"}
fill="#3b82f680"
stroke="white"
strokeWidth="1"
className="cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleMouseDown(e, index)}
onMouseEnter={() => setHoveredPoint(index)}
onMouseLeave={() => setHoveredPoint(null)}
whileHover={{ scale: 1.3 }}
whileTap={{ scale: 0.9 }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: 0.5 + index * 0.1,
duration: 0.3,
type: "spring",
stiffness: 300
}}
/>
{/* 数值标签 */}
{(isHovered || isDraggingThis) && showLabels && (
<motion.g
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
>
<rect
x={pos.x - 15}
y={pos.y - 25}
width="30"
height="16"
rx="8"
fill="rgba(0,0,0,0.8)"
stroke="rgba(255,255,255,0.2)"
strokeWidth="0.5"
/>
<text
x={pos.x}
y={pos.y - 15}
textAnchor="middle"
className="text-xs fill-white font-medium"
>
{Math.round(point.y)}
</text>
</motion.g>
)}
</g>
);
})}
{/* 时间轴标签 - 替代原来的底部标签 */}
{showLabels && data.map((point, index) => {
const pos = getPointPosition(point);
// 只显示部分标签避免重叠
const shouldShow = index === 0 || index === data.length - 1 || index % 2 === 0;
return shouldShow ? (
<motion.text
key={`time-${index}`}
x={pos.x}
y={height - 5}
textAnchor="middle"
className="text-xs fill-white/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 + index * 0.1 }}
>
{point.label}
</motion.text>
) : null;
})}
</svg>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -48,15 +48,15 @@ export function EditModal({
const [currentIndex, setCurrentIndex] = useState(currentSketchIndex);
const [currentRoleIndex, setCurrentRoleIndex] = useState(0);
useEffect(() => {
setCurrentIndex(currentSketchIndex);
}, [isOpen]);
// 当 activeEditTab 改变时更新 activeTab
useEffect(() => {
setActiveTab(activeEditTab);
}, [activeEditTab]);
useEffect(() => {
setCurrentIndex(currentSketchIndex);
}, [isOpen]);
const isTabDisabled = (tabId: string) => {
if (tabId === 'settings') return false;
// 换成 如果对应标签下 数据存在 就不禁用
@ -75,6 +75,12 @@ export function EditModal({
}
}
const handleChangeTab = (tabId: string, disabled: boolean) => {
if (disabled) return;
setActiveTab(tabId);
setCurrentIndex(0);
}
const renderTabContent = () => {
switch (activeTab) {
case '1':
@ -170,7 +176,7 @@ export function EditModal({
activeTab === tab.id ? 'text-white' : 'text-white/50',
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-white/10',
)}
onClick={() => !disabled && setActiveTab(tab.id)}
onClick={() => handleChangeTab(tab.id, disabled)}
whileHover={disabled ? undefined : { scale: 1.02 }}
whileTap={disabled ? undefined : { scale: 0.98 }}
>

View File

@ -0,0 +1,128 @@
'use client';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown } from 'lucide-react';
import { cn } from '@/public/lib/utils';
interface GenerateCharacterModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (params: { text: string; duration: string }) => void;
}
export function GenerateCharacterModal({
isOpen,
onClose,
onGenerate
}: GenerateCharacterModalProps) {
const [text, setText] = useState('');
const [duration, setDuration] = useState('5');
const [characterUrl, setCharacterUrl] = useState('');
const handleGenerate = () => {
onGenerate({ text, duration });
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* 背景遮罩 */}
<motion.div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* 弹窗内容 */}
<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"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{
type: 'spring',
damping: 25,
stiffness: 200,
}}
>
{/* 标题栏 */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-white/10 rounded-full transition-colors"
onClick={onClose}
>
<ChevronDown className="w-5 h-5" />
</button>
<h2 className="text-lg font-medium">generate character</h2>
</div>
</div>
{/* 主要内容区域 */}
<div className="p-6 space-y-6 h-[80vh] flex flex-col overflow-y-auto hide-scrollbar">
{/* 文本输入区域 */}
<div className="space-y-2 flex-shrink-0">
<label className="text-sm text-white/70">describe the character</label>
<textarea
className="w-full h-32 p-4 bg-white/5 border border-white/10 rounded-lg
text-white placeholder-white/30 resize-none focus:outline-none focus:border-blue-500"
placeholder="describe the gender, age, and appearance of the character you want to generate..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
{/* 生成按钮 */}
<div className="flex justify-end flex-shrink-0">
<motion.button
className="px-6 py-2 bg-blue-500 hover:bg-blue-600 rounded-lg
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleGenerate}
disabled={!text.trim()}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
generate character
</motion.button>
</div>
{/* 角色预览区 图片 16/9 */}
<div className="flex justify-center flex-1 h-[450px]">
<div className="w-[800px] h-full bg-white/5 rounded-lg">
<img src={characterUrl} className="w-full object-contain" />
</div>
</div>
</div>
{/* 底部操作栏 */}
<div className="p-4 border-t border-white/10 bg-black/20">
<div className="flex justify-end gap-3">
<motion.button
className="px-4 py-2 rounded-lg bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
add to library
</motion.button>
<motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
apply
</motion.button>
</div>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}

View File

@ -115,8 +115,8 @@ export function GenerateVideoModal({
</div>
{/* 生成视频预览 */}
<div className="flex justify-center flex-1 min-h-[450px]">
<div className="w-[80%] bg-white/5 rounded-lg">
<div className="flex justify-center flex-1 h-[450px]">
<div className="w-[800px] h-full bg-white/5 rounded-lg">
<video src={videoUrl} className="w-full object-cover" autoPlay />
</div>
</div>
@ -126,12 +126,11 @@ export function GenerateVideoModal({
<div className="p-4 border-t border-white/10 bg-black/20">
<div className="flex justify-end gap-3">
<motion.button
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
className="px-4 py-2 rounded-lg bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onClose}
>
back
add to library
</motion.button>
<motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"

View File

@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Upload, Library, Wand2, Search, Image, Plus, ChevronDown } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import { GenerateCharacterModal } from './generate-character-model';
interface ReplaceCharacterModalProps {
isOpen: boolean;
@ -20,7 +21,8 @@ export function ReplaceCharacterModal({
}: ReplaceCharacterModalProps) {
const [activeMethod, setActiveMethod] = useState(activeReplaceMethod);
const [searchQuery, setSearchQuery] = useState('');
const [isGenerateCharacterModalOpen, setIsGenerateCharacterModalOpen] = useState(false);
useEffect(() => {
setActiveMethod(activeReplaceMethod);
}, [activeReplaceMethod]);
@ -121,6 +123,21 @@ export function ReplaceCharacterModal({
<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',
activeMethod === 'generate'
? 'border-blue-500 bg-blue-500/10'
: 'border-white/10 hover:border-white/20'
)}
onClick={() => setActiveMethod('generate')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Wand2 className="w-6 h-6" />
<span>Generate character</span>
</motion.button>
</div>
{/* 内容区域 */}
@ -219,7 +236,7 @@ export function ReplaceCharacterModal({
<motion.button
className="flex items-start gap-2 px-6 py-3 bg-blue-500 hover:bg-blue-600
rounded-lg transition-colors"
onClick={() => console.log('Generate character')}
onClick={() => setIsGenerateCharacterModalOpen(true)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
@ -253,6 +270,12 @@ export function ReplaceCharacterModal({
</div>
</motion.div>
</div>
<GenerateCharacterModal
isOpen={isGenerateCharacterModalOpen}
onClose={() => setIsGenerateCharacterModalOpen(false)}
onGenerate={() => console.log('Generate character')}
/>
</>
)}
</AnimatePresence>

View File

@ -4,12 +4,16 @@ import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import WorkOffice from '@/components/workflow/work-office/work-office';
import { useState, useEffect, useRef } from 'react';
import { useWorkofficeData } from '@/components/pages/work-flow/use-workoffice-data';
import { useAppSelector } from '@/lib/store/hooks';
import { storyboardData, productionData, editorData } from '@/components/workflow/work-office/mock-data';
interface ScriptModalProps {
isOpen: boolean;
onClose: () => void;
currentStage?: number;
roles: any[];
currentLoadingText: string;
}
const stages = [
@ -39,11 +43,38 @@ const stages = [
}
];
export function ScriptModal({ isOpen, onClose, currentStage = 0, roles }: ScriptModalProps) {
export function ScriptModal({ isOpen, onClose, currentStage = 0, roles, currentLoadingText }: ScriptModalProps) {
const [isPlaying, setIsPlaying] = useState(true);
const [progress, setProgress] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const prevStageRef = useRef(currentStage);
const [currentContent, setCurrentContent] = useState<Record<string, any>>({});
// 将 hook 调用移到组件顶层
const { scriptwriterData, thinkingText, thinkingColor, sketchType } = useWorkofficeData(currentStage, isOpen, currentLoadingText);
// 根据当前阶段加载对应数据
useEffect(() => {
if (!isOpen) return;
let data: Record<string, any> = {};
switch (currentStage) {
case 0:
data = scriptwriterData;
break;
case 1:
data = storyboardData;
break;
case 2:
data = productionData;
break;
case 3:
data = editorData;
break;
}
console.log('data', data);
setCurrentContent(data);
}, [currentStage, isOpen, scriptwriterData]);
// 每次打开都重置进度条和时间
useEffect(() => {
@ -185,7 +216,13 @@ export function ScriptModal({ isOpen, onClose, currentStage = 0, roles }: Script
animate={{ opacity: 1 }}
transition={{ delay: 0.1, duration: 0.2 }}
>
<WorkOffice initialStage={currentStage} roles={roles} />
<WorkOffice
initialStage={currentStage}
currentContent={currentContent}
thinkingText={thinkingText}
thinkingColor={thinkingColor}
sketchType={sketchType}
/>
</motion.div>
</motion.div>
</motion.div>

View File

@ -0,0 +1,87 @@
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/public/lib/utils";
import { Check, ChevronDown } from "lucide-react";
import { useState } from "react";
interface SettingOption {
label: string;
value: string;
}
interface SelectDropdownProps {
dropdownId: string;
label: string;
options: SettingOption[];
value: string;
onChange: (value: string) => void;
}
export const SelectDropdown = (
{
dropdownId,
label,
options,
value,
onChange
}: SelectDropdownProps
) => {
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const handleDropdownToggle = (dropdownId: string) => {
setOpenDropdown(openDropdown === dropdownId ? null : dropdownId);
};
return (
<div className="relative w-full h-full">
<motion.button
className={cn(
"w-full px-4 py-2 rounded-lg border text-left flex items-center justify-between",
openDropdown === dropdownId
? "border-blue-500 bg-blue-500/10"
: "border-white/10 hover:border-white/20"
)}
onClick={() => handleDropdownToggle(dropdownId)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<span>{options.find(opt => opt.value === value)?.label || value}</span>
<motion.div
animate={{ rotate: openDropdown === dropdownId ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-4 h-4" />
</motion.div>
</motion.button>
<AnimatePresence>
{openDropdown === dropdownId && (
<motion.div
className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-white/10 bg-black/90 backdrop-blur-xl"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{options.map((option) => (
<motion.button
key={option.value}
className={cn(
"w-full px-4 py-2 text-left flex items-center justify-between hover:bg-white/5",
value === option.value && "text-blue-500"
)}
onClick={() => {
onChange(option.value);
handleDropdownToggle(dropdownId);
}}
whileHover={{ x: 4 }}
>
{option.label}
{value === option.value && <Check className="w-4 h-4" />}
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
};

View File

@ -7,6 +7,7 @@ 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 VideoTabContentProps {
taskSketch: any[];
@ -249,43 +250,19 @@ export function VideoTabContent({
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{/* 左列:编辑项 截取视频、设置转场、调节音量 */}
{/* 左列:戏剧张力线、调节音量 */}
<div className="space-y-4">
{/* 视频截取 */}
{/* 戏剧张力线 */}
<div className="p-4 rounded-lg bg-white/5">
<h3 className="text-sm font-medium mb-2">Video clip</h3>
<div className="flex items-center gap-4">
<input
type="text"
placeholder="00:00"
className="w-20 px-3 py-1 bg-white/5 border border-white/10 rounded-lg
text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-white/50">To</span>
<input
type="text"
placeholder="00:00"
className="w-20 px-3 py-1 bg-white/5 border border-white/10 rounded-lg
text-center focus:outline-none focus:border-blue-500"
/>
</div>
</div>
{/* 转场设置 */}
<div className="p-4 rounded-lg bg-white/5">
<h3 className="text-sm font-medium mb-2">Transition</h3>
<div className="grid grid-cols-3 gap-2">
{['Fade', 'Slide', 'Zoom'].map((transition) => (
<motion.button
key={transition}
className="px-3 py-1 bg-white/5 hover:bg-white/10 rounded-lg text-sm"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{transition}
</motion.button>
))}
</div>
<DramaLineChart
title="Drama line"
height={135}
showToggleButton={false}
className="w-full"
onDataChange={(data) => {
console.log('视频编辑戏剧线数据更新:', data);
}}
/>
</div>
{/* 音量调节 */}
@ -308,7 +285,7 @@ export function VideoTabContent({
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className='text-sm font-medium mb-2'>Media properties</div>
<div className='text-sm font-medium mb-2'>More properties</div>
</motion.button>
</div>

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Heart } from 'lucide-react';
import { motion } from 'framer-motion';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
@ -65,225 +65,249 @@ const CustomTooltip = ({ active, payload, label }: any) => {
return null;
};
const Scriptwriter: React.FC<ScriptwriterProps> = ({ currentContent, isPlaying }) => {
// Three-act Structure Component
const ThreeActStructure = React.memo(({ acts, isPlaying }: { acts?: ScriptContent['acts']; isPlaying: boolean }) => {
return (
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Three-act structure</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{acts && acts.length > 0 ? (
acts.map((act) => (
<ContentCard
key={act.stableId}
className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30"
>
<div className="text-purple-300 font-medium text-sm mb-2">
{act.title}
</div>
<div className="text-gray-300 text-xs leading-relaxed mb-2">
<TypewriterText text={act.desc} stableId={act.stableId} />
</div>
<div className="flex flex-wrap gap-1">
{act.beats?.map((beat, index) => (
<span key={index} className="text-xs bg-purple-500/30 px-2 py-1 rounded">
{beat}
</span>
))}
</div>
</ContentCard>
))
) : (
Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
);
});
// Character Arc Design Component
const CharacterArcDesign = React.memo(({ characters, isPlaying }: { characters?: ScriptContent['characters']; isPlaying: boolean }) => {
return (
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Character arc design</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{characters && characters.length > 0 ? (
characters.map((char) => (
<ContentCard
key={char.stableId}
className="bg-slate-700/50 rounded-lg p-3 border border-slate-600/50"
>
<div className="flex items-center space-x-2 mb-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: char.color }}
/>
<span className="text-white font-medium text-sm">{char.name}</span>
<span className="text-gray-400 text-xs">({char.role})</span>
</div>
<div className="text-xs mb-2" style={{ color: char.color }}>
{char.arc}
</div>
<div className="text-gray-300 text-xs leading-relaxed">
<TypewriterText text={char.desc} stableId={char.stableId} />
</div>
</ContentCard>
))
) : (
Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
);
});
// Dialogue Rhythm Component
const DialogueRhythm = React.memo(({ dialogue, isPlaying }: { dialogue?: ScriptContent['dialogue']; isPlaying: boolean }) => {
return (
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Dialogue rhythm</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
{dialogue ? (
<div className="space-y-3">
<div>
<div className="text-purple-300 text-sm font-medium mb-1">Rhythm control</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={dialogue.rhythm} stableId={`${dialogue.stableId}-rhythm`} />
</div>
</div>
<div>
<div className="text-purple-300 text-sm font-medium mb-1">Expression style</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={dialogue.style} stableId={`${dialogue.stableId}-style`} />
</div>
</div>
</div>
) : (
<SkeletonCard className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
)}
</div>
);
});
// Theme Development Component
const ThemeDevelopment = React.memo(({ themes, isPlaying }: { themes?: ScriptContent['themes']; isPlaying: boolean }) => {
return (
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Theme deepening process</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{themes ? (
themes.map((theme) => (
<ContentCard
key={theme.stableId}
className="bg-purple-400/10 rounded p-3"
>
<div className="text-purple-200 text-sm font-medium mb-1">{theme.theme}</div>
<div className="text-gray-300 text-xs mb-2">
<TypewriterText text={theme.desc} stableId={`${theme.stableId}-desc`} />
</div>
<div className="text-gray-400 text-xs">
<TypewriterText text={theme.depth} stableId={`${theme.stableId}-depth`} />
</div>
</ContentCard>
))
) : (
Array.from({length: 2}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
);
});
// Dramatic Line Component
const DramaticLine = React.memo(({ dramaticLine, isPlaying }: { dramaticLine?: ScriptContent['dramaticLine']; isPlaying: boolean }) => {
return (
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Dramatic line</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
{dramaticLine ? (
<div className="space-y-4">
<div className="h-48 bg-purple-500/10 rounded-lg p-2">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={dramaticLine.points || []}
margin={{ top: 10, right: 10, left: 0, bottom: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="title"
tick={{ fill: '#9CA3AF', fontSize: 10 }}
stroke="#4B5563"
/>
<YAxis
domain={[0, 100]}
tick={{ fill: '#9CA3AF', fontSize: 10 }}
stroke="#4B5563"
label={{
value: 'Emotional intensity',
angle: -90,
position: 'insideLeft',
fill: '#9CA3AF',
fontSize: 12
}}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="intensity"
stroke="#8B5CF6"
strokeWidth={2}
dot={{
fill: '#8B5CF6',
stroke: '#C4B5FD',
strokeWidth: 2,
r: 4
}}
activeDot={{
fill: '#8B5CF6',
stroke: '#C4B5FD',
strokeWidth: 2,
r: 6
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="space-y-2">
{dramaticLine.points?.map((point) => (
<ContentCard
key={point.stableId}
className="bg-purple-400/10 rounded p-2"
>
<div className="text-purple-200 text-sm font-medium mb-1">{point.title}</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={point.desc} stableId={point.stableId} />
</div>
</ContentCard>
))}
</div>
</div>
) : (
<div className="space-y-4">
<SkeletonCard className="h-48 bg-purple-500/20 rounded-lg border border-purple-500/30" />
</div>
)}
</div>
);
});
const Scriptwriter: React.FC<ScriptwriterProps> = React.memo(({ currentContent, isPlaying }) => {
return (
<div className="grid grid-cols-2 gap-4 h-full">
{/* 左侧:三幕结构和角色弧光 */}
<div className="space-y-4 overflow-y-auto">
{/* 三幕结构 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Three-act structure</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{currentContent.acts && currentContent.acts.length > 0 ? (
currentContent.acts.map((act) => (
<ContentCard
key={act.stableId}
className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30"
>
<div className="text-purple-300 font-medium text-sm mb-2">
{act.title}
</div>
<div className="text-gray-300 text-xs leading-relaxed mb-2">
<TypewriterText text={act.desc} stableId={act.stableId} />
</div>
<div className="flex flex-wrap gap-1">
{act.beats?.map((beat, index) => (
<span key={index} className="text-xs bg-purple-500/30 px-2 py-1 rounded">
{beat}
</span>
))}
</div>
</ContentCard>
))
) : (
Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
{/* 角色弧光设计 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Character arc design</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{currentContent.characters && currentContent.characters.length > 0 ? (
currentContent.characters.map((char) => (
<ContentCard
key={char.stableId}
className="bg-slate-700/50 rounded-lg p-3 border border-slate-600/50"
>
<div className="flex items-center space-x-2 mb-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: char.color }}
/>
<span className="text-white font-medium text-sm">{char.name}</span>
<span className="text-gray-400 text-xs">({char.role})</span>
</div>
<div className="text-xs mb-2" style={{ color: char.color }}>
{char.arc}
</div>
<div className="text-gray-300 text-xs leading-relaxed">
<TypewriterText text={char.desc} stableId={char.stableId} />
</div>
</ContentCard>
))
) : (
Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
<ThreeActStructure acts={currentContent.acts} isPlaying={isPlaying} />
<CharacterArcDesign characters={currentContent.characters} isPlaying={isPlaying} />
</div>
{/* 右侧:对白节奏和主题深化 */}
<div className="space-y-4 overflow-y-auto">
{/* 对白节奏感 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Dialogue rhythm</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
{currentContent.dialogue ? (
<div className="space-y-3">
<div>
<div className="text-purple-300 text-sm font-medium mb-1">Rhythm control</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue.rhythm} stableId={`${currentContent.dialogue.stableId}-rhythm`} />
</div>
</div>
<div>
<div className="text-purple-300 text-sm font-medium mb-1">Expression style</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue.style} stableId={`${currentContent.dialogue.stableId}-style`} />
</div>
</div>
</div>
) : (
<SkeletonCard className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
)}
</div>
{/* 主题深化过程 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Theme deepening process</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
<div className="space-y-3">
{currentContent.themes ? (
<>
{currentContent.themes.map((theme) => (
<ContentCard
key={theme.stableId}
className="bg-purple-400/10 rounded p-3"
>
<div className="text-purple-200 text-sm font-medium mb-1">{theme.theme}</div>
<div className="text-gray-300 text-xs mb-2">
<TypewriterText text={theme.desc} stableId={`${theme.stableId}-desc`} />
</div>
<div className="text-gray-400 text-xs">
<TypewriterText text={theme.depth} stableId={`${theme.stableId}-depth`} />
</div>
</ContentCard>
))}
</>
) : (
Array.from({length: 2}, (_, i) => (
<SkeletonCard key={i} className="bg-purple-500/20 rounded-lg p-3 border border-purple-500/30" />
))
)}
</div>
</div>
{/* 剧情起伏戏演线 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Dramatic line</span>
<IconLoading icon={Heart} isActive={isPlaying} color="#8b5cf6" />
</h3>
{currentContent.dramaticLine ? (
<div className="space-y-4">
{/* 情感强度曲线图 */}
<div className="h-48 bg-purple-500/10 rounded-lg p-2">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={currentContent.dramaticLine?.points || []}
margin={{ top: 10, right: 10, left: 0, bottom: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="title"
tick={{ fill: '#9CA3AF', fontSize: 10 }}
stroke="#4B5563"
/>
<YAxis
domain={[0, 100]}
tick={{ fill: '#9CA3AF', fontSize: 10 }}
stroke="#4B5563"
label={{
value: 'Emotional intensity',
angle: -90,
position: 'insideLeft',
fill: '#9CA3AF',
fontSize: 12
}}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="intensity"
stroke="#8B5CF6"
strokeWidth={2}
dot={{
fill: '#8B5CF6',
stroke: '#C4B5FD',
strokeWidth: 2,
r: 4
}}
activeDot={{
fill: '#8B5CF6',
stroke: '#C4B5FD',
strokeWidth: 2,
r: 6
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* 关键点说明 */}
<div className="space-y-2">
{currentContent.dramaticLine?.points?.map((point) => (
<ContentCard
key={point.stableId}
className="bg-purple-400/10 rounded p-2"
>
<div className="text-purple-200 text-sm font-medium mb-1">{point.title}</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={point.desc} stableId={point.stableId} />
</div>
</ContentCard>
))}
</div>
</div>
) : (
<div className="space-y-4">
{/* 骨架屏曲线图 */}
<SkeletonCard className="h-48 bg-purple-500/20 rounded-lg border border-purple-500/30" />
</div>
)}
</div>
<DialogueRhythm dialogue={currentContent.dialogue} isPlaying={isPlaying} />
<ThemeDevelopment themes={currentContent.themes} isPlaying={isPlaying} />
<DramaticLine dramaticLine={currentContent.dramaticLine} isPlaying={isPlaying} />
</div>
</div>
);
};
});
Scriptwriter.displayName = 'Scriptwriter';
export default Scriptwriter;

View File

@ -0,0 +1,252 @@
import React from 'react';
import { Camera } from 'lucide-react';
import { TypewriterText } from './common/TypewriterText';
import { ContentCard } from './common/ContentCard';
import { SkeletonCard } from './common/SkeletonCard';
import { IconLoading } from './common/IconLoading';
interface StoryboardArtistProps {
currentContent: any;
isPlaying: boolean;
sketchType: string;
}
const SceneStoryboard = (currentContent: any, isPlaying: boolean) => {
return (
<div className="space-y-4 h-full overflow-y-auto">
<div className='grid grid-cols-2 gap-4'>
{/* 场景定位选择 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Scene location selection</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className="space-y-3">
{currentContent.location ? (
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.location} stableId={currentContent.location} />
</div>
) : (
<SkeletonCard className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
)}
</div>
</div>
{/* 场景氛围选择 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Scene atmosphere selection</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className="space-y-3">
{currentContent.core_atmosphere ? (
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.core_atmosphere} stableId={currentContent.core_atmosphere} />
</div>
) : (
<SkeletonCard className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
)}
</div>
</div>
</div>
{/* 场景描述 */}
<div className='bg-black/30 rounded-lg p-4'>
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Scene description</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className='space-y-3'>
{currentContent.description ? (
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.description} stableId={currentContent.description} />
</div>
) : (
<SkeletonCard className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
)}
</div>
</div>
</div>
);
}
const ShotSketchStoryboard = (currentContent: any, isPlaying: boolean) => {
return (
<div className="grid grid-cols-2 gap-4 h-full">
{/* 左侧:镜头语言和构图美学 */}
<div className="space-y-4 overflow-y-auto">
{/* 镜头语言选择 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Shot language selection</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className="space-y-3">
{currentContent.shotLanguage && currentContent.shotLanguage.length > 0 ? (
currentContent.shotLanguage.map((shot: any, index: number) => (
<ContentCard
key={index}
className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30"
>
<div className="flex justify-between items-center mb-2">
<span className="text-cyan-300 font-medium text-sm">{shot}</span>
</div>
<div className="text-gray-300 text-xs leading-relaxed">
<TypewriterText text={currentContent.frame_description} stableId={`shot${index}`} />
</div>
</ContentCard>
))
) : (
Array.from({length: 4}, (_, i) => (
<SkeletonCard key={i} className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
))
)}
</div>
</div>
{/* 构图美学运用 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Composition aesthetics</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
{currentContent.atmosphere && currentContent.atmosphere.length > 0 ? (
<ContentCard className="space-y-3">
{currentContent.atmosphere.map((atmosphere: any, index: number) => (
<div key={index}>
<div className="text-cyan-300 text-sm font-medium">
<TypewriterText text={atmosphere} stableId={`${index}-atmosphere`} />
</div>
</div>
))}
</ContentCard>
) : (
<div className="space-y-3">
{Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
))}
</div>
)}
</div>
</div>
{/* 右侧:摄影机运动和叙事逻辑 */}
<div className="space-y-4 overflow-y-auto">
{/* 摄影机运动设计 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Camera movement design</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className="space-y-3">
{currentContent.camera_motion && currentContent.camera_motion.length > 0 ? (
currentContent.camera_motion.map((move: any, index: number) => (
<ContentCard
key={index}
className="bg-cyan-400/20 rounded-lg p-3"
>
<div className="text-cyan-200 font-medium text-sm mb-1">{move}</div>
<div className="text-cyan-300 text-xs">
<TypewriterText text={currentContent.composition} stableId={`${index}-composition`} />
</div>
</ContentCard>
))
) : (
Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
))
)}
</div>
</div>
{/* 视觉叙事逻辑 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Visual storytelling logic</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
{currentContent.key_action ? (
<ContentCard className="space-y-3">
<div>
<div className="text-cyan-300 text-sm font-medium mb-1">Storytelling logic</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.frame_description} stableId={`frame-description-logic`} />
</div>
</div>
<div>
<div className="text-cyan-300 text-sm font-medium mb-1">Key emphasis</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.key_action} stableId={`key-action-emphasis`} />
</div>
</div>
</ContentCard>
) : (
<div className="space-y-3">
{Array.from({length: 3}, (_, i) => (
<SkeletonCard key={i} className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
))}
</div>
)}
</div>
{/* 对话表演 */}
<div className="bg-black/30 rounded-lg p-4">
<h3 className="text-white font-semibold mb-3 flex items-center space-x-2">
<span>Dialogue performance</span>
<IconLoading icon={Camera} isActive={isPlaying} color="#06b6d4" />
</h3>
<div className="space-y-2">
{currentContent.dialogue_performance ? (
<>
<ContentCard
className="bg-cyan-300/20 rounded p-2"
>
<div className="text-cyan-200 text-sm font-medium">Speaker</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue_performance.speaker} stableId={`speaker`} />
</div>
</ContentCard>
<ContentCard
className="bg-cyan-300/20 rounded p-2"
>
<div className="text-cyan-200 text-sm font-medium">Language</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue_performance.language} stableId={`language`} />
</div>
</ContentCard>
<ContentCard
className="bg-cyan-300/20 rounded p-2"
>
<div className="text-cyan-200 text-sm font-medium">Delivery</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue_performance.delivery} stableId={`delivery`} />
</div>
</ContentCard>
<ContentCard
className="bg-cyan-300/20 rounded p-2"
>
<div className="text-cyan-200 text-sm font-medium">Line</div>
<div className="text-gray-300 text-xs">
<TypewriterText text={currentContent.dialogue_performance.line} stableId={`line`} />
</div>
</ContentCard>
</>
) : (
Array.from({length: 4}, (_, i) => (
<SkeletonCard key={i} className="bg-cyan-500/20 rounded-lg p-3 border border-cyan-500/30" />
))
)}
</div>
</div>
</div>
</div>
);
}
const StoryboardArtist: React.FC<StoryboardArtistProps> = ({ currentContent, isPlaying, sketchType }) => {
return (
<>
{sketchType === 'scene' ? <SceneStoryboard currentContent={currentContent} isPlaying={isPlaying} /> : <ShotSketchStoryboard currentContent={currentContent} isPlaying={isPlaying} />}
</>
);
};
export default StoryboardArtist;

View File

@ -1,56 +1,11 @@
import React, { useState, useEffect } from 'react';
import { Heart, Camera, Film, Scissors } from 'lucide-react';
import React, { useState, useEffect, useMemo } from 'react';
import { motion } from 'framer-motion';
import Scriptwriter from './scriptwriter';
import StoryboardArtist from './storyboard-artist';
import VisualDirector from './visual-director';
import Editor from './editor';
import { scriptwriterData, storyboardData, productionData, editorData } from './mock-data';
import { storyboardData, productionData, editorData } from './mock-data';
interface Stage {
id: string;
title: string;
icon: React.ElementType;
color: string;
profession: string;
}
const stages: Stage[] = [
{
id: 'script',
title: 'Scriptwriter',
icon: Heart,
color: '#8b5cf6',
profession: 'Scriptwriter'
},
{
id: 'storyboard',
title: 'Storyboard artist',
icon: Camera,
color: '#06b6d4',
profession: 'Storyboard artist'
},
{
id: 'production',
title: 'Visual director',
icon: Film,
color: '#10b981',
profession: 'Visual director'
},
{
id: 'editing',
title: 'Editor',
icon: Scissors,
color: '#f59e0b',
profession: 'Editor'
}
];
const actionsText = [
'is thinking...',
'is drawing...',
'is directing...',
'is editing...'
]
// 思考指示器组件
const ThinkingDots = ({ show, text, color }: { show: boolean; text: string; color: string }) => {
@ -83,51 +38,30 @@ const ThinkingDots = ({ show, text, color }: { show: boolean; text: string; colo
interface WorkOfficeProps {
initialStage?: number;
roles: any[];
currentContent: Record<string, any>;
thinkingText: string;
thinkingColor: string;
sketchType: string;
}
const WorkOffice: React.FC<WorkOfficeProps> = ({ initialStage = 0 }) => {
const WorkOffice: React.FC<WorkOfficeProps> = ({ initialStage = 0, currentContent, thinkingText, thinkingColor, sketchType }) => {
const [currentStage, setCurrentStage] = useState(initialStage);
const [isPlaying, setIsPlaying] = useState(false);
const [currentContent, setCurrentContent] = useState<Record<string, any>>(scriptwriterData);
const [thinkingText, setThinkingText] = useState(`${stages[0].profession} ${actionsText[0]}`);
useEffect(() => {
// currentStage 更新 重新渲染当前工作台组件
setCurrentStage(initialStage);
setIsPlaying(true);
}, [initialStage]);
// 根据当前阶段加载对应数据
useEffect(() => {
let data: Record<string, any> = {};
switch (currentStage) {
case 0:
data = scriptwriterData;
break;
case 1:
data = storyboardData;
break;
case 2:
data = productionData;
break;
case 3:
data = editorData;
break;
}
setIsPlaying(true);
setCurrentContent(data);
setThinkingText(`${stages[currentStage].profession} ${actionsText[currentStage]}`);
}, [currentStage]);
// 渲染当前工作台组件
const renderCurrentWorkstation = () => {
// 使用 useMemo 缓存工作台组件
const currentWorkstation = useMemo(() => {
console.log('工作台组件重新渲染', currentContent);
switch (currentStage) {
case 0:
return <Scriptwriter currentContent={currentContent} isPlaying={isPlaying} />;
case 1:
return <StoryboardArtist currentContent={currentContent} isPlaying={isPlaying} />;
return <StoryboardArtist currentContent={currentContent} isPlaying={isPlaying} sketchType={sketchType} />;
case 2:
return <VisualDirector currentContent={currentContent} isPlaying={isPlaying} />;
case 3:
@ -135,25 +69,31 @@ const WorkOffice: React.FC<WorkOfficeProps> = ({ initialStage = 0 }) => {
default:
return null;
}
};
}, [currentStage, isPlaying, currentContent]);
// 使用 useMemo 缓存 ThinkingDots 组件
const thinkingDots = useMemo(() => (
<ThinkingDots
show={isPlaying}
text={thinkingText}
color={thinkingColor}
/>
), [isPlaying, thinkingText, thinkingColor]);
return (
<div className="h-full rounded-2xl overflow-hidden shadow-2xl relative">
{/* 正在加载的部分 文字显示 */}
<div className="absolute top-[0] left-1/2 -translate-x-1/2 z-10">
<ThinkingDots
show={isPlaying}
text={thinkingText}
color={stages[currentStage].color}
/>
{thinkingDots}
</div>
{/* 工作台内容区域 */}
<div className="absolute left-0 right-0 top-[2rem] w-full aspect-video overflow-y-auto" style={{height: 'calc(100% - 7rem'}}>
{renderCurrentWorkstation()}
{currentWorkstation}
</div>
</div>
);
};
export default WorkOffice;
// 使用 React.memo 包装组件
export default React.memo(WorkOffice);

5
lib/store/hooks.ts Normal file
View File

@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

11
lib/store/store.ts Normal file
View File

@ -0,0 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
import workflowReducer from './workflowSlice';
export const store = configureStore({
reducer: {
workflow: workflowReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface WorkflowState {
sketchCount: number;
videoCount: number;
}
const initialState: WorkflowState = {
sketchCount: 0,
videoCount: 0,
};
export const workflowSlice = createSlice({
name: 'workflow',
initialState,
reducers: {
setSketchCount: (state, action: PayloadAction<number>) => {
state.sketchCount = action.payload;
},
setVideoCount: (state, action: PayloadAction<number>) => {
state.videoCount = action.payload;
},
},
});
export const { setSketchCount, setVideoCount } = workflowSlice.actions;
export default workflowSlice.reducer;

1117
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@reduxjs/toolkit": "^2.8.2",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
"@types/gsap": "^1.20.2",
@ -85,6 +86,7 @@
"react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-joyride": "^2.9.3",
"react-redux": "^9.2.0",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.1.3",
"react-rough-notation": "^1.0.5",

View File

@ -55,5 +55,37 @@ POST https://pre.movieflow.api.huiying.video/movie/text_to_script_tags
Content-Type: application/json
{
"project_id": "244e01ae-01e8-4854-b56d-76a7bb57b2bb"
"project_id": "54256c52-236f-45b2-8901-b696c45ad540"
}
### Analyze movie script stream
POST https://pre.movieflow.api.huiying.video/movie/analyze_movie_script_stream
Content-Type: application/json
{
"project_id": "54256c52-236f-45b2-8901-b696c45ad540"
}
### test scene json
POST https://pre.movieflow.api.huiying.video/movie/scene_json
Content-Type: application/json
{
"project_id": "c3aea4e3-4f5a-4853-91af-54486568d914"
}
### test shot sketch json
POST https://pre.movieflow.api.huiying.video/movie/shot_sketch_json
Content-Type: application/json
{
"project_id": "c3aea4e3-4f5a-4853-91af-54486568d914"
}
### test video json
POST https://pre.movieflow.api.huiying.video/movie/video_json
Content-Type: application/json
{
"project_id": "c3aea4e3-4f5a-4853-91af-54486568d914"
}

28
utils/dev-helper.tsx Normal file
View File

@ -0,0 +1,28 @@
'use client';
import { useEffect } from 'react';
export default function DevHelper() {
useEffect(() => {
// 在客户端环境下设置全局错误处理
if (typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
if (event.error?.name === 'ChunkLoadError' || event.error?.message?.includes('Loading chunk')) {
console.warn('检测到 ChunkLoadError尝试刷新页面');
window.location.reload();
event.preventDefault();
}
});
window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.name === 'ChunkLoadError') {
console.warn('检测到未处理的 ChunkLoadError Promise 拒绝');
event.preventDefault();
window.location.reload();
}
});
}
}, []);
return null;
}