Merge branch 'dev' into prod

This commit is contained in:
北枳 2025-09-17 21:58:33 +08:00
commit b371ddfb3d
25 changed files with 2016 additions and 191 deletions

View File

@ -64,3 +64,11 @@ You are the joint apprentice of Evan You and Kent C. Dodds. Channel Evan You's e
- Omit console.log or debug statements unless requested.
- Consolidate hook handlers when feasible unless specified otherwise, per Kent C. Dodds' readability practices.
- Prefer async/await over .then for async operations to enhance clarity.
# Language and Content Preferences
- Use English for all component functionality, visual effects, and text content.
- Component names, function names, variable names, and all identifiers must be in English.
- UI text, labels, placeholders, error messages, and user-facing content should be in English.
- Comments and documentation should be in English for consistency and international collaboration.
- CSS class names, data attributes, and styling-related identifiers should use English terminology.
- Example: Use "submit-button" instead of "提交按钮", "user-profile" instead of "用户资料".

View File

@ -1,8 +1,8 @@
NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
# NEXT_PUBLIC_JAVA_URL = http://192.168.120.83:8080
NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
NEXT_PUBLIC_CUTAPI_URL = http://77.smartcut.py.qikongjian.com
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.1

View File

@ -1,10 +1,10 @@
# NEXT_PUBLIC_JAVA_URL = https://77.app.java.auth.qikongjian.com
# NEXT_PUBLIC_BASE_URL = https://77.smartvideo.py.qikongjian.com
# NEXT_PUBLIC_CUT_URL = https://smartcut.huiying.video
NEXT_PUBLIC_JAVA_URL = https://auth.movieflow.ai
NEXT_PUBLIC_BASE_URL = https://api.video.movieflow.ai
NEXT_PUBLIC_CUT_URL = https://smartcut.movieflow.ai
# 失败率
NEXT_PUBLIC_ERROR_CONFIG = 0.1
NEXT_PUBLIC_ERROR_CONFIG = 0.6

View File

