forked from 77media/video-flow
中文化
This commit is contained in:
parent
3019383c5e
commit
99b8c9b474
@ -84,7 +84,7 @@ export async function withQueuePolling<T>(
|
|||||||
const poll = async (): Promise<QueueResponse> => {
|
const poll = async (): Promise<QueueResponse> => {
|
||||||
try {
|
try {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
throw new Error('操作已取消');
|
throw new Error('Operation canceled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiCall(params);
|
const response = await apiCall(params);
|
||||||
@ -110,7 +110,7 @@ export async function withQueuePolling<T>(
|
|||||||
// 检查是否达到最大尝试次数
|
// 检查是否达到最大尝试次数
|
||||||
if (attempts >= maxAttempts) {
|
if (attempts >= maxAttempts) {
|
||||||
notification.destroy(); // 关闭通知
|
notification.destroy(); // 关闭通知
|
||||||
throw new Error('超过最大轮询次数限制');
|
throw new Error('Exceeded the maximum polling limit');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 继续轮询
|
// 继续轮询
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
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 = `[分析场景核心]: 一位舞者对<Annot type="circle">完美</Annot>的执念,<Annot type="highlight" color="#ef4444">使她的美丽舞蹈转化为一种自我毁灭的仪式</Annot>。
|
|
||||||
|
|
||||||
### <Annot type="highlight">第一幕:开端</Annot>
|
|
||||||
|
|
||||||
内景 · 舞蹈室 - 傍晚
|
|
||||||
|
|
||||||
夕阳透过窗户,阳光携带着飞舞的尘埃,划出一块金色矩形洒在斑驳的木地板上。墙面苍白、剥落。一整面落地镜映出这空旷房间的寂静。
|
|
||||||
|
|
||||||
<Annot type="box" color="#a855f7">ELARA</Annot>(20岁出头),身姿优雅至极,立于光影正中。她赤脚,穿着一件简单的黑色紧身舞衣,随着一段大提琴独奏起舞——旋律哀伤而高昂。
|
|
||||||
|
|
||||||
她的身体如流水般线条优雅。一只手臂伸展,手指在空气中描绘出无形的轨迹。她缓缓旋转,像是违抗了重力的引力。<Annot type="highlight" color="#84cc16">在这一刻,她是完美的化身。</Annot>
|
|
||||||
|
|
||||||
但她的双眼,始终盯着镜中的自己,突然微微眯起——<Annot type="highlight" color="#f59e0b">一丝自我厌弃的情绪一闪而过</Annot>。<Annot type="highlight" color="#f59e0b">那个旋转,不够纯净。不够完美。对她而言。</Annot>
|
|
||||||
|
|
||||||
她打断了舞姿。现在,她的呼吸变得可闻,如利刃般切入大提琴的温柔旋律。
|
|
||||||
|
|
||||||
她毫不迟疑地重新起舞。这一次,更快、更狠。<Annot type="highlight" color="#ef4444">舞姿仍美,却变得尖锐,带着绝望的锋芒。</Annot>
|
|
||||||
|
|
||||||
一滴汗水,从她的太阳穴滑落。
|
|
||||||
|
|
||||||
她再次旋转,又旋转。一个狂热、残酷的段落。她的脸不再沉静,而是绷紧的面具,咬牙切齿。音乐愈发高亢,但我们所听见的,只有她脚步在木地板上滑出的尖锐声响,以及她粗重、急促的喘息。
|
|
||||||
|
|
||||||
<Annot type="highlight" color="#ef4444">镜中的倒影已模糊成一道疯狂的涂抹。</Annot>她强迫自己再做一个旋转——那种超越极限的最后一圈。
|
|
||||||
|
|
||||||
<Annot type="highlight" color="#ef4444">她的脚踝崩溃了。</Annot>
|
|
||||||
|
|
||||||
不是咔嚓断裂的声响,而是一种沉默、恶心的扭曲。<Annot type="box" color="#a855f7">Elara</Annot> 倒在地上,如阳光外的一团残骸,四肢交错,失去光环。
|
|
||||||
|
|
||||||
大提琴继续演奏,平静、冷漠。
|
|
||||||
|
|
||||||
<Annot type="highlight" color="#06b6d4">她喉间发出一声哽咽般的呜咽,是房间中唯一的声音。</Annot>镜头穿过她起伏的肩膀,聚焦她的脚——足弓紧绷,脚趾青紫,地板上划出一丝血痕。
|
|
||||||
|
|
||||||
### <Annot type="highlight">导演与表演附录</Annot>
|
|
||||||
|
|
||||||
【<Annot type="highlight">导演风格解码</Annot>】
|
|
||||||
• 核心视觉基调: 高反差明暗对比,采用单一自然光源(窗户)。随着角色状态的恶化,色调从温暖金色逐渐转为冷峻的蓝灰。
|
|
||||||
• 关键镜头处理建议: 开始用宽阔流畅的镜头呼应舞者的优雅;当其内心破裂时,剪切到突兀的特写镜头:颤抖的大腿肌肉、颈侧汗珠、扭曲的表情与模糊的镜中倒影。最后一个镜头采用固定机位,锁定她瘫倒的身体,强化突然静止的冲击感。她的舞步原本是以光影为中心的循环,她的倒地打破了这一循环,使她落入阴影。
|
|
||||||
|
|
||||||
### <Annot type="highlight">核心表演关键</Annot>
|
|
||||||
|
|
||||||
• 角色肢体表现: 表演需呈现双重性——外在看似毫不费力的优雅与内在痛苦的微妙细节同在。崩溃时不应夸张,而应如过度拉紧的身体对重力的安静屈服。
|
|
||||||
• 潜台词驱动: 这是与"无形评审"——镜中自己——之间的战争。每一个动作都是抗议,每一个旋转是一种恳求,每一次喘息都是诅咒。<Annot type="highlight" color="#a855f7">核心动机是:"我要强迫这个不完美的身体创造一个完美的瞬间,即使这将摧毁我。"</Annot>
|
|
||||||
|
|
||||||
### <Annot type="highlight">当代表达的连接</Annot>
|
|
||||||
|
|
||||||
这个故事呼应了当代数字时代"精心营造的完美主义"现象,在理想化的自我形象背后,隐藏着焦虑与倦怠的真实生活。
|
|
||||||
`;
|
|
||||||
|
|
||||||
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' }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,8 +3,6 @@ import './globals.css';
|
|||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { Providers } from '@/components/providers';
|
import { Providers } from '@/components/providers';
|
||||||
import { ConfigProvider, theme } from 'antd';
|
import { ConfigProvider, theme } from 'antd';
|
||||||
import { createScreenAdapter } from '@/utils/tools';
|
|
||||||
import { ScreenAdapter } from './ScreenAdapter';
|
|
||||||
import CallbackModal from '@/components/common/CallbackModal';
|
import CallbackModal from '@/components/common/CallbackModal';
|
||||||
|
|
||||||
// 创建上下文来传递弹窗控制方法
|
// 创建上下文来传递弹窗控制方法
|
||||||
|
|||||||
@ -64,11 +64,11 @@ export const ProjectTypeMap = {
|
|||||||
export const ModeMap = {
|
export const ModeMap = {
|
||||||
[ModeEnum.MANUAL]: {
|
[ModeEnum.MANUAL]: {
|
||||||
value: "manual",
|
value: "manual",
|
||||||
label: "手动"
|
label: "Manual"
|
||||||
},
|
},
|
||||||
[ModeEnum.AUTOMATIC]: {
|
[ModeEnum.AUTOMATIC]: {
|
||||||
value: "automatic",
|
value: "automatic",
|
||||||
label: "自动"
|
label: "Automatic"
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -108,66 +108,6 @@ export const VideoDurationMap = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 工作流阶段映射
|
|
||||||
export const FlowStageMap = {
|
|
||||||
[FlowStageEnum.PREVIEW]: {
|
|
||||||
value: "preview",
|
|
||||||
label: "预览图列表分镜草图"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.CHARACTERS]: {
|
|
||||||
value: "characters",
|
|
||||||
label: "角色列表"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.SHOTS]: {
|
|
||||||
value: "shots",
|
|
||||||
label: "分镜视频列表"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.BGM]: {
|
|
||||||
value: "bgm",
|
|
||||||
label: "背景音乐列表"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.POST_PRODUCTION]: {
|
|
||||||
value: "post_production",
|
|
||||||
label: "后期操作中"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.MERGE_VIDEO]: {
|
|
||||||
value: "merge_video",
|
|
||||||
label: "合并视频完成"
|
|
||||||
},
|
|
||||||
[FlowStageEnum.ERROR]: {
|
|
||||||
value: "error",
|
|
||||||
label: "出错"
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 任务状态映射
|
|
||||||
export const TaskStatusMap = {
|
|
||||||
[TaskStatusEnum.PENDING]: {
|
|
||||||
value: "pending",
|
|
||||||
label: "待处理"
|
|
||||||
},
|
|
||||||
[TaskStatusEnum.PROCESSING]: {
|
|
||||||
value: "processing",
|
|
||||||
label: "处理中"
|
|
||||||
},
|
|
||||||
[TaskStatusEnum.COMPLETED]: {
|
|
||||||
value: "completed",
|
|
||||||
label: "已完成"
|
|
||||||
},
|
|
||||||
[TaskStatusEnum.FAILED]: {
|
|
||||||
value: "failed",
|
|
||||||
label: "失败"
|
|
||||||
},
|
|
||||||
[TaskStatusEnum.RETRYING]: {
|
|
||||||
value: "retrying",
|
|
||||||
label: "重试中"
|
|
||||||
},
|
|
||||||
[TaskStatusEnum.CANCELLED]: {
|
|
||||||
value: "cancelled",
|
|
||||||
label: "已取消"
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 分镜脚本编辑器类型定义
|
// 分镜脚本编辑器类型定义
|
||||||
export interface StoryboardCard {
|
export interface StoryboardCard {
|
||||||
id: string;
|
id: string;
|
||||||
@ -221,39 +161,3 @@ export interface CharacterOption {
|
|||||||
age: string;
|
age: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock 数据
|
|
||||||
export const mockSceneOptions: SceneOption[] = [
|
|
||||||
{ sceneId: '1', name: '暮色森林', image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockCharacterOptions: CharacterOption[] = [
|
|
||||||
{ characterId: '1', name: '艾琳', image: 'https://c.huiying.video/images/32f6b07c-bceb-4b63-8a13-4749703ab08d.jpg', gender: '女', age: '24', description: '银发女剑士' },
|
|
||||||
{ characterId: '2', name: '影子猎手', image: 'https://c.huiying.video/images/97c6c59a-50cc-4159-aacd-94ab9d208150.jpg', gender: '男', age: '35', description: '神秘追踪者' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockStoryboards: StoryboardCard[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
shotId: 'SC-01',
|
|
||||||
scene: mockSceneOptions[0],
|
|
||||||
characters: [mockCharacterOptions[0], mockCharacterOptions[1]],
|
|
||||||
description: '艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现艾琳警惕地穿过森林,影子猎手的身影若隐若现',
|
|
||||||
shotType: '中景',
|
|
||||||
cameraMove: '缓慢推进',
|
|
||||||
dialogues: [
|
|
||||||
{ id: 'd1', speaker: '艾琳', text: '我们必须在 #影子猎手# 到来前穿过 [暮色森林]。' },
|
|
||||||
{ id: 'd2', speaker: '影子猎手', text: '你以为能逃出这里?' },
|
|
||||||
],
|
|
||||||
notes: '镜头缓慢推进至人物背影',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const characterInfoMap: Record<string, CharacterInfo> = {
|
|
||||||
'艾琳': { avatar: 'https://c.huiying.video/images/32f6b07c-bceb-4b63-8a13-4749703ab08d.jpg', gender: '女', age: '24', description: '银发女剑士' },
|
|
||||||
'影子猎手': { avatar: 'https://c.huiying.video/images/97c6c59a-50cc-4159-aacd-94ab9d208150.jpg', gender: '男', age: '35', description: '神秘追踪者' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sceneInfoMap: Record<string, SceneInfo> = {
|
|
||||||
'暮色森林': { image: 'https://c.huiying.video/images/7fd3f2d6-840a-46ac-a97d-d3d1b37ec4e0.jpg', location: '西境边陲', time: '傍晚' },
|
|
||||||
};
|
|
||||||
|
|||||||
@ -80,16 +80,16 @@ export default function PaymentSuccessPage() {
|
|||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<Loader2 className="w-16 h-16 text-blue-500 animate-spin" />
|
<Loader2 className="w-16 h-16 text-blue-500 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle>处理中...</CardTitle>
|
<CardTitle>Processing...</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
正在确认您的支付,请稍候
|
Confirming your payment, please wait
|
||||||
<br />
|
<br />
|
||||||
尝试次数: {attempts}/30
|
Attempts: {attempts}/30
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-gray-600 text-center">
|
<p className="text-sm text-gray-600 text-center">
|
||||||
请不要关闭此页面,我们正在处理您的订阅
|
Please do not close this page, we are processing your subscription
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -102,32 +102,32 @@ export default function PaymentSuccessPage() {
|
|||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-green-600">支付成功!</CardTitle>
|
<CardTitle className="text-green-600">Payment successful!</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
您的订阅已激活
|
Your subscription has been activated
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{paymentData?.subscription && (
|
{paymentData?.subscription && (
|
||||||
<div className="bg-green-50 p-4 rounded-lg">
|
<div className="bg-green-50 p-4 rounded-lg">
|
||||||
<h3 className="font-semibold text-green-800">
|
<h3 className="font-semibold text-green-800">
|
||||||
{paymentData.subscription.plan_display_name} 套餐
|
{paymentData.subscription.plan_display_name} Plan
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-green-600">
|
||||||
状态: {paymentData.subscription.status}
|
Status: {paymentData.subscription.status}
|
||||||
</p>
|
</p>
|
||||||
{paymentData.subscription.current_period_end && (
|
{paymentData.subscription.current_period_end && (
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-green-600">
|
||||||
有效期至: {new Date(paymentData.subscription.current_period_end).toLocaleDateString()}
|
Valid until: {new Date(paymentData.subscription.current_period_end).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
<p>订单号: {paymentData?.biz_order_no}</p>
|
<p>Order number: {paymentData?.biz_order_no}</p>
|
||||||
{paymentData?.pay_time && (
|
{paymentData?.pay_time && (
|
||||||
<p>支付时间: {new Date(paymentData.pay_time).toLocaleString()}</p>
|
<p>Payment time: {new Date(paymentData.pay_time).toLocaleString()}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,14 +136,14 @@ export default function PaymentSuccessPage() {
|
|||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
前往控制台
|
Go to dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/'}
|
onClick={() => window.location.href = '/'}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
返回首页
|
Go to home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -157,14 +157,14 @@ export default function PaymentSuccessPage() {
|
|||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<XCircle className="w-16 h-16 text-red-500" />
|
<XCircle className="w-16 h-16 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-red-600">支付失败</CardTitle>
|
<CardTitle className="text-red-600">Payment failed</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
很抱歉,您的支付未能成功完成
|
Sorry, your payment was not successful
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-gray-600 text-center">
|
<p className="text-sm text-gray-600 text-center">
|
||||||
请检查您的支付信息或稍后重试
|
Please check your payment information or try again later
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -172,14 +172,14 @@ export default function PaymentSuccessPage() {
|
|||||||
onClick={() => window.location.href = '/pricing'}
|
onClick={() => window.location.href = '/pricing'}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
重新选择套餐
|
Re-select plan
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/'}
|
onClick={() => window.location.href = '/'}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
返回首页
|
Go to home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -193,14 +193,14 @@ export default function PaymentSuccessPage() {
|
|||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<Loader2 className="w-16 h-16 text-yellow-500" />
|
<Loader2 className="w-16 h-16 text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-yellow-600">处理中</CardTitle>
|
<CardTitle className="text-yellow-600">Processing</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
支付正在处理中,请稍后查看
|
Payment is being processed, please check later
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-gray-600 text-center">
|
<p className="text-sm text-gray-600 text-center">
|
||||||
您的支付可能仍在处理中,请稍后检查您的订阅状态
|
Your payment may still be processing, please check your subscription status later
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -208,14 +208,14 @@ export default function PaymentSuccessPage() {
|
|||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
刷新页面
|
Refresh page
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
查看订阅状态
|
Check subscription status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -80,7 +80,7 @@ function HomeModule5() {
|
|||||||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||||||
|
|
||||||
if (!User.id) {
|
if (!User.id) {
|
||||||
throw new Error("无法获取用户ID,请重新登录");
|
throw new Error("Unable to obtain user ID, please log in again");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 创建Checkout Session
|
// 1. 创建Checkout Session
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export class VideoSegmentEntityAdapter {
|
|||||||
|
|
||||||
// 如果没有任何镜头但有narrative_goal,将其作为镜头1
|
// 如果没有任何镜头但有narrative_goal,将其作为镜头1
|
||||||
if (lens.length === 0 && result.narrative_goal) {
|
if (lens.length === 0 && result.narrative_goal) {
|
||||||
const narrativeLens = new LensType("镜头1", result.narrative_goal, []);
|
const narrativeLens = new LensType("shot_1", result.narrative_goal, []);
|
||||||
lens.push(narrativeLens);
|
lens.push(narrativeLens);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +159,7 @@ export class VideoSegmentEntityAdapter {
|
|||||||
// 创建VideoSegmentEntity
|
// 创建VideoSegmentEntity
|
||||||
const entity: VideoSegmentEntity = {
|
const entity: VideoSegmentEntity = {
|
||||||
id: `video_mock_${index}`, // 生成临时ID,包含索引
|
id: `video_mock_${index}`, // 生成临时ID,包含索引
|
||||||
name: `视频片段_${index}`, // 生成临时名称,包含索引
|
name: `video_${index}`, // 生成临时名称,包含索引
|
||||||
sketchUrl: "", // 后端数据中没有sketchUrl,设为空字符串
|
sketchUrl: "", // 后端数据中没有sketchUrl,设为空字符串
|
||||||
videoUrl: result.videos,
|
videoUrl: result.videos,
|
||||||
status: status,
|
status: status,
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
import { notification } from 'antd';
|
|
||||||
|
|
||||||
const darkGlassStyle = {
|
|
||||||
background: 'rgba(30, 32, 40, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
WebkitBackdropFilter: 'blur(10px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 胶片容器样式 */
|
|
||||||
const filmStripContainerStyle = {
|
|
||||||
position: 'relative' as const,
|
|
||||||
width: '100%',
|
|
||||||
height: '80px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 胶片样式 */
|
|
||||||
const filmStripStyle = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
animation: 'filmScroll 20s linear infinite',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 文字样式 */
|
|
||||||
const textStyle = {
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 胶片帧组件 */
|
|
||||||
const FilmFrame = () => (
|
|
||||||
<div style={{ margin: '0 4px' }}>
|
|
||||||
<svg width="60" height="80" viewBox="0 0 60 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{/* 胶片外框 */}
|
|
||||||
<rect x="0" y="0" width="60" height="80" fill="#1A1B1E" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
{/* 齿孔 */}
|
|
||||||
<circle cx="10" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
<circle cx="50" cy="5" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
<circle cx="10" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
<circle cx="50" cy="75" r="3" fill="none" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
{/* 胶片画面区域 */}
|
|
||||||
<rect x="5" y="15" width="50" height="50" fill="#2A2B2E" stroke="#F6B266" strokeWidth="1"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 放映机音效组件 */
|
|
||||||
const ProjectorSound = () => (
|
|
||||||
<audio
|
|
||||||
src="/assets/audio/projector.mp3"
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示队列等待通知
|
|
||||||
* @param position - 当前队列位置
|
|
||||||
* @param estimatedMinutes - 预计等待分钟数
|
|
||||||
*/
|
|
||||||
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
|
|
||||||
notification.open({
|
|
||||||
message: null,
|
|
||||||
description: (
|
|
||||||
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
|
|
||||||
{/* 胶片动画区域 */}
|
|
||||||
<div style={filmStripContainerStyle}>
|
|
||||||
<div style={filmStripStyle}>
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<FilmFrame key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={filmStripStyle} className="film-strip-2">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<FilmFrame key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 队列信息 */}
|
|
||||||
<div style={textStyle}>
|
|
||||||
<span style={{ marginRight: '8px' }}>🎬</span>
|
|
||||||
您的作品正在第 {position} 位等待制作
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预计等待时间 */}
|
|
||||||
<div style={{ ...textStyle, color: 'rgba(255, 255, 255, 0.65)' }}>
|
|
||||||
预计等待时间:约 {estimatedMinutes} 分钟
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 取消按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => notification.destroy()}
|
|
||||||
style={{
|
|
||||||
color: 'rgb(250 173 20 / 90%)',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: '2px',
|
|
||||||
textDecorationColor: 'rgb(250 173 20 / 60%)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
data-alt="cancel-queue-button"
|
|
||||||
>
|
|
||||||
取消制作 →
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 放映机音效 */}
|
|
||||||
<ProjectorSound />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
duration: 0,
|
|
||||||
placement: 'topRight',
|
|
||||||
style: {
|
|
||||||
...darkGlassStyle,
|
|
||||||
border: '1px solid rgba(246, 178, 102, 0.2)',
|
|
||||||
},
|
|
||||||
className: 'movie-queue-notification',
|
|
||||||
closeIcon: (
|
|
||||||
<button
|
|
||||||
className="hover:text-white"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
padding: '2px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'rgba(255, 255, 255, 0.45)',
|
|
||||||
transition: 'color 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加必要的CSS动画
|
|
||||||
const styles = `
|
|
||||||
@keyframes filmScroll {
|
|
||||||
0% { transform: translateX(0); }
|
|
||||||
100% { transform: translateX(-360px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.film-strip-2 {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-queue-notification {
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 将样式注入到页面
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const styleSheet = document.createElement('style');
|
|
||||||
styleSheet.textContent = styles;
|
|
||||||
document.head.appendChild(styleSheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置通知
|
|
||||||
notification.config({
|
|
||||||
maxCount: 3,
|
|
||||||
});
|
|
||||||
@ -123,7 +123,7 @@ export const showQueueNotification = (
|
|||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ marginRight: '8px' }}>🎬</span>
|
<span style={{ marginRight: '8px' }}>🎬</span>
|
||||||
{status === 'process' ? `您的作品正在制作,请等待完成后再创建新作品` : `您的作品正在第 ${position} 位等待制作`}
|
{status === 'process' ? `Your work is being produced. Please wait until it is completed before creating a new work.` : `Your work is waiting for production at the ${position} position`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 预计等待时间 */}
|
{/* 预计等待时间 */}
|
||||||
@ -132,7 +132,7 @@ export const showQueueNotification = (
|
|||||||
color: 'rgba(255, 255, 255, 0.65)',
|
color: 'rgba(255, 255, 255, 0.65)',
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
}}>
|
}}>
|
||||||
{status !== 'process' && `预计等待时间:约 ${estimatedMinutes} 分钟`}
|
{status !== 'process' && `Estimated waiting time: about ${estimatedMinutes} minutes`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 取消按钮 */}
|
{/* 取消按钮 */}
|
||||||
@ -156,7 +156,7 @@ export const showQueueNotification = (
|
|||||||
}}
|
}}
|
||||||
data-alt="cancel-queue-button"
|
data-alt="cancel-queue-button"
|
||||||
>
|
>
|
||||||
取消排队 →
|
Cancel queue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,209 +0,0 @@
|
|||||||
import { notification } from 'antd';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
type NotificationType = 'success' | 'info' | 'warning' | 'error';
|
|
||||||
|
|
||||||
const darkGlassStyle = {
|
|
||||||
background: 'rgba(30, 32, 40, 0.95)',
|
|
||||||
backdropFilter: 'blur(10px)',
|
|
||||||
WebkitBackdropFilter: 'blur(10px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
|
||||||
padding: '12px 16px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageStyle = {
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 500,
|
|
||||||
color: '#ffffff',
|
|
||||||
marginBottom: '6px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconStyle = {
|
|
||||||
color: '#F6B266', // 警告图标颜色
|
|
||||||
background: 'rgba(246, 178, 102, 0.15)',
|
|
||||||
padding: '4px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
};
|
|
||||||
|
|
||||||
const descriptionStyle = {
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'rgba(255, 255, 255, 0.65)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const btnStyle = {
|
|
||||||
color: 'rgb(250 173 20 / 90%)',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: 'underline',
|
|
||||||
textUnderlineOffset: '2px',
|
|
||||||
textDecorationColor: 'rgb(250 173 20 / 60%)',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 场记板动画样式 */
|
|
||||||
const clapperboardStyle = {
|
|
||||||
position: 'relative' as const,
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
marginRight: '12px',
|
|
||||||
animation: 'clap 2s infinite',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 场记板文字样式 */
|
|
||||||
const sceneTextStyle = {
|
|
||||||
fontSize: '14px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: '#F6B266',
|
|
||||||
marginBottom: '8px',
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 队列信息样式 */
|
|
||||||
const queueInfoStyle = {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: '13px',
|
|
||||||
color: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
marginBottom: '12px',
|
|
||||||
background: 'rgba(246, 178, 102, 0.1)',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 场记板SVG组件
|
|
||||||
*/
|
|
||||||
const Clapperboard = () => (
|
|
||||||
<div style={clapperboardStyle}>
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M4 4H20V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V4Z"
|
|
||||||
stroke="#F6B266" strokeWidth="1.5"/>
|
|
||||||
<path d="M4 8H20" stroke="#F6B266" strokeWidth="1.5"/>
|
|
||||||
<path d="M9 4L11 8M15 4L17 8" stroke="#F6B266" strokeWidth="1.5"/>
|
|
||||||
<path className="clap-top" d="M4 4L20 4L17 8L4 8L4 4Z"
|
|
||||||
fill="rgba(246, 178, 102, 0.2)" stroke="#F6B266" strokeWidth="1.5"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示队列等待通知
|
|
||||||
* @param position - 当前队列位置
|
|
||||||
* @param estimatedMinutes - 预计等待分钟数
|
|
||||||
*/
|
|
||||||
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
|
|
||||||
// 生成场景号和镜次号
|
|
||||||
const sceneNumber = Math.floor(Math.random() * 5) + 1;
|
|
||||||
const takeNumber = Math.floor(Math.random() * 3) + 1;
|
|
||||||
|
|
||||||
notification.open({
|
|
||||||
message: null,
|
|
||||||
description: (
|
|
||||||
<div data-alt="queue-notification" style={{ minWidth: '320px' }}>
|
|
||||||
{/* 场记板和场景信息 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
|
|
||||||
<Clapperboard />
|
|
||||||
<div>
|
|
||||||
<div style={sceneTextStyle}>
|
|
||||||
Scene {sceneNumber} - Take {takeNumber}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.6)' }}>
|
|
||||||
AI Director's Cut
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 队列信息 */}
|
|
||||||
<div style={queueInfoStyle}>
|
|
||||||
<span style={{ marginRight: '8px' }}>🎬</span>
|
|
||||||
您的作品正在第 {position} 位等待制作
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预计等待时间 */}
|
|
||||||
<div style={descriptionStyle}>
|
|
||||||
预计等待时间:约 {estimatedMinutes} 分钟
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 取消按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => notification.destroy()}
|
|
||||||
style={{
|
|
||||||
...btnStyle,
|
|
||||||
marginTop: '8px',
|
|
||||||
}}
|
|
||||||
data-alt="cancel-queue-button"
|
|
||||||
>
|
|
||||||
取消制作 →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
duration: 0, // 保持通知直到用户关闭
|
|
||||||
placement: 'topRight',
|
|
||||||
style: {
|
|
||||||
...darkGlassStyle,
|
|
||||||
border: '1px solid rgba(246, 178, 102, 0.2)',
|
|
||||||
},
|
|
||||||
className: 'movie-queue-notification',
|
|
||||||
closeIcon: (
|
|
||||||
<button
|
|
||||||
className="hover:text-white"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
padding: '2px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'rgba(255, 255, 255, 0.45)',
|
|
||||||
transition: 'color 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加必要的CSS动画
|
|
||||||
const styles = `
|
|
||||||
@keyframes clap {
|
|
||||||
0%, 100% { transform: rotate(0deg); }
|
|
||||||
5% { transform: rotate(-15deg); }
|
|
||||||
10% { transform: rotate(0deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.movie-queue-notification {
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 将样式注入到页面
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
const styleSheet = document.createElement('style');
|
|
||||||
styleSheet.textContent = styles;
|
|
||||||
document.head.appendChild(styleSheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保持现有的notification配置
|
|
||||||
notification.config({
|
|
||||||
maxCount: 3,
|
|
||||||
});
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Sparkles, Send, X, Lightbulb, ChevronUp } from 'lucide-react';
|
|
||||||
import { GlassIconButton } from "@/components/ui/glass-icon-button";
|
|
||||||
|
|
||||||
interface AISuggestionBarProps {
|
|
||||||
suggestions: string[];
|
|
||||||
onSuggestionClick: (suggestion: string) => void;
|
|
||||||
onSubmit: (text: string) => void;
|
|
||||||
onClick: () => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AISuggestionBar({
|
|
||||||
suggestions,
|
|
||||||
onSuggestionClick,
|
|
||||||
onSubmit,
|
|
||||||
onClick,
|
|
||||||
placeholder = "输入你的想法,或点击预设词条获取 AI 建议..."
|
|
||||||
}: AISuggestionBarProps) {
|
|
||||||
const [inputText, setInputText] = useState('');
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// 自动调整输入框高度
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.style.height = 'auto';
|
|
||||||
inputRef.current.style.height = `${inputRef.current.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
}, [inputText]);
|
|
||||||
|
|
||||||
// 处理提交
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (inputText.trim()) {
|
|
||||||
onSubmit(inputText.trim());
|
|
||||||
setInputText('');
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.style.height = 'auto';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理回车提交
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换折叠状态
|
|
||||||
const toggleCollapse = () => {
|
|
||||||
setIsCollapsed(!isCollapsed);
|
|
||||||
if (isCollapsed) {
|
|
||||||
// 展开时自动显示建议
|
|
||||||
setShowSuggestions(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
y: isCollapsed ? 'calc(100% - 10px)' : 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 30
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 bg-gradient-to-t from-[#0C0E11] via-[#0C0E11] to-transparent pb-8"
|
|
||||||
>
|
|
||||||
{/* 折叠/展开按钮 */}
|
|
||||||
<div className="absolute -top-[1rem] left-1/2 -translate-x-1/2" style={{ zIndex: 9 }}>
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: isCollapsed ? 180 : 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
|
||||||
>
|
|
||||||
<GlassIconButton
|
|
||||||
icon={ChevronUp}
|
|
||||||
size='sm'
|
|
||||||
tooltip={isCollapsed ? "展开" : "收起"}
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-6" onClick={onClick}>
|
|
||||||
{/* 智能预设词条 英文 */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showSuggestions && !isCollapsed && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{
|
|
||||||
height: { type: "spring", stiffness: 300, damping: 30 },
|
|
||||||
opacity: { duration: 0.2 }
|
|
||||||
}}
|
|
||||||
className="mb-2 pt-4 px-4 overflow-hidden bg-black/40 rounded-xl backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="flex items-center gap-3 mb-3"
|
|
||||||
initial={{ x: -20, opacity: 0 }}
|
|
||||||
animate={{ x: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
rotate: [0, 15, -15, 0],
|
|
||||||
scale: [1, 1.2, 1.2, 1]
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1,
|
|
||||||
repeat: Infinity,
|
|
||||||
repeatDelay: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
|
||||||
</motion.div>
|
|
||||||
<span className="text-sm text-white/60">Smart preset tags</span>
|
|
||||||
</motion.div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{suggestions.map((suggestion, index) => (
|
|
||||||
<motion.button
|
|
||||||
key={suggestion}
|
|
||||||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
delay: index * 0.1,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 25
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
whileHover={{
|
|
||||||
scale: 1.05,
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.15)"
|
|
||||||
}}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
className="px-3 py-1.5 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm
|
|
||||||
text-sm text-white/70 hover:text-white transition-colors flex items-center gap-2"
|
|
||||||
onClick={() => onSuggestionClick(suggestion)}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3 h-3" />
|
|
||||||
{suggestion}
|
|
||||||
</motion.button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 输入区域 */}
|
|
||||||
<motion.div
|
|
||||||
className={`
|
|
||||||
relative rounded-xl bg-white/5 backdrop-blur-sm transition-all duration-300
|
|
||||||
${isFocused ? 'ring-2 ring-blue-500/50 bg-white/10' : 'hover:bg-white/[0.07]'}
|
|
||||||
${isCollapsed ? 'opacity-50 hover:opacity-100' : ''}
|
|
||||||
`}
|
|
||||||
whileHover={isCollapsed ? { scale: 1.02 } : {}}
|
|
||||||
onClick={() => isCollapsed && toggleCollapse()}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={inputRef}
|
|
||||||
value={inputText}
|
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => {
|
|
||||||
setIsFocused(true);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
if (isCollapsed) {
|
|
||||||
toggleCollapse();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setIsFocused(false);
|
|
||||||
setShowSuggestions(!showSuggestions)
|
|
||||||
}}
|
|
||||||
placeholder={isCollapsed ? "点击展开..." : placeholder}
|
|
||||||
className="w-full resize-none bg-transparent border-none px-4 py-3 text-white placeholder:text-white/40
|
|
||||||
focus:outline-none min-h-[52px] max-h-[150px] pr-[100px]"
|
|
||||||
rows={1}
|
|
||||||
disabled={isCollapsed}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="absolute right-2 bottom-2 flex items-center gap-2">
|
|
||||||
<motion.button
|
|
||||||
className={`
|
|
||||||
p-2 rounded-lg transition-colors
|
|
||||||
${showSuggestions ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'}
|
|
||||||
`}
|
|
||||||
onClick={() => !isCollapsed && setShowSuggestions(!showSuggestions)}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
disabled={isCollapsed}
|
|
||||||
>
|
|
||||||
{showSuggestions ? <X className="w-5 h-5" /> : <Sparkles className="w-5 h-5" />}
|
|
||||||
</motion.button>
|
|
||||||
<motion.button
|
|
||||||
className={`
|
|
||||||
p-2 rounded-lg transition-colors
|
|
||||||
${inputText.trim() ? 'bg-blue-500 text-white' : 'bg-white/5 text-white/20'}
|
|
||||||
`}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!inputText.trim() || isCollapsed}
|
|
||||||
whileHover={inputText.trim() ? { scale: 1.1 } : {}}
|
|
||||||
whileTap={inputText.trim() ? { scale: 0.9 } : {}}
|
|
||||||
>
|
|
||||||
<Send className="w-5 h-5" />
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { motion, useAnimation, useMotionValue, useTransform, useSpring } from 'framer-motion';
|
|
||||||
import { ChevronLeft, ChevronRight, Upload, BookOpen, Brain, Users, Film, Flag } from 'lucide-react';
|
|
||||||
|
|
||||||
// 定义步骤数据结构
|
|
||||||
interface Step {
|
|
||||||
id: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 步骤配置
|
|
||||||
const STEPS: Step[] = [
|
|
||||||
{
|
|
||||||
id: 'overview',
|
|
||||||
icon: <BookOpen className="w-6 h-6" />,
|
|
||||||
title: '剧本大纲',
|
|
||||||
subtitle: 'Script Overview',
|
|
||||||
description: '提取剧本结构和关键要素'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'storyboard',
|
|
||||||
icon: <Brain className="w-6 h-6" />,
|
|
||||||
title: '分镜草图',
|
|
||||||
subtitle: 'Storyboard',
|
|
||||||
description: '可视化场景设计和转场'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'character',
|
|
||||||
icon: <Users className="w-6 h-6" />,
|
|
||||||
title: '演员角色',
|
|
||||||
subtitle: 'Character Design',
|
|
||||||
description: '定制角色形象和个性'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'post',
|
|
||||||
icon: <Film className="w-6 h-6" />,
|
|
||||||
title: '后期制作',
|
|
||||||
subtitle: 'Post Production',
|
|
||||||
description: '音效配乐和特效处理'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'output',
|
|
||||||
icon: <Flag className="w-6 h-6" />,
|
|
||||||
title: '最终成品',
|
|
||||||
subtitle: 'Final Output',
|
|
||||||
description: '预览和导出作品'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
interface FilmstripStepperProps {
|
|
||||||
currentStep: string;
|
|
||||||
onStepChange: (stepId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FilmstripStepper({ currentStep, onStepChange }: FilmstripStepperProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const controls = useAnimation();
|
|
||||||
|
|
||||||
// 滚动位置状态
|
|
||||||
const x = useMotionValue(0);
|
|
||||||
const springX = useSpring(x, { stiffness: 300, damping: 30 });
|
|
||||||
|
|
||||||
// 处理滚动边界
|
|
||||||
useEffect(() => {
|
|
||||||
if (!containerRef.current || !scrollRef.current) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const scroll = scrollRef.current;
|
|
||||||
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
|
|
||||||
|
|
||||||
x.set(Math.max(Math.min(x.get(), 0), maxScroll));
|
|
||||||
}, [x]);
|
|
||||||
|
|
||||||
// 处理拖拽结束
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
if (!containerRef.current || !scrollRef.current) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const scroll = scrollRef.current;
|
|
||||||
const maxScroll = -(scroll.scrollWidth - container.clientWidth);
|
|
||||||
|
|
||||||
// 确保不会过度滚动
|
|
||||||
if (x.get() > 0) {
|
|
||||||
controls.start({ x: 0 });
|
|
||||||
} else if (x.get() < maxScroll) {
|
|
||||||
controls.start({ x: maxScroll });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 滚动到指定步骤
|
|
||||||
const scrollToStep = (stepId: string) => {
|
|
||||||
if (!containerRef.current || !scrollRef.current) return;
|
|
||||||
|
|
||||||
const stepElement = document.getElementById(`step-${stepId}`);
|
|
||||||
if (!stepElement) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const stepLeft = stepElement.offsetLeft;
|
|
||||||
const stepWidth = stepElement.offsetWidth;
|
|
||||||
const containerWidth = container.clientWidth;
|
|
||||||
const targetX = -(stepLeft - (containerWidth - stepWidth) / 2);
|
|
||||||
|
|
||||||
controls.start({ x: targetX });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full">
|
|
||||||
{/* 滚动箭头 */}
|
|
||||||
<button
|
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
|
|
||||||
onClick={() => controls.start({ x: x.get() + 300 })}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-white/5 hover:bg-white/10 backdrop-blur-sm flex items-center justify-center transition-colors"
|
|
||||||
onClick={() => controls.start({ x: x.get() - 300 })}
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 胶片容器 */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="w-full overflow-hidden px-20"
|
|
||||||
style={{ perspective: '1000px' }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
ref={scrollRef}
|
|
||||||
drag="x"
|
|
||||||
dragConstraints={containerRef}
|
|
||||||
dragElastic={0.1}
|
|
||||||
onDragStart={() => setIsDragging(true)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
animate={controls}
|
|
||||||
style={{ x: springX }}
|
|
||||||
className="flex gap-6 px-4 py-8"
|
|
||||||
>
|
|
||||||
{STEPS.map((step, index) => {
|
|
||||||
const isActive = currentStep === step.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={step.id}
|
|
||||||
id={`step-${step.id}`}
|
|
||||||
className={`
|
|
||||||
relative flex-shrink-0 w-64 h-40 rounded-lg cursor-pointer
|
|
||||||
${isActive ? 'z-10' : 'opacity-70'}
|
|
||||||
`}
|
|
||||||
whileHover={{ scale: 1.05, y: -5 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
animate={isActive ? {
|
|
||||||
scale: 1.1,
|
|
||||||
y: -10,
|
|
||||||
transition: { type: 'spring', stiffness: 300, damping: 25 }
|
|
||||||
} : {
|
|
||||||
scale: 1,
|
|
||||||
y: 0
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isDragging) {
|
|
||||||
onStepChange(step.id);
|
|
||||||
scrollToStep(step.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 胶片打孔效果 */}
|
|
||||||
<div className="absolute -left-2 top-1/2 -translate-y-1/2 space-y-4">
|
|
||||||
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
||||||
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
||||||
</div>
|
|
||||||
<div className="absolute -right-2 top-1/2 -translate-y-1/2 space-y-4">
|
|
||||||
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
||||||
<div className="w-4 h-4 rounded-full bg-black/20 backdrop-blur-sm border border-white/10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 卡片内容 */}
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
relative w-full h-full rounded-lg p-4 overflow-hidden
|
|
||||||
bg-gradient-to-br from-white/10 to-white/5
|
|
||||||
${isActive ? 'ring-2 ring-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.5)]' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-black/20 backdrop-blur-[2px]" />
|
|
||||||
<div className="relative z-10 flex flex-col h-full">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className={`
|
|
||||||
p-2 rounded-lg
|
|
||||||
${isActive ? 'bg-blue-500/20 text-blue-400' : 'bg-white/10 text-white/60'}
|
|
||||||
`}>
|
|
||||||
{step.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium">{step.title}</h3>
|
|
||||||
<p className="text-xs text-white/50">{step.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-white/70">{step.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 步骤序号 */}
|
|
||||||
<div className={`
|
|
||||||
absolute -top-3 -right-3 w-8 h-8 rounded-full
|
|
||||||
flex items-center justify-center text-sm font-medium
|
|
||||||
${isActive ? 'bg-blue-500 text-white' : 'bg-white/10 text-white/60'}
|
|
||||||
`}>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -13,9 +13,9 @@ export const CharacterToken = Node.create({
|
|||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
id: { default: null },
|
id: { default: null },
|
||||||
name: { default: '角色名' },
|
name: { default: 'Role name' },
|
||||||
avatar: { default: '' },
|
avatar: { default: '' },
|
||||||
gender: { default: '未知' },
|
gender: { default: 'Unknown' },
|
||||||
age: { default: '-' },
|
age: { default: '-' },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -39,8 +39,8 @@ function CharacterView(props: ReactNodeViewProps) {
|
|||||||
const { name, avatar, gender, age } = node.attrs
|
const { name, avatar, gender, age } = node.attrs
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
console.log('点击角色:', name)
|
console.log('Click role:', name)
|
||||||
alert(`点击角色:${name}`)
|
alert(`Click role: ${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function CharacterLibrarySelector({
|
|||||||
onClose={() => setIsReplaceLibraryOpen(false)}
|
onClose={() => setIsReplaceLibraryOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center h-64 text-white/50">
|
<div className="flex items-center justify-center h-64 text-white/50">
|
||||||
<p>暂无角色库数据</p>
|
<p>No character library data</p>
|
||||||
</div>
|
</div>
|
||||||
</FloatingGlassPanel>
|
</FloatingGlassPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -242,7 +242,7 @@ CharacterTabContentProps
|
|||||||
|
|
||||||
// 检查文件类型
|
// 检查文件类型
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
alert('请选择图片文件');
|
alert('Please select an image file');
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -427,7 +427,7 @@ CharacterTabContentProps
|
|||||||
<div className="flex flex-col items-center gap-4 text-white py-4">
|
<div className="flex flex-col items-center gap-4 text-white py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<TriangleAlert className="w-6 h-6 text-yellow-400" />
|
<TriangleAlert className="w-6 h-6 text-yellow-400" />
|
||||||
<p className="text-lg font-medium">角色已修改,不替换就会丢失修改,是否需要替换?</p>
|
<p className="text-lg font-medium">The role has been modified, if you do not replace it, the modification will be lost, do you need to replace it?</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 mt-2">
|
<div className="flex gap-3 mt-2">
|
||||||
@ -437,7 +437,7 @@ CharacterTabContentProps
|
|||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md transition-colors duration-200 flex items-center gap-2"
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md transition-colors duration-200 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ReplaceAll className="w-4 h-4" />
|
<ReplaceAll className="w-4 h-4" />
|
||||||
去替换
|
Go to replace
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -446,7 +446,7 @@ CharacterTabContentProps
|
|||||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md transition-colors duration-200 flex items-center gap-2"
|
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md transition-colors duration-200 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
忽略
|
Ignore
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const tabs = [
|
|||||||
{ id: '3', label: 'Video', icon: Video },
|
{ id: '3', label: 'Video', icon: Video },
|
||||||
{ id: '4', label: 'Music', icon: Music },
|
{ id: '4', label: 'Music', icon: Music },
|
||||||
// { id: '5', label: '剪辑', icon: Scissors },
|
// { id: '5', label: '剪辑', icon: Scissors },
|
||||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
// { id: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function EditModal({
|
export function EditModal({
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export const ScriptTabContent = forwardRef<
|
|||||||
<div className="flex flex-col items-center gap-4 text-white py-4">
|
<div className="flex flex-col items-center gap-4 text-white py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<TriangleAlert className="w-6 h-6 text-yellow-400" />
|
<TriangleAlert className="w-6 h-6 text-yellow-400" />
|
||||||
<p className="text-lg font-medium">剧本内容已修改,是否需要应用?</p>
|
<p className="text-lg font-medium">The script content has been modified. Do I need to apply it?</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 mt-2">
|
<div className="flex gap-3 mt-2">
|
||||||
|
|||||||
@ -1,317 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { ArrowLeft, ArrowRight, Play, Pause, Music, Upload, Wand2, Volume2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface AddMusicStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockChapters = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Introduction',
|
|
||||||
duration: 45,
|
|
||||||
music: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Uplifting Corporate',
|
|
||||||
genre: 'Corporate',
|
|
||||||
duration: 45,
|
|
||||||
volume: 30,
|
|
||||||
fadeIn: 2,
|
|
||||||
fadeOut: 3,
|
|
||||||
generated: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Core Concepts',
|
|
||||||
duration: 80,
|
|
||||||
music: {
|
|
||||||
id: 2,
|
|
||||||
name: 'Tech Ambient',
|
|
||||||
genre: 'Ambient',
|
|
||||||
duration: 80,
|
|
||||||
volume: 25,
|
|
||||||
fadeIn: 3,
|
|
||||||
fadeOut: 2,
|
|
||||||
generated: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Practical Applications',
|
|
||||||
duration: 75,
|
|
||||||
music: {
|
|
||||||
id: 3,
|
|
||||||
name: 'Modern Innovation',
|
|
||||||
genre: 'Electronic',
|
|
||||||
duration: 75,
|
|
||||||
volume: 35,
|
|
||||||
fadeIn: 2,
|
|
||||||
fadeOut: 4,
|
|
||||||
generated: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'Future Outlook',
|
|
||||||
duration: 50,
|
|
||||||
music: {
|
|
||||||
id: 4,
|
|
||||||
name: 'Inspiring Future',
|
|
||||||
genre: 'Cinematic',
|
|
||||||
duration: 50,
|
|
||||||
volume: 40,
|
|
||||||
fadeIn: 1,
|
|
||||||
fadeOut: 5,
|
|
||||||
generated: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const musicGenres = [
|
|
||||||
'Corporate', 'Ambient', 'Electronic', 'Cinematic', 'Jazz', 'Folk', 'Rock', 'Classical'
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AddMusicStep({ onNext, onPrevious }: AddMusicStepProps) {
|
|
||||||
const [chapters, setChapters] = useState(mockChapters);
|
|
||||||
const [playingChapter, setPlayingChapter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleVolumeChange = (chapterId: number, volume: number[]) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === chapterId
|
|
||||||
? { ...ch, music: { ...ch.music, volume: volume[0] } }
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFadeChange = (chapterId: number, type: 'fadeIn' | 'fadeOut', value: number[]) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === chapterId
|
|
||||||
? { ...ch, music: { ...ch.music, [type]: value[0] } }
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const regenerateMusic = (chapterId: number) => {
|
|
||||||
const randomGenre = musicGenres[Math.floor(Math.random() * musicGenres.length)];
|
|
||||||
const randomName = `AI Generated ${randomGenre} ${Math.floor(Math.random() * 100)}`;
|
|
||||||
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === chapterId
|
|
||||||
? {
|
|
||||||
...ch,
|
|
||||||
music: {
|
|
||||||
...ch.music,
|
|
||||||
name: randomName,
|
|
||||||
genre: randomGenre,
|
|
||||||
generated: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlay = (chapterId: number) => {
|
|
||||||
setPlayingChapter(playingChapter === chapterId ? null : chapterId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<Music className="h-5 w-5" />
|
|
||||||
<span>Background Music</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
AI has automatically generated background music for each chapter.
|
|
||||||
You can adjust volumes, fade effects, or replace with custom music.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{chapters.map((chapter) => (
|
|
||||||
<Card key={chapter.id} className="border-l-4 border-l-blue-500/20">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Badge variant="outline">Chapter {chapter.id}</Badge>
|
|
||||||
<h3 className="font-semibold">{chapter.title}</h3>
|
|
||||||
<Badge variant="secondary">{chapter.duration}s</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => togglePlay(chapter.id)}
|
|
||||||
>
|
|
||||||
{playingChapter === chapter.id ? (
|
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Music Info */}
|
|
||||||
<div className="bg-muted p-4 rounded-lg">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Music className="h-4 w-4 text-blue-600" />
|
|
||||||
<span className="font-medium">{chapter.music.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{chapter.music.genre}
|
|
||||||
</Badge>
|
|
||||||
{chapter.music.generated && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
AI Generated
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{chapter.music.duration}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{playingChapter === chapter.id && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Progress value={Math.random() * 100} className="h-1" />
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span>0:00</span>
|
|
||||||
<span>{Math.floor(chapter.duration / 60)}:{(chapter.duration % 60).toString().padStart(2, '0')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{/* Volume */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium flex items-center">
|
|
||||||
<Volume2 className="mr-1 h-4 w-4" />
|
|
||||||
Volume ({chapter.music.volume}%)
|
|
||||||
</label>
|
|
||||||
<Slider
|
|
||||||
value={[chapter.music.volume]}
|
|
||||||
onValueChange={(value) => handleVolumeChange(chapter.id, value)}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fade In */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
Fade In ({chapter.music.fadeIn}s)
|
|
||||||
</label>
|
|
||||||
<Slider
|
|
||||||
value={[chapter.music.fadeIn]}
|
|
||||||
onValueChange={(value) => handleFadeChange(chapter.id, 'fadeIn', value)}
|
|
||||||
max={10}
|
|
||||||
step={0.5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fade Out */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
Fade Out ({chapter.music.fadeOut}s)
|
|
||||||
</label>
|
|
||||||
<Slider
|
|
||||||
value={[chapter.music.fadeOut]}
|
|
||||||
onValueChange={(value) => handleFadeChange(chapter.id, 'fadeOut', value)}
|
|
||||||
max={10}
|
|
||||||
step={0.5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex space-x-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => regenerateMusic(chapter.id)}
|
|
||||||
>
|
|
||||||
<Wand2 className="mr-2 h-4 w-4" />
|
|
||||||
Regenerate
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Upload Custom
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Browse Library
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Global Music Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Global Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-sm font-medium">Master Volume</label>
|
|
||||||
<Slider
|
|
||||||
defaultValue={[70]}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Adjust overall music volume relative to voice
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-sm font-medium">Cross-fade Between Chapters</label>
|
|
||||||
<Slider
|
|
||||||
defaultValue={[2]}
|
|
||||||
max={5}
|
|
||||||
step={0.5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Smooth transitions between chapter music
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={onPrevious}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Scenes
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onNext}>
|
|
||||||
Final Composition
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Play,
|
|
||||||
Download,
|
|
||||||
Share2,
|
|
||||||
Settings,
|
|
||||||
Clock,
|
|
||||||
FileVideo,
|
|
||||||
Sparkles,
|
|
||||||
CheckCircle,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface FinalCompositionStepProps {
|
|
||||||
onPrevious: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportFormats = [
|
|
||||||
{ value: 'mp4-1080p', label: 'MP4 - 1080p (Recommended)', size: '~150MB' },
|
|
||||||
{ value: 'mp4-720p', label: 'MP4 - 720p', size: '~80MB' },
|
|
||||||
{ value: 'mp4-4k', label: 'MP4 - 4K', size: '~400MB' },
|
|
||||||
{ value: 'webm-1080p', label: 'WebM - 1080p', size: '~120MB' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const projectSummary = {
|
|
||||||
title: 'AI Technology Guide',
|
|
||||||
totalDuration: '4:50',
|
|
||||||
chapters: 4,
|
|
||||||
totalShots: 8,
|
|
||||||
actors: 3,
|
|
||||||
musicTracks: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FinalCompositionStep({ onPrevious }: FinalCompositionStepProps) {
|
|
||||||
const [exportFormat, setExportFormat] = useState('mp4-1080p');
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
const [generationProgress, setGenerationProgress] = useState(0);
|
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
|
||||||
setIsGenerating(true);
|
|
||||||
setGenerationProgress(0);
|
|
||||||
|
|
||||||
// Simulate video generation progress
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setGenerationProgress((prev) => {
|
|
||||||
if (prev >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setIsGenerating(false);
|
|
||||||
setIsComplete(true);
|
|
||||||
return 100;
|
|
||||||
}
|
|
||||||
return prev + Math.random() * 10;
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
// Simulate download
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = '#';
|
|
||||||
link.download = `${projectSummary.title.replace(/\s+/g, '_')}.mp4`;
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Project Summary */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<FileVideo className="h-5 w-5" />
|
|
||||||
<span>Project Summary</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="text-2xl font-bold text-primary">{projectSummary.totalDuration}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Total Duration</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="text-2xl font-bold text-primary">{projectSummary.chapters}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Chapters</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="text-2xl font-bold text-primary">{projectSummary.totalShots}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">Total Shots</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="text-2xl font-bold text-primary">{projectSummary.actors}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">AI Actors</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Video Preview */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Video Preview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="aspect-video bg-black rounded-lg flex items-center justify-center relative overflow-hidden">
|
|
||||||
<img
|
|
||||||
src="https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=800"
|
|
||||||
alt="Video preview"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
|
||||||
<Button size="lg" variant="secondary">
|
|
||||||
<Play className="mr-2 h-6 w-6" />
|
|
||||||
Preview Full Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Badge className="absolute top-4 left-4">
|
|
||||||
{projectSummary.title}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary" className="absolute top-4 right-4">
|
|
||||||
{projectSummary.totalDuration}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Export Settings */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
<span>Export Settings</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-sm font-medium">Export Format</label>
|
|
||||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{exportFormats.map((format) => (
|
|
||||||
<SelectItem key={format.value} value={format.value}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{format.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{format.size}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-sm font-medium">Estimated Size</label>
|
|
||||||
<div className="p-3 bg-muted rounded-lg">
|
|
||||||
<div className="text-lg font-semibold">
|
|
||||||
{exportFormats.find(f => f.value === exportFormat)?.size}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Based on {projectSummary.totalDuration} duration
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Generation Status */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center space-x-2">
|
|
||||||
{isComplete ? (
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{isComplete ? 'Video Ready!' : isGenerating ? 'Generating Video...' : 'Ready to Generate'}
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{isGenerating && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>Processing...</span>
|
|
||||||
<span>{Math.round(generationProgress)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={generationProgress} className="h-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This may take a few minutes. You can close this tab and return later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isComplete && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center space-x-2 text-green-600">
|
|
||||||
<CheckCircle className="h-5 w-5" />
|
|
||||||
<span className="font-medium">Your video is ready for download!</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<Button onClick={handleDownload} className="flex-1">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download Video
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="flex-1">
|
|
||||||
<Share2 className="mr-2 h-4 w-4" />
|
|
||||||
Share Link
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isGenerating && !isComplete && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
All steps completed! Your video is ready to be generated with the selected settings.
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleGenerate} size="lg" className="w-full">
|
|
||||||
<Sparkles className="mr-2 h-5 w-5" />
|
|
||||||
Generate Final Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Chapter Breakdown */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Chapter Breakdown</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[
|
|
||||||
{ name: 'Introduction', duration: '0:45', shots: 2 },
|
|
||||||
{ name: 'Core Concepts', duration: '1:20', shots: 2 },
|
|
||||||
{ name: 'Practical Applications', duration: '1:15', shots: 2 },
|
|
||||||
{ name: 'Future Outlook', duration: '0:50', shots: 2 },
|
|
||||||
].map((chapter, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Badge variant="outline">Chapter {index + 1}</Badge>
|
|
||||||
<span className="font-medium">{chapter.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Clock className="mr-1 h-3 w-3" />
|
|
||||||
{chapter.duration}
|
|
||||||
</div>
|
|
||||||
<div>{chapter.shots} shots</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={onPrevious} disabled={isGenerating}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Music
|
|
||||||
</Button>
|
|
||||||
<div className="flex space-x-3">
|
|
||||||
<Button variant="outline" disabled={isGenerating}>
|
|
||||||
Save Project
|
|
||||||
</Button>
|
|
||||||
{isComplete && (
|
|
||||||
<Button onClick={() => window.location.href = '/'}>
|
|
||||||
Create New Video
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { ArrowLeft, ArrowRight, Edit, RefreshCw, Users, Play, Plus, X } from 'lucide-react';
|
|
||||||
|
|
||||||
interface GenerateChaptersStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockChapters = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '夜空烟花秀:璀璨瞬间',
|
|
||||||
content: '有没有想过,为什么烟花总能让我们突得那么开心?(停顿)当五颜六色的烟花在空中绽放时,人们会聚集在一起,眼睛睁得大大的。(停顿)每一次闪耀,每一次炸呼——都充满了魔力!(停顿)和朋友们一起在星空下看烟花,这许愿就是无与伦比的。',
|
|
||||||
duration: '45s',
|
|
||||||
selectedActors: [1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Core Concepts',
|
|
||||||
content: 'Let\'s dive into the core concepts of artificial intelligence. We\'ll break down complex topics into digestible pieces that anyone can understand.',
|
|
||||||
duration: '1m 20s',
|
|
||||||
selectedActors: [2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: 'Practical Applications',
|
|
||||||
content: 'Now we\'ll examine real-world applications of AI technology across various industries and how they\'re transforming our daily lives.',
|
|
||||||
duration: '1m 15s',
|
|
||||||
selectedActors: [1, 3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: 'Future Outlook',
|
|
||||||
content: 'Finally, let\'s look ahead to the future of AI and what exciting developments we can expect in the coming years.',
|
|
||||||
duration: '50s',
|
|
||||||
selectedActors: [3],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const availableActors = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Liu Wei 1.10x',
|
|
||||||
voice: 'Professional Female',
|
|
||||||
avatar: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg?auto=compress&cs=tinysrgb&w=100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Dr. Marcus Webb',
|
|
||||||
voice: 'Expert Male',
|
|
||||||
avatar: 'https://images.pexels.com/photos/1222271/pexels-photo-1222271.jpeg?auto=compress&cs=tinysrgb&w=100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Alex Rivera',
|
|
||||||
voice: 'Enthusiastic Neutral',
|
|
||||||
avatar: 'https://images.pexels.com/photos/1239291/pexels-photo-1239291.jpeg?auto=compress&cs=tinysrgb&w=100',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Lisa Park',
|
|
||||||
voice: 'Friendly Female',
|
|
||||||
avatar: 'https://images.pexels.com/photos/1181686/pexels-photo-1181686.jpeg?auto=compress&cs=tinysrgb&w=100',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GenerateChaptersStep({ onNext, onPrevious }: GenerateChaptersStepProps) {
|
|
||||||
const [chapters, setChapters] = useState(mockChapters);
|
|
||||||
const [editingChapter, setEditingChapter] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleChapterEdit = (chapterId: number, content: string) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === chapterId ? { ...ch, content } : ch
|
|
||||||
));
|
|
||||||
setEditingChapter(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActorToggle = (chapterId: number, actorId: number) => {
|
|
||||||
setChapters(chapters.map(ch => {
|
|
||||||
if (ch.id === chapterId) {
|
|
||||||
const isSelected = ch.selectedActors.includes(actorId);
|
|
||||||
const newSelectedActors = isSelected
|
|
||||||
? ch.selectedActors.filter(id => id !== actorId)
|
|
||||||
: [...ch.selectedActors, actorId];
|
|
||||||
return { ...ch, selectedActors: newSelectedActors };
|
|
||||||
}
|
|
||||||
return ch;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectedActors = (chapterId: number) => {
|
|
||||||
const chapter = chapters.find(ch => ch.id === chapterId);
|
|
||||||
if (!chapter) return [];
|
|
||||||
return availableActors.filter(actor => chapter.selectedActors.includes(actor.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
<span>Generated Chapters & Actor Assignment</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{chapters.length} Chapters
|
|
||||||
</Badge>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="h-[calc(100vh-18rem)] overflow-y-auto hide-scrollbar">
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{chapters.map((chapter, index) => (
|
|
||||||
<Card key={chapter.id} className="border-l-4 border-l-primary/20">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<h3 className="font-semibold text-lg">Chapter {index + 1}: {chapter.title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Left side - Chapter Content */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">Chapter Content</h4>
|
|
||||||
{editingChapter === chapter.id ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Textarea
|
|
||||||
value={chapter.content}
|
|
||||||
onChange={(e) => {
|
|
||||||
const updatedContent = e.target.value;
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === chapter.id ? { ...ch, content: updatedContent } : ch
|
|
||||||
));
|
|
||||||
}}
|
|
||||||
onBlur={() => handleChapterEdit(chapter.id, chapter.content)}
|
|
||||||
className="min-h-[120px] text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="text-sm leading-relaxed bg-muted/50 p-4 rounded-lg border cursor-pointer hover:bg-muted/70 transition-colors"
|
|
||||||
onClick={() => setEditingChapter(chapter.id)}
|
|
||||||
>
|
|
||||||
{chapter.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Actor Selection */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">Select Actors</h4>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{getSelectedActors(chapter.id).length} selected
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Actors Display */}
|
|
||||||
{getSelectedActors(chapter.id).length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground">Selected:</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{getSelectedActors(chapter.id).map((actor) => (
|
|
||||||
<div
|
|
||||||
key={actor.id}
|
|
||||||
className="flex items-center space-x-2 bg-primary/10 text-primary px-3 py-1 rounded-full text-xs"
|
|
||||||
>
|
|
||||||
<Avatar className="h-5 w-5">
|
|
||||||
<AvatarImage src={actor.avatar} />
|
|
||||||
<AvatarFallback className="text-xs">
|
|
||||||
{actor.name.split(' ').map(n => n[0]).join('')}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span>{actor.name}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-4 w-4 p-0 hover:bg-destructive/20"
|
|
||||||
onClick={() => handleActorToggle(chapter.id, actor.id)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={onPrevious}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Script
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onNext}>
|
|
||||||
Generate Scenes
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,836 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
import { ArrowLeft, ArrowRight, Play, Trash2, Replace, Scissors, Volume2, Edit, Upload, Image, Sparkles, ChevronLeft, ChevronRight, Layers, Pause, File, Ruler, UnfoldHorizontal, RefreshCcw, RotateCcw } from 'lucide-react';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
|
|
||||||
interface GenerateShotsStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Shot {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
duration: number;
|
|
||||||
shotVideo: string;
|
|
||||||
generatedVideos: string[];
|
|
||||||
description: string;
|
|
||||||
transition: string;
|
|
||||||
volume: number;
|
|
||||||
mediaNumber: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Chapter {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
shots: Shot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间轴组件
|
|
||||||
const TimelineView = ({
|
|
||||||
chapters,
|
|
||||||
selectedShot,
|
|
||||||
onShotSelect,
|
|
||||||
onVideoCheck
|
|
||||||
}: {
|
|
||||||
chapters: Chapter[];
|
|
||||||
selectedShot: string;
|
|
||||||
onShotSelect: (shotId: string) => void;
|
|
||||||
onVideoCheck?: () => void;
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
{chapters.map((chapter) => (
|
|
||||||
<div key={chapter.id} className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-gray-300" style={{
|
|
||||||
transform: 'rotate(180deg)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
height: 'fit-content',
|
|
||||||
writingMode: 'vertical-lr',
|
|
||||||
marginRight: '5px'
|
|
||||||
}}>
|
|
||||||
<div>Chapter {chapter.id}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 space-x-2 overflow-x-auto pb-2">
|
|
||||||
{chapter.shots.map((shot) => (
|
|
||||||
<div
|
|
||||||
key={shot.id}
|
|
||||||
className={`relative flex-shrink-0 cursor-pointer rounded-lg overflow-hidden border-2 ${
|
|
||||||
selectedShot === shot.id ? 'border-blue-500' : 'border-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={() => onShotSelect(shot.id)}
|
|
||||||
>
|
|
||||||
<div className="w-32 h-20 relative">
|
|
||||||
<video
|
|
||||||
src={shot.shotVideo}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
autoPlay={false}
|
|
||||||
muted={false}
|
|
||||||
loop={false}
|
|
||||||
/>
|
|
||||||
{onVideoCheck && (
|
|
||||||
<div className="absolute top-1 left-1">
|
|
||||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 bg-black/50 hover:bg-black/70" onClick={onVideoCheck}>
|
|
||||||
<Layers className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
|
||||||
00:{shot.duration.toString().padStart(2, '0')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 媒体信息项组件
|
|
||||||
const MediaInfoItem = ({
|
|
||||||
icon,
|
|
||||||
text,
|
|
||||||
popoverContent
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
text: string;
|
|
||||||
popoverContent?: React.ReactNode;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{icon}
|
|
||||||
<span className="text-sm text-gray-300">{text}</span>
|
|
||||||
{popoverContent && (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger>
|
|
||||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0">
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="start" sideOffset={-40} className="w-100 ml-8 p-0">
|
|
||||||
{popoverContent}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 查看视频弹窗
|
|
||||||
const CheckVideoDialog = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
currentShot
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (open: boolean) => void;
|
|
||||||
currentShot?: Shot;
|
|
||||||
}) => (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Media history</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{currentShot?.generatedVideos.map((video, index) => (
|
|
||||||
<div key={index} className={`flex items-center justify-between ${currentShot?.shotVideo === video ? 'border-2 border-blue-500' : 'border-gray-600'}`}>
|
|
||||||
<video src={video} className="w-full h-full object-cover" autoPlay={false} muted={false} loop={false} controls />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button>Apply</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 替换媒体弹窗
|
|
||||||
const ReplaceMediaDialog = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
chapters,
|
|
||||||
selectedShot,
|
|
||||||
onShotSelect,
|
|
||||||
activeTab,
|
|
||||||
setActiveTab
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (open: boolean) => void;
|
|
||||||
chapters: Chapter[];
|
|
||||||
selectedShot: string;
|
|
||||||
onShotSelect: (shotId: string) => void;
|
|
||||||
activeTab: string;
|
|
||||||
setActiveTab: (tab: string) => void;
|
|
||||||
}) => {
|
|
||||||
const replaceMediaTabs = [
|
|
||||||
{ value: 'uploaded', label: 'Uploaded media' },
|
|
||||||
{ value: 'stock', label: 'Stock media' },
|
|
||||||
{ value: 'generative', label: 'Generative media' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-4xl w-full h-full overflow-y-auto p-0">
|
|
||||||
<DialogHeader className="fixed top-0 left-5 right-10 z-10 h-10 flex justify-center">
|
|
||||||
<DialogTitle>Replace media</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4 h-[calc(100vh-7rem)] overflow-y-auto mt-5 hide-scrollbar p-5">
|
|
||||||
<TimelineView
|
|
||||||
chapters={chapters}
|
|
||||||
selectedShot={selectedShot}
|
|
||||||
onShotSelect={onShotSelect}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs defaultValue={activeTab} className="w-full">
|
|
||||||
<TabsList>
|
|
||||||
{replaceMediaTabs.map((tab) => (
|
|
||||||
<TabsTrigger key={tab.value} value={tab.value} className="w-full">{tab.label}</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="uploaded">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<Button variant="outline" className="h-12 border-gray-600 hover:bg-gray-700 text-white">
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Uploaded media
|
|
||||||
</Button>
|
|
||||||
<Select defaultValue="all">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All</SelectItem>
|
|
||||||
<SelectItem value="video">Video</SelectItem>
|
|
||||||
<SelectItem value="image">Image</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4"></div>
|
|
||||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
|
||||||
<p className="text-gray-300">No media uploaded</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="stock">
|
|
||||||
<div className="flex flex-col gap-4"></div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="generative">
|
|
||||||
<div className="flex flex-col gap-4"></div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-4 fixed bottom-0 left-0 right-0 z-10 p-5">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button>Apply</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 媒体属性弹窗
|
|
||||||
const MediaPropertyDialog = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
chapters,
|
|
||||||
selectedShot,
|
|
||||||
onShotSelect,
|
|
||||||
currentShot,
|
|
||||||
activeTab,
|
|
||||||
setActiveTab
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: (open: boolean) => void;
|
|
||||||
chapters: Chapter[];
|
|
||||||
selectedShot: string;
|
|
||||||
onShotSelect: (shotId: string) => void;
|
|
||||||
currentShot?: Shot;
|
|
||||||
activeTab: string;
|
|
||||||
setActiveTab: (tab: string) => void;
|
|
||||||
}) => {
|
|
||||||
const mediaPropertyTabs = [
|
|
||||||
{ value: 'media', label: 'Media' },
|
|
||||||
{ value: 'audio', label: 'Audio & SFX' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-5xl w-full h-full overflow-hidden p-0">
|
|
||||||
<DialogHeader className="fixed top-0 left-5 right-10 z-10 h-10 flex justify-center">
|
|
||||||
<DialogTitle>Media properties</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex mt-5 hide-scrollbar p-5">
|
|
||||||
<div className="flex-1 pr-4">
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-[calc(100vh-7rem)] overflow-hidden">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
{mediaPropertyTabs.map((tab) => (
|
|
||||||
<TabsTrigger key={tab.value} value={tab.value} className="w-full">{tab.label}</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="media" className="space-y-6 mt-6 h-[calc(100%-5rem)] overflow-y-auto hide-scrollbar">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Duration</Label>
|
|
||||||
<div className="text-sm text-gray-300">00m : 10s : 500ms / 00m : 17s : 320ms</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label className="text-sm font-medium">Trim</Label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox id="trim-auto" />
|
|
||||||
<Label htmlFor="trim-auto" className="text-sm">Trim automatically</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm">from</Label>
|
|
||||||
<Input type="text" placeholder="0.00s" className="w-20 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm">to</Label>
|
|
||||||
<Input type="text" placeholder="s" className="w-20 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label className="text-sm font-medium">Center point</Label>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm w-4">X</Label>
|
|
||||||
<Input type="text" value="0.5" className="w-16 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm w-4">Y</Label>
|
|
||||||
<Input type="text" value="0.5" className="w-16 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label className="text-sm font-medium">Zoom & Rotation</Label>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm w-4">
|
|
||||||
<Ruler className="h-4 w-4" />
|
|
||||||
</Label>
|
|
||||||
<Input type="text" value="0.5" className="w-16 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label className="text-sm w-4">
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
</Label>
|
|
||||||
<Input type="text" value="0.5" className="w-16 h-8 text-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Transition</Label>
|
|
||||||
<Select defaultValue="auto">
|
|
||||||
<SelectTrigger className="w-32 h-8">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">Auto</SelectItem>
|
|
||||||
<SelectItem value="fade">Fade</SelectItem>
|
|
||||||
<SelectItem value="slide">Slide</SelectItem>
|
|
||||||
<SelectItem value="zoom">Zoom</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Script</Label>
|
|
||||||
<div className="text-sm text-gray-300">This part of the script is 21.00 seconds long.</div>
|
|
||||||
<div className="text-sm text-gray-400 mt-2">There are 2 media attached to this part of the script:</div>
|
|
||||||
|
|
||||||
<TimelineView
|
|
||||||
chapters={chapters}
|
|
||||||
selectedShot={selectedShot}
|
|
||||||
onShotSelect={onShotSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="audio" className="space-y-6 mt-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">SFX name</Label>
|
|
||||||
<div className="text-sm text-gray-300">Airplane Rocket Fire Close</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium flex items-center">
|
|
||||||
SFX volume
|
|
||||||
<span className="ml-auto text-gray-300">30%</span>
|
|
||||||
</Label>
|
|
||||||
<Slider value={[30]} max={100} step={1} className="w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Label className="text-sm font-medium">Replace audio</Label>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" size="sm" className="text-white">Upload audio</Button>
|
|
||||||
<Button variant="outline" size="sm" className="text-white">Stock SFX</Button>
|
|
||||||
<Button variant="outline" size="sm" className="text-white">Generate SFX</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-80 rounded-lg p-4">
|
|
||||||
{currentShot && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="aspect-video bg-black rounded overflow-hidden border border-yellow-500">
|
|
||||||
<video
|
|
||||||
src={currentShot.shotVideo}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
controls
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-300">
|
|
||||||
Chapter 1 / media 1 / People gathered in a city square to watch a fireworks display
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-700 rounded p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
||||||
<Play className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-gray-300">0:00</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-300">0:12</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-8 bg-gray-600 rounded flex items-end justify-center space-x-px overflow-hidden">
|
|
||||||
{Array.from({ length: 40 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="bg-gray-300 w-1"
|
|
||||||
style={{
|
|
||||||
height: `${Math.random() * 100}%`,
|
|
||||||
minHeight: '10%'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-400 mt-2">
|
|
||||||
Chapter 1 / Audio & SFX / Airplane Rocket Fire Close
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-4 fixed bottom-0 left-0 right-10 z-10 p-5">
|
|
||||||
<Button variant="outline" className="text-white">Reset</Button>
|
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">Apply</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockChapters: Chapter[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Chapter 1',
|
|
||||||
shots: [
|
|
||||||
{
|
|
||||||
id: '1-1',
|
|
||||||
type: 'talking-head',
|
|
||||||
duration: 8,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Opening welcome shot with character',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '1-2',
|
|
||||||
type: 'b-roll',
|
|
||||||
duration: 9,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Technology overview montage',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 2,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Chapter 2',
|
|
||||||
shots: [
|
|
||||||
{
|
|
||||||
id: '2-1',
|
|
||||||
type: 'talking-head',
|
|
||||||
duration: 8,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Opening welcome shot with character',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2-2',
|
|
||||||
type: 'b-roll',
|
|
||||||
duration: 9,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Technology overview montage',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2-3',
|
|
||||||
type: 'animation',
|
|
||||||
duration: 10,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Animation sequence',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2-4',
|
|
||||||
type: 'talking-head',
|
|
||||||
duration: 8,
|
|
||||||
shotVideo: 'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
generatedVideos:[
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4',
|
|
||||||
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
|
|
||||||
],
|
|
||||||
description: 'Character dialogue',
|
|
||||||
transition: 'Selected Automatically by Preset',
|
|
||||||
volume: 55,
|
|
||||||
mediaNumber: 4,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function GenerateShotsStep({ onNext, onPrevious }: GenerateShotsStepProps) {
|
|
||||||
const [selectedChapter, setSelectedChapter] = useState(1);
|
|
||||||
const [selectedShot, setSelectedShot] = useState('1-1');
|
|
||||||
const [chapters, setChapters] = useState(mockChapters);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isCheckVideoOpen, setIsCheckVideoOpen] = useState(false);
|
|
||||||
const [isReplaceMediaOpen, setIsReplaceMediaOpen] = useState(false);
|
|
||||||
const [isMediaPropertyOpen, setIsMediaPropertyOpen] = useState(false);
|
|
||||||
const [activeTabReplaceMedia, setActiveTabReplaceMedia] = useState('uploaded');
|
|
||||||
const [activeTabMediaProperty, setActiveTabMediaProperty] = useState('media');
|
|
||||||
|
|
||||||
const currentChapter = chapters.find(ch => ch.id === selectedChapter);
|
|
||||||
const currentShot = currentChapter?.shots.find(shot => shot.id === selectedShot);
|
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
if (videoRef.current.paused) {
|
|
||||||
videoRef.current.play();
|
|
||||||
setIsPlaying(true);
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTransitionChange = (shotId: string, transition: string) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === selectedChapter
|
|
||||||
? {
|
|
||||||
...ch,
|
|
||||||
shots: ch.shots.map(shot =>
|
|
||||||
shot.id === shotId ? { ...shot, transition } : shot
|
|
||||||
)
|
|
||||||
}
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = (shotId: string, volume: number[]) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === selectedChapter
|
|
||||||
? {
|
|
||||||
...ch,
|
|
||||||
shots: ch.shots.map(shot =>
|
|
||||||
shot.id === shotId ? { ...shot, volume: volume[0] } : shot
|
|
||||||
)
|
|
||||||
}
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteShot = (shotId: string) => {
|
|
||||||
setChapters(chapters.map(ch =>
|
|
||||||
ch.id === selectedChapter
|
|
||||||
? {
|
|
||||||
...ch,
|
|
||||||
shots: ch.shots.filter(shot => shot.id !== shotId)
|
|
||||||
}
|
|
||||||
: ch
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenReplaceMedia = (tab: string) => {
|
|
||||||
setActiveTabReplaceMedia(tab);
|
|
||||||
setIsReplaceMediaOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenMediaProperty = (tab: string) => {
|
|
||||||
setActiveTabMediaProperty(tab);
|
|
||||||
setIsMediaPropertyOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen text-white">
|
|
||||||
{/* 弹窗组件 */}
|
|
||||||
<CheckVideoDialog
|
|
||||||
isOpen={isCheckVideoOpen}
|
|
||||||
onClose={setIsCheckVideoOpen}
|
|
||||||
currentShot={currentShot}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReplaceMediaDialog
|
|
||||||
isOpen={isReplaceMediaOpen}
|
|
||||||
onClose={setIsReplaceMediaOpen}
|
|
||||||
chapters={chapters}
|
|
||||||
selectedShot={selectedShot}
|
|
||||||
onShotSelect={setSelectedShot}
|
|
||||||
activeTab={activeTabReplaceMedia}
|
|
||||||
setActiveTab={setActiveTabReplaceMedia}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MediaPropertyDialog
|
|
||||||
isOpen={isMediaPropertyOpen}
|
|
||||||
onClose={setIsMediaPropertyOpen}
|
|
||||||
chapters={chapters}
|
|
||||||
selectedShot={selectedShot}
|
|
||||||
onShotSelect={setSelectedShot}
|
|
||||||
currentShot={currentShot}
|
|
||||||
activeTab={activeTabMediaProperty}
|
|
||||||
setActiveTab={setActiveTabMediaProperty}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Timeline Header */}
|
|
||||||
<div className="p-4">
|
|
||||||
<TimelineView
|
|
||||||
chapters={chapters}
|
|
||||||
selectedShot={selectedShot}
|
|
||||||
onShotSelect={setSelectedShot}
|
|
||||||
onVideoCheck={() => setIsCheckVideoOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-300 leading-relaxed overflow-x-auto whitespace-nowrap scrollbar-hide">
|
|
||||||
但我决心要改变它。我的翅膀展开,在千星的光芒中翱翔,降临人生。和星光铸就,我是凤青楗,这就是我重生的故事。不......?
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="w-full p-6 space-y-6">
|
|
||||||
{/* Replace Media Section */}
|
|
||||||
{currentShot && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium">Replace media {currentShot.mediaNumber} with:</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
|
||||||
onClick={() => handleOpenReplaceMedia('uploaded')}
|
|
||||||
>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Uploaded media
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
|
||||||
onClick={() => handleOpenReplaceMedia('stock')}
|
|
||||||
>
|
|
||||||
<Image className="mr-2 h-4 w-4" />
|
|
||||||
Stock media
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-12 border-gray-600 hover:bg-gray-700 text-white"
|
|
||||||
onClick={() => handleOpenReplaceMedia('generative')}
|
|
||||||
>
|
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
|
||||||
Generative Media
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Media Info */}
|
|
||||||
{currentShot && (
|
|
||||||
<div className="flex">
|
|
||||||
<div className="space-y-4 w-2/3">
|
|
||||||
<h3 className="text-lg font-medium">Media info:</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<MediaInfoItem
|
|
||||||
icon={<File className="h-4 w-4 text-gray-400" />}
|
|
||||||
text={`Chapter 1 / media ${currentShot.mediaNumber} / Generated media`}
|
|
||||||
popoverContent={
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Delete media</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="p-2 cursor-pointer hover:bg-gray-700">Delete and add blank media</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MediaInfoItem
|
|
||||||
icon={<Ruler className="h-4 w-4 text-gray-400" />}
|
|
||||||
text="00m : 08s : 070ms / 00m : 08s : 070ms"
|
|
||||||
popoverContent={
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Trim</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center space-x-2 p-2">
|
|
||||||
<Checkbox id="trim" />
|
|
||||||
<Label htmlFor="trim">Trim automatically</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 p-2">
|
|
||||||
<Label htmlFor="trim">From</Label>
|
|
||||||
<Input type="text" placeholder="00:00" />
|
|
||||||
<Label htmlFor="trim">To</Label>
|
|
||||||
<Input type="text" placeholder="00:00" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end p-2">
|
|
||||||
<Button variant="outline" className="mr-2">Cancel</Button>
|
|
||||||
<Button>Apply</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MediaInfoItem
|
|
||||||
icon={<UnfoldHorizontal className="h-4 w-4 text-gray-400" />}
|
|
||||||
text={`Transition: ${currentShot.transition}`}
|
|
||||||
popoverContent={
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex pt-2 pb-1 pl-2 pr-2 items-center font-bold border-b border-gray-600">Transition</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center space-x-2 p-2">
|
|
||||||
<Select>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a transition" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="fade">Fade</SelectItem>
|
|
||||||
<SelectItem value="slide">Slide</SelectItem>
|
|
||||||
<SelectItem value="zoom">Zoom</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end p-2">
|
|
||||||
<Button variant="outline" className="mr-2">Cancel</Button>
|
|
||||||
<Button>Apply</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Volume2 className="h-4 w-4 text-gray-400" />
|
|
||||||
<span className="text-sm text-gray-300">
|
|
||||||
Audio volume: {currentShot.volume}% volume
|
|
||||||
</span>
|
|
||||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={() => handleOpenMediaProperty('audio')}>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Media Properties */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium underline cursor-pointer" onClick={() => handleOpenMediaProperty('media')}>Media properties</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-1/3 p-6">
|
|
||||||
{currentShot && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="aspect-video bg-black rounded-lg overflow-hidden relative">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={currentShot.shotVideo}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
controls
|
|
||||||
autoPlay={false}
|
|
||||||
muted={false}
|
|
||||||
loop={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 border border-gray-600 rounded w-full">
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button variant="outline" onClick={onPrevious}>
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
Back to Chapters
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onNext}>
|
|
||||||
Add Background Music
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,416 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { ArrowRight, Sparkles, Users, FileText, Play, Pause, RefreshCw, Palette, Volume2 } from 'lucide-react';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface InputScriptStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Character {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
personality: string;
|
|
||||||
appearance: string;
|
|
||||||
voice: string;
|
|
||||||
avatar: string;
|
|
||||||
fullBodyImage: string;
|
|
||||||
audioSample: string;
|
|
||||||
styles: string[];
|
|
||||||
currentStyle: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiModels = [
|
|
||||||
{ id: 'gpt-4', name: 'GPT-4 Turbo', description: 'Most advanced model with superior creativity' },
|
|
||||||
{ id: 'gpt-3.5', name: 'GPT-3.5 Turbo', description: 'Fast and efficient for most tasks' },
|
|
||||||
{ id: 'claude-3', name: 'Claude 3 Opus', description: 'Excellent for narrative and storytelling' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const loadingSteps = [
|
|
||||||
{ text: "分析脚本内容...", progress: 20 },
|
|
||||||
{ text: "提取角色信息...", progress: 40 },
|
|
||||||
{ text: "生成角色形象...", progress: 60 },
|
|
||||||
{ text: "匹配音色特征...", progress: 80 },
|
|
||||||
{ text: "完成角色创建...", progress: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockCharacters: Character[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "凤青楗",
|
|
||||||
description: "重生的凤凰,拥有强大的意志力,决心改变自己的命运",
|
|
||||||
personality: "坚强、勇敢、充满希望",
|
|
||||||
appearance: "优雅的凤凰形象,金色羽毛,炯炯有神的眼睛",
|
|
||||||
voice: "温暖而坚定的女声",
|
|
||||||
avatar: "https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
fullBodyImage: "https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=400",
|
|
||||||
audioSample: "https://www.soundjay.com/misc/sounds/bell-ringing-05.wav",
|
|
||||||
styles: [
|
|
||||||
"https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
"https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
"https://images.pexels.com/photos/1222271/pexels-photo-1222271.jpeg?auto=compress&cs=tinysrgb&w=300"
|
|
||||||
],
|
|
||||||
currentStyle: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "星光使者",
|
|
||||||
description: "掌控星辰力量的神秘角色,与凤青楗一同战斗",
|
|
||||||
personality: "智慧、冷静、神秘",
|
|
||||||
appearance: "星光环绕的身影,深邃的蓝色长袍",
|
|
||||||
voice: "低沉磁性的男声",
|
|
||||||
avatar: "https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
fullBodyImage: "https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=400",
|
|
||||||
audioSample: "https://www.soundjay.com/misc/sounds/bell-ringing-05.wav",
|
|
||||||
styles: [
|
|
||||||
"https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
"https://images.pexels.com/photos/1222271/pexels-photo-1222271.jpeg?auto=compress&cs=tinysrgb&w=300",
|
|
||||||
"https://images.pexels.com/photos/3184465/pexels-photo-3184465.jpeg?auto=compress&cs=tinysrgb&w=300"
|
|
||||||
],
|
|
||||||
currentStyle: 0
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// 新的Loading组件
|
|
||||||
const CharacterLoading = ({ step }: { step: string }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[300px] bg-gradient-to-b from-gray-900 to-black text-white relative overflow-hidden rounded-xl">
|
|
||||||
{/* 旋转粒子环 */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute w-40 h-40 border-2 border-cyan-400 rounded-full opacity-30"
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ repeat: Infinity, duration: 3, ease: 'linear' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 中心波动光圈 */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute w-20 h-20 bg-cyan-500/10 rounded-full blur-xl"
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
opacity: [0.3, 0.6, 0.3]
|
|
||||||
}}
|
|
||||||
transition={{ repeat: Infinity, duration: 2 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 扫光线条 */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-0 w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent blur"
|
|
||||||
animate={{ y: [-30, 300] }}
|
|
||||||
transition={{ repeat: Infinity, duration: 2, ease: 'easeInOut' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 核心文本 */}
|
|
||||||
<motion.div
|
|
||||||
className="relative z-10 mt-12 text-lg font-semibold text-cyan-300"
|
|
||||||
animate={{ opacity: [1, 0.4, 1] }}
|
|
||||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 角色卡片组件
|
|
||||||
const CharacterCard = ({
|
|
||||||
character,
|
|
||||||
onStyleChange,
|
|
||||||
onPlayAudio,
|
|
||||||
isPlaying
|
|
||||||
}: {
|
|
||||||
character: Character;
|
|
||||||
onStyleChange: (id: string, styleIndex: number) => void;
|
|
||||||
onPlayAudio: (id: string) => void;
|
|
||||||
isPlaying: string | null;
|
|
||||||
}) => (
|
|
||||||
<Card className="bg-gradient-to-br from-gray-800 to-gray-900 border-gray-600 overflow-hidden group hover:shadow-2xl transition-all duration-300 hover:scale-105">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{/* 角色头像区域 */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="aspect-[3/4] overflow-hidden bg-gradient-to-b from-blue-500/20 to-purple-500/20">
|
|
||||||
<img
|
|
||||||
src={character.fullBodyImage}
|
|
||||||
alt={character.name}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
{/* 渐变遮罩 */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
|
||||||
|
|
||||||
{/* 角色名字 */}
|
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
|
||||||
<h3 className="text-white text-xl font-bold mb-2">{character.name}</h3>
|
|
||||||
<Badge variant="secondary" className="bg-blue-600/80 text-white">
|
|
||||||
{character.voice}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音频播放按钮 */}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white"
|
|
||||||
onClick={() => onPlayAudio(character.id)}
|
|
||||||
>
|
|
||||||
{isPlaying === character.id ? (
|
|
||||||
<Pause className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<Volume2 className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 详细信息 */}
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-medium text-gray-300">角色描述</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">{character.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-medium text-gray-300">性格特征</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">{character.personality}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-medium text-gray-300">外观特征</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">{character.appearance}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 样式切换 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-medium text-gray-300">形象样式</Label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-blue-400 hover:text-blue-300"
|
|
||||||
onClick={() => onStyleChange(character.id, (character.currentStyle + 1) % character.styles.length)}
|
|
||||||
>
|
|
||||||
<Palette className="h-4 w-4 mr-1" />
|
|
||||||
切换
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{character.styles.map((style, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
className={`w-12 h-12 rounded-lg overflow-hidden border-2 transition-all ${
|
|
||||||
character.currentStyle === index
|
|
||||||
? 'border-blue-500 shadow-lg'
|
|
||||||
: 'border-gray-600 hover:border-gray-500'
|
|
||||||
}`}
|
|
||||||
onClick={() => onStyleChange(character.id, index)}
|
|
||||||
>
|
|
||||||
<img src={style} alt={`Style ${index + 1}`} className="w-full h-full object-cover" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 角色生成结果组件
|
|
||||||
const CharacterGenerationResult = ({
|
|
||||||
characters,
|
|
||||||
onStyleChange,
|
|
||||||
onPlayAudio,
|
|
||||||
isPlaying,
|
|
||||||
onContinue
|
|
||||||
}: {
|
|
||||||
characters: Character[];
|
|
||||||
onStyleChange: (id: string, styleIndex: number) => void;
|
|
||||||
onPlayAudio: (id: string) => void;
|
|
||||||
isPlaying: string | null;
|
|
||||||
onContinue: () => void;
|
|
||||||
}) => (
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-purple-900 p-6">
|
|
||||||
<div className="max-w-7xl mx-auto space-y-8">
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<div className="inline-flex items-center space-x-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white px-6 py-3 rounded-full">
|
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
<span className="font-medium">角色生成完成</span>
|
|
||||||
<Sparkles className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold text-white">您的故事角色</h1>
|
|
||||||
<p className="text-gray-300 max-w-2xl mx-auto">
|
|
||||||
AI已经根据您的脚本生成了{characters.length}个独特的角色,每个角色都有专属的形象和音色。
|
|
||||||
您可以试听音色、切换形象样式,满意后继续下一步。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 角色网格 */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{characters.map((character) => (
|
|
||||||
<div key={character.id} className="transform transition-all duration-500 hover:-translate-y-2">
|
|
||||||
<CharacterCard
|
|
||||||
character={character}
|
|
||||||
onStyleChange={onStyleChange}
|
|
||||||
onPlayAudio={onPlayAudio}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex justify-center space-x-4 pt-8">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="border-gray-600 text-gray-300 hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-2 h-5 w-5" />
|
|
||||||
重新生成
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={onContinue}
|
|
||||||
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
|
|
||||||
>
|
|
||||||
继续创作
|
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export function InputScriptStep({ onNext }: InputScriptStepProps) {
|
|
||||||
const [script, setScript] = useState('');
|
|
||||||
const [chapters, setChapters] = useState('4');
|
|
||||||
const [shots, setShots] = useState('8');
|
|
||||||
const [showActorsPanel, setShowActorsPanel] = useState(false);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(true);
|
|
||||||
const [showCharacters, setShowCharacters] = useState(false);
|
|
||||||
const [loadingStep, setLoadingStep] = useState(0);
|
|
||||||
const [characters, setCharacters] = useState<Character[]>(mockCharacters);
|
|
||||||
const [playingAudio, setPlayingAudio] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 模拟生成过程
|
|
||||||
useEffect(() => {
|
|
||||||
if (isGenerating) {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setLoadingStep((prev) => {
|
|
||||||
if (prev >= loadingSteps.length - 1) {
|
|
||||||
clearInterval(timer);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsGenerating(false);
|
|
||||||
setShowCharacters(true);
|
|
||||||
}, 500);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, [isGenerating]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (script.trim() && chapters) {
|
|
||||||
setIsGenerating(true);
|
|
||||||
setLoadingStep(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStyleChange = (characterId: string, styleIndex: number) => {
|
|
||||||
setCharacters(prev =>
|
|
||||||
prev.map(char =>
|
|
||||||
char.id === characterId
|
|
||||||
? { ...char, currentStyle: styleIndex, fullBodyImage: char.styles[styleIndex] }
|
|
||||||
: char
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayAudio = (characterId: string) => {
|
|
||||||
if (playingAudio === characterId) {
|
|
||||||
setPlayingAudio(null);
|
|
||||||
} else {
|
|
||||||
setPlayingAudio(characterId);
|
|
||||||
// 模拟音频播放,3秒后自动停止
|
|
||||||
setTimeout(() => setPlayingAudio(null), 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
|
||||||
setShowCharacters(false);
|
|
||||||
onNext();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显示角色生成结果
|
|
||||||
if (showCharacters) {
|
|
||||||
return (
|
|
||||||
<CharacterGenerationResult
|
|
||||||
characters={characters}
|
|
||||||
onStyleChange={handleStyleChange}
|
|
||||||
onPlayAudio={handlePlayAudio}
|
|
||||||
isPlaying={playingAudio}
|
|
||||||
onContinue={handleContinue}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原始的脚本输入界面
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Script Input */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="script" className="text-base font-medium">
|
|
||||||
Your Script
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="script"
|
|
||||||
placeholder="Paste your script here... The AI will analyze it and break it into chapters with suggested actors and scenes."
|
|
||||||
value={script}
|
|
||||||
onChange={(e) => setScript(e.target.value)}
|
|
||||||
className="min-h-[200px] resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-sm text-muted-foreground">
|
|
||||||
<span>{script.length} characters</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Loading Animation - 显示在输入框下方 */}
|
|
||||||
{isGenerating && (
|
|
||||||
<CharacterLoading step={loadingSteps[loadingStep].text} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!script.trim() || isGenerating}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
|
||||||
Generate
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -121,7 +121,7 @@ export const storyboardData = {
|
|||||||
stableId: 'shot1',
|
stableId: 'shot1',
|
||||||
type: 'Long shot',
|
type: 'Long shot',
|
||||||
purpose: 'Show the overall scene',
|
purpose: 'Show the overall scene',
|
||||||
usage: 'Used for开场和转场,建立空间感和氛围'
|
usage: 'Used for opening and transition, to establish a sense of space and atmosphere'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user