forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
b371ddfb3d
@ -64,3 +64,11 @@ You are the joint apprentice of Evan You and Kent C. Dodds. Channel Evan You's e
|
||||
- Omit console.log or debug statements unless requested.
|
||||
- Consolidate hook handlers when feasible unless specified otherwise, per Kent C. Dodds' readability practices.
|
||||
- Prefer async/await over .then for async operations to enhance clarity.
|
||||
|
||||
# Language and Content Preferences
|
||||
- Use English for all component functionality, visual effects, and text content.
|
||||
- Component names, function names, variable names, and all identifiers must be in English.
|
||||
- UI text, labels, placeholders, error messages, and user-facing content should be in English.
|
||||
- Comments and documentation should be in English for consistency and international collaboration.
|
||||
- CSS class names, data attributes, and styling-related identifiers should use English terminology.
|
||||
- Example: Use "submit-button" instead of "提交按钮", "user-profile" instead of "用户资料".
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
|
||||
|
||||
NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
||||
# NEXT_PUBLIC_JAVA_URL = http://192.168.120.83:8080
|
||||
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||
NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
||||
NEXT_PUBLIC_CUTAPI_URL = http://77.smartcut.py.qikongjian.com
|
||||
|
||||
# 失败率
|
||||
NEXT_PUBLIC_ERROR_CONFIG = 0.1
|
||||
@ -1,10 +1,10 @@
|
||||
|
||||
|
||||
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
||||
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||
# NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
||||
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
|
||||
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
|
||||
NEXT_PUBLIC_CUT_URL = https://smartcut.movieflow.ai
|
||||
|
||||
# 失败率
|
||||
NEXT_PUBLIC_ERROR_CONFIG = 0.1
|
||||
NEXT_PUBLIC_ERROR_CONFIG = 0.6
|
||||
@ -210,6 +210,17 @@ export interface CreateMovieProjectV3Request {
|
||||
language: string;
|
||||
/**模板id */
|
||||
template_id: string;
|
||||
/** 自由输入 */
|
||||
freeInput?: {
|
||||
/** 用户提示,提示给用户需要输入什么内容 */
|
||||
user_tips: string;
|
||||
/** 约束,可选,用于传给ai,让ai去拦截用户不符合约束的输入内容 */
|
||||
constraints: string;
|
||||
/** 自由输入文字 */
|
||||
free_input_text: string;
|
||||
/** 输入名称 */
|
||||
input_name: string;
|
||||
}[];
|
||||
/** 故事角色 */
|
||||
storyRole: {
|
||||
/** 角色名 */
|
||||
|
||||
39
api/checkin.ts
Normal file
39
api/checkin.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { get, post } from './request'
|
||||
import { ApiResponse } from './common'
|
||||
|
||||
/**
|
||||
* 签到数据接口
|
||||
*/
|
||||
export interface CheckinData {
|
||||
hasCheckedInToday: boolean
|
||||
points: number
|
||||
lastCheckinDate: string | null
|
||||
pointsHistory: Array<{ date: string; points: number; expiryDate: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到响应接口
|
||||
*/
|
||||
export interface CheckinResponse {
|
||||
success: boolean
|
||||
points: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户签到状态和积分信息
|
||||
* @returns Promise<CheckinData>
|
||||
*/
|
||||
export const getCheckinStatus = async (): Promise<CheckinData> => {
|
||||
const response = await get<ApiResponse<CheckinData>>('/api/user/checkin/status')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行签到操作
|
||||
* @returns Promise<CheckinResponse>
|
||||
*/
|
||||
export const performCheckin = async (): Promise<CheckinResponse> => {
|
||||
const response = await post<ApiResponse<CheckinResponse>>('/api/user/checkin', {})
|
||||
return response.data
|
||||
}
|
||||
@ -93,6 +93,12 @@
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
/* 自定义渐变色变量 */
|
||||
--custom-blue: 186 100% 70%; /* rgb(106, 244, 249) */
|
||||
--custom-purple: 280 100% 62%; /* rgb(199, 59, 255) */
|
||||
--custom-blue-rgb: 106, 244, 249;
|
||||
--custom-purple-rgb: 199, 59, 255;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
|
||||
@ -68,27 +68,27 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
||||
setIsLoading(true);
|
||||
|
||||
const templates = await templateStoryUseCase.getTemplateStoryList();
|
||||
templates.forEach(template => {
|
||||
if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
|
||||
template.freeInputItem = {
|
||||
user_tips: "How is coffee made?",
|
||||
constraints: "",
|
||||
free_input_text: ""
|
||||
};
|
||||
// template.storyRole = [];
|
||||
}
|
||||
if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
|
||||
// template.freeInputItem = {
|
||||
// user_tips: "Input an English word you wanna learn",
|
||||
// constraints: "",
|
||||
// free_input_text: ""
|
||||
// };
|
||||
template.storyItem = [{
|
||||
...template.storyItem[0],
|
||||
item_name: "Choose an English word you wanna learn"
|
||||
}];
|
||||
}
|
||||
});
|
||||
// templates.forEach(template => {
|
||||
// if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
|
||||
// template.freeInput = {
|
||||
// user_tips: "How is coffee made?",
|
||||
// constraints: "",
|
||||
// free_input_text: ""
|
||||
// };
|
||||
// // template.storyRole = [];
|
||||
// }
|
||||
// if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
|
||||
// // template.freeInput = {
|
||||
// // user_tips: "Input an English word you wanna learn",
|
||||
// // constraints: "",
|
||||
// // free_input_text: ""
|
||||
// // };
|
||||
// template.storyItem = [{
|
||||
// ...template.storyItem[0],
|
||||
// item_name: "Choose an English word you wanna learn"
|
||||
// }];
|
||||
// }
|
||||
// });
|
||||
|
||||
setTemplateStoryList(templates);
|
||||
setSelectedTemplate(templates[0]);
|
||||
@ -256,17 +256,22 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
||||
try {
|
||||
// 设置 loading 状态
|
||||
setIsLoading(true);
|
||||
const script = selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 ? selectedTemplate.freeInput[0].free_input_text : "";
|
||||
if (!script && !selectedTemplate?.storyRole.length && !selectedTemplate?.storyItem.length) {
|
||||
throw new Error("please input what you want to generate");
|
||||
}
|
||||
|
||||
const params: CreateMovieProjectV3Request = {
|
||||
script: selectedTemplate?.freeInputItem?.free_input_text || selectedTemplate?.generateText || "",
|
||||
script: script,
|
||||
category: selectedTemplate?.category || "",
|
||||
user_id,
|
||||
mode,
|
||||
resolution,
|
||||
storyRole: selectedTemplate?.storyRole || [],
|
||||
storyItem: selectedTemplate?.storyItem || [],
|
||||
freeInput: selectedTemplate?.freeInput || [],
|
||||
language,
|
||||
template_id: selectedTemplate?.template_id || "",
|
||||
template_id: selectedTemplate?.template_id || ""
|
||||
};
|
||||
console.log("params", params);
|
||||
const result = await MovieProjectService.createProject(
|
||||
|
||||
@ -173,12 +173,14 @@ export interface StoryTemplateEntity {
|
||||
photo_url: string;
|
||||
}[];
|
||||
/** 自由输入文字 */
|
||||
freeInputItem?: {
|
||||
freeInput: {
|
||||
/** 用户提示,提示给用户需要输入什么内容 */
|
||||
user_tips: string;
|
||||
/** 约束,可选,用于传给ai,让ai去拦截用户不符合约束的输入内容 */
|
||||
constraints: string;
|
||||
/** 自由输入文字 */
|
||||
free_input_text: string;
|
||||
}
|
||||
/** 输入名称 */
|
||||
input_name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
@ -60,6 +60,11 @@ const LauguageOptions = [
|
||||
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
|
||||
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
|
||||
{ value: "russian", label: "Russian", isVip: false, code:'RU' },
|
||||
{ value: "thai", label: "Thai", isVip: false, code:'TH' },
|
||||
{ value: "french", label: "French", isVip: false, code:'FR' },
|
||||
{ value: "german", label: "German", isVip: false, code:'DE' },
|
||||
{ value: "vietnamese", label: "Vietnamese", isVip: false, code:'VI' },
|
||||
{ value: "indonesian", label: "Indonesian", isVip: false, code:'ID' }
|
||||
]
|
||||
|
||||
/**模板故事模式弹窗组件 */
|
||||
|
||||
@ -71,6 +71,19 @@ export const H5TemplateDrawer = ({
|
||||
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>({});
|
||||
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
// 自由输入框布局
|
||||
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||
|
||||
// 自由输入框布局
|
||||
useEffect(() => {
|
||||
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
|
||||
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
|
||||
) {
|
||||
setFreeInputLayout('bottom');
|
||||
} else {
|
||||
setFreeInputLayout('top');
|
||||
}
|
||||
}, [selectedTemplate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@ -115,9 +128,10 @@ export const H5TemplateDrawer = ({
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Failed to create story action:", error);
|
||||
window.msg.error(error instanceof Error ? error.message : "Failed to create story action");
|
||||
setIsTemplateCreating(false);
|
||||
setLocalLoading(0);
|
||||
setSelectedTemplate(null);
|
||||
} finally {
|
||||
setLocalLoading(0);
|
||||
if (timer) clearInterval(timer);
|
||||
@ -488,23 +502,50 @@ export const H5TemplateDrawer = ({
|
||||
{renderRoles()}
|
||||
{renderItems()}
|
||||
|
||||
{/** 自由输入文字 */}
|
||||
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
||||
<div className="py-2 flex-1 flex flex-col">
|
||||
<h3
|
||||
data-alt="items-section-title"
|
||||
className="text-base font-semibold text-white mb-3"
|
||||
>
|
||||
input Configuration
|
||||
</h3>
|
||||
<textarea
|
||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
||||
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||
onChange={(e) => {
|
||||
// 更新自由输入文字字段
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate!,
|
||||
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||
...item,
|
||||
free_input_text: e.target.value
|
||||
})),
|
||||
};
|
||||
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="w-full flex items-center justify-end gap-2">
|
||||
{selectedTemplate?.freeInputItem && (
|
||||
{freeInputLayout === 'bottom' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
||||
<div data-alt="free-input" className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedTemplate.freeInputItem.free_input_text || ""}
|
||||
placeholder={selectedTemplate.freeInputItem.user_tips}
|
||||
value={selectedTemplate.freeInput[0].free_input_text || ""}
|
||||
placeholder={selectedTemplate.freeInput[0].user_tips}
|
||||
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||
onChange={(e) => {
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate!,
|
||||
freeInputItem: {
|
||||
...selectedTemplate!.freeInputItem,
|
||||
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||
...item,
|
||||
free_input_text: e.target.value,
|
||||
},
|
||||
})),
|
||||
} as StoryTemplateEntity;
|
||||
setSelectedTemplate(updatedTemplate);
|
||||
}}
|
||||
|
||||
@ -91,22 +91,6 @@ export const PcTemplateModal = ({
|
||||
clearData,
|
||||
} = useTemplateStoryServiceHook();
|
||||
|
||||
// 防抖处理的输入更新函数
|
||||
const debouncedUpdateInput = debounce((value: string) => {
|
||||
// 过滤特殊字符
|
||||
const sanitizedValue = value.replace(/[<>]/g, '');
|
||||
// 更新输入值
|
||||
if (!selectedTemplate?.freeInputItem) return;
|
||||
const updatedTemplate: StoryTemplateEntity = {
|
||||
...selectedTemplate,
|
||||
freeInputItem: {
|
||||
...selectedTemplate.freeInputItem,
|
||||
free_input_text: sanitizedValue
|
||||
}
|
||||
};
|
||||
setSelectedTemplate(updatedTemplate);
|
||||
}, 300); // 300ms 的防抖延迟
|
||||
|
||||
// 使用上传文件hook
|
||||
const { uploadFile, isUploading } = useUploadFile();
|
||||
// 本地加载状态,用于 UI 反馈
|
||||
@ -115,6 +99,8 @@ export const PcTemplateModal = ({
|
||||
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
|
||||
{}
|
||||
);
|
||||
// 自由输入框布局
|
||||
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||
const router = useRouter();
|
||||
|
||||
// 组件挂载时获取模板列表
|
||||
@ -124,6 +110,17 @@ export const PcTemplateModal = ({
|
||||
}
|
||||
}, [isOpen, getTemplateStoryList]);
|
||||
|
||||
// 自由输入框布局
|
||||
useEffect(() => {
|
||||
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
|
||||
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
|
||||
) {
|
||||
setFreeInputLayout('bottom');
|
||||
} else {
|
||||
setFreeInputLayout('top');
|
||||
}
|
||||
}, [selectedTemplate])
|
||||
|
||||
// 监听点击外部区域关闭输入框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@ -194,12 +191,10 @@ export const PcTemplateModal = ({
|
||||
}
|
||||
console.log("Story action created:", projectId);
|
||||
} catch (error) {
|
||||
console.error("Failed to create story action:", error);
|
||||
console.log("Failed to create story action:", error);
|
||||
window.msg.error(error instanceof Error ? error.message : "Failed to create story action");
|
||||
setIsTemplateCreating(false);
|
||||
setLocalLoading(0);
|
||||
// 这里可以添加 toast 提示
|
||||
// 重置状态
|
||||
setSelectedTemplate(null);
|
||||
} finally {
|
||||
setLocalLoading(0);
|
||||
if (timer) {
|
||||
@ -655,24 +650,54 @@ export const PcTemplateModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/** 自由输入文字 */}
|
||||
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
||||
<div className="py-2 flex-1 flex flex-col" style={{
|
||||
height: 'calc(70vh - 300px - 8rem)'
|
||||
}}>
|
||||
<h3
|
||||
data-alt="items-section-title"
|
||||
className="text-lg font-semibold text-white mb-4"
|
||||
>
|
||||
input Configuration
|
||||
</h3>
|
||||
<textarea
|
||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
||||
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||
onChange={(e) => {
|
||||
// 更新自由输入文字字段
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate!,
|
||||
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||
...item,
|
||||
free_input_text: e.target.value
|
||||
})),
|
||||
};
|
||||
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
|
||||
{/** 自由输入文字 */}
|
||||
{(selectedTemplate?.freeInputItem) && (
|
||||
{freeInputLayout === 'bottom' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
||||
<div className="py-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedTemplate?.freeInputItem?.free_input_text || ""}
|
||||
placeholder={selectedTemplate?.freeInputItem.user_tips}
|
||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||
placeholder={selectedTemplate?.freeInput[0].user_tips}
|
||||
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||
onChange={(e) => {
|
||||
// 更新自由输入文字字段
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate!,
|
||||
freeInputItem: {
|
||||
...selectedTemplate!.freeInputItem,
|
||||
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||
...item,
|
||||
free_input_text: e.target.value
|
||||
}
|
||||
};
|
||||
})),
|
||||
}
|
||||
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
||||
}}
|
||||
/>
|
||||
|
||||
155
components/layout/checkin-box.tsx
Normal file
155
components/layout/checkin-box.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Coins, Trophy, HelpCircle } from "lucide-react"
|
||||
import { getCheckinStatus, performCheckin, CheckinData } from "@/api/checkin"
|
||||
|
||||
|
||||
export default function CheckinPage() {
|
||||
const [checkinData, setCheckinData] = useState<CheckinData>({
|
||||
hasCheckedInToday: false,
|
||||
points: 0,
|
||||
lastCheckinDate: null,
|
||||
pointsHistory: [],
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showTip, setShowTip] = useState(false)
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true)
|
||||
|
||||
|
||||
/**
|
||||
* Fetch checkin status
|
||||
*/
|
||||
const fetchCheckinStatus = async () => {
|
||||
try {
|
||||
setIsInitialLoading(true)
|
||||
const data = await getCheckinStatus()
|
||||
setCheckinData(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch checkin status:', error)
|
||||
// Keep default state
|
||||
} finally {
|
||||
setIsInitialLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCheckinStatus()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Perform checkin operation
|
||||
*/
|
||||
const handleCheckin = async () => {
|
||||
if (checkinData.hasCheckedInToday) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await performCheckin()
|
||||
|
||||
if (response.success) {
|
||||
// Refresh status after successful checkin
|
||||
await fetchCheckinStatus()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkin failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<Card className="bg-transparent border-0 shadow-none">
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
|
||||
{/* Checkin status card */}
|
||||
<Card className="bg-transparent border-0 shadow-none">
|
||||
<CardHeader className="text-center pb-4 pt-0">
|
||||
<h1 className="text-3xl font-bold text-balance bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||
Daily Check-in
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-muted-foreground">Check in to earn credits, credits valid for 7 days</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
onMouseEnter={() => setShowTip(true)}
|
||||
onMouseLeave={() => setShowTip(false)}
|
||||
className="p-1 rounded-full hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{showTip && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 p-3 bg-popover border rounded-lg shadow-lg z-10">
|
||||
<div className="text-sm space-y-1 text-left">
|
||||
<p className="font-medium text-foreground">Check-in Rules</p>
|
||||
<p className="text-muted-foreground">• Daily check-in earns 100 credits</p>
|
||||
<p className="text-muted-foreground">• Credits are valid for 7 days</p>
|
||||
<p className="text-muted-foreground">• Expired credits will be automatically cleared</p>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-popover"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="text-center p-6 rounded-lg bg-gradient-to-br from-custom-blue/20 via-custom-purple/20 to-custom-blue/10 border border-custom-blue/30">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Coins className="w-6 h-6 text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Current Credits</span>
|
||||
</div>
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||
{checkinData.points}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Check-in button */}
|
||||
<Button
|
||||
onClick={handleCheckin}
|
||||
disabled={checkinData.hasCheckedInToday || isLoading}
|
||||
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-custom-blue to-custom-purple hover:from-custom-blue/90 hover:to-custom-purple/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Checking in...
|
||||
</div>
|
||||
) : checkinData.hasCheckedInToday ? (
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Trophy className="w-4 h-4" />
|
||||
Checked in Today
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Coins className="w-4 h-4" />
|
||||
Check In Now +100 Credits
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -34,7 +34,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
||||
<div
|
||||
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 z-[999] px-4"
|
||||
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 px-4"
|
||||
style={getLayoutStyles()}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
|
||||
import "../pages/style/top-bar.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { GradientText } from "@/components/ui/gradient-text";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
@ -14,6 +18,7 @@ import {
|
||||
PanelsLeftBottom,
|
||||
Bell,
|
||||
Info,
|
||||
CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { createPortal } from "react-dom";
|
||||
@ -28,6 +33,7 @@ import {
|
||||
} from "@/lib/stripe";
|
||||
import UserCard from "@/components/common/userCard";
|
||||
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
||||
import CheckinBox from "./checkin-box";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@ -55,6 +61,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
||||
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
||||
const [customAmount, setCustomAmount] = useState<string>("");
|
||||
const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false);
|
||||
|
||||
// 获取用户订阅信息
|
||||
const fetchSubscriptionInfo = async () => {
|
||||
@ -239,8 +246,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
element.classList.add("on");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理签到功能,打开签到modal
|
||||
*/
|
||||
const handleCheckin = () => {
|
||||
setIsCheckinModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="fixed right-0 top-0 h-16 header z-[999]"
|
||||
style={{
|
||||
isolation: "isolate",
|
||||
@ -371,7 +385,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
position: "fixed",
|
||||
top: "4rem",
|
||||
right: "1rem",
|
||||
zIndex: 9999,
|
||||
zIndex: 999,
|
||||
}}
|
||||
className="overflow-hidden rounded-xl max-h-[90vh]"
|
||||
data-alt="user-menu-dropdown"
|
||||
@ -394,6 +408,16 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
{currentUser.email}
|
||||
</p>
|
||||
</div>
|
||||
{/* Check-in entry */}
|
||||
{/* <div>
|
||||
<button
|
||||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
onClick={() => handleCheckin()}
|
||||
title="Daily Check-in"
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* AI 积分 */}
|
||||
@ -527,6 +551,19 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check-in Modal */}
|
||||
<Dialog open={isCheckinModalOpen} onOpenChange={setIsCheckinModalOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
|
||||
data-alt="checkin-modal"
|
||||
>
|
||||
<DialogTitle></DialogTitle>
|
||||
<div className="p-4">
|
||||
<CheckinBox />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
"use client"
|
||||
import React, { useRef, useEffect, useCallback } from "react";
|
||||
import "./style/work-flow.css";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { EditModal } from "@/components/ui/edit-modal";
|
||||
import { TaskInfo } from "./work-flow/task-info";
|
||||
import { MediaViewer } from "./work-flow/media-viewer";
|
||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast, ChevronsLeft, Bot, BriefcaseBusiness, Scissors } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Bot, TestTube } from "lucide-react";
|
||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||||
import { Drawer, Tooltip, notification } from 'antd';
|
||||
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
|
||||
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||||
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||||
import { exportVideoWithRetry } from '@/utils/export-service';
|
||||
|
||||
const WorkFlow = React.memo(function WorkFlow() {
|
||||
useEffect(() => {
|
||||
@ -36,15 +36,23 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
||||
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
||||
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
||||
const [aiEditingInProgress, setAiEditingInProgress] = React.useState(false);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
|
||||
const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
|
||||
// const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
|
||||
const [editingStatus, setEditingStatus] = React.useState<'initial' | 'idle' | 'success' | 'error'>('initial');
|
||||
const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState<string>(`iframe-ai-editing-${Date.now()}`);
|
||||
// const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState<string>(`iframe-ai-editing-${Date.now()}`);
|
||||
const [isEditingInProgress, setIsEditingInProgress] = React.useState(false);
|
||||
const isEditingInProgressRef = useRef(false);
|
||||
|
||||
// 导出进度状态
|
||||
const [exportProgress, setExportProgress] = React.useState<{
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
percentage: number;
|
||||
message: string;
|
||||
stage?: string;
|
||||
taskId?: string;
|
||||
} | null>(null);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const episodeId = searchParams.get('episodeId') || '';
|
||||
|
||||
@ -53,23 +61,64 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
SaveEditUseCase.setProjectId(episodeId);
|
||||
let editingNotificationKey = useRef<string>(`editing-${Date.now()}`);
|
||||
const [isHandleEdit, setIsHandleEdit] = React.useState(false);
|
||||
|
||||
// 使用 ref 存储 handleTestExport 避免循环依赖
|
||||
const handleTestExportRef = useRef<(() => Promise<any>) | null>(null);
|
||||
|
||||
// 导出进度回调处理
|
||||
const handleExportProgress = useCallback((progressData: {
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
percentage: number;
|
||||
message: string;
|
||||
stage?: string;
|
||||
taskId?: string;
|
||||
}) => {
|
||||
console.log('📊 导出进度更新:', progressData);
|
||||
setExportProgress(progressData);
|
||||
|
||||
// 根据状态显示不同的通知 - 已注释
|
||||
/*
|
||||
if (progressData.status === 'processing') {
|
||||
notification.info({
|
||||
message: '导出进度',
|
||||
description: `${progressData.message} (${progressData.percentage}%)`,
|
||||
placement: 'topRight',
|
||||
duration: 2,
|
||||
key: 'export-progress'
|
||||
});
|
||||
} else if (progressData.status === 'completed') {
|
||||
notification.success({
|
||||
message: '导出成功',
|
||||
description: progressData.message,
|
||||
placement: 'topRight',
|
||||
duration: 5,
|
||||
key: 'export-progress'
|
||||
});
|
||||
} else if (progressData.status === 'failed') {
|
||||
notification.error({
|
||||
message: '导出失败',
|
||||
description: progressData.message,
|
||||
placement: 'topRight',
|
||||
duration: 8,
|
||||
key: 'export-progress'
|
||||
});
|
||||
}
|
||||
*/
|
||||
}, []);
|
||||
// 处理编辑计划生成完成的回调
|
||||
const handleEditPlanGenerated = useCallback(() => {
|
||||
console.log('🚀 handleEditPlanGenerated called, current ref:', isEditingInProgressRef.current);
|
||||
|
||||
// 防止重复调用 - 使用 ref 避免依赖项变化
|
||||
if (isEditingInProgressRef.current) {
|
||||
console.log('⚠️ 编辑已在进行中,跳过重复调用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✨ 编辑计划生成完成,开始AI剪辑');
|
||||
setIsHandleEdit(true);
|
||||
setEditingStatus('idle');
|
||||
setIsEditingInProgress(true);
|
||||
// setIsEditingInProgress(true); // 已移除该状态变量
|
||||
isEditingInProgressRef.current = true;
|
||||
|
||||
aiEditingButtonRef.current?.handleAIEditing();
|
||||
// 改为调用测试剪辑计划导出按钮方法
|
||||
// aiEditingButtonRef.current?.handleAIEditing();
|
||||
// 使用 ref 调用避免循环依赖
|
||||
setTimeout(() => {
|
||||
handleTestExportRef.current?.();
|
||||
}, 0);
|
||||
editingNotificationKey.current = `editing-${Date.now()}`;
|
||||
showEditingNotification({
|
||||
description: 'Performing intelligent editing...',
|
||||
@ -88,7 +137,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
}
|
||||
|
||||
// 重新生成 iframeAiEditingKey 触发重新渲染
|
||||
setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
|
||||
// setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
|
||||
|
||||
// 延时200ms后显示重试通知,确保之前的通知已销毁
|
||||
setTimeout(() => {
|
||||
@ -117,13 +166,13 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}, [episodeId]); // 移除 isEditingInProgress 依赖
|
||||
}, [episodeId]); // handleTestExport 在内部调用,无需作为依赖
|
||||
|
||||
/** 处理导出失败 */
|
||||
const handleExportFailed = useCallback(() => {
|
||||
console.log('Export failed, setting error status');
|
||||
setEditingStatus('error');
|
||||
setIsEditingInProgress(false);
|
||||
// setIsEditingInProgress(false); // 已移除该状态变量
|
||||
isEditingInProgressRef.current = false;
|
||||
|
||||
// 销毁当前编辑通知
|
||||
@ -166,7 +215,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
||||
}, [currentSketchIndex, taskObject]);
|
||||
|
||||
// 监听粗剪是否完成,如果完成 更新 showEditingNotification 的状态 为完成,延时 3s 并关闭
|
||||
// 监听粗剪是否完成
|
||||
useEffect(() => {
|
||||
console.log('🎬 final video useEffect triggered:', {
|
||||
finalUrl: taskObject.final.url,
|
||||
@ -219,16 +268,47 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
// 切换到最终视频阶段
|
||||
setAnyAttribute('currentStage', 'final_video');
|
||||
|
||||
setAiEditingInProgress(false);
|
||||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||
}, [setAnyAttribute]);
|
||||
|
||||
const handleAIEditingError = useCallback((error: string) => {
|
||||
console.error('❌ AI剪辑失败:', error);
|
||||
// 这里可以显示错误提示
|
||||
setAiEditingInProgress(false);
|
||||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||
}, []);
|
||||
|
||||
// iframe智能剪辑回调函数
|
||||
// 测试导出接口的处理函数(使用封装的导出服务)
|
||||
const handleTestExport = useCallback(async () => {
|
||||
console.log('🧪 开始测试导出接口...');
|
||||
console.log('📊 当前taskObject状态:', {
|
||||
currentStage: taskObject.currentStage,
|
||||
videosCount: taskObject.videos?.data?.length || 0,
|
||||
completedVideos: taskObject.videos?.data?.filter(v => v.video_status === 1).length || 0
|
||||
});
|
||||
|
||||
try {
|
||||
// 使用封装的导出服务,传递进度回调
|
||||
const result = await exportVideoWithRetry(episodeId, taskObject, handleExportProgress);
|
||||
console.log('🎉 导出服务完成,结果:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ 导出服务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [episodeId, taskObject, handleExportProgress]);
|
||||
|
||||
// 将 handleTestExport 赋值给 ref
|
||||
React.useEffect(() => {
|
||||
handleTestExportRef.current = handleTestExport;
|
||||
}, [handleTestExport]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// iframe智能剪辑回调函数 - 已注释
|
||||
/*
|
||||
const handleIframeAIEditingComplete = useCallback((result: any) => {
|
||||
console.log('🎉 iframe AI剪辑完成,结果:', result);
|
||||
|
||||
@ -244,18 +324,23 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
// 切换到最终视频阶段
|
||||
setAnyAttribute('currentStage', 'final_video');
|
||||
|
||||
setAiEditingInProgress(false);
|
||||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||
}, [setAnyAttribute]);
|
||||
*/
|
||||
|
||||
/*
|
||||
const handleIframeAIEditingError = useCallback((error: string) => {
|
||||
console.error('❌ iframe AI剪辑失败:', error);
|
||||
setAiEditingInProgress(false);
|
||||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||
}, []);
|
||||
*/
|
||||
|
||||
/*
|
||||
const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => {
|
||||
console.log(`📊 AI剪辑进度: ${progress}% - ${message}`);
|
||||
setAiEditingInProgress(true);
|
||||
// setAiEditingInProgress(true); // 已移除该状态变量
|
||||
}, []);
|
||||
*/
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
||||
@ -320,7 +405,8 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI剪辑按钮 - 当可以跳转剪辑时显示 */}
|
||||
{/* AI剪辑按钮 - 已注释,不加载iframe */}
|
||||
{/*
|
||||
{
|
||||
isShowAutoEditing && (
|
||||
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
||||
@ -341,6 +427,45 @@ const WorkFlow = React.memo(function WorkFlow() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
*/}
|
||||
|
||||
{/* 导出进度显示 - 已注释 */}
|
||||
{/*
|
||||
{exportProgress && exportProgress.status === 'processing' && (
|
||||
<div className="fixed right-[1rem] bottom-[20rem] z-[49]">
|
||||
<div className="backdrop-blur-lg bg-black/30 border border-white/20 rounded-lg p-4 max-w-xs">
|
||||
<div className="text-white text-sm mb-2">
|
||||
导出进度: {exportProgress.percentage}%
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${exportProgress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-300 text-xs">
|
||||
{exportProgress.message}
|
||||
{exportProgress.stage && ` (${exportProgress.stage})`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* 测试导出接口按钮 - 隐藏显示(仍可通过逻辑调用) */}
|
||||
<div
|
||||
className="fixed right-[1rem] bottom-[16rem] z-[49]"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<Tooltip title="测试剪辑计划导出接口" placement="left">
|
||||
<GlassIconButton
|
||||
icon={TestTube}
|
||||
size='md'
|
||||
onClick={handleTestExport}
|
||||
className="backdrop-blur-lg"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 智能对话按钮 */}
|
||||
<div
|
||||
|
||||
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-[999]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg z-[999]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
119
docs/3.md
Normal file
119
docs/3.md
Normal file
@ -0,0 +1,119 @@
|
||||
https://smartcut.api.movieflow.ai/api/export/stream
|
||||
|
||||
post 请求
|
||||
|
||||
{"ir":{"width":1920,"height":1080,"fps":30,"duration":88000,"video":[{"id":"ec87abdf-dcaa-4460-ad82-f453b6a47519","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_47e2b503-c6e7-457e-b179-17ed2533ece4-20250912151950.mp4","in":0,"out":8000,"start":0,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_47e2b503-c6e7-457e-b179-17ed2533ece4-20250912151950.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"6c029f2d-5d99-403a-81be-fc4629c51873","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ2_7d99f823-fb39-46cf-bdc9-0de8b97762a5-20250912151854.mp4","in":0,"out":8000,"start":8000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ2_7d99f823-fb39-46cf-bdc9-0de8b97762a5-20250912151854.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"d0d2a8f8-f4b9-4dfe-89de-76947adc6d49","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3_e0d53da3-e48f-4b8b-b559-c280d0fc2c3f-20250912151949.mp4","in":0,"out":8000,"start":16000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3_e0d53da3-e48f-4b8b-b559-c280d0fc2c3f-20250912151949.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"006ba900-5f1b-4f4b-86c2-4231f717dd4d","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ4_580198cb-99fa-485f-891f-3cd15b1d4f51-20250912151950.mp4","in":0,"out":8000,"start":24000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ4_580198cb-99fa-485f-891f-3cd15b1d4f51-20250912151950.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"a1a41f72-86d1-47b6-8e1d-26a479a3fb95","src":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ5_d9420926-c9de-44be-937d-fc34b99bbc6b-20250912151853.mp4","in":0,"out":8000,"start":32000,"trackId":"d5b8a916-8e13-4955-a677-c7f12fd6037c","transform":{"x":0,"y":0,"scale":1,"rotate":0,"horizontalFlip":false,"verticalFlip":false},"muted":false,"actualPath":"https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ5_d9420926-c9de-44be-937d-fc34b99bbc6b-20250912151853.mp4","metadata":{"size":0,"duration":8,"width":1920,"height":1080,"type":"video"}},{"id":"62eee9f0-1040-4afc-a22b-c9c3ceddb360","Show more
|
||||
|
||||
剪辑计划完整数据
|
||||
{
|
||||
"project_id": "3a994c3f-8a98-45e5-be9b-cdf3fabcc335",
|
||||
"director_intent": "",
|
||||
"success": true,
|
||||
"editing_plan": {
|
||||
"finalized_dialogue_track": {
|
||||
"final_dialogue_segments": [
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_001",
|
||||
"source_clip_id": "E1-S1-C01",
|
||||
"start_timecode": "00:00:00.000",
|
||||
"end_timecode": "00:00:08.000",
|
||||
"transcript": "Discipline is a fortress built stone by stone.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_003",
|
||||
"source_clip_id": "E1-S1-C05",
|
||||
"start_timecode": "00:00:00.000",
|
||||
"end_timecode": "00:00:02.000",
|
||||
"transcript": "But every fortress has a gate.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_005",
|
||||
"source_clip_id": "E1-S1-C06_C07_Combined",
|
||||
"start_timecode": "00:00:02.000",
|
||||
"end_timecode": "00:00:04.000",
|
||||
"transcript": "Li. To the inner chambers. Go.",
|
||||
"speaker": "JIN"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_009",
|
||||
"source_clip_id": "E1-S1-C19_E1-S1-C20",
|
||||
"start_timecode": "00:00:01.000",
|
||||
"end_timecode": "00:00:03.000",
|
||||
"transcript": "And some lessons are taught in blood.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"material_classification_results": {
|
||||
"discarded_footage_list": [
|
||||
{
|
||||
"clip_id": "E1-S1-C15-16_Combined",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ10_5d3d2102-a1a0-44f2-afb1-10ce66690c00-20250916160659.mp4",
|
||||
"reason": "该素材包含严重 AI 生成伪影和恐怖谷效应,导致核心内容无法识别,且存在更好的替代品(E1-S1-C13_E1-S1-C14_Sequence 的结尾部分可以替代其叙事功能)。"
|
||||
},
|
||||
{
|
||||
"clip_id": "E1-S1-C17_E1-S1-C18_Combined",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ11_7db3d22f-f7c1-40bb-9b14-cb8b4993adc3-20250916160659.mp4",
|
||||
"reason": "该素材所有片段均存在严重 AI 生成缺陷,无法有效传达核心价值,且无法通过后期修复。已在 production_suggestions 中请求补拍。"
|
||||
}
|
||||
],
|
||||
"alternative_footage_list": []
|
||||
},
|
||||
"editing_sequence_plans": [
|
||||
{
|
||||
"version_name": "Final Cut - Action Focus",
|
||||
"version_summary": "本剪辑方案严格遵循默奇六原则和最高指令,以快节奏、高信息密度和紧张感为核心,构建了一个从宁静到暴力冲突的叙事弧线。优先保留情感和故事驱动的镜头,对物理连贯性 Bug 采取容忍或修复策略,确保故事连贯性。",
|
||||
"timeline_clips": [
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_001",
|
||||
"source_clip_id": "E1-S1-C01",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_9a868131-8594-43f1-84a6-f83b2246b91f-20250916160702.mp4",
|
||||
"corresponding_script_scene_id": "SCENE 1",
|
||||
"clip_type": "Establishing Shot",
|
||||
"sequence_start_timecode": "00:00:00.000",
|
||||
"source_in_timecode": "00:00:00.000",
|
||||
"source_out_timecode": "00:00:08.000",
|
||||
"clip_duration_in_sequence": "00:00:08.000",
|
||||
"transition_from_previous": {
|
||||
"transition_type": "Fade In",
|
||||
"transition_duration_ms": 1000,
|
||||
"audio_sync_offset_ms": 0,
|
||||
"reason_for_transition": "从黑场淡入,强调宁静的开场,与金大师的画外音完美结合,建立氛围。"
|
||||
},
|
||||
"clip_placement_reasons": {
|
||||
"prime_directive_justification": "此为场景开场,建立环境和角色,是叙事不可或缺的节拍。",
|
||||
"core_intent_and_audience_effect": "通过广阔的夜景和金大师的画外音,建立修道院的宁静与纪律,为后续的暴力打破提供强烈对比,引发观众对和平被侵犯的共鸣。",
|
||||
"emotion_priority_51": "传达宁静、秩序与潜在的脆弱感。",
|
||||
"story_priority_23": "引入主要角色(李和金),建立场景背景,并以画外音点明主题。",
|
||||
"rhythm_priority_10": "缓慢的开场节奏,为后续的冲突积蓄力量。",
|
||||
"eyeline_priority_7": "高角度俯瞰,引导观众总览整个场景。",
|
||||
"2d_space_priority_5": "静态镜头,无轴线问题。",
|
||||
"3d_space_priority_4": "空间布局清晰,道具位置合理。",
|
||||
"lens_language_application": "高角度 EWS,强调环境的广阔和人物的渺小,营造史诗感。"
|
||||
},
|
||||
"continuity_correction_details": {
|
||||
"error_detected_in_audit": true,
|
||||
"error_type": "Script-to-Picture Mismatch (Camera Movement)",
|
||||
"error_description": "剧本要求慢速摇臂和摇摄,但素材为静态广角镜头。李的扫地动作重复机械。",
|
||||
"resolution_strategy_applied": "Tolerate (Sole Coverage) & VFX Suggested",
|
||||
"details": "尽管运镜不符,但该镜头作为开场建立场景的功能强大,且是唯一覆盖素材。选择容忍其静态运镜,并建议后期 VFX 修复李的扫地动作以增加自然度。其核心叙事价值(建立宁静)高于运镜缺陷。"
|
||||
},
|
||||
"sound_design_suggestions": [
|
||||
{
|
||||
"sound_type": "Ambient Sound",
|
||||
"description": "加入微弱的夜间环境音,如风声、远处虫鸣,以增强宁静感。",
|
||||
"timing_in_clip": "00:00:00.000 - 00:00:08.000",
|
||||
"intensity_suggestion": "Low"
|
||||
},
|
||||
{
|
||||
"sound_type": "Voice-over",
|
||||
"description": "确保画外音清晰、洪亮,与画面氛围匹配。",
|
||||
"timing_in_clip": "00:00:00.000 - 00:00:08.000",
|
||||
"intensity_suggestion": "Medium"
|
||||
}
|
||||
],
|
||||
"visual_enhancement_suggestions": [
|
||||
{
|
||||
"enh
|
||||
322
docs/API_EXPORT_STREAM_GUIDE.md
Normal file
322
docs/API_EXPORT_STREAM_GUIDE.md
Normal file
@ -0,0 +1,322 @@
|
||||
# 视频导出流式接口 API 规范
|
||||
|
||||
## 接口概述
|
||||
|
||||
**接口地址**: `POST /api/export/stream`
|
||||
**接口类型**: Server-Sent Events (SSE) 流式接口
|
||||
**功能描述**: 实时流式视频导出,支持进度推送和高质量流复制模式
|
||||
|
||||
## 请求参数
|
||||
|
||||
### 请求头
|
||||
```http
|
||||
Content-Type: application/json
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
### 请求体结构
|
||||
|
||||
```typescript
|
||||
interface ExportRequest {
|
||||
project_id?: string; // 项目ID(可选)
|
||||
ir: IRData; // 时间轴中间表示数据(必需)
|
||||
options?: ExportOptions; // 导出选项(可选)
|
||||
videoFiles?: Record<string, string>; // 视频文件base64数据(可选)
|
||||
}
|
||||
```
|
||||
|
||||
## 详细参数说明
|
||||
|
||||
### 1. project_id (可选)
|
||||
- **类型**: `string`
|
||||
- **描述**: 项目唯一标识符
|
||||
- **默认值**: 如果未提供,系统会生成 `default_project_{task_id前8位}`
|
||||
- **示例**: `"project_12345"`
|
||||
|
||||
### 2. ir (必需) - 时间轴中间表示数据
|
||||
|
||||
```typescript
|
||||
interface IRData {
|
||||
width: number; // 视频宽度(必需)
|
||||
height: number; // 视频高度(必需)
|
||||
fps: number; // 帧率(必需)
|
||||
duration: number; // 总时长,单位毫秒(必需)
|
||||
video: VideoElement[]; // 视频轨道数据(必需)
|
||||
texts?: TextElement[]; // 字幕轨道数据(可选)
|
||||
audio?: AudioElement[]; // 音频轨道数据(可选)
|
||||
transitions?: TransitionElement[]; // 转场效果(可选)
|
||||
}
|
||||
```
|
||||
|
||||
#### VideoElement 结构
|
||||
```typescript
|
||||
interface VideoElement {
|
||||
id: string; // 视频元素唯一ID
|
||||
src: string; // 视频源路径/URL/blob URL
|
||||
start: number; // 在时间轴上的开始时间(毫秒)
|
||||
end?: number; // 在时间轴上的结束时间(毫秒)
|
||||
in: number; // 视频内部开始时间(毫秒)
|
||||
out: number; // 视频内部结束时间(毫秒)
|
||||
_source_type?: 'local' | 'remote_url' | 'blob'; // 源类型标识
|
||||
}
|
||||
```
|
||||
|
||||
#### TextElement 结构
|
||||
```typescript
|
||||
interface TextElement {
|
||||
id: string; // 字幕元素唯一ID
|
||||
text: string; // 字幕内容
|
||||
start: number; // 开始时间(毫秒)
|
||||
end: number; // 结束时间(毫秒)
|
||||
style?: TextStyle; // 字幕样式
|
||||
}
|
||||
|
||||
interface TextStyle {
|
||||
fontFamily?: string; // 字体,默认 'Arial'
|
||||
fontSize?: number; // 字体大小,默认 40
|
||||
color?: string; // 字体颜色,默认 '#FFFFFF'
|
||||
backgroundColor?: string; // 背景色,默认 'transparent'
|
||||
fontWeight?: 'normal' | 'bold'; // 字体粗细
|
||||
fontStyle?: 'normal' | 'italic'; // 字体样式
|
||||
align?: 'left' | 'center' | 'right'; // 对齐方式
|
||||
shadow?: boolean; // 是否显示阴影
|
||||
rotation?: number; // 旋转角度
|
||||
}
|
||||
```
|
||||
|
||||
### 3. options (可选) - 导出选项
|
||||
|
||||
```typescript
|
||||
interface ExportOptions {
|
||||
quality?: 'preview' | 'standard' | 'professional'; // 质量等级
|
||||
codec?: string; // 编码器,默认 'libx264'
|
||||
subtitleMode?: 'hard' | 'soft'; // 字幕模式,默认 'hard'
|
||||
bitrate?: string; // 比特率,如 '5000k'
|
||||
preset?: string; // 编码预设,如 'medium'
|
||||
}
|
||||
```
|
||||
|
||||
**默认值**:
|
||||
```json
|
||||
{
|
||||
"quality": "standard",
|
||||
"codec": "libx264",
|
||||
"subtitleMode": "hard"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. videoFiles (可选) - Base64视频数据
|
||||
|
||||
```typescript
|
||||
interface VideoFiles {
|
||||
[blobId: string]: string; // blobId -> base64编码的视频数据
|
||||
}
|
||||
```
|
||||
|
||||
**使用场景**: 当 `VideoElement.src` 为 blob URL 时,需要提供对应的 base64 数据
|
||||
|
||||
## 完整请求示例
|
||||
|
||||
### 基础示例
|
||||
```json
|
||||
{
|
||||
"project_id": "demo_project_001",
|
||||
"ir": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"fps": 30,
|
||||
"duration": 15000,
|
||||
"video": [
|
||||
{
|
||||
"id": "video_1",
|
||||
"src": "https://example.com/video1.mp4",
|
||||
"start": 0,
|
||||
"end": 10000,
|
||||
"in": 2000,
|
||||
"out": 12000,
|
||||
"_source_type": "remote_url"
|
||||
},
|
||||
{
|
||||
"id": "video_2",
|
||||
"src": "blob:http://localhost:3000/abc-123",
|
||||
"start": 10000,
|
||||
"end": 15000,
|
||||
"in": 0,
|
||||
"out": 5000,
|
||||
"_source_type": "blob"
|
||||
}
|
||||
],
|
||||
"texts": [
|
||||
{
|
||||
"id": "subtitle_1",
|
||||
"text": "欢迎观看演示视频",
|
||||
"start": 1000,
|
||||
"end": 4000,
|
||||
"style": {
|
||||
"fontSize": 48,
|
||||
"color": "#FFFFFF",
|
||||
"fontFamily": "Arial",
|
||||
"align": "center"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"quality": "professional",
|
||||
"codec": "libx264",
|
||||
"subtitleMode": "hard"
|
||||
},
|
||||
"videoFiles": {
|
||||
"abc-123": "data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28y..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 响应格式 (SSE)
|
||||
|
||||
接口返回 Server-Sent Events 流,每个事件包含以下格式:
|
||||
|
||||
```
|
||||
data: {"type": "progress", "message": "处理中...", "progress": 0.5}
|
||||
|
||||
```
|
||||
|
||||
### 事件类型
|
||||
|
||||
#### 1. start - 开始事件
|
||||
```json
|
||||
{
|
||||
"type": "start",
|
||||
"message": "开始导出...",
|
||||
"timestamp": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. progress - 进度事件
|
||||
```json
|
||||
{
|
||||
"type": "progress",
|
||||
"stage": "preparing|stream_copy|uploading",
|
||||
"message": "当前阶段描述",
|
||||
"progress": 0.65,
|
||||
"timestamp": "2024-01-01T12:00:30.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. complete - 完成事件
|
||||
```json
|
||||
{
|
||||
"type": "complete",
|
||||
"message": "🎬 高清视频导出完成",
|
||||
"timestamp": "2024-01-01T12:01:00.000Z",
|
||||
"file_size": 52428800,
|
||||
"export_id": "export_abc123",
|
||||
"quality_mode": "stream_copy",
|
||||
"download_url": "https://cdn.example.com/video.mp4",
|
||||
"cloud_storage": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. error - 错误事件
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "导出失败: 文件不存在",
|
||||
"timestamp": "2024-01-01T12:00:45.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成示例
|
||||
|
||||
### JavaScript/TypeScript
|
||||
```typescript
|
||||
async function exportVideo(exportRequest: ExportRequest) {
|
||||
const response = await fetch('/api/export/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
body: JSON.stringify(exportRequest)
|
||||
});
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader!.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handleProgressEvent(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressEvent(event: any) {
|
||||
switch (event.type) {
|
||||
case 'start':
|
||||
console.log('导出开始');
|
||||
break;
|
||||
case 'progress':
|
||||
console.log(`进度: ${event.progress * 100}% - ${event.message}`);
|
||||
break;
|
||||
case 'complete':
|
||||
console.log('导出完成:', event.download_url);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('导出失败:', event.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 重要注意事项
|
||||
|
||||
### 1. 视频源处理优先级
|
||||
1. **本地文件路径** - 直接使用
|
||||
2. **HTTP/HTTPS URL** - 自动下载
|
||||
3. **Blob URL** - 需要提供 `videoFiles` 中的 base64 数据
|
||||
|
||||
### 2. 高质量流复制模式
|
||||
- 系统默认启用流复制模式,保持原始视频质量
|
||||
- 处理速度提升 10-20 倍
|
||||
- 零质量损失
|
||||
|
||||
### 3. 音频兼容性
|
||||
- 自动检测混合音频情况
|
||||
- 智能处理有音频/无音频片段的兼容性
|
||||
|
||||
### 4. 错误处理
|
||||
- 无效视频源会被自动跳过
|
||||
- 详细的错误信息通过 SSE 实时推送
|
||||
|
||||
### 5. 云存储集成
|
||||
- 支持七牛云自动上传
|
||||
- 上传失败时提供本地下载链接
|
||||
|
||||
## 验证接口
|
||||
|
||||
在正式导出前,建议先调用验证接口:
|
||||
|
||||
```http
|
||||
POST /api/export/validate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ir": { /* 同导出接口的ir参数 */ },
|
||||
"options": { /* 同导出接口的options参数 */ }
|
||||
}
|
||||
```
|
||||
|
||||
验证接口会检查:
|
||||
- IR 数据完整性
|
||||
- 视频分辨率、帧率、时长
|
||||
- 导出选项有效性
|
||||
- 返回详细的验证错误信息
|
||||
114
docs/剪辑计划.md
Normal file
114
docs/剪辑计划.md
Normal file
@ -0,0 +1,114 @@
|
||||
|
||||
剪辑计划完整数据
|
||||
{
|
||||
"project_id": "3a994c3f-8a98-45e5-be9b-cdf3fabcc335",
|
||||
"director_intent": "",
|
||||
"success": true,
|
||||
"editing_plan": {
|
||||
"finalized_dialogue_track": {
|
||||
"final_dialogue_segments": [
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_001",
|
||||
"source_clip_id": "E1-S1-C01",
|
||||
"start_timecode": "00:00:00.000",
|
||||
"end_timecode": "00:00:08.000",
|
||||
"transcript": "Discipline is a fortress built stone by stone.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_003",
|
||||
"source_clip_id": "E1-S1-C05",
|
||||
"start_timecode": "00:00:00.000",
|
||||
"end_timecode": "00:00:02.000",
|
||||
"transcript": "But every fortress has a gate.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_005",
|
||||
"source_clip_id": "E1-S1-C06_C07_Combined",
|
||||
"start_timecode": "00:00:02.000",
|
||||
"end_timecode": "00:00:04.000",
|
||||
"transcript": "Li. To the inner chambers. Go.",
|
||||
"speaker": "JIN"
|
||||
},
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_009",
|
||||
"source_clip_id": "E1-S1-C19_E1-S1-C20",
|
||||
"start_timecode": "00:00:01.000",
|
||||
"end_timecode": "00:00:03.000",
|
||||
"transcript": "And some lessons are taught in blood.",
|
||||
"speaker": "JIN (V.O.)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"material_classification_results": {
|
||||
"discarded_footage_list": [
|
||||
{
|
||||
"clip_id": "E1-S1-C15-16_Combined",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ10_5d3d2102-a1a0-44f2-afb1-10ce66690c00-20250916160659.mp4",
|
||||
"reason": "该素材包含严重AI生成伪影和恐怖谷效应,导致核心内容无法识别,且存在更好的替代品(E1-S1-C13_E1-S1-C14_Sequence的结尾部分可以替代其叙事功能)。"
|
||||
},
|
||||
{
|
||||
"clip_id": "E1-S1-C17_E1-S1-C18_Combined",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ11_7db3d22f-f7c1-40bb-9b14-cb8b4993adc3-20250916160659.mp4",
|
||||
"reason": "该素材所有片段均存在严重AI生成缺陷,无法有效传达核心价值,且无法通过后期修复。已在production_suggestions中请求补拍。"
|
||||
}
|
||||
],
|
||||
"alternative_footage_list": []
|
||||
},
|
||||
"editing_sequence_plans": [
|
||||
{
|
||||
"version_name": "Final Cut - Action Focus",
|
||||
"version_summary": "本剪辑方案严格遵循默奇六原则和最高指令,以快节奏、高信息密度和紧张感为核心,构建了一个从宁静到暴力冲突的叙事弧线。优先保留情感和故事驱动的镜头,对物理连贯性Bug采取容忍或修复策略,确保故事连贯性。",
|
||||
"timeline_clips": [
|
||||
{
|
||||
"sequence_clip_id": "seq_clip_001",
|
||||
"source_clip_id": "E1-S1-C01",
|
||||
"video_url": "https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1_9a868131-8594-43f1-84a6-f83b2246b91f-20250916160702.mp4",
|
||||
"corresponding_script_scene_id": "SCENE 1",
|
||||
"clip_type": "Establishing Shot",
|
||||
"sequence_start_timecode": "00:00:00.000",
|
||||
"source_in_timecode": "00:00:00.000",
|
||||
"source_out_timecode": "00:00:08.000",
|
||||
"clip_duration_in_sequence": "00:00:08.000",
|
||||
"transition_from_previous": {
|
||||
"transition_type": "Fade In",
|
||||
"transition_duration_ms": 1000,
|
||||
"audio_sync_offset_ms": 0,
|
||||
"reason_for_transition": "从黑场淡入,强调宁静的开场,与金大师的画外音完美结合,建立氛围。"
|
||||
},
|
||||
"clip_placement_reasons": {
|
||||
"prime_directive_justification": "此为场景开场,建立环境和角色,是叙事不可或缺的节拍。",
|
||||
"core_intent_and_audience_effect": "通过广阔的夜景和金大师的画外音,建立修道院的宁静与纪律,为后续的暴力打破提供强烈对比,引发观众对和平被侵犯的共鸣。",
|
||||
"emotion_priority_51": "传达宁静、秩序与潜在的脆弱感。",
|
||||
"story_priority_23": "引入主要角色(李和金),建立场景背景,并以画外音点明主题。",
|
||||
"rhythm_priority_10": "缓慢的开场节奏,为后续的冲突积蓄力量。",
|
||||
"eyeline_priority_7": "高角度俯瞰,引导观众总览整个场景。",
|
||||
"2d_space_priority_5": "静态镜头,无轴线问题。",
|
||||
"3d_space_priority_4": "空间布局清晰,道具位置合理。",
|
||||
"lens_language_application": "高角度EWS,强调环境的广阔和人物的渺小,营造史诗感。"
|
||||
},
|
||||
"continuity_correction_details": {
|
||||
"error_detected_in_audit": true,
|
||||
"error_type": "Script-to-Picture Mismatch (Camera Movement)",
|
||||
"error_description": "剧本要求慢速摇臂和摇摄,但素材为静态广角镜头。李的扫地动作重复机械。",
|
||||
"resolution_strategy_applied": "Tolerate (Sole Coverage) & VFX Suggested",
|
||||
"details": "尽管运镜不符,但该镜头作为开场建立场景的功能强大,且是唯一覆盖素材。选择容忍其静态运镜,并建议后期VFX修复李的扫地动作以增加自然度。其核心叙事价值(建立宁静)高于运镜缺陷。"
|
||||
},
|
||||
"sound_design_suggestions": [
|
||||
{
|
||||
"sound_type": "Ambient Sound",
|
||||
"description": "加入微弱的夜间环境音,如风声、远处虫鸣,以增强宁静感。",
|
||||
"timing_in_clip": "00:00:00.000 - 00:00:08.000",
|
||||
"intensity_suggestion": "Low"
|
||||
},
|
||||
{
|
||||
"sound_type": "Voice-over",
|
||||
"description": "确保画外音清晰、洪亮,与画面氛围匹配。",
|
||||
"timing_in_clip": "00:00:00.000 - 00:00:08.000",
|
||||
"intensity_suggestion": "Medium"
|
||||
}
|
||||
],
|
||||
"visual_enhancement_suggestions": [
|
||||
{
|
||||
"enh
|
||||
15
docs/导出进度.md
Normal file
15
docs/导出进度.md
Normal file
@ -0,0 +1,15 @@
|
||||
{task_id: "90a0d810-5bc7-4491-889d-b018df41903a", status: "processing",…}
|
||||
progress:
|
||||
{percentage: 10.665, message: "正在上传高清视频到云存储...", stage: "synthesis",…}
|
||||
message: "正在上传高清视频到云存储..."
|
||||
percentage: 10.665
|
||||
stage:
|
||||
"synthesis"
|
||||
timestamp:
|
||||
"2025-09-17T10:10:21.146770"
|
||||
project_id:
|
||||
"e9be9495-fe4b-41da-9d73-9b7179cd72a6"
|
||||
stage: "synthesis"
|
||||
status: "processing"
|
||||
task_id: "90a0d810-5bc7-4491-889d-b018df41903a"
|
||||
updated_at: "2025-09-17T10:10:21.146770"
|
||||
5
docs/请求.md
Normal file
5
docs/请求.md
Normal file
File diff suppressed because one or more lines are too long
@ -43,6 +43,8 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
'custom-blue': '#6AF4F9',
|
||||
'custom-purple': '#C73BFF',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))',
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: '0',
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
},
|
||||
'accordion-up': {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
to: {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
export default config;
|
||||
879
utils/export-service.ts
Normal file
879
utils/export-service.ts
Normal file
@ -0,0 +1,879 @@
|
||||
import { notification } from 'antd';
|
||||
import { downloadVideo } from './tools';
|
||||
import { getGenerateEditPlan } from '@/api/video_flow';
|
||||
|
||||
/**
|
||||
* 导出服务 - 封装视频导出相关功能
|
||||
* 支持流式导出、进度轮询、失败重试机制
|
||||
*/
|
||||
|
||||
// 导出请求接口
|
||||
interface ExportRequest {
|
||||
project_id: string;
|
||||
ir: IRData;
|
||||
options: ExportOptions;
|
||||
}
|
||||
|
||||
// IR数据结构
|
||||
interface IRData {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
duration: number;
|
||||
video: VideoElement[];
|
||||
texts: TextElement[];
|
||||
audio: any[];
|
||||
transitions: TransitionElement[];
|
||||
}
|
||||
|
||||
// 视频元素结构
|
||||
interface VideoElement {
|
||||
id: string;
|
||||
src: string;
|
||||
start: number;
|
||||
end: number;
|
||||
in: number;
|
||||
out: number;
|
||||
_source_type: 'remote_url' | 'local';
|
||||
}
|
||||
|
||||
// 文本元素结构
|
||||
interface TextElement {
|
||||
id: string;
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
style: {
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontWeight: 'normal' | 'bold';
|
||||
fontStyle: 'normal' | 'italic';
|
||||
align: 'left' | 'center' | 'right';
|
||||
shadow: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 转场元素结构
|
||||
interface TransitionElement {
|
||||
id: string;
|
||||
type: string;
|
||||
duration: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// 导出选项
|
||||
interface ExportOptions {
|
||||
quality: 'preview' | 'standard' | 'professional';
|
||||
codec: string;
|
||||
subtitleMode: 'hard' | 'soft';
|
||||
}
|
||||
|
||||
// 导出进度状态类型
|
||||
type ExportStatus = 'processing' | 'completed' | 'failed';
|
||||
|
||||
// 导出进度回调接口
|
||||
interface ExportProgressCallback {
|
||||
onProgress?: (data: {
|
||||
status: ExportStatus;
|
||||
percentage: number;
|
||||
message: string;
|
||||
stage?: string;
|
||||
taskId?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// 导出服务配置
|
||||
interface ExportServiceConfig {
|
||||
maxRetries?: number;
|
||||
pollInterval?: number;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
// 导出结果
|
||||
interface ExportResult {
|
||||
task_id: string;
|
||||
status: string;
|
||||
video_url?: string;
|
||||
file_size?: number;
|
||||
export_id?: string;
|
||||
quality_mode?: string;
|
||||
watermark_status?: string;
|
||||
upload_time?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频导出服务类
|
||||
*/
|
||||
export class VideoExportService {
|
||||
private config: Required<ExportServiceConfig>;
|
||||
private cachedExportRequest: ExportRequest | null = null;
|
||||
|
||||
constructor(config: ExportServiceConfig = {}) {
|
||||
this.config = {
|
||||
maxRetries: config.maxRetries || 3,
|
||||
pollInterval: config.pollInterval || 5000, // 5秒轮询
|
||||
apiBaseUrl: 'https://smartcut.api.movieflow.ai',
|
||||
// apiBaseUrl: process.env.NEXT_PUBLIC_CUTAPI_URL || 'https://smartcut.api.movieflow.ai'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:将时间码转换为毫秒
|
||||
*/
|
||||
private parseTimecodeToMs(timecode: string): number {
|
||||
// 处理两种时间码格式:
|
||||
// 1. "00:00:08.000" (时:分:秒.毫秒) - 剪辑计划格式
|
||||
// 2. "00:00:08:00" (时:分:秒:帧) - 传统时间码格式
|
||||
|
||||
if (timecode.includes('.')) {
|
||||
// 格式: "00:00:08.000"
|
||||
const [timePart, msPart] = timecode.split('.');
|
||||
const [hours, minutes, seconds] = timePart.split(':').map(Number);
|
||||
const milliseconds = parseInt(msPart.padEnd(3, '0').slice(0, 3));
|
||||
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
|
||||
} else {
|
||||
// 格式: "00:00:08:00" (时:分:秒:帧)
|
||||
const parts = timecode.split(':');
|
||||
if (parts.length !== 4) return 0;
|
||||
|
||||
const hours = parseInt(parts[0]) || 0;
|
||||
const minutes = parseInt(parts[1]) || 0;
|
||||
const seconds = parseInt(parts[2]) || 0;
|
||||
const frames = parseInt(parts[3]) || 0;
|
||||
|
||||
// 假设30fps
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds + frames / 30;
|
||||
return Math.round(totalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剪辑计划(带重试机制)
|
||||
* 8秒重试一次,最长重试10分钟,直到成功
|
||||
*/
|
||||
private async getEditingPlanWithRetry(episodeId: string, progressCallback?: ExportProgressCallback['onProgress']): Promise<any> {
|
||||
const maxRetryTime = 10 * 60 * 1000; // 10分钟
|
||||
const retryInterval = 8 * 1000; // 8秒
|
||||
const maxAttempts = Math.floor(maxRetryTime / retryInterval); // 75次
|
||||
|
||||
console.log('🎬 开始获取剪辑计划(带重试机制)...');
|
||||
console.log(`⏰ 重试配置: ${retryInterval/1000}秒间隔,最多${maxAttempts}次,总时长${maxRetryTime/1000/60}分钟`);
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
console.log(`🔄 第${attempts}次尝试获取剪辑计划...`);
|
||||
|
||||
// 触发进度回调
|
||||
if (progressCallback) {
|
||||
// 剪辑计划获取占总进度的80%,因为可能需要很长时间
|
||||
const progressPercent = Math.min(Math.round((attempts / maxAttempts) * 80), 75);
|
||||
progressCallback({
|
||||
status: 'processing',
|
||||
percentage: progressPercent,
|
||||
message: `正在获取剪辑计划... (第${attempts}次尝试,预计${Math.ceil((maxAttempts - attempts) * retryInterval / 1000 / 60)}分钟)`,
|
||||
stage: 'fetching_editing_plan'
|
||||
});
|
||||
}
|
||||
|
||||
const editPlanResponse = await getGenerateEditPlan({ project_id: episodeId });
|
||||
|
||||
if (editPlanResponse.successful && editPlanResponse.data.editing_plan) {
|
||||
console.log(`✅ 第${attempts}次尝试成功获取剪辑计划`);
|
||||
return editPlanResponse.data.editing_plan;
|
||||
} else {
|
||||
console.log(`⚠️ 第${attempts}次尝试失败: ${editPlanResponse.message || '剪辑计划未生成'}`);
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${editPlanResponse.message}`);
|
||||
}
|
||||
|
||||
console.log(`⏳ ${retryInterval/1000}秒后进行第${attempts + 1}次重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ 第${attempts}次尝试出现错误:`, error);
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error(`获取剪辑计划失败,已重试${maxAttempts}次: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
}
|
||||
|
||||
console.log(`⏳ ${retryInterval/1000}秒后进行第${attempts + 1}次重试...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`获取剪辑计划超时,已重试${maxAttempts}次`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于剪辑计划生成导出数据
|
||||
*/
|
||||
private async generateExportDataFromEditingPlan(
|
||||
episodeId: string,
|
||||
taskObject: any,
|
||||
progressCallback?: ExportProgressCallback['onProgress']
|
||||
): Promise<{
|
||||
exportRequest: ExportRequest;
|
||||
editingPlan: any;
|
||||
}> {
|
||||
try {
|
||||
// 1. 首先获取剪辑计划(带重试机制)
|
||||
const editingPlan = await this.getEditingPlanWithRetry(episodeId, progressCallback);
|
||||
console.log('📋 最终获取到剪辑计划:', editingPlan);
|
||||
|
||||
// 2. 检查是否有可用的视频数据
|
||||
if (!taskObject.videos?.data || taskObject.videos.data.length === 0) {
|
||||
throw new Error('没有可用的视频数据');
|
||||
}
|
||||
|
||||
// 3. 过滤出已完成的视频
|
||||
const completedVideos = taskObject.videos.data.filter((video: any) =>
|
||||
video.video_status === 1 && video.urls && video.urls.length > 0
|
||||
);
|
||||
|
||||
if (completedVideos.length === 0) {
|
||||
throw new Error('没有已完成的视频片段');
|
||||
}
|
||||
|
||||
console.log(`📊 找到 ${completedVideos.length} 个已完成的视频片段`);
|
||||
|
||||
// 4. 根据剪辑计划转换视频数据 - 符合API文档的VideoElement格式
|
||||
const defaultClipDuration = 8000; // 默认8秒每个片段(毫秒)
|
||||
let currentStartTime = 0; // 当前时间轴位置
|
||||
|
||||
// 构建视频元素数组 - 严格按照API文档的VideoElement结构
|
||||
let videoElements: VideoElement[];
|
||||
|
||||
if (editingPlan.editing_sequence_plans && editingPlan.editing_sequence_plans.length > 0) {
|
||||
// 使用剪辑计划中的时间线信息
|
||||
const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips || [];
|
||||
console.log('🎞️ 使用剪辑计划中的时间线信息:', timelineClips);
|
||||
|
||||
videoElements = timelineClips.map((clip: any, index: number) => {
|
||||
// 查找对应的视频数据
|
||||
const matchedVideo = completedVideos.find((video: any) =>
|
||||
video.video_id === clip.source_clip_id ||
|
||||
video.urls?.some((url: string) => url === clip.video_url)
|
||||
);
|
||||
|
||||
// 优先使用剪辑计划中的video_url,其次使用匹配视频的URL
|
||||
const videoUrl = clip.video_url || matchedVideo?.urls?.[0];
|
||||
|
||||
// 解析剪辑计划中的精确时间码
|
||||
const sequenceStartMs = this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000");
|
||||
const sourceInMs = this.parseTimecodeToMs(clip.source_in_timecode || "00:00:00.000");
|
||||
const sourceOutMs = this.parseTimecodeToMs(clip.source_out_timecode || "00:00:08.000");
|
||||
const clipDurationMs = this.parseTimecodeToMs(clip.clip_duration_in_sequence || "00:00:08.000");
|
||||
|
||||
console.log(`🎬 处理片段 ${clip.sequence_clip_id}:`, {
|
||||
video_url: videoUrl,
|
||||
sequence_start: sequenceStartMs,
|
||||
source_in: sourceInMs,
|
||||
source_out: sourceOutMs,
|
||||
duration: clipDurationMs
|
||||
});
|
||||
|
||||
// 严格按照API文档的VideoElement结构
|
||||
const element: VideoElement = {
|
||||
id: clip.sequence_clip_id || matchedVideo?.video_id || `video_${index + 1}`,
|
||||
src: videoUrl,
|
||||
start: currentStartTime, // 在时间轴上的开始时间
|
||||
end: currentStartTime + clipDurationMs, // 在时间轴上的结束时间
|
||||
in: sourceInMs, // 视频内部开始时间
|
||||
out: sourceOutMs, // 视频内部结束时间
|
||||
_source_type: videoUrl?.startsWith('http') ? 'remote_url' : 'local'
|
||||
};
|
||||
|
||||
currentStartTime += clipDurationMs;
|
||||
return element;
|
||||
});
|
||||
} else {
|
||||
// 如果没有具体的时间线信息,使用视频数据生成
|
||||
console.log('📹 使用视频数据生成时间线');
|
||||
videoElements = completedVideos.map((video: any, index: number) => {
|
||||
const videoUrl = video.urls![0];
|
||||
|
||||
// 严格按照API文档的VideoElement结构
|
||||
const element: VideoElement = {
|
||||
id: video.video_id || `video_${index + 1}`,
|
||||
src: videoUrl,
|
||||
start: currentStartTime,
|
||||
end: currentStartTime + defaultClipDuration, // 添加end字段
|
||||
in: 0,
|
||||
out: defaultClipDuration,
|
||||
_source_type: videoUrl.startsWith('http') ? 'remote_url' : 'local'
|
||||
};
|
||||
|
||||
currentStartTime += defaultClipDuration;
|
||||
return element;
|
||||
});
|
||||
}
|
||||
|
||||
const totalDuration = currentStartTime;
|
||||
|
||||
// 处理转场效果
|
||||
const transitions: TransitionElement[] = [];
|
||||
if (editingPlan.editing_sequence_plans?.[0]?.timeline_clips) {
|
||||
const timelineClips = editingPlan.editing_sequence_plans[0].timeline_clips;
|
||||
|
||||
for (let i = 0; i < timelineClips.length; i++) {
|
||||
const clip = timelineClips[i];
|
||||
if (clip.transition_from_previous && i > 0) {
|
||||
const transition: TransitionElement = {
|
||||
id: `transition_${i}`,
|
||||
type: clip.transition_from_previous.transition_type || 'Cut',
|
||||
duration: clip.transition_from_previous.transition_duration_ms || 0,
|
||||
start: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000") - (clip.transition_from_previous.transition_duration_ms || 0),
|
||||
end: this.parseTimecodeToMs(clip.sequence_start_timecode || "00:00:00.000")
|
||||
};
|
||||
transitions.push(transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字幕/对话轨道
|
||||
const texts: TextElement[] = [];
|
||||
if (editingPlan.finalized_dialogue_track?.final_dialogue_segments) {
|
||||
editingPlan.finalized_dialogue_track.final_dialogue_segments.forEach((dialogue: any, index: number) => {
|
||||
const textElement: TextElement = {
|
||||
id: dialogue.sequence_clip_id || `text_${index + 1}`,
|
||||
text: dialogue.transcript,
|
||||
start: this.parseTimecodeToMs(dialogue.start_timecode || "00:00:00.000"),
|
||||
end: this.parseTimecodeToMs(dialogue.end_timecode || "00:00:02.000"),
|
||||
style: {
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 40,
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: 'transparent',
|
||||
fontWeight: 'normal',
|
||||
fontStyle: 'normal',
|
||||
align: 'center',
|
||||
shadow: true
|
||||
}
|
||||
};
|
||||
texts.push(textElement);
|
||||
});
|
||||
}
|
||||
|
||||
// 构建符合API文档的IR数据结构
|
||||
const irData: IRData = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
duration: totalDuration,
|
||||
video: videoElements,
|
||||
texts: texts, // 从剪辑计划中提取的字幕
|
||||
audio: [], // 可选字段,空数组
|
||||
transitions: transitions // 从剪辑计划中提取的转场
|
||||
};
|
||||
|
||||
// 构建完整的导出请求数据 - 符合API文档的ExportRequest格式
|
||||
const exportRequest: ExportRequest = {
|
||||
project_id: episodeId,
|
||||
ir: irData,
|
||||
options: {
|
||||
quality: 'standard',
|
||||
codec: 'libx264',
|
||||
subtitleMode: 'hard'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
exportRequest,
|
||||
editingPlan: editingPlan // 保存剪辑计划信息用于调试
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 生成导出数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用导出流接口的核心函数
|
||||
*/
|
||||
private async callExportStreamAPI(exportRequest: ExportRequest, attemptNumber: number = 1): Promise<any> {
|
||||
console.log(`🚀 第${attemptNumber}次调用流式导出接口...`);
|
||||
console.log('📋 发送的完整导出请求数据:', JSON.stringify(exportRequest, null, 2));
|
||||
|
||||
const response = await fetch(`${this.config.apiBaseUrl}/api/export/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
body: JSON.stringify(exportRequest)
|
||||
});
|
||||
|
||||
console.log('📡 导出接口响应状态:', response.status, response.statusText);
|
||||
console.log('📋 响应头信息:', Object.fromEntries(response.headers.entries()));
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ 导出接口错误响应:', errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
|
||||
}
|
||||
|
||||
console.log('✅ 导出接口调用成功,开始处理SSE流...');
|
||||
|
||||
// 处理SSE流式响应
|
||||
console.log('📺 开始处理流式响应...');
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let eventCount = 0;
|
||||
let finalResult = null;
|
||||
let detectedTaskId = null; // 用于收集任务ID
|
||||
|
||||
if (reader) {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const eventData = JSON.parse(line.slice(6));
|
||||
eventCount++;
|
||||
console.log(`📨 SSE事件 #${eventCount}:`, eventData);
|
||||
|
||||
// 尝试从任何事件中提取任务ID
|
||||
if (eventData.export_id || eventData.task_id) {
|
||||
detectedTaskId = eventData.export_id || eventData.task_id;
|
||||
console.log('🔍 在SSE事件中发现任务ID:', detectedTaskId);
|
||||
}
|
||||
|
||||
// 处理不同类型的事件,按照API文档规范
|
||||
switch (eventData.type) {
|
||||
case 'start':
|
||||
console.log('🚀 导出开始:', eventData.message);
|
||||
// start事件中可能包含任务ID
|
||||
if (eventData.export_id || eventData.task_id) {
|
||||
detectedTaskId = eventData.export_id || eventData.task_id;
|
||||
console.log('📋 从start事件获取任务ID:', detectedTaskId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
const progressPercent = Math.round((eventData.progress || 0) * 100);
|
||||
console.log(`📊 导出进度: ${progressPercent}% - ${eventData.stage || 'processing'} - ${eventData.message}`);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
console.log('🎉 导出完成!完整事件数据:', JSON.stringify(eventData, null, 2));
|
||||
finalResult = eventData;
|
||||
// 确保最终结果包含任务ID
|
||||
if (detectedTaskId && !finalResult.export_id && !finalResult.task_id) {
|
||||
finalResult.export_id = detectedTaskId;
|
||||
console.log('📋 添加检测到的任务ID到完成结果:', detectedTaskId);
|
||||
}
|
||||
console.log('✅ 最终SSE结果:', JSON.stringify(finalResult, null, 2));
|
||||
// 导出完成,退出循环
|
||||
return finalResult;
|
||||
|
||||
case 'error':
|
||||
throw new Error(`导出失败: ${eventData.message}`);
|
||||
|
||||
default:
|
||||
console.log('📋 其他事件:', eventData);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('⚠️ 解析SSE事件失败:', line, parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有检测到的任务ID,确保添加到最终结果中
|
||||
if (detectedTaskId && finalResult && !finalResult.export_id && !finalResult.task_id) {
|
||||
finalResult.export_id = detectedTaskId;
|
||||
console.log('📋 将检测到的任务ID添加到最终结果:', detectedTaskId);
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询导出进度的函数
|
||||
* - status: 'completed' 时立即停止轮询并返回结果
|
||||
* - status: 'failed' 时抛出 EXPORT_FAILED 错误,触发重新调用 api/export/stream
|
||||
* - 其他状态继续轮询,最多轮询10分钟(5秒间隔)
|
||||
*/
|
||||
private async pollExportProgress(taskId: string, progressCallback?: ExportProgressCallback['onProgress']): Promise<ExportResult> {
|
||||
console.log('🔄 开始轮询导出进度,任务ID:', taskId);
|
||||
const maxAttempts = 120; // 最多轮询10分钟(5秒间隔)
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const progressUrl = `${this.config.apiBaseUrl}/api/export/task/${taskId}/progress`;
|
||||
console.log(`📊 第${attempts + 1}次查询进度:`, progressUrl);
|
||||
|
||||
const response = await fetch(progressUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`进度查询失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const progressData = await response.json();
|
||||
console.log('📈 进度查询响应状态:', response.status, response.statusText);
|
||||
console.log('📊 完整进度数据:', JSON.stringify(progressData, null, 2));
|
||||
|
||||
// 根据API返回的数据结构处理
|
||||
const { status, progress } = progressData;
|
||||
|
||||
if (status === 'completed') {
|
||||
console.log('🎉 导出任务完成!progress数据:', JSON.stringify(progress, null, 2));
|
||||
|
||||
// 触发完成状态回调
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
status: 'completed',
|
||||
percentage: 100,
|
||||
message: progress?.message || '导出完成',
|
||||
stage: 'completed',
|
||||
taskId
|
||||
});
|
||||
}
|
||||
|
||||
const completedResult = {
|
||||
task_id: taskId,
|
||||
status: status,
|
||||
video_url: progress?.video_url,
|
||||
file_size: progress?.file_size,
|
||||
export_id: progress?.export_id,
|
||||
quality_mode: progress?.quality_mode,
|
||||
watermark_status: progress?.watermark_status,
|
||||
upload_time: progress?.upload_time
|
||||
};
|
||||
|
||||
console.log('✅ 轮询返回的完成结果:', JSON.stringify(completedResult, null, 2));
|
||||
return completedResult;
|
||||
} else if (status === 'failed') {
|
||||
console.log('❌ 导出任务失败,需要重新调用 api/export/stream');
|
||||
|
||||
// 触发失败状态回调
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
status: 'failed',
|
||||
percentage: 0,
|
||||
message: progress?.message || '导出任务失败',
|
||||
stage: 'failed',
|
||||
taskId
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`EXPORT_FAILED: ${progress?.message || '导出任务失败'}`);
|
||||
} else if (status === 'error') {
|
||||
throw new Error(`导出任务错误: ${progress?.message || '未知错误'}`);
|
||||
} else {
|
||||
// 任务仍在进行中
|
||||
const percentage = progress?.percentage || 0;
|
||||
const message = progress?.message || '处理中...';
|
||||
const stage = progress?.stage || 'processing';
|
||||
|
||||
console.log(`⏳ 导出进度: ${percentage}% - ${stage} - ${message}`);
|
||||
|
||||
// 触发处理中状态回调
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
status: 'processing',
|
||||
percentage,
|
||||
message,
|
||||
stage,
|
||||
taskId
|
||||
});
|
||||
}
|
||||
|
||||
// 等待5秒后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.pollInterval));
|
||||
attempts++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 第${attempts + 1}次进度查询失败:`, error);
|
||||
attempts++;
|
||||
|
||||
// 如果不是最后一次尝试,等待5秒后重试
|
||||
if (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.pollInterval));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('导出进度查询超时,请稍后手动检查');
|
||||
}
|
||||
|
||||
/**
|
||||
* 主要的导出方法 - 支持重试机制
|
||||
*/
|
||||
public async exportVideo(episodeId: string, taskObject: any, progressCallback?: ExportProgressCallback['onProgress']): Promise<ExportResult> {
|
||||
let currentAttempt = 1;
|
||||
|
||||
try {
|
||||
// 重试循环
|
||||
while (currentAttempt <= this.config.maxRetries) {
|
||||
try {
|
||||
// 第一步:获取剪辑计划(只在第一次尝试时获取,或缓存不存在时重新获取)
|
||||
let exportRequest: ExportRequest;
|
||||
if (currentAttempt === 1 || !this.cachedExportRequest) {
|
||||
console.log('🎬 步骤1: 获取剪辑计划...');
|
||||
try {
|
||||
const { exportRequest: generatedExportRequest, editingPlan: generatedEditingPlan } = await this.generateExportDataFromEditingPlan(episodeId, taskObject, progressCallback);
|
||||
exportRequest = generatedExportRequest;
|
||||
|
||||
console.log('📤 生成的导出请求数据:', exportRequest);
|
||||
console.log(`📊 包含 ${exportRequest.ir.video.length} 个视频片段,总时长: ${exportRequest.ir.duration}ms`);
|
||||
console.log('🎬 使用的剪辑计划:', generatedEditingPlan);
|
||||
|
||||
// 缓存exportRequest以便重试时使用
|
||||
this.cachedExportRequest = exportRequest;
|
||||
} catch (editPlanError) {
|
||||
console.error('❌ 获取剪辑计划失败,无法继续导出:', editPlanError);
|
||||
// 剪辑计划获取失败是致命错误,直接抛出,不进行导出重试
|
||||
throw editPlanError;
|
||||
}
|
||||
} else {
|
||||
// 重试时使用缓存的请求数据
|
||||
exportRequest = this.cachedExportRequest;
|
||||
console.log(`🔄 第${currentAttempt}次重试,使用缓存的导出请求数据`);
|
||||
}
|
||||
|
||||
// 第二步:调用导出接口
|
||||
console.log(`🚀 步骤2: 第${currentAttempt}次调用流式导出接口...`);
|
||||
const result = await this.callExportStreamAPI(exportRequest, currentAttempt);
|
||||
|
||||
console.log('✅ 导出接口调用成功');
|
||||
console.log('🔍 SSE最终结果详情:', JSON.stringify(result, null, 2));
|
||||
|
||||
// 尝试获取任务ID进行轮询
|
||||
let taskId = null;
|
||||
|
||||
// 方法1: 从SSE结果中获取
|
||||
if (result?.export_id || result?.task_id) {
|
||||
taskId = result.export_id || result.task_id;
|
||||
console.log('📋 从SSE结果中获取到任务ID:', taskId);
|
||||
}
|
||||
|
||||
// 如果没有任务ID,无法进行轮询
|
||||
if (!taskId) {
|
||||
console.log('⚠️ SSE结果中未找到任务ID,无法进行进度轮询');
|
||||
|
||||
// 显示警告通知 - 已注释
|
||||
/*
|
||||
notification.warning({
|
||||
message: `第${currentAttempt}次导出接口调用成功`,
|
||||
description: 'SSE流中未找到任务ID,无法进行进度轮询。请检查API返回数据结构。',
|
||||
placement: 'topRight',
|
||||
duration: 8
|
||||
});
|
||||
*/
|
||||
|
||||
// 如果SSE中直接有完整结果,直接处理
|
||||
if (result?.download_url || result?.video_url) {
|
||||
const downloadUrl = result.download_url || result.video_url;
|
||||
console.log('📥 直接从SSE结果下载视频:', downloadUrl);
|
||||
await downloadVideo(downloadUrl);
|
||||
|
||||
// notification.success({
|
||||
// message: '视频下载完成!',
|
||||
// description: result?.file_size
|
||||
// ? `文件大小: ${(result.file_size / 1024 / 1024).toFixed(2)}MB`
|
||||
// : '视频已成功下载到本地',
|
||||
// placement: 'topRight',
|
||||
// duration: 8
|
||||
// });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 如果有任务ID,开始轮询进度
|
||||
console.log('🔄 开始轮询导出进度,任务ID:', taskId);
|
||||
|
||||
try {
|
||||
const finalExportResult = await this.pollExportProgress(taskId, progressCallback);
|
||||
|
||||
// 导出成功
|
||||
console.log('🎉 导出成功完成!');
|
||||
console.log('📋 轮询最终结果:', JSON.stringify(finalExportResult, null, 2));
|
||||
|
||||
// 显示最终成功通知 - 已注释
|
||||
/*
|
||||
notification.success({
|
||||
message: `导出成功!(第${currentAttempt}次尝试)`,
|
||||
description: `文件大小: ${(finalExportResult.file_size! / 1024 / 1024).toFixed(2)}MB,正在下载到本地...`,
|
||||
placement: 'topRight',
|
||||
duration: 8
|
||||
});
|
||||
*/
|
||||
|
||||
// 自动下载视频
|
||||
if (finalExportResult.video_url) {
|
||||
console.log('📥 开始下载视频:', finalExportResult.video_url);
|
||||
console.log('📋 视频文件信息:', {
|
||||
url: finalExportResult.video_url,
|
||||
file_size: finalExportResult.file_size,
|
||||
quality_mode: finalExportResult.quality_mode
|
||||
});
|
||||
await downloadVideo(finalExportResult.video_url);
|
||||
console.log('✅ 视频下载完成');
|
||||
}
|
||||
|
||||
// 清除缓存的请求数据
|
||||
this.cachedExportRequest = null;
|
||||
|
||||
return finalExportResult;
|
||||
|
||||
} catch (pollError) {
|
||||
console.error(`❌ 第${currentAttempt}次轮询进度失败:`, pollError);
|
||||
|
||||
// 检查是否是导出失败错误(需要重新调用 api/export/stream)
|
||||
const isExportFailed = pollError instanceof Error && pollError.message.startsWith('EXPORT_FAILED:');
|
||||
|
||||
if (isExportFailed) {
|
||||
console.log(`❌ 第${currentAttempt}次导出任务失败(status: 'failed'),需要重新调用 api/export/stream`);
|
||||
|
||||
// 如果还有重试次数,继续重试
|
||||
if (currentAttempt < this.config.maxRetries) {
|
||||
console.log(`🔄 准备第${currentAttempt + 1}次重试(重新调用 api/export/stream)...`);
|
||||
|
||||
// notification.warning({
|
||||
// message: `第${currentAttempt}次导出失败`,
|
||||
// description: `导出状态: failed。正在准备第${currentAttempt + 1}次重试...`,
|
||||
// placement: 'topRight',
|
||||
// duration: 5
|
||||
// });
|
||||
|
||||
currentAttempt++;
|
||||
continue; // 继续重试循环,重新调用 api/export/stream
|
||||
} else {
|
||||
// 已达到最大重试次数
|
||||
throw new Error(`导出失败,已重试${this.config.maxRetries}次。最后状态: failed`);
|
||||
}
|
||||
} else {
|
||||
// 其他轮询错误(网络错误等)
|
||||
if (currentAttempt < this.config.maxRetries) {
|
||||
console.log(`🔄 轮询失败,准备第${currentAttempt + 1}次重试...`);
|
||||
|
||||
// notification.warning({
|
||||
// message: `第${currentAttempt}次轮询失败`,
|
||||
// description: `${pollError instanceof Error ? pollError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`,
|
||||
// placement: 'topRight',
|
||||
// duration: 5
|
||||
// });
|
||||
|
||||
currentAttempt++;
|
||||
continue; // 继续重试循环
|
||||
} else {
|
||||
// 已达到最大重试次数,回退到SSE结果
|
||||
console.log('❌ 已达到最大重试次数,回退到SSE结果');
|
||||
|
||||
// notification.error({
|
||||
// message: '轮询重试失败',
|
||||
// description: `已重试${this.config.maxRetries}次仍然失败。${pollError instanceof Error ? pollError.message : '未知错误'}`,
|
||||
// placement: 'topRight',
|
||||
// duration: 10
|
||||
// });
|
||||
|
||||
// 回退到SSE结果
|
||||
if (result?.download_url || result?.video_url) {
|
||||
const downloadUrl = result.download_url || result.video_url;
|
||||
console.log('📥 回退到SSE结果下载视频:', downloadUrl);
|
||||
await downloadVideo(downloadUrl);
|
||||
}
|
||||
|
||||
// 清除缓存的请求数据
|
||||
this.cachedExportRequest = null;
|
||||
|
||||
throw pollError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (attemptError) {
|
||||
console.error(`❌ 第${currentAttempt}次尝试失败:`, attemptError);
|
||||
|
||||
// 如果还有重试次数,继续重试
|
||||
if (currentAttempt < this.config.maxRetries) {
|
||||
console.log(`🔄 第${currentAttempt}次尝试失败,准备第${currentAttempt + 1}次重试...`);
|
||||
|
||||
// notification.warning({
|
||||
// message: `第${currentAttempt}次导出尝试失败`,
|
||||
// description: `${attemptError instanceof Error ? attemptError.message : '未知错误'}。正在准备第${currentAttempt + 1}次重试...`,
|
||||
// placement: 'topRight',
|
||||
// duration: 5
|
||||
// });
|
||||
|
||||
currentAttempt++;
|
||||
continue; // 继续重试循环
|
||||
} else {
|
||||
// 已达到最大重试次数
|
||||
throw attemptError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果退出循环还没有成功,抛出错误
|
||||
throw new Error(`导出失败,已重试${this.config.maxRetries}次`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 视频导出最终失败:', error);
|
||||
|
||||
// 清除缓存的请求数据
|
||||
this.cachedExportRequest = null;
|
||||
|
||||
// 显示最终错误通知 - 已注释
|
||||
/*
|
||||
notification.error({
|
||||
message: '视频导出失败',
|
||||
description: `经过${this.config.maxRetries}次尝试后仍然失败:${error instanceof Error ? error.message : '未知错误'}`,
|
||||
placement: 'topRight',
|
||||
duration: 10
|
||||
});
|
||||
*/
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认的导出服务实例
|
||||
export const videoExportService = new VideoExportService({
|
||||
maxRetries: 3,
|
||||
pollInterval: 5000, // 5秒轮询间隔
|
||||
// apiBaseUrl 使用环境变量 NEXT_PUBLIC_CUT_URL,在构造函数中处理
|
||||
});
|
||||
|
||||
/**
|
||||
* 便捷的导出函数
|
||||
*/
|
||||
export async function exportVideoWithRetry(
|
||||
episodeId: string,
|
||||
taskObject: any,
|
||||
progressCallback?: ExportProgressCallback['onProgress']
|
||||
): Promise<ExportResult> {
|
||||
return videoExportService.exportVideo(episodeId, taskObject, progressCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试轮询逻辑的函数(开发调试用)
|
||||
*/
|
||||
export async function testPollingLogic(taskId: string): Promise<ExportResult> {
|
||||
console.log('🧪 测试轮询逻辑,任务ID:', taskId);
|
||||
return videoExportService['pollExportProgress'](taskId);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user