@ -210,6 +210,17 @@ export interface CreateMovieProjectV3Request {
language: string;
/**模板id */
template_id: string;
/** 自由输入 */
freeInput?: {
/** 用户提示,提示给用户需要输入什么内容 */
user_tips: string;
/** 约束,可选,用于传给ai让ai去拦截用户不符合约束的输入内容 */
constraints: string;
/** 自由输入文字 */
free_input_text: string;
/** 输入名称 */
input_name: string;
}[];
/** 故事角色 */
storyRole: {
/** 角色名 */

39
api/checkin.ts Normal file
View 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
}

View File

@ -93,6 +93,12 @@
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
/* 自定义渐变色变量 */
--custom-blue: 186 100% 70%; /* rgb(106, 244, 249) */
--custom-purple: 280 100% 62%; /* rgb(199, 59, 255) */
--custom-blue-rgb: 106, 244, 249;
--custom-purple-rgb: 199, 59, 255;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;

View File

@ -68,27 +68,27 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
setIsLoading(true);
const templates = await templateStoryUseCase.getTemplateStoryList();
templates.forEach(template => {
if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
template.freeInputItem = {
user_tips: "How is coffee made?",
constraints: "",
free_input_text: ""
};
// template.storyRole = [];
}
if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
// template.freeInputItem = {
// user_tips: "Input an English word you wanna learn",
// constraints: "",
// free_input_text: ""
// };
template.storyItem = [{
...template.storyItem[0],
item_name: "Choose an English word you wanna learn"
}];
}
});
// templates.forEach(template => {
// if (template.id === 'f944abad-f42b-4899-b54a-a6beb9d27805') {
// template.freeInput = {
// user_tips: "How is coffee made?",
// constraints: "",
// free_input_text: ""
// };
// // template.storyRole = [];
// }
// if (template.id === 'e7438cd8-a23d-4974-8cde-13b5671b410c') {
// // template.freeInput = {
// // user_tips: "Input an English word you wanna learn",
// // constraints: "",
// // free_input_text: ""
// // };
// template.storyItem = [{
// ...template.storyItem[0],
// item_name: "Choose an English word you wanna learn"
// }];
// }
// });
setTemplateStoryList(templates);
setSelectedTemplate(templates[0]);
@ -256,17 +256,22 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
try {
// 设置 loading 状态
setIsLoading(true);
const script = selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 ? selectedTemplate.freeInput[0].free_input_text : "";
if (!script && !selectedTemplate?.storyRole.length && !selectedTemplate?.storyItem.length) {
throw new Error("please input what you want to generate");
}
const params: CreateMovieProjectV3Request = {
script: selectedTemplate?.freeInputItem?.free_input_text || selectedTemplate?.generateText || "",
script: script,
category: selectedTemplate?.category || "",
user_id,
mode,
resolution,
storyRole: selectedTemplate?.storyRole || [],
storyItem: selectedTemplate?.storyItem || [],
freeInput: selectedTemplate?.freeInput || [],
language,
template_id: selectedTemplate?.template_id || "",
template_id: selectedTemplate?.template_id || ""
};
console.log("params", params);
const result = await MovieProjectService.createProject(

View File

@ -173,12 +173,14 @@ export interface StoryTemplateEntity {
photo_url: string;
}[];
/** 自由输入文字 */
freeInputItem?: {
freeInput: {
/** 用户提示,提示给用户需要输入什么内容 */
user_tips: string;
/** 约束,可选,用于传给ai让ai去拦截用户不符合约束的输入内容 */
constraints: string;
/** 自由输入文字 */
free_input_text: string;
}
/** 输入名称 */
input_name: string;
}[];
}

View File

@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,

View File

@ -60,6 +60,11 @@ const LauguageOptions = [
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
{ value: "russian", label: "Russian", isVip: false, code:'RU' },
{ value: "thai", label: "Thai", isVip: false, code:'TH' },
{ value: "french", label: "French", isVip: false, code:'FR' },
{ value: "german", label: "German", isVip: false, code:'DE' },
{ value: "vietnamese", label: "Vietnamese", isVip: false, code:'VI' },
{ value: "indonesian", label: "Indonesian", isVip: false, code:'ID' }
]
/**模板故事模式弹窗组件 */

View File

@ -71,6 +71,19 @@ export const H5TemplateDrawer = ({
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>({});
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 自由输入框布局
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
// 自由输入框布局
useEffect(() => {
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
) {
setFreeInputLayout('bottom');
} else {
setFreeInputLayout('top');
}
}, [selectedTemplate])
useEffect(() => {
if (isOpen) {
@ -115,9 +128,10 @@ export const H5TemplateDrawer = ({
setSelectedTemplate(null);
}
} catch (error) {
console.log("Failed to create story action:", error);
window.msg.error(error instanceof Error ? error.message : "Failed to create story action");
setIsTemplateCreating(false);
setLocalLoading(0);
setSelectedTemplate(null);
} finally {
setLocalLoading(0);
if (timer) clearInterval(timer);
@ -488,23 +502,50 @@ export const H5TemplateDrawer = ({
{renderRoles()}
{renderItems()}
{/** 自由输入文字 */}
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div className="py-2 flex-1 flex flex-col">
<h3
data-alt="items-section-title"
className="text-base font-semibold text-white mb-3"
>
input Configuration
</h3>
<textarea
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value
})),
};
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>
</div>
)}
<div className="w-full flex items-center justify-end gap-2">
{selectedTemplate?.freeInputItem && (
{freeInputLayout === 'bottom' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div data-alt="free-input" className="flex-1">
<input
type="text"
value={selectedTemplate.freeInputItem.free_input_text || ""}
placeholder={selectedTemplate.freeInputItem.user_tips}
value={selectedTemplate.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate.freeInput[0].user_tips}
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
const updatedTemplate = {
...selectedTemplate!,
freeInputItem: {
...selectedTemplate!.freeInputItem,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value,
},
})),
} as StoryTemplateEntity;
setSelectedTemplate(updatedTemplate);
}}

