diff --git a/api/constants.ts b/api/constants.ts index 02fd0d9..7089bbb 100644 --- a/api/constants.ts +++ b/api/constants.ts @@ -1 +1 @@ -export const BASE_URL = "https://77.smartvideo.py.qikongjian.com" +export const BASE_URL = "https://pre.movieflow.api.huiying.video" diff --git a/api/video_flow.ts b/api/video_flow.ts index 4944dc8..bf78d86 100644 --- a/api/video_flow.ts +++ b/api/video_flow.ts @@ -182,38 +182,20 @@ export const convertVideoToScene = async ( // 新-获取剧集详情 export const detailScriptEpisodeNew = async (data: { project_id: string }): Promise> => { - return post>('/movie/get_movie_project_detail', data); - return { - code: 0, - successful: true, - message: 'success', - data: { - project_id: 'uuid', - name: '没有返回就调 name 接口', - status: 'running', - step: 'sketch', - last_message: 'loading detail info...', - data: null, - mode: 'auto', - resolution: '1080p', - } - } + return post>('/movie/get_movie_project_detail', data); }; // 获取 title 接口 export const getScriptTitle = async (data: { project_id: string }): Promise> => { - return post>('/movie/get_movie_project_name', data); - return { - code: 0, - successful: true, - message: 'success', - data: { - name: '提取出视频标题' - } - } + return post>('/movie/get_movie_project_description', data); } // 获取 数据 全量(需轮询) export const getRunningStreamData = async (data: { project_id: string }): Promise> => { return post>('/movie/get_status', data); +}; + +// 获取 脚本 接口 +export const getScriptTags = async (data: { project_id: string }): Promise => { + return post('/movie/text_to_script_tags', data); }; \ No newline at end of file diff --git a/app/api/stream/route.ts b/app/api/stream/route.ts new file mode 100644 index 0000000..269c1e6 --- /dev/null +++ b/app/api/stream/route.ts @@ -0,0 +1,112 @@ +import { parseMDXContent } from '@/lib/mdx'; +import { BASE_URL } from '@/api/constants'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'edge'; + +export async function GET() { + try { + const content = `[分析场景核心]: 一位舞者对完美的执念,使她的美丽舞蹈转化为一种自我毁灭的仪式。 + +### 第一幕:开端 + +内景 · 舞蹈室 - 傍晚 + +夕阳透过窗户,阳光携带着飞舞的尘埃,划出一块金色矩形洒在斑驳的木地板上。墙面苍白、剥落。一整面落地镜映出这空旷房间的寂静。 + +ELARA(20岁出头),身姿优雅至极,立于光影正中。她赤脚,穿着一件简单的黑色紧身舞衣,随着一段大提琴独奏起舞——旋律哀伤而高昂。 + +她的身体如流水般线条优雅。一只手臂伸展,手指在空气中描绘出无形的轨迹。她缓缓旋转,像是违抗了重力的引力。在这一刻,她是完美的化身。 + +但她的双眼,始终盯着镜中的自己,突然微微眯起——一丝自我厌弃的情绪一闪而过那个旋转,不够纯净。不够完美。对她而言。 + +她打断了舞姿。现在,她的呼吸变得可闻,如利刃般切入大提琴的温柔旋律。 + +她毫不迟疑地重新起舞。这一次,更快、更狠。舞姿仍美,却变得尖锐,带着绝望的锋芒。 + +一滴汗水,从她的太阳穴滑落。 + +她再次旋转,又旋转。一个狂热、残酷的段落。她的脸不再沉静,而是绷紧的面具,咬牙切齿。音乐愈发高亢,但我们所听见的,只有她脚步在木地板上滑出的尖锐声响,以及她粗重、急促的喘息。 + +镜中的倒影已模糊成一道疯狂的涂抹。她强迫自己再做一个旋转——那种超越极限的最后一圈。 + +她的脚踝崩溃了。 + +不是咔嚓断裂的声响,而是一种沉默、恶心的扭曲。Elara 倒在地上,如阳光外的一团残骸,四肢交错,失去光环。 + +大提琴继续演奏,平静、冷漠。 + +她喉间发出一声哽咽般的呜咽,是房间中唯一的声音。镜头穿过她起伏的肩膀,聚焦她的脚——足弓紧绷,脚趾青紫,地板上划出一丝血痕。 + +### 导演与表演附录 + +【导演风格解码】 +• 核心视觉基调: 高反差明暗对比,采用单一自然光源(窗户)。随着角色状态的恶化,色调从温暖金色逐渐转为冷峻的蓝灰。 +• 关键镜头处理建议: 开始用宽阔流畅的镜头呼应舞者的优雅;当其内心破裂时,剪切到突兀的特写镜头:颤抖的大腿肌肉、颈侧汗珠、扭曲的表情与模糊的镜中倒影。最后一个镜头采用固定机位,锁定她瘫倒的身体,强化突然静止的冲击感。她的舞步原本是以光影为中心的循环,她的倒地打破了这一循环,使她落入阴影。 + +### 核心表演关键 + +• 角色肢体表现: 表演需呈现双重性——外在看似毫不费力的优雅与内在痛苦的微妙细节同在。崩溃时不应夸张,而应如过度拉紧的身体对重力的安静屈服。 +• 潜台词驱动: 这是与"无形评审"——镜中自己——之间的战争。每一个动作都是抗议,每一个旋转是一种恳求,每一次喘息都是诅咒。核心动机是:"我要强迫这个不完美的身体创造一个完美的瞬间,即使这将摧毁我。" + +### 当代表达的连接 + +这个故事呼应了当代数字时代"精心营造的完美主义"现象,在理想化的自我形象背后,隐藏着焦虑与倦怠的真实生活。 +`; + + console.log('开始解析 MDX 内容...'); + const chunks = await parseMDXContent(content); + console.log('解析完成,获得块数:', chunks?.length); + + if (!chunks || chunks.length === 0) { + console.error('没有生成有效的内容块'); + return new Response( + JSON.stringify({ error: '内容解析失败' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // 创建一个可写流来处理数据 + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + const encoder = new TextEncoder(); + + // 异步处理数据流 + (async () => { + try { + for (const chunk of chunks) { + const data = encoder.encode(JSON.stringify(chunk) + '\n'); + await writer.write(data); + } + } catch (error) { + console.error('写入流时出错:', error); + } finally { + await writer.close(); + } + })(); + + return new Response(stream.readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + }, + }); + + } catch (error) { + console.error('API 路由处理出错:', error); + return new Response( + JSON.stringify({ + error: '服务器处理请求时出错', + details: error instanceof Error ? error.message : '未知错误' + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); + } +} \ No newline at end of file diff --git a/components/pages/create-to-video2.tsx b/components/pages/create-to-video2.tsx index 0ddc75a..9e9c982 100644 --- a/components/pages/create-to-video2.tsx +++ b/components/pages/create-to-video2.tsx @@ -279,42 +279,93 @@ export function CreateToVideo2() { } }; - const handleCompositionStart = () => { - setIsComposing(true); - }; - - const handleCompositionEnd = (e: React.CompositionEvent) => { - setIsComposing(false); - handleEditorChange(e as any); - }; - const handleEditorChange = (e: React.FormEvent) => { - // 如果正在输入中文,不要更新内容 - if (isComposing) return; - const newText = e.currentTarget.textContent || ''; + + // 如果正在输入中文,只更新内部文本,不更新状态 + if (isComposing) { + return; + } + + // 更新状态 setInputText(newText); + // 保存当前选区位置 const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const currentPosition = range.startOffset; - setTimeout(() => { + // 使用 requestAnimationFrame 确保在下一帧恢复光标位置 + requestAnimationFrame(() => { if (editorRef.current) { - const textNode = Array.from(editorRef.current.childNodes).find( + // 找到或创建文本节点 + let textNode = Array.from(editorRef.current.childNodes).find( node => node.nodeType === Node.TEXT_NODE - ); + ) as Text; - if (textNode) { - const newRange = document.createRange(); - newRange.setStart(textNode, currentPosition); - newRange.setEnd(textNode, currentPosition); - selection.removeAllRanges(); - selection.addRange(newRange); + if (!textNode) { + textNode = document.createTextNode(newText); + editorRef.current.appendChild(textNode); } + + // 计算正确的光标位置 + const finalPosition = Math.min(currentPosition, textNode.length); + + // 设置新的选区 + const newRange = document.createRange(); + newRange.setStart(textNode, finalPosition); + newRange.setEnd(textNode, finalPosition); + + selection.removeAllRanges(); + selection.addRange(newRange); } - }, 0); + }); + } + }; + + // 处理中文输入开始 + const handleCompositionStart = () => { + setIsComposing(true); + }; + + // 处理中文输入结束 + const handleCompositionEnd = (e: React.CompositionEvent) => { + setIsComposing(false); + + // 在输入完成后更新内容 + const newText = e.currentTarget.textContent || ''; + setInputText(newText); + + // 保存并恢复光标位置 + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const currentPosition = range.startOffset; + + requestAnimationFrame(() => { + if (editorRef.current) { + let textNode = Array.from(editorRef.current.childNodes).find( + node => node.nodeType === Node.TEXT_NODE + ) as Text; + + if (!textNode) { + textNode = document.createTextNode(newText); + editorRef.current.appendChild(textNode); + } + + // 计算正确的光标位置 + const finalPosition = Math.min(currentPosition, textNode.length); + + // 设置新的选区 + const newRange = document.createRange(); + newRange.setStart(textNode, finalPosition); + newRange.setEnd(textNode, finalPosition); + + selection.removeAllRanges(); + selection.addRange(newRange); + } + }); } }; diff --git a/components/pages/style/work-flow.css b/components/pages/style/work-flow.css index c39a07c..bc853f7 100644 --- a/components/pages/style/work-flow.css +++ b/components/pages/style/work-flow.css @@ -119,8 +119,8 @@ .info-UUGkPJ { box-sizing: border-box; flex-direction: column; - gap: 12px; - display: flex + gap: 10px; + display: flex; ; } .title-JtMejk { diff --git a/components/pages/work-flow/task-info.tsx b/components/pages/work-flow/task-info.tsx index efd89ab..f46c2c4 100644 --- a/components/pages/work-flow/task-info.tsx +++ b/components/pages/work-flow/task-info.tsx @@ -1,8 +1,8 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { motion } from 'framer-motion'; -import { Skeleton } from '@/components/ui/skeleton'; +import { ScriptModal } from '@/components/ui/script-modal'; import { Image, Video, @@ -11,7 +11,8 @@ import { Loader2, User, Scissors, - Tv + Tv, + Airplay } from 'lucide-react'; interface TaskInfoProps { @@ -44,8 +45,29 @@ const getStageIcon = (loadingText: string) => { } }; +const TAG_COLORS = ['#FF5733', '#126821', '#8d3913', '#FF33A1', '#A133FF', '#FF3333', '#3333FF', '#A1A1A1', '#a1115e', '#30527f']; + export function TaskInfo({ isLoading, taskObject, currentLoadingText, dataLoadError }: TaskInfoProps) { const StageIcon = getStageIcon(currentLoadingText); + const [isScriptModalOpen, setIsScriptModalOpen] = useState(false); + + // 使用 useMemo 缓存标签颜色映射 + const tagColors = useMemo(() => { + if (!taskObject?.tags) return {}; + return taskObject.tags.reduce((acc: Record, tag: string) => { + acc[tag] = TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)]; + return acc; + }, {}); + }, [taskObject?.tags]); // 只在 tags 改变时重新计算 + + // 自动触发打开 剧本 弹窗 延迟5秒 + useEffect(() => { + if (taskObject?.title && currentLoadingText !== 'Task completed') { + setTimeout(() => { + setIsScriptModalOpen(true); + }, 5000); + } + }, [taskObject?.title]); if (isLoading) { return ( @@ -56,7 +78,7 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText, dataLoadEr animate={{ opacity: 1 }} transition={{ duration: 0.5 }} > - {taskObject?.title || '正在加载项目信息...'} + {taskObject?.title || 'loading project info...'} {/* 加载状态显示 */} @@ -153,9 +175,42 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText, dataLoadEr return ( <> -
- {taskObject?.title || '正在加载项目信息...'} +
+ {taskObject?.title ? ( + <> + + {taskObject?.title || 'loading project info...'} + + {/*
+ setIsScriptModalOpen(true)} + > + + +
*/} + + ) : 'loading project info...'}
+ + {/* 主题 彩色标签tags */} +
+ {taskObject?.tags?.map((tag: string) => ( +
+ {tag} +
+ ))} +
+ + setIsScriptModalOpen(false)} + /> {currentLoadingText === 'Task completed' ? ( - - - - - - - {currentLoadingText} - - - +
+ + {currentLoadingText} +
) : ( `Drawing character ${count + 1 > total ? total : count + 1}/${total}...`, + getShotSketchStatus: 'Getting shot sketch status...', + shotSketch: (count: number, total: number) => `Generating shot sketch ${count + 1 > total ? total : count + 1}/${total}...`, getVideoStatus: 'Getting video status...', video: (count: number, total: number) => `Generating video ${count + 1 > total ? total : count + 1}/${total}...`, videoComplete: 'Video generation complete', @@ -43,6 +45,7 @@ interface TaskObject { roles?: any[]; music?: any[]; final?: any; + tags?: any[]; } export function useWorkflowData() { @@ -78,7 +81,7 @@ export function useWorkflowData() { } let loadingText: any = LOADING_TEXT_MAP.initializing; - let finalStep = '1', sketchCount = 0; + let finalStep = '1', sketchCount = 0, isChange = false; const all_task_data = response.data; // all_task_data 下标0 和 下标1 换位置 const temp = all_task_data[0]; @@ -90,7 +93,7 @@ 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) { + if (task.task_result.data.length >= 0 && taskSketch.length < task.task_result.data.length) { // 正在生成草图中 替换 sketch 数据 const sketchList = []; for (const sketch of task.task_result.data) { @@ -125,7 +128,7 @@ export function useWorkflowData() { url: character.image_path, sound: null, soundDescription: '', - roleDescription: '' + roleDescription: character.character_description }); } setRoles(characterList); @@ -136,18 +139,46 @@ export function useWorkflowData() { // 角色生成完成 finalStep = '3'; - loadingText = LOADING_TEXT_MAP.getVideoStatus; + loadingText = LOADING_TEXT_MAP.getShotSketchStatus; } } + if (task.task_name === 'generate_shot_sketch' && task.task_result) { + if (task.task_result.data.length >= 0 && taskSketch.length < task.task_result.data.length) { + console.log('----------正在生成草图中 替换 sketch 数据'); + // 正在生成草图中 替换 sketch 数据 + const sketchList = []; + for (const sketch of task.task_result.data) { + sketchList.push({ + url: sketch.url, + script: sketch.description + }); + } + setTaskSketch(sketchList); + setSketchCount(sketchList.length); + setIsGeneratingSketch(true); + setCurrentSketchIndex(sketchList.length - 1); + loadingText = LOADING_TEXT_MAP.shotSketch(sketchList.length, task.task_result.total_count); + } + if (task.task_status === 'COMPLETED') { + // 草图生成完成 + setIsGeneratingSketch(false); + sketchCount = task.task_result.total_count; + console.log('----------草图生成完成', sketchCount); + loadingText = LOADING_TEXT_MAP.video(0, task.task_result.total_count); + finalStep = '2'; + } + setTotalSketchCount(task.task_result.total_count); + } if (task.task_name === 'generate_videos' && task.task_result) { if (task.task_result.data.length >= 0 && taskVideos.length !== task.task_result.data.length) { + console.log('----------正在生成视频中-发生变化才触发'); // 正在生成视频中 替换视频数据 const videoList = []; for (const video of task.task_result.data) { // 每一项 video 有多个视频 先默认取第一个 videoList.push({ - url: video[0].qiniuVideoUrl, - script: video[0].operation.metadata.video.prompt, + url: video.urls[0], + script: video.description, audio: null, }); } @@ -232,14 +263,15 @@ export function useWorkflowData() { throw new Error(response.message); } - const { name, status, data } = response.data; + const { name, status, data, tags } = response.data; setIsLoading(false); // 设置初始数据 setTaskObject({ taskStatus: '0', title: name || 'generating...', - currentLoadingText: status === 'COMPLETED' ? LOADING_TEXT_MAP.complete : LOADING_TEXT_MAP.initializing + currentLoadingText: status === 'COMPLETED' ? LOADING_TEXT_MAP.complete : LOADING_TEXT_MAP.initializing, + tags: tags || [] }); // 设置标题 @@ -250,7 +282,8 @@ export function useWorkflowData() { if (titleResponse.successful) { setTaskObject((prev: TaskObject | null) => ({ ...(prev || {}), - title: titleResponse.data.name + title: titleResponse.data.title, + tags: titleResponse.data.tags || [] } as TaskObject)); } } @@ -294,7 +327,7 @@ export function useWorkflowData() { url: character.image_path, sound: null, soundDescription: '', - roleDescription: '' + roleDescription: character.character_description }); } setRoles(characterList); @@ -303,7 +336,30 @@ export function useWorkflowData() { } else { finalStep = '3'; if (!data.video || !data.video.data || !data.video.data.length) { - loadingText = LOADING_TEXT_MAP.getVideoStatus; + loadingText = LOADING_TEXT_MAP.getShotSketchStatus; + } + } + } + if (data.shot_sketch && data.shot_sketch.data && data.shot_sketch.data.length > 0) { + const sketchList = []; + for (const sketch of data.shot_sketch.data) { + sketchList.push({ + url: sketch.url, + script: sketch.description, + }); + } + setTaskSketch(sketchList); + setSketchCount(sketchList.length); + setTotalSketchCount(data.shot_sketch.total_count); + // 设置为最后一个草图 + if (data.shot_sketch.total_count > data.shot_sketch.data.length) { + setIsGeneratingSketch(true); + setCurrentSketchIndex(data.shot_sketch.data.length - 1); + loadingText = LOADING_TEXT_MAP.shotSketch(data.shot_sketch.data.length, data.shot_sketch.total_count); + } else { + finalStep = '2'; + if (!data.character || !data.character.data || !data.character.data.length) { + loadingText = LOADING_TEXT_MAP.video(0, data.shot_sketch.data.length); } } } @@ -312,8 +368,8 @@ export function useWorkflowData() { for (const video of data.video.data) { // 每一项 video 有多个视频 先默认取第一个 videoList.push({ - url: video[0].qiniuVideoUrl, - script: video[0].operation.metadata.video.prompt, + url: video.urls[0], + script: video.description, audio: null, }); } diff --git a/components/ui/edit-modal.tsx b/components/ui/edit-modal.tsx index fa996cf..f99656c 100644 --- a/components/ui/edit-modal.tsx +++ b/components/ui/edit-modal.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, FileText, Users, Video, Music, Scissors, Settings } from 'lucide-react'; +import { X, Image, Users, Video, Music, Settings } from 'lucide-react'; import { cn } from '@/public/lib/utils'; import { ScriptTabContent } from './script-tab-content'; import { VideoTabContent } from './video-tab-content'; @@ -24,9 +24,9 @@ interface EditModalProps { } const tabs = [ - { id: '1', label: 'Script', icon: FileText }, + { id: '1', label: 'Shot Sketch', icon: Image }, { id: '2', label: 'Character', icon: Users }, - { id: '3', label: 'Sketch video', icon: Video }, + { id: '3', label: 'Shot video', icon: Video }, { id: '4', label: 'Music', icon: Music }, // { id: '5', label: '剪辑', icon: Scissors }, { id: 'settings', label: 'Settings', icon: Settings }, @@ -59,7 +59,12 @@ export function EditModal({ const isTabDisabled = (tabId: string) => { if (tabId === 'settings') return false; - return parseInt(tabId) > parseInt(taskStatus); + // 换成 如果对应标签下 数据存在 就不禁用 + if (tabId === '1') return taskSketch.length === 0; + if (tabId === '2') return roles.length === 0; + if (tabId === '3') return sketchVideo.length === 0; + if (tabId === '4') return false; + return false; }; const hanldeChangeSelect = (index: number) => { diff --git a/components/ui/generate-video-modal.tsx b/components/ui/generate-video-modal.tsx index b1e4b65..0eb87e7 100644 --- a/components/ui/generate-video-modal.tsx +++ b/components/ui/generate-video-modal.tsx @@ -59,7 +59,7 @@ export function GenerateVideoModal({ > -

生成视频

+

generate video

@@ -67,11 +67,11 @@ export function GenerateVideoModal({
{/* 文本输入区域 */}
- +