@@ -1,10 +1,10 @@
/**
* 剪辑计划 编辑器 — V8 原型 1:1 还原
* 模板 编辑器 — 制作/编辑剪辑模板
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/
import React , { useState , useCallback , useEffect , useRef } from "react"
import { useSearchParams } from "react-router-dom"
import { message , Modal , Progress , Button } from "antd"
import { message , Modal } from "antd"
import { useQuery } from "@tanstack/react-query"
import type {
EditingTemplate ,
@@ -20,31 +20,8 @@ import {
getTemplateCategories ,
MODE_LABELS ,
} from "@/api/editingPlanner"
import type {
EditPlanGeneration ,
EditPlanConfig ,
GeneratedVideo ,
MediaAsset ,
TransitionEffect ,
TitleConfig ,
} from "@/api/editPlans"
import {
getMediaAssets ,
getEditPlanGenerations ,
getEditPlan ,
createEditPlan ,
updateEditPlan ,
generateEditPlan ,
getGenerationStatus ,
getGenerationTaskResults ,
cancelGeneration ,
getEditPlanClips ,
createEditPlanClip ,
batchDeleteEditPlanClips ,
type EditPlanClip ,
type CreateEditPlanClipRequest ,
type ClipStatusItem ,
} from "@/api/editPlans"
import type { MediaAsset , TransitionEffect , TitleConfig } from "@/api/editPlans"
import { getMediaAssets , getEditPlan , getEditPlanClips } from "@/api/editPlans"
import { useUndoRedo } from "./hooks/useUndoRedo"
import type {
ClipData ,
@@ -97,7 +74,6 @@ import GreenScreenPanel from "./components/GreenScreenPanel"
import StickerPanel from "./components/StickerPanel"
import SaveModal from "./components/SaveModal"
import GenerationHistoryModal from "./components/GenerationHistoryModal"
import { DEFAULT_BGM_MIX_CONFIG , type BgmMixConfig } from "@/api/bgm"
import "./EditingPlanner.css"
@@ -230,26 +206,8 @@ const EditingPlanner: React.FC = () => {
setSelectedAssetIds ( ids )
}
/* ── 生成历史 ── */
const [ genHistoryOpen , setGenHistoryOpen ] = useState ( false )
const [ genHistory , setGenHistory ] = useState < EditPlanGeneration [ ] > ( [ ] )
const [ genHistoryLoading , setGenHistoryLoading ] = useState ( false )
/* ── 剪辑计划(从列表页编辑进入时) ── */
const [ loadedPlanId , setLoadedPlanId ] = useState < string | null > ( urlPlanId || null )
/* ── 生成进度 ── */
const [ generating , setGenerating ] = useState ( false )
const [ genProgress , setGenProgress ] = useState ( 0 )
const [ genTotalClips , setGenTotalClips ] = useState ( 0 )
const [ genDoneClips , setGenDoneClips ] = useState ( 0 )
const [ generated , setGenerated ] = useState ( false )
const [ generatedVideos , setGeneratedVideos ] = useState < GeneratedVideo [ ] > ( [ ] )
const [ genError , setGenError ] = useState < string | null > ( null )
const [ cancelling , setCancelling ] = useState ( false )
const [ genCancelled , setGenCancelled ] = useState ( false )
const [ genClipStatuses , setGenClipStatuses ] = useState < ClipStatusItem [ ] > ( [ ] )
const genTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
const [ loadedPlanId ] = useState < string | null > ( urlPlanId || null )
/* ── 播放 ── */
const [ isPlaying , setIsPlaying ] = useState ( false )
@@ -772,58 +730,6 @@ const EditingPlanner: React.FC = () => {
setStickerSettings ( config )
} , [ ] )
/** 构建剪辑计划 config(编辑器状态 → API config) */
const buildPlanConfig = ( ) : EditPlanConfig = > ( {
title_config : titleConfig ,
subtitle_config : {
enabled : subtitleSettings.enabled ,
position : subtitleSettings.position ,
font : subtitleSettings.font ,
color : subtitleSettings.fontColor ,
size : subtitleSettings.fontSize ,
animation : subtitleSettings.animation ,
} ,
bgm_config : {
enabled : bgmSettings.enabled ,
music_id : bgmSettings.music_id ,
} ,
estimated_duration : totalDuration ,
segments : clips.map ( ( c , i ) = > ( {
segment_order : i ,
duration_min : Math.max ( 1 , c . duration - 2 ) ,
duration_max : c.duration + 2 ,
material_type : c.type === "voice" ? "voiceover" : "video" ,
transition : c.transition
? { type : c . transition . type , duration : c.transition.duration }
: undefined ,
playback_speed : c.speed ? c.speed.rate : undefined ,
tts_config : c.tts_config
? {
mode : c.tts_config.mode ,
text : c.tts_config.text ,
voice_id : c.tts_config.voice_id ,
speed : c.tts_config.speed ,
pitch : c.tts_config.pitch ,
volume : c.tts_config.volume ,
subtitle_sync : c.tts_config.subtitle_sync ,
}
: undefined ,
trim_config : c.trim_config
? {
start_time : c.trim_config.start_time ,
end_time : c.trim_config.end_time ,
}
: undefined ,
} ) ) ,
watermark_config : { . . . watermarkSettings } ,
intro_outro_config : { . . . introOutroSettings } ,
pip_config : { . . . pipSettings } ,
filter_config : { . . . filterSettings } ,
green_screen_config : { . . . chromaKeySettings } ,
sticker_config : { . . . stickerSettings } ,
cover_config : { . . . coverConfig } ,
} )
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = ( ) = > {
setSaveModalOpen ( true )
@@ -908,247 +814,6 @@ const EditingPlanner: React.FC = () => {
}
}
/**
* 将本地编辑的片段同步到后端 clips 表
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
*/
const syncClipsToBackend = async ( planId : string ) : Promise < void > = > {
if ( clips . length === 0 ) return
// 1. 获取并删除后端现有片段
const existing = await getEditPlanClips ( planId , { limit : 500 } )
if ( existing . items . length > 0 ) {
await batchDeleteEditPlanClips (
planId ,
existing . items . map ( ( c ) = > c . id ) ,
)
}
// 2. 批量创建新片段(并发 3 个)
const clipDataList : CreateEditPlanClipRequest [ ] = clips . map ( ( c , i ) = > ( {
clip_type : c.type === "voice" ? "voiceover" : "main" ,
order : i ,
asset_id : c.media_asset_id || "" ,
text_content : c.script_text || "" ,
start_time : 0 ,
duration : c.duration ,
transition_effect : c.transition?.type || "cut" ,
transition_duration : c.transition?.duration || 0 ,
playback_speed : c.speed?.rate || 1.0 ,
config : {
tts_config : c.tts_config || null ,
trim_config : c.trim_config || null ,
template_segment_id : c.template_segment_id || null ,
} ,
} ) )
// 并发控制:最多同时 3 个请求
const results : EditPlanClip [ ] = [ ]
const concurrency = 3
for ( let i = 0 ; i < clipDataList . length ; i += concurrency ) {
const batch = clipDataList . slice ( i , i + concurrency )
const batchResults = await Promise . all ( batch . map ( ( data ) = > createEditPlanClip ( planId , data ) ) )
results . push ( . . . batchResults )
}
console . log ( ` [片段同步] 创建了 ${ results . length } 个片段 ` )
}
/**
* 剪辑计划生成
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
* 3. 触发生成后轮询状态,完成后获取视频结果
*/
const handleGoToGenerate = async ( ) = > {
if ( ! loadedTemplateId ) {
message . warning ( "请先选择一个模板" )
return
}
if ( clips . length === 0 ) {
message . warning ( "请先添加片段" )
return
}
setGenerating ( true )
setGenerated ( false )
setGeneratedVideos ( [ ] )
setGenError ( null )
setGenProgress ( 0 )
setGenCancelled ( false )
try {
const config = buildPlanConfig ( )
let planId = loadedPlanId
if ( planId ) {
// 已有计划 → 先重置状态为 draft( failed/editing 等非 draft 状态会被后端拒绝更新和生成)
try {
await updateEditPlan ( planId , { status : "draft" } )
} catch ( resetErr ) {
console . warn ( "[状态重置跳过]" , resetErr )
}
// 再更新配置
try {
await updateEditPlan ( planId , {
config ,
total_duration : totalDuration ,
} )
} catch ( updateErr ) {
console . warn ( "[计划更新跳过]" , updateErr )
}
} else {
// 无计划 → 创建新计划
const plan = await createEditPlan ( {
template_id : loadedTemplateId ,
name : draftName || "未命名计划" ,
config ,
total_duration : totalDuration ,
} )
planId = plan . id
setLoadedPlanId ( planId )
// 更新 URL 参数(不刷新页面)
const params = new URLSearchParams ( window . location . search )
params . set ( "planId" , planId )
window . history . replaceState ( null , "" , ` ? ${ params . toString ( ) } ` )
}
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
try {
await syncClipsToBackend ( planId )
} catch ( syncErr ) {
console . warn ( "[片段同步失败]" , syncErr )
message . warning ( "片段同步失败,将使用模板默认配置生成" )
// 同步失败不阻塞生成,后端有模板兜底
}
// 触发生成
const genRes = await generateEditPlan ( planId )
setGenTotalClips ( genRes . clip_count )
message . info ( "已提交生成,等待处理..." )
// 开始轮询
startPolling ( planId )
} catch ( err ) {
console . error ( "[生成失败]" , err )
setGenError ( "生成提交失败,请重试" )
setGenerating ( false )
}
}
/** 轮询生成状态,每 2 秒一次 */
const startPolling = ( planId : string ) = > {
const poll = async ( ) = > {
try {
const status = await getGenerationStatus ( planId )
// 计算进度
const total = status . clips . length || genTotalClips
const done = status . clips . filter (
( c ) = > c . status === "completed" || c . status === "failed" ,
) . length
setGenDoneClips ( done )
setGenTotalClips ( total )
setGenClipStatuses ( status . clips || [ ] )
setGenProgress ( total > 0 ? Math . round ( ( done / total ) * 100 ) : 5 )
if ( status . plan_status === "completed" ) {
setGenProgress ( 100 )
setGenerating ( false )
setGenerated ( true )
// 获取视频结果
if ( status . generation_task_id ) {
try {
const videos = await getGenerationTaskResults ( status . generation_task_id )
setGeneratedVideos ( videos )
} catch ( e ) {
console . error ( "[获取视频结果失败]" , e )
}
}
message . success ( "视频生成完成!" )
return // 停止轮询
}
if ( status . plan_status === "failed" ) {
setGenerating ( false )
setGenError ( "生成失败,请重试" )
return // 停止轮询
}
if ( status . plan_status === "cancelled" ) {
setGenerating ( false )
setGenError ( "生成已取消" )
setGenCancelled ( true )
message . info ( "生成任务已取消" )
return // 停止轮询
}
// 继续轮询
genTimerRef . current = setTimeout ( poll , 2000 )
} catch ( err ) {
console . error ( "[轮询状态失败]" , err )
genTimerRef . current = setTimeout ( poll , 5000 ) // 出错后 5 秒重试
}
}
// 首次延迟 2 秒后开始
genTimerRef . current = setTimeout ( poll , 2000 )
}
/** 清理轮询定时器 */
useEffect ( ( ) = > {
return ( ) = > {
if ( genTimerRef . current ) clearTimeout ( genTimerRef . current )
}
} , [ ] )
/** 取消生成任务 */
const handleCancelGeneration = async ( ) = > {
const targetId = loadedPlanId
if ( ! targetId ) return
Modal . confirm ( {
title : "确认取消生成" ,
content : "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?" ,
okText : "确认取消" ,
cancelText : "继续生成" ,
okButtonProps : { danger : true } ,
onOk : async ( ) = > {
try {
setCancelling ( true )
await cancelGeneration ( targetId )
message . success ( "已提交取消请求" )
// 轮询会继续运行直到检测到 cancelled 状态
} catch ( err ) {
console . error ( "[取消失败]" , err )
message . error ( "取消失败,请稍后重试" )
} finally {
setCancelling ( false )
}
} ,
} )
}
/* 查看生成历史 */
const handleViewGenHistory = async ( ) = > {
const targetId = loadedPlanId || loadedTemplateId
if ( ! targetId ) {
message . warning ( "请先加载一个模板或计划" )
return
}
setGenHistoryOpen ( true )
setGenHistoryLoading ( true )
try {
const items = await getEditPlanGenerations ( targetId )
setGenHistory ( items )
} catch {
message . error ( "加载生成历史失败" )
} finally {
setGenHistoryLoading ( false )
}
}
/* ──────────── 渲染 ──────────── */
return (
@@ -1157,7 +822,7 @@ const EditingPlanner: React.FC = () => {
< div className = "ep-top-bar" >
< div className = "ep-top-bar-left" >
< span className = "ep-logo" > ✂ ️ < / span >
< span className = "ep-app-title" > 小 虾 剪 辑 编 排 器 < / span >
< span className = "ep-app-title" > 模 板 制 作 < / span >
< span className = "ep-divider" > | < / span >
< span className = "ep-template-name" > { currentTemplate ? . name || "未选择模板" } < / span >
< / div >
@@ -1181,13 +846,6 @@ const EditingPlanner: React.FC = () => {
< button className = "ep-btn ep-btn-secondary" onClick = { handleOpenSaveModal } >
💾 保 存 模 板
< / button >
< button
className = "ep-btn ep-btn-primary"
onClick = { handleGoToGenerate }
disabled = { generating }
>
{ loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成" }
< / button >
< / div >
< / div >
@@ -1352,10 +1010,6 @@ const EditingPlanner: React.FC = () => {
< span > 🎬 { MODE_LABELS [ currentMode ] } < / span >
< span className = "ep-status-sep" > | < / span >
< span > 📐 模 板 片 段 : { currentTemplate ? . segments . length || 0 } < / span >
< span className = "ep-status-sep" > | < / span >
< button className = "ep-status-link" onClick = { handleViewGenHistory } >
📋 生 成 历 史
< / button >
< / div >
< / div >
@@ -1376,214 +1030,6 @@ const EditingPlanner: React.FC = () => {
onCancel = { ( ) = > setSaveModalOpen ( false ) }
/ >
{ /* ═══ 生成历史弹窗 ═══ */ }
< GenerationHistoryModal
open = { genHistoryOpen }
loading = { genHistoryLoading }
history = { genHistory }
onClose = { ( ) = > setGenHistoryOpen ( false ) }
onCancel = { async ( ) = > {
Modal . confirm ( {
title : "确认取消生成" ,
content : "确定要取消这个生成任务吗?此操作不可恢复。" ,
okText : "确认取消" ,
cancelText : "再等等" ,
okButtonProps : { danger : true } ,
onOk : async ( ) = > {
if ( ! loadedPlanId ) return
try {
await cancelGeneration ( loadedPlanId )
message . success ( "已提交取消请求" )
// 刷新历史列表
handleViewGenHistory ( )
} catch ( err ) {
console . error ( "[取消失败]" , err )
message . error ( "取消失败,请稍后重试" )
}
} ,
} )
} }
cancelLoading = { cancelling }
/ >
{ /* ═══ 生成进度弹窗 ═══ */ }
< Modal
title = {
genError
? "生成失败"
: genCancelled
? "已取消生成"
: generated
? "生成完成"
: "正在生成视频"
}
open = { generating || generated || ! ! genError || genCancelled }
footer = {
generated
? [
< Button
key = "close"
onClick = { ( ) = > {
setGenerated ( false )
setGenerating ( false )
setGenError ( null )
} }
>
关 闭
< / Button > ,
generatedVideos . length > 0 && (
< Button
key = "download"
type = "primary"
onClick = { ( ) = > {
const v = generatedVideos [ 0 ]
const url = v . download_url || v . file_url
if ( url ) {
const a = document . createElement ( "a" )
a . href = url
a . download = v . name || "video.mp4"
a . target = "_blank"
document . body . appendChild ( a )
a . click ( )
document . body . removeChild ( a )
}
} }
>
下 载 视 频
< / Button >
) ,
]
: generating
? [
< Button key = "cancel" danger loading = { cancelling } onClick = { handleCancelGeneration } >
取 消 生 成
< / Button > ,
]
: genCancelled
? [
< Button
key = "close"
type = "primary"
onClick = { ( ) = > {
setGenCancelled ( false )
setGenerating ( false )
} }
>
关 闭
< / Button > ,
]
: genError
? [
< Button
key = "close"
onClick = { ( ) = > {
setGenError ( null )
setGenerating ( false )
} }
>
关 闭
< / Button > ,
]
: null
}
closable = { ! generating }
maskClosable = { false }
width = { 520 }
>
{ generating && (
< div style = { { padding : "16px 0" } } >
< Progress percent = { genProgress } status = "active" / >
< p style = { { marginTop : 8 , color : "var(--text-secondary)" } } >
已 处 理 { genDoneClips } / { genTotalClips } 个 片 段
< / p >
{ genClipStatuses . length > 0 && (
< div className = "ep-gen-clip-list" >
{ genClipStatuses . map ( ( clip , index ) = > (
< div key = { clip . clip_id || index } className = "ep-gen-clip-item" >
< span className = "ep-gen-clip-index" > { index + 1 } < / span >
< span className = "ep-gen-clip-name" >
{ clip . text_content
? clip . text_content . slice ( 0 , 20 )
: clip . clip_type || ` 片段 ${ index + 1 } ` }
< / span >
< span className = { ` ep-gen-clip-status status- ${ clip . status } ` } >
{ clip . status === "completed"
? "✓ 完成"
: clip . status === "failed"
? "✗ 失败"
: clip . status === "processing"
? "⟳ 处理中"
: "⏳ 等待中" }
< / span >
< / div >
) ) }
< / div >
) }
< p
style = { {
color : "var(--text-secondary)" ,
fontSize : 12 ,
marginTop : 12 ,
} }
>
请 耐 心 等 待 , 生 成 过 程 中 请 勿 关 闭 页 面
< / p >
< / div >
) }
{ genCancelled && (
< div style = { { padding : "24px 0" , textAlign : "center" } } >
< p > 生 成 已 取 消 < / p >
< p style = { { color : "var(--text-secondary)" , fontSize : 12 } } > 你 可 以 继 续 编 辑 后 重 新 生 成 < / p >
< / div >
) }
{ generated && generatedVideos . length > 0 && (
< div style = { { padding : "8px 0" } } >
< video
src = { generatedVideos [ 0 ] . file_url || generatedVideos [ 0 ] . download_url }
controls
preload = "metadata"
style = { { width : "100%" , maxHeight : 320 , borderRadius : 8 } }
/ >
< p
style = { {
marginTop : 8 ,
textAlign : "center" ,
color : "var(--text-secondary)" ,
} }
>
{ generatedVideos [ 0 ] . name }
< / p >
< / div >
) }
{ generated && ! generatedVideos . length && (
< div style = { { padding : "24px 0" , textAlign : "center" } } >
< p > 生 成 完 成 , 但 暂 未 获 取 到 视 频 结 果 < / p >
< p style = { { color : "var(--text-secondary)" , fontSize : 12 } } >
请 稍 后 在 剪 辑 计 划 列 表 中 查 看
< / p >
< / div >
) }
{ genError && (
< div
style = { {
padding : "16px 0" ,
textAlign : "center" ,
color : "#ff4d4f" ,
} }
>
< p > { genError } < / p >
< Button
onClick = { ( ) = > {
setGenError ( null )
setGenerating ( false )
} }
>
关 闭
< / Button >
< / div >
) }
< / Modal >
{ /* ═══ BGM 选择器 Drawer ═══ */ }
< BgmSelector
open = { bgmDrawerOpen }