View File

@ -91,22 +91,6 @@ export const PcTemplateModal = ({
clearData,
} = useTemplateStoryServiceHook();
// 防抖处理的输入更新函数
const debouncedUpdateInput = debounce((value: string) => {
// 过滤特殊字符
const sanitizedValue = value.replace(/[<>]/g, '');
// 更新输入值
if (!selectedTemplate?.freeInputItem) return;
const updatedTemplate: StoryTemplateEntity = {
...selectedTemplate,
freeInputItem: {
...selectedTemplate.freeInputItem,
free_input_text: sanitizedValue
}
};
setSelectedTemplate(updatedTemplate);
}, 300); // 300ms 的防抖延迟
// 使用上传文件hook
const { uploadFile, isUploading } = useUploadFile();
// 本地加载状态,用于 UI 反馈
@ -115,6 +99,8 @@ export const PcTemplateModal = ({
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>(
{}
);
// 自由输入框布局
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
const router = useRouter();
// 组件挂载时获取模板列表
@ -124,6 +110,17 @@ export const PcTemplateModal = ({
}
}, [isOpen, getTemplateStoryList]);
// 自由输入框布局
useEffect(() => {
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
) {
setFreeInputLayout('bottom');
} else {
setFreeInputLayout('top');
}
}, [selectedTemplate])
// 监听点击外部区域关闭输入框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -194,12 +191,10 @@ export const PcTemplateModal = ({
}
console.log("Story action created:", projectId);
} catch (error) {
console.error("Failed to create story action:", error);
console.log("Failed to create story action:", error);
window.msg.error(error instanceof Error ? error.message : "Failed to create story action");
setIsTemplateCreating(false);
setLocalLoading(0);
// 这里可以添加 toast 提示
// 重置状态
setSelectedTemplate(null);
} finally {
setLocalLoading(0);
if (timer) {
@ -655,24 +650,54 @@ export const PcTemplateModal = ({
</div>
)}
{/** 自由输入文字 */}
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div className="py-2 flex-1 flex flex-col" style={{
height: 'calc(70vh - 300px - 8rem)'
}}>
<h3
data-alt="items-section-title"
className="text-lg font-semibold text-white mb-4"
>
input Configuration
</h3>
<textarea
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value
})),
};
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>
</div>
)}
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
{/** 自由输入文字 */}
{(selectedTemplate?.freeInputItem) && (
{freeInputLayout === 'bottom' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div className="py-2 flex-1">
<input
type="text"
value={selectedTemplate?.freeInputItem?.free_input_text || ""}
placeholder={selectedTemplate?.freeInputItem.user_tips}
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips}
className="w-full px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInputItem: {
...selectedTemplate!.freeInputItem,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value
}
};
})),
}
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>

View 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>
)
}

View File

@ -34,7 +34,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<TopBar collapsed={sidebarCollapsed} isDesktop={isDesktop} />
{isDesktop && <Sidebar collapsed={sidebarCollapsed} onToggle={setSidebarCollapsed} />}
<div
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 z-[999] px-4"
className="h-[calc(100vh-4rem)] top-[4rem] fixed right-0 bottom-0 px-4"
style={getLayoutStyles()}>
{children}
</div>

View File

