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.
|
- Omit console.log or debug statements unless requested.
|
||||||
- Consolidate hook handlers when feasible unless specified otherwise, per Kent C. Dodds' readability practices.
|
- 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.
|
- 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 = 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_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
||||||
|
NEXT_PUBLIC_CUTAPI_URL = http://77.smartcut.py.qikongjian.com
|
||||||
|
|
||||||
# 失败率
|
# 失败率
|
||||||
NEXT_PUBLIC_ERROR_CONFIG = 0.1
|
NEXT_PUBLIC_ERROR_CONFIG = 0.1
|
||||||
@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
|
|
||||||
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
|
||||||
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
|
||||||
# NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
# NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
|
||||||
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
|
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
|
||||||
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
|
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
|
||||||
NEXT_PUBLIC_CUT_URL = https://smartcut.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;
|
language: string;
|
||||||
/**模板id */
|
/**模板id */
|
||||||
template_id: string;
|
template_id: string;
|
||||||
|
/** 自由输入 */
|
||||||
|
freeInput?: {
|
||||||
|
/** 用户提示,提示给用户需要输入什么内容 */
|
||||||
|
user_tips: string;
|
||||||
|
/** 约束,可选,用于传给ai,让ai去拦截用户不符合约束的输入内容 */
|
||||||
|
constraints: string;
|
||||||
|
/** 自由输入文字 */
|
||||||
|
free_input_text: string;
|
||||||
|
/** 输入名称 */
|
||||||
|
input_name: string;
|
||||||
|
}[];
|
||||||
/** 故事角色 */
|
/** 故事角色 */
|
||||||
storyRole: {
|
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%;
|
--muted-foreground: 0 0% 63.9%;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 0 0% 14.9%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--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: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 0 0% 14.9%;
|
||||||
|
|||||||
@ -68,27 +68,27 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const templates = await templateStoryUseCase.getTemplateStoryList();
|
const templates = await templateStoryUseCase.getTemplateStoryList();
|
||||||
templates.forEach(template => {
|
// templates.forEach(template => {
|
||||||
if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
|
// if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
|
||||||
template.freeInputItem = {
|
// template.freeInput = {
|
||||||
user_tips: "How is coffee made?",
|
// user_tips: "How is coffee made?",
|
||||||
constraints: "",
|
// constraints: "",
|
||||||
free_input_text: ""
|
// free_input_text: ""
|
||||||
};
|
// };
|
||||||
// template.storyRole = [];
|
// // template.storyRole = [];
|
||||||
}
|
// }
|
||||||
if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
|
// if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
|
||||||
// template.freeInputItem = {
|
// // template.freeInput = {
|
||||||
// user_tips: "Input an English word you wanna learn",
|
// // user_tips: "Input an English word you wanna learn",
|
||||||
// constraints: "",
|
// // constraints: "",
|
||||||
// free_input_text: ""
|
// // free_input_text: ""
|
||||||
// };
|
// // };
|
||||||
template.storyItem = [{
|
// template.storyItem = [{
|
||||||
...template.storyItem[0],
|
// ...template.storyItem[0],
|
||||||
item_name: "Choose an English word you wanna learn"
|
// item_name: "Choose an English word you wanna learn"
|
||||||
}];
|
// }];
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
|
||||||
setTemplateStoryList(templates);
|
setTemplateStoryList(templates);
|
||||||
setSelectedTemplate(templates[0]);
|
setSelectedTemplate(templates[0]);
|
||||||
@ -256,17 +256,22 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
|
|||||||
try {
|
try {
|
||||||
// 设置 loading 状态
|
// 设置 loading 状态
|
||||||
setIsLoading(true);
|
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 = {
|
const params: CreateMovieProjectV3Request = {
|
||||||
script: selectedTemplate?.freeInputItem?.free_input_text || selectedTemplate?.generateText || "",
|
script: script,
|
||||||
category: selectedTemplate?.category || "",
|
category: selectedTemplate?.category || "",
|
||||||
user_id,
|
user_id,
|
||||||
mode,
|
mode,
|
||||||
resolution,
|
resolution,
|
||||||
storyRole: selectedTemplate?.storyRole || [],
|
storyRole: selectedTemplate?.storyRole || [],
|
||||||
storyItem: selectedTemplate?.storyItem || [],
|
storyItem: selectedTemplate?.storyItem || [],
|
||||||
|
freeInput: selectedTemplate?.freeInput || [],
|
||||||
language,
|
language,
|
||||||
template_id: selectedTemplate?.template_id || "",
|
template_id: selectedTemplate?.template_id || ""
|
||||||
};
|
};
|
||||||
console.log("params", params);
|
console.log("params", params);
|
||||||
const result = await MovieProjectService.createProject(
|
const result = await MovieProjectService.createProject(
|
||||||
|
|||||||
@ -173,12 +173,14 @@ export interface StoryTemplateEntity {
|
|||||||
photo_url: string;
|
photo_url: string;
|
||||||
}[];
|
}[];
|
||||||
/** 自由输入文字 */
|
/** 自由输入文字 */
|
||||||
freeInputItem?: {
|
freeInput: {
|
||||||
/** 用户提示,提示给用户需要输入什么内容 */
|
/** 用户提示,提示给用户需要输入什么内容 */
|
||||||
user_tips: string;
|
user_tips: string;
|
||||||
/** 约束,可选,用于传给ai,让ai去拦截用户不符合约束的输入内容 */
|
/** 约束,可选,用于传给ai,让ai去拦截用户不符合约束的输入内容 */
|
||||||
constraints: string;
|
constraints: string;
|
||||||
/** 自由输入文字 */
|
/** 自由输入文字 */
|
||||||
free_input_text: string;
|
free_input_text: string;
|
||||||
}
|
/** 输入名称 */
|
||||||
|
input_name: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "tailwind.config.js",
|
||||||
"css": "app/globals.css",
|
"css": "app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
|
|||||||
@ -60,6 +60,11 @@ const LauguageOptions = [
|
|||||||
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
|
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
|
||||||
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
|
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
|
||||||
{ value: "russian", label: "Russian", isVip: false, code:'RU' },
|
{ 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 [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>({});
|
||||||
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
|
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
|
||||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@ -115,9 +128,10 @@ export const H5TemplateDrawer = ({
|
|||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
setIsTemplateCreating(false);
|
||||||
setLocalLoading(0);
|
setLocalLoading(0);
|
||||||
setSelectedTemplate(null);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLocalLoading(0);
|
setLocalLoading(0);
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
@ -488,23 +502,50 @@ export const H5TemplateDrawer = ({
|
|||||||
{renderRoles()}
|
{renderRoles()}
|
||||||
{renderItems()}
|
{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">
|
<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">
|
<div data-alt="free-input" className="flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedTemplate.freeInputItem.free_input_text || ""}
|
value={selectedTemplate.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate.freeInputItem.user_tips}
|
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"
|
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) => {
|
onChange={(e) => {
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate!,
|
...selectedTemplate!,
|
||||||
freeInputItem: {
|
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||||
...selectedTemplate!.freeInputItem,
|
...item,
|
||||||
free_input_text: e.target.value,
|
free_input_text: e.target.value,
|
||||||
},
|
})),
|
||||||
} as StoryTemplateEntity;
|
} as StoryTemplateEntity;
|
||||||
setSelectedTemplate(updatedTemplate);
|
setSelectedTemplate(updatedTemplate);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -91,22 +91,6 @@ export const PcTemplateModal = ({
|
|||||||
clearData,
|
clearData,
|
||||||
} = useTemplateStoryServiceHook();
|
} = 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
|
// 使用上传文件hook
|
||||||
const { uploadFile, isUploading } = useUploadFile();
|
const { uploadFile, isUploading } = useUploadFile();
|
||||||
// 本地加载状态,用于 UI 反馈
|
// 本地加载状态,用于 UI 反馈
|
||||||
@ -115,6 +99,8 @@ export const PcTemplateModal = ({
|
|||||||
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
|
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
// 自由输入框布局
|
||||||
|
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 组件挂载时获取模板列表
|
// 组件挂载时获取模板列表
|
||||||
@ -124,6 +110,17 @@ export const PcTemplateModal = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen, getTemplateStoryList]);
|
}, [isOpen, getTemplateStoryList]);
|
||||||
|
|
||||||
|
// 自由输入框布局
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
|
||||||
|
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
|
||||||
|
) {
|
||||||
|
setFreeInputLayout('bottom');
|
||||||
|
} else {
|
||||||
|
setFreeInputLayout('top');
|
||||||
|
}
|
||||||
|
}, [selectedTemplate])
|
||||||
|
|
||||||
// 监听点击外部区域关闭输入框
|
// 监听点击外部区域关闭输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@ -194,12 +191,10 @@ export const PcTemplateModal = ({
|
|||||||
}
|
}
|
||||||
console.log("Story action created:", projectId);
|
console.log("Story action created:", projectId);
|
||||||
} catch (error) {
|
} 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);
|
setIsTemplateCreating(false);
|
||||||
setLocalLoading(0);
|
setLocalLoading(0);
|
||||||
// 这里可以添加 toast 提示
|
|
||||||
// 重置状态
|
|
||||||
setSelectedTemplate(null);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLocalLoading(0);
|
setLocalLoading(0);
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@ -655,24 +650,54 @@ export const PcTemplateModal = ({
|
|||||||
</div>
|
</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">
|
<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">
|
<div className="py-2 flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={selectedTemplate?.freeInputItem?.free_input_text || ""}
|
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||||
placeholder={selectedTemplate?.freeInputItem.user_tips}
|
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"
|
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) => {
|
onChange={(e) => {
|
||||||
// 更新自由输入文字字段
|
// 更新自由输入文字字段
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate!,
|
...selectedTemplate!,
|
||||||
freeInputItem: {
|
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||||
...selectedTemplate!.freeInputItem,
|
...item,
|
||||||
free_input_text: e.target.value
|
free_input_text: e.target.value
|
||||||
}
|
})),
|
||||||
};
|
}
|
||||||
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
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} />
|
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
|
||||||
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
|
||||||
<div
|
<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()}>
|
style={getLayoutStyles()}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import "../pages/style/top-bar.css";
|
import "../pages/style/top-bar.css";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { GradientText } from "@/components/ui/gradient-text";
|
import { GradientText } from "@/components/ui/gradient-text";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
@ -14,6 +18,7 @@ import {
|
|||||||
PanelsLeftBottom,
|
PanelsLeftBottom,
|
||||||
Bell,
|
Bell,
|
||||||
Info,
|
Info,
|
||||||
|
CalendarDays,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@ -28,6 +33,7 @@ import {
|
|||||||
} from "@/lib/stripe";
|
} from "@/lib/stripe";
|
||||||
import UserCard from "@/components/common/userCard";
|
import UserCard from "@/components/common/userCard";
|
||||||
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
||||||
|
import CheckinBox from "./checkin-box";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -55,6 +61,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
||||||
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
||||||
const [customAmount, setCustomAmount] = useState<string>("");
|
const [customAmount, setCustomAmount] = useState<string>("");
|
||||||
|
const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false);
|
||||||
|
|
||||||
// 获取用户订阅信息
|
// 获取用户订阅信息
|
||||||
const fetchSubscriptionInfo = async () => {
|
const fetchSubscriptionInfo = async () => {
|
||||||
@ -239,8 +246,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
element.classList.add("on");
|
element.classList.add("on");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理签到功能,打开签到modal
|
||||||
|
*/
|
||||||
|
const handleCheckin = () => {
|
||||||
|
setIsCheckinModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed right-0 top-0 h-16 header z-[999]"
|
className="fixed right-0 top-0 h-16 header z-[999]"
|
||||||
style={{
|
style={{
|
||||||
isolation: "isolate",
|
isolation: "isolate",
|
||||||
@ -371,7 +385,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: "4rem",
|
top: "4rem",
|
||||||
right: "1rem",
|
right: "1rem",
|
||||||
zIndex: 9999,
|
zIndex: 999,
|
||||||
}}
|
}}
|
||||||
className="overflow-hidden rounded-xl max-h-[90vh]"
|
className="overflow-hidden rounded-xl max-h-[90vh]"
|
||||||
data-alt="user-menu-dropdown"
|
data-alt="user-menu-dropdown"
|
||||||
@ -394,6 +408,16 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
{currentUser.email}
|
{currentUser.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* AI 积分 */}
|
{/* AI 积分 */}
|
||||||
@ -527,6 +551,19 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import React, { useRef, useEffect, useCallback } from "react";
|
import React, { useRef, useEffect, useCallback } from "react";
|
||||||
import "./style/work-flow.css";
|
import "./style/work-flow.css";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { EditModal } from "@/components/ui/edit-modal";
|
import { EditModal } from "@/components/ui/edit-modal";
|
||||||
import { TaskInfo } from "./work-flow/task-info";
|
import { TaskInfo } from "./work-flow/task-info";
|
||||||
import { MediaViewer } from "./work-flow/media-viewer";
|
import { MediaViewer } from "./work-flow/media-viewer";
|
||||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||||
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast, ChevronsLeft, Bot, BriefcaseBusiness, Scissors } from "lucide-react";
|
import { Bot, TestTube } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||||||
import { Drawer, Tooltip, notification } from 'antd';
|
import { Drawer, Tooltip, notification } from 'antd';
|
||||||
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
|
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() {
|
const WorkFlow = React.memo(function WorkFlow() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -36,15 +36,23 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
||||||
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
||||||
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
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 [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 [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 [isEditingInProgress, setIsEditingInProgress] = React.useState(false);
|
||||||
const isEditingInProgressRef = useRef(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 searchParams = useSearchParams();
|
||||||
const episodeId = searchParams.get('episodeId') || '';
|
const episodeId = searchParams.get('episodeId') || '';
|
||||||
|
|
||||||
@ -53,23 +61,64 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
SaveEditUseCase.setProjectId(episodeId);
|
SaveEditUseCase.setProjectId(episodeId);
|
||||||
let editingNotificationKey = useRef<string>(`editing-${Date.now()}`);
|
let editingNotificationKey = useRef<string>(`editing-${Date.now()}`);
|
||||||
const [isHandleEdit, setIsHandleEdit] = React.useState(false);
|
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(() => {
|
const handleEditPlanGenerated = useCallback(() => {
|
||||||
console.log('🚀 handleEditPlanGenerated called, current ref:', isEditingInProgressRef.current);
|
|
||||||
|
|
||||||
// 防止重复调用 - 使用 ref 避免依赖项变化
|
|
||||||
if (isEditingInProgressRef.current) {
|
|
||||||
console.log('⚠️ 编辑已在进行中,跳过重复调用');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✨ 编辑计划生成完成,开始AI剪辑');
|
console.log('✨ 编辑计划生成完成,开始AI剪辑');
|
||||||
setIsHandleEdit(true);
|
setIsHandleEdit(true);
|
||||||
setEditingStatus('idle');
|
setEditingStatus('idle');
|
||||||
setIsEditingInProgress(true);
|
// setIsEditingInProgress(true); // 已移除该状态变量
|
||||||
isEditingInProgressRef.current = true;
|
isEditingInProgressRef.current = true;
|
||||||
|
|
||||||
aiEditingButtonRef.current?.handleAIEditing();
|
// 改为调用测试剪辑计划导出按钮方法
|
||||||
|
// aiEditingButtonRef.current?.handleAIEditing();
|
||||||
|
// 使用 ref 调用避免循环依赖
|
||||||
|
setTimeout(() => {
|
||||||
|
handleTestExportRef.current?.();
|
||||||
|
}, 0);
|
||||||
editingNotificationKey.current = `editing-${Date.now()}`;
|
editingNotificationKey.current = `editing-${Date.now()}`;
|
||||||
showEditingNotification({
|
showEditingNotification({
|
||||||
description: 'Performing intelligent editing...',
|
description: 'Performing intelligent editing...',
|
||||||
@ -88,7 +137,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重新生成 iframeAiEditingKey 触发重新渲染
|
// 重新生成 iframeAiEditingKey 触发重新渲染
|
||||||
setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
|
// setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
|
||||||
|
|
||||||
// 延时200ms后显示重试通知,确保之前的通知已销毁
|
// 延时200ms后显示重试通知,确保之前的通知已销毁
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -117,13 +166,13 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [episodeId]); // 移除 isEditingInProgress 依赖
|
}, [episodeId]); // handleTestExport 在内部调用,无需作为依赖
|
||||||
|
|
||||||
/** 处理导出失败 */
|
/** 处理导出失败 */
|
||||||
const handleExportFailed = useCallback(() => {
|
const handleExportFailed = useCallback(() => {
|
||||||
console.log('Export failed, setting error status');
|
console.log('Export failed, setting error status');
|
||||||
setEditingStatus('error');
|
setEditingStatus('error');
|
||||||
setIsEditingInProgress(false);
|
// setIsEditingInProgress(false); // 已移除该状态变量
|
||||||
isEditingInProgressRef.current = false;
|
isEditingInProgressRef.current = false;
|
||||||
|
|
||||||
// 销毁当前编辑通知
|
// 销毁当前编辑通知
|
||||||
@ -166,7 +215,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
||||||
}, [currentSketchIndex, taskObject]);
|
}, [currentSketchIndex, taskObject]);
|
||||||
|
|
||||||
// 监听粗剪是否完成,如果完成 更新 showEditingNotification 的状态 为完成,延时 3s 并关闭
|
// 监听粗剪是否完成
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🎬 final video useEffect triggered:', {
|
console.log('🎬 final video useEffect triggered:', {
|
||||||
finalUrl: taskObject.final.url,
|
finalUrl: taskObject.final.url,
|
||||||
@ -219,16 +268,47 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
// 切换到最终视频阶段
|
// 切换到最终视频阶段
|
||||||
setAnyAttribute('currentStage', 'final_video');
|
setAnyAttribute('currentStage', 'final_video');
|
||||||
|
|
||||||
setAiEditingInProgress(false);
|
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||||
}, [setAnyAttribute]);
|
}, [setAnyAttribute]);
|
||||||
|
|
||||||
const handleAIEditingError = useCallback((error: string) => {
|
const handleAIEditingError = useCallback((error: string) => {
|
||||||
console.error('❌ AI剪辑失败:', error);
|
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) => {
|
const handleIframeAIEditingComplete = useCallback((result: any) => {
|
||||||
console.log('🎉 iframe AI剪辑完成,结果:', result);
|
console.log('🎉 iframe AI剪辑完成,结果:', result);
|
||||||
|
|
||||||
@ -244,18 +324,23 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
// 切换到最终视频阶段
|
// 切换到最终视频阶段
|
||||||
setAnyAttribute('currentStage', 'final_video');
|
setAnyAttribute('currentStage', 'final_video');
|
||||||
|
|
||||||
setAiEditingInProgress(false);
|
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||||
}, [setAnyAttribute]);
|
}, [setAnyAttribute]);
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
const handleIframeAIEditingError = useCallback((error: string) => {
|
const handleIframeAIEditingError = useCallback((error: string) => {
|
||||||
console.error('❌ iframe AI剪辑失败:', error);
|
console.error('❌ iframe AI剪辑失败:', error);
|
||||||
setAiEditingInProgress(false);
|
// setAiEditingInProgress(false); // 已移除该状态变量
|
||||||
}, []);
|
}, []);
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => {
|
const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => {
|
||||||
console.log(`📊 AI剪辑进度: ${progress}% - ${message}`);
|
console.log(`📊 AI剪辑进度: ${progress}% - ${message}`);
|
||||||
setAiEditingInProgress(true);
|
// setAiEditingInProgress(true); // 已移除该状态变量
|
||||||
}, []);
|
}, []);
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
||||||
@ -320,7 +405,8 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI剪辑按钮 - 当可以跳转剪辑时显示 */}
|
{/* AI剪辑按钮 - 已注释,不加载iframe */}
|
||||||
|
{/*
|
||||||
{
|
{
|
||||||
isShowAutoEditing && (
|
isShowAutoEditing && (
|
||||||
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
||||||
@ -341,6 +427,45 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
</div>
|
</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
|
<div
|
||||||
|
|||||||
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</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))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
'custom-blue': '#6AF4F9',
|
||||||
|
'custom-purple': '#C73BFF',
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
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