diff --git a/api/request.ts b/api/request.ts index fff7482..d49ec19 100644 --- a/api/request.ts +++ b/api/request.ts @@ -84,6 +84,95 @@ export const del = (url: string, config?: AxiosRequestConfig): Promise => return request.delete(url, config); }; +// utils/streamJsonPost.ts +export async function streamJsonPost( + 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 ({ url, diff --git a/api/video_flow.ts b/api/video_flow.ts index bf78d86..6580e3c 100644 --- a/api/video_flow.ts +++ b/api/video_flow.ts @@ -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 { @@ -198,4 +199,19 @@ export const getRunningStreamData = async (data: { project_id: string }): Promis // 获取 脚本 接口 export const getScriptTags = async (data: { project_id: string }): Promise => { return post('/movie/text_to_script_tags', data); +}; + +// 获取 loading-场景 接口 +export const getSceneJson = async (data: { project_id: string }): Promise> => { + return post('/movie/scene_json', data); +}; + +// 获取 loading-分镜 接口 +export const getShotSketchJson = async (data: { project_id: string }): Promise> => { + return post('/movie/shot_sketch_json', data); +}; + +// 获取 loading-视频 接口 +export const getVideoJson = async (data: { project_id: string }): Promise> => { + return post('/movie/video_json', data); }; \ No newline at end of file diff --git a/app/create/work-flow/page.tsx b/app/create/work-flow/page.tsx index 686015d..83efc7b 100644 --- a/app/create/work-flow/page.tsx +++ b/app/create/work-flow/page.tsx @@ -1,3 +1,5 @@ +'use client'; + import WorkFlow from '@/components/pages/work-flow'; export default function ScriptWorkFlowPage() { diff --git a/app/layout.tsx b/app/layout.tsx index ac0f765..2f78571 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - - {children} - - - - {process.env.NODE_ENV === 'development' && } - + + {children} + ); diff --git a/components/pages/work-flow/task-info.tsx b/components/pages/work-flow/task-info.tsx index 263915a..010c0be 100644 --- a/components/pages/work-flow/task-info.tsx +++ b/components/pages/work-flow/task-info.tsx @@ -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 ( <>
@@ -293,6 +271,7 @@ export function TaskInfo({ }} currentStage={currentStage} roles={roles} + currentLoadingText={currentLoadingText} /> {currentLoadingText === 'Task completed' ? ( diff --git a/components/pages/work-flow/use-workflow-data.tsx b/components/pages/work-flow/use-workflow-data.tsx index d54d028..cf51291 100644 --- a/components/pages/work-flow/use-workflow-data.tsx +++ b/components/pages/work-flow/use-workflow-data.tsx @@ -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([]); const [taskShotSketch, setTaskShotSketch] = useState([]); const [taskVideos, setTaskVideos] = useState([]); - 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([]); const [music, setMusic] = useState([]); @@ -72,6 +73,9 @@ export function useWorkflowData() { const [dataLoadError, setDataLoadError] = useState(null); const [needStreamData, setNeedStreamData] = useState(false); + const dispatch = useAppDispatch(); + const { sketchCount, videoCount } = useAppSelector((state) => state.workflow); + // 自动开始播放一轮 const autoPlaySketch = useCallback(() => { return new Promise((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); diff --git a/components/pages/work-flow/use-workoffice-data.tsx b/components/pages/work-flow/use-workoffice-data.tsx new file mode 100644 index 0000000..6190fac --- /dev/null +++ b/components/pages/work-flow/use-workoffice-data.tsx @@ -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({ + acts: [], + characters: [], + dialogue: undefined, + themes: [], + dramaticLine: undefined + }); + const [thinkingColor, setThinkingColor] = useState(''); + const [thinkingText, setThinkingText] = useState(''); + const searchParams = useSearchParams(); + const project_id = searchParams.get('episodeId') || ''; + const [sceneData, setSceneData] = useState([]); + const [shotSketchData, setShotSketchData] = useState([]); + const [videoData, setVideoData] = useState([]); + const [storyboardData, setStoryboardData] = useState({}); + const [sketchType, setSketchType] = useState(''); + const { sketchCount, videoCount } = useAppSelector((state) => state.workflow); + + // 使用 ref 存储临时数据,避免重复创建对象 + const tempDataRef = useRef({ + 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) => { + 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 = {}; + + // 三幕结构 + 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 + }; +} \ No newline at end of file diff --git a/components/providers.tsx b/components/providers.tsx new file mode 100644 index 0000000..834476f --- /dev/null +++ b/components/providers.tsx @@ -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 ( + + + + {children} + + + + {process.env.NODE_ENV === 'development' && } + + + ); +} \ No newline at end of file diff --git a/components/ui/character-tab-content.tsx b/components/ui/character-tab-content.tsx index cca4be9..8cf642b 100644 --- a/components/ui/character-tab-content.tsx +++ b/components/ui/character-tab-content.tsx @@ -151,6 +151,24 @@ export function CharacterTabContent({ Character library + + { + setActiveReplaceMethod('generate'); + setIsReplaceModalOpen(true); + }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + + Generate character +
diff --git a/components/ui/drama-line-chart.tsx b/components/ui/drama-line-chart.tsx new file mode 100644 index 0000000..63e22f0 --- /dev/null +++ b/components/ui/drama-line-chart.tsx @@ -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(initialData || mockData); + const [isDragging, setIsDragging] = useState(false); + const [dragIndex, setDragIndex] = useState(null); + const [isVisible, setIsVisible] = useState(!showToggleButton); + const [hoveredPoint, setHoveredPoint] = useState(null); + const [containerWidth, setContainerWidth] = useState(width || 320); + const svgRef = useRef(null); + const containerRef = useRef(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 ( +
+ {/* 切换按钮 */} + {showToggleButton && ( + + + {isVisible ? ( + + ) : ( + + )} + + + )} + + {/* 折线图容器 */} + + {isVisible && ( + +
+ {/* 标题 */} + + + {title} + (Drag to adjust the tension value) + + + {/* SVG 图表 */} + + + {/* 网格线 */} + + + + + + {/* 渐变 */} + + + + + + + {/* 区域渐变 */} + + + + + + + + + {/* 主线条 */} + + + {/* 数据点 */} + {data.map((point, index) => { + const pos = getPointPosition(point); + const isHovered = hoveredPoint === index; + const isDraggingThis = dragIndex === index; + + return ( + + {/* 点的光环效果 */} + {(isHovered || isDraggingThis) && ( + + )} + + {/* 主要的点 */} + 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 && ( + + + + {Math.round(point.y)} + + + )} + + ); + })} + + {/* 时间轴标签 - 替代原来的底部标签 */} + {showLabels && data.map((point, index) => { + const pos = getPointPosition(point); + // 只显示部分标签避免重叠 + const shouldShow = index === 0 || index === data.length - 1 || index % 2 === 0; + + return shouldShow ? ( + + {point.label} + + ) : null; + })} + + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/ui/edit-modal.tsx b/components/ui/edit-modal.tsx index f99656c..9238eb3 100644 --- a/components/ui/edit-modal.tsx +++ b/components/ui/edit-modal.tsx @@ -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 }} > diff --git a/components/ui/generate-character-model.tsx b/components/ui/generate-character-model.tsx new file mode 100644 index 0000000..90c4102 --- /dev/null +++ b/components/ui/generate-character-model.tsx @@ -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 ( + + {isOpen && ( + <> + {/* 背景遮罩 */} + + + {/* 弹窗内容 */} +
+ + {/* 标题栏 */} +
+
+ +

generate character

+
+
+ + {/* 主要内容区域 */} +
+ + {/* 文本输入区域 */} +
+ +