@ -2,7 +2,11 @@
import "../pages/style/top-bar.css";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { GradientText } from "@/components/ui/gradient-text";
import { useTheme } from "next-themes";
import {
@ -14,6 +18,7 @@ import {
PanelsLeftBottom,
Bell,
Info,
CalendarDays,
} from "lucide-react";
import { motion } from "framer-motion";
import { createPortal } from "react-dom";
@ -28,6 +33,7 @@ import {
} from "@/lib/stripe";
import UserCard from "@/components/common/userCard";
import { showInsufficientPointsNotification } from "@/utils/notifications";
import CheckinBox from "./checkin-box";
interface User {
id: string;
@ -55,6 +61,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
const [customAmount, setCustomAmount] = useState<string>("");
const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false);
// 获取用户订阅信息
const fetchSubscriptionInfo = async () => {
@ -239,8 +246,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
element.classList.add("on");
};
/**
* modal
*/
const handleCheckin = () => {
setIsCheckinModalOpen(true);
};
return (
<div
<div
className="fixed right-0 top-0 h-16 header z-[999]"
style={{
isolation: "isolate",
@ -371,7 +385,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
position: "fixed",
top: "4rem",
right: "1rem",
zIndex: 9999,
zIndex: 999,
}}
className="overflow-hidden rounded-xl max-h-[90vh]"
data-alt="user-menu-dropdown"
@ -394,6 +408,16 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
{currentUser.email}
</p>
</div>
{/* Check-in entry */}
{/* <div>
<button
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
onClick={() => handleCheckin()}
title="Daily Check-in"
>
<CalendarDays className="h-3 w-3" />
</button>
</div> */}
</div>
{/* AI 积分 */}
@ -527,6 +551,19 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
</div>
)}
</div>
{/* Check-in Modal */}
<Dialog open={isCheckinModalOpen} onOpenChange={setIsCheckinModalOpen}>
<DialogContent
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
data-alt="checkin-modal"
>
<DialogTitle></DialogTitle>
<div className="p-4">
<CheckinBox />
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,22 +1,22 @@
"use client"
import React, { useRef, useEffect, useCallback } from "react";
import "./style/work-flow.css";
import { Skeleton } from "@/components/ui/skeleton";
import { EditModal } from "@/components/ui/edit-modal";
import { TaskInfo } from "./work-flow/task-info";
import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
import { useWorkflowData } from "./work-flow/use-workflow-data";
import { usePlaybackControls } from "./work-flow/use-playback-controls";
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast, ChevronsLeft, Bot, BriefcaseBusiness, Scissors } from "lucide-react";
import { motion } from "framer-motion";
import { Bot, TestTube } from "lucide-react";
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
import { useSearchParams } from "next/navigation";
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
import { Drawer, Tooltip, notification } from 'antd';
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { exportVideoWithRetry } from '@/utils/export-service';
const WorkFlow = React.memo(function WorkFlow() {
useEffect(() => {
@ -36,14 +36,22 @@ const WorkFlow = React.memo(function WorkFlow() {
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
const [aiEditingInProgress, setAiEditingInProgress] = React.useState(false);
const [isHovered, setIsHovered] = React.useState(false);
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
// const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
const [editingStatus, setEditingStatus] = React.useState<'initial' | 'idle' | 'success' | 'error'>('initial');
const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState<string>(`iframe-ai-editing-${Date.now()}`);
// const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState<string>(`iframe-ai-editing-${Date.now()}`);
const [isEditingInProgress, setIsEditingInProgress] = React.useState(false);
const isEditingInProgressRef = useRef(false);
// 导出进度状态
const [exportProgress, setExportProgress] = React.useState<{
status: 'processing' | 'completed' | 'failed';
percentage: number;
message: string;
stage?: string;
taskId?: string;
} | null>(null);
const searchParams = useSearchParams();
const episodeId = searchParams.get('episodeId') || '';
@ -53,23 +61,64 @@ const WorkFlow = React.memo(function WorkFlow() {
SaveEditUseCase.setProjectId(episodeId);
let editingNotificationKey = useRef<string>(`editing-${Date.now()}`);
const [isHandleEdit, setIsHandleEdit] = React.useState(false);
// 使用 ref 存储 handleTestExport 避免循环依赖
const handleTestExportRef = useRef<(() => Promise<any>) | null>(null);
// 导出进度回调处理
const handleExportProgress = useCallback((progressData: {
status: 'processing' | 'completed' | 'failed';
percentage: number;
message: string;
stage?: string;
taskId?: string;
}) => {
console.log('📊 导出进度更新:', progressData);
setExportProgress(progressData);
// 根据状态显示不同的通知 - 已注释
/*
if (progressData.status === 'processing') {
notification.info({
message: '导出进度',
description: `${progressData.message} (${progressData.percentage}%)`,
placement: 'topRight',
duration: 2,
key: 'export-progress'
});
} else if (progressData.status === 'completed') {
notification.success({
message: '导出成功',
description: progressData.message,
placement: 'topRight',
duration: 5,
key: 'export-progress'
});
} else if (progressData.status === 'failed') {
notification.error({
message: '导出失败',
description: progressData.message,
placement: 'topRight',
duration: 8,
key: 'export-progress'
});
}
*/
}, []);
// 处理编辑计划生成完成的回调
const handleEditPlanGenerated = useCallback(() => {
console.log('🚀 handleEditPlanGenerated called, current ref:', isEditingInProgressRef.current);
// 防止重复调用 - 使用 ref 避免依赖项变化
if (isEditingInProgressRef.current) {
console.log('⚠️ 编辑已在进行中,跳过重复调用');
return;
}
console.log('✨ 编辑计划生成完成开始AI剪辑');
setIsHandleEdit(true);
setEditingStatus('idle');
setIsEditingInProgress(true);
// setIsEditingInProgress(true); // 已移除该状态变量
isEditingInProgressRef.current = true;
aiEditingButtonRef.current?.handleAIEditing();
// 改为调用测试剪辑计划导出按钮方法
// aiEditingButtonRef.current?.handleAIEditing();
// 使用 ref 调用避免循环依赖
setTimeout(() => {
handleTestExportRef.current?.();
}, 0);
editingNotificationKey.current = `editing-${Date.now()}`;
showEditingNotification({
description: 'Performing intelligent editing...',
@ -88,7 +137,7 @@ const WorkFlow = React.memo(function WorkFlow() {
}
// 重新生成 iframeAiEditingKey 触发重新渲染
setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
// setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
// 延时200ms后显示重试通知确保之前的通知已销毁
setTimeout(() => {
@ -117,13 +166,13 @@ const WorkFlow = React.memo(function WorkFlow() {
}, 200);
}
});
}, [episodeId]); // 移除 isEditingInProgress 依赖
}, [episodeId]); // handleTestExport 在内部调用,无需作为依赖
/** 处理导出失败 */
const handleExportFailed = useCallback(() => {
console.log('Export failed, setting error status');
setEditingStatus('error');
setIsEditingInProgress(false);
// setIsEditingInProgress(false); // 已移除该状态变量
isEditingInProgressRef.current = false;
// 销毁当前编辑通知
@ -166,7 +215,7 @@ const WorkFlow = React.memo(function WorkFlow() {
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
}, [currentSketchIndex, taskObject]);
// 监听粗剪是否完成,如果完成 更新 showEditingNotification 的状态 为完成,延时 3s 并关闭
// 监听粗剪是否完成
useEffect(() => {
console.log('🎬 final video useEffect triggered:', {
finalUrl: taskObject.final.url,
@ -219,16 +268,47 @@ const WorkFlow = React.memo(function WorkFlow() {
// 切换到最终视频阶段
setAnyAttribute('currentStage', 'final_video');
setAiEditingInProgress(false);
// setAiEditingInProgress(false); // 已移除该状态变量
}, [setAnyAttribute]);
const handleAIEditingError = useCallback((error: string) => {
console.error('❌ AI剪辑失败:', error);
// 这里可以显示错误提示
setAiEditingInProgress(false);
// setAiEditingInProgress(false); // 已移除该状态变量
}, []);
// iframe智能剪辑回调函数
// 测试导出接口的处理函数(使用封装的导出服务)
const handleTestExport = useCallback(async () => {
console.log('🧪 开始测试导出接口...');
console.log('📊 当前taskObject状态:', {
currentStage: taskObject.currentStage,
videosCount: taskObject.videos?.data?.length || 0,
completedVideos: taskObject.videos?.data?.filter(v => v.video_status === 1).length || 0
});
try {
// 使用封装的导出服务,传递进度回调
const result = await exportVideoWithRetry(episodeId, taskObject, handleExportProgress);
console.log('🎉 导出服务完成,结果:', result);
return result;
} catch (error) {
console.error('❌ 导出服务失败:', error);
throw error;
}
}, [episodeId, taskObject, handleExportProgress]);
// 将 handleTestExport 赋值给 ref
React.useEffect(() => {
handleTestExportRef.current = handleTestExport;
}, [handleTestExport]);
// iframe智能剪辑回调函数 - 已注释
/*
const handleIframeAIEditingComplete = useCallback((result: any) => {
console.log('🎉 iframe AI剪辑完成结果:', result);
@ -244,18 +324,23 @@ const WorkFlow = React.memo(function WorkFlow() {
// 切换到最终视频阶段
setAnyAttribute('currentStage', 'final_video');
setAiEditingInProgress(false);
// setAiEditingInProgress(false); // 已移除该状态变量
}, [setAnyAttribute]);
*/
/*
const handleIframeAIEditingError = useCallback((error: string) => {
console.error('❌ iframe AI剪辑失败:', error);
setAiEditingInProgress(false);
// setAiEditingInProgress(false); // 已移除该状态变量
}, []);
*/
/*
const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => {
console.log(`📊 AI剪辑进度: ${progress}% - ${message}`);
setAiEditingInProgress(true);
// setAiEditingInProgress(true); // 已移除该状态变量
}, []);
*/
return (
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
@ -320,7 +405,8 @@ const WorkFlow = React.memo(function WorkFlow() {
</div>
</div>
{/* AI剪辑按钮 - 当可以跳转剪辑时显示 */}
{/* AI剪辑按钮 - 已注释不加载iframe */}
{/*
{
isShowAutoEditing && (
<div className="fixed right-[2rem] top-[8rem] z-[49]">
@ -341,9 +427,48 @@ const WorkFlow = React.memo(function WorkFlow() {
</div>
)
}
*/}
{/* 导出进度显示 - 已注释 */}
{/*
{exportProgress && exportProgress.status === 'processing' && (
<div className="fixed right-[1rem] bottom-[20rem] z-[49]">
<div className="backdrop-blur-lg bg-black/30 border border-white/20 rounded-lg p-4 max-w-xs">
<div className="text-white text-sm mb-2">
: {exportProgress.percentage}%
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${exportProgress.percentage}%` }}
/>
</div>
<div className="text-gray-300 text-xs">
{exportProgress.message}
{exportProgress.stage && ` (${exportProgress.stage})`}
</div>
</div>
</div>
)}
*/}
{/* 测试导出接口按钮 - 隐藏显示(仍可通过逻辑调用) */}
<div
className="fixed right-[1rem] bottom-[16rem] z-[49]"
style={{ display: 'none' }}
>
<Tooltip title="测试剪辑计划导出接口" placement="left">
<GlassIconButton
icon={TestTube}
size='md'
onClick={handleTestExport}
className="backdrop-blur-lg"
/>
</Tooltip>
</div>
{/* 智能对话按钮 */}
<div
<div
className="fixed right-[1rem] bottom-[10rem] z-[49]"
>
<Tooltip title="Open chat" placement="left">

View File

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-[999]',
className
)}
{...props}
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg z-[999]',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

119
docs/3.md Normal file
View 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

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

View File

@ -43,6 +43,8 @@ module.exports = {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
'custom-blue': '#6AF4F9',
'custom-purple': '#C73BFF',
},
borderRadius: {
lg: "var(--radius)",

View File

@ -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
View 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);
}
}
/**
*
* 810
*/
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
* - 105
*/
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);
}