Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 83491297f6 feat(generate): 编辑流程自动保存草稿+创建任务参数修复+轮询改新接口+清理幽灵API
CI/CD Pipeline / Validate - Code Quality (pull_request) Has started running
CI/CD Pipeline / Integration Tests (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Web Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Production Worker Image (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Blocked by required conditions
CI/CD Pipeline / Canary Release to Production (pull_request) Blocked by required conditions
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Blocked by required conditions
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m1s
AI Code Review / AI Code Review (pull_request) Failing after 3m56s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m30s
1. Step2选素材后防抖500ms自动保存asset_ids到草稿
2. Step4标题变化后防抖800ms自动保存title到草稿
3. Step6封面请求带上title_config(初始调用+预览缺失后重试)
4. Step7创建任务带source_edit_plan_id + cover_url优先thumbnail_url
5. 轮询改用GET /generation/tasks/{task_id},task_id从创建响应获取
6. 删除7个幽灵API函数:getEditPlans/createEditPlan/deleteEditPlan/
   cancelGeneration/copyEditPlan/generateFromTemplate,BGM预设路径改为
   /templates/{id}/editor/bgm/presets
7. CreateGenerationTaskResponse对齐后端批量结构{items,total}
8. 所有自动保存失败console.warn静默,不弹窗不阻塞
2026-08-23 13:34:04 +08:00
27 changed files with 360 additions and 238 deletions
+12 -3
View File
@@ -4,11 +4,20 @@
import apiClient from "../client"
import type { BgmPreset, BgmPresetsQuery } from "./types"
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
/**
* 获取 BGM 预设列表
* @param templateId 模板/草稿 ID
* @param params 分类/关键词筛选
*/
export const getBgmPresets = async (
templateId: string,
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get("/bgm/presets", { params: searchParams })
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, {
params: searchParams,
})
return res.data?.data ?? res.data ?? []
}
+13
View File
@@ -1,9 +1,22 @@
import apiClient from "../client"
export interface GenerateCoverTitleConfig {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
/** 标题样式,用于在封面上叠加标题文字 */
title_config?: GenerateCoverTitleConfig
}
export interface GenerateCoverResponse {
+7
View File
@@ -6,6 +6,7 @@ import apiClient from "../client"
import type {
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
GenerationTaskDetail,
TaskItem,
TaskListParams,
TaskListResponse,
@@ -19,6 +20,12 @@ export const createGenerationTask = async (
return data
}
/** 获取单个生成任务详情(轮询用) */
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
+14 -2
View File
@@ -82,10 +82,12 @@ export interface CreateGenerationTaskRequest {
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface CreateGenerationTaskResponse {
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */
export interface GenerationTaskDetail {
id: string
project_id: string
asset_library_id: string
@@ -95,8 +97,18 @@ export interface CreateGenerationTaskResponse {
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
source_edit_plan_id?: string
status: string
progress: number
result_count: number
error_message: string
error_info?: TaskErrorInfo
created_at?: string | null
updated_at?: string | null
}
/** 创建生成任务响应(后端返回批量结构 {items, total} */
export interface CreateGenerationTaskResponse {
items: GenerationTaskDetail[]
total: number
}
+3 -42
View File
@@ -4,51 +4,29 @@
import apiClient from "../client"
import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal })
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
@@ -72,20 +50,3 @@ export async function getGenerationTaskResults(taskId: string): Promise<Generate
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
@@ -15,10 +15,7 @@ export type {
EditPlanSegment,
EditPlanConfig,
EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
@@ -37,7 +34,6 @@ export type {
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect,
MediaAsset,
} from "./types"
@@ -53,17 +49,12 @@ export {
// 模板草稿 CRUD + 生成
export {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans"
// 片段 CRUD + 批量操作
+11
View File
@@ -118,6 +118,17 @@ export interface EditPlanConfig {
generate_count?: number
/** 素材模式 */
material_mode?: string
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
title?: {
text?: string
font?: string
font_size?: number
color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 预览视频 URL(封面生成用) */
rendered_storage_key?: string
/** 生成任务 ID */
-3
View File
@@ -9,8 +9,6 @@ export type {
TemplateSegment,
TemplateListParams,
TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse,
} from "./types"
@@ -24,5 +22,4 @@ export {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "./templates"
-14
View File
@@ -5,8 +5,6 @@
import apiClient from "../client"
import type {
CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem,
TemplateListParams,
TemplateListResponse,
@@ -45,15 +43,3 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
@@ -16,9 +16,17 @@ interface BgmSelectorProps {
onClose: () => void
config: BgmMixConfig
onChange: (config: BgmMixConfig) => void
/** 模板/草稿 ID,用于请求 BGM 预设 */
templateId?: string
}
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
const BgmSelector: React.FC<BgmSelectorProps> = ({
open,
onClose,
config,
onChange,
templateId,
}) => {
const {
presets,
loading,
@@ -30,7 +38,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChan
loadPresets,
handlePreview,
stopPreview,
} = useBgmSelector(open)
} = useBgmSelector(open, templateId)
/* ── 选中 BGM ── */
const handleSelect = useCallback(
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
* BGM 选择器数据与交互 Hook
* 封装列表加载、搜索、分类筛选、试听播放逻辑
*/
export function useBgmSelector(open: boolean) {
export function useBgmSelector(open: boolean, templateId?: string) {
const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
@@ -30,19 +30,23 @@ export function useBgmSelector(open: boolean) {
/* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => {
if (!templateId) {
setPresets([])
return
}
setLoading(true)
try {
const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(params)
const data = await getBgmPresets(templateId, params)
setPresets(data)
} catch {
message.error("加载 BGM 列表失败")
} finally {
setLoading(false)
}
}, [activeCategory, keyword])
}, [activeCategory, keyword, templateId])
useEffect(() => {
if (open) loadPresets()
@@ -171,6 +171,7 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
generateCount,
sourceEditPlanId: editPlanId,
})
/* ================================================================
@@ -141,6 +141,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSelectedMaterialsChange={onSelectedMaterialsChange}
smartSelectedIds={smartSelectedIds}
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
selectedTemplate={selectedTemplate}
/>
)
case 3:
@@ -156,6 +157,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
<Step4TitleSettings
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
selectedTemplate={selectedTemplate}
/>
)
case 5:
@@ -15,6 +15,8 @@ interface Step2MaterialSelectProps {
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -12,6 +12,8 @@ import AiTitleGenerator from "./title/AiTitleGenerator"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
@@ -19,6 +19,8 @@ export interface UseGenerateVideoProps {
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 当前草稿 IDURL 参数 edit_plan_id,用于后端回写任务关联) */
sourceEditPlanId?: string | null
}
/** 生成阶段 */
@@ -1,87 +1,122 @@
import { useRef, useCallback } from "react"
import { message } from "antd"
import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor"
import axios from "axios"
import { getGenerationTask } from "@/api/tasks/tasks"
import { getGenerationTaskResults } from "@/api/template-editor"
import { safeExtractError } from "./errorUtils"
interface UseGenerationPollingOptions {
templateId: string
onProgress: (progress: number) => void
onComplete: (videos: unknown[]) => void
onFailed: (errorMsg: string) => void
}
/** 最大连续错误次数(仅对可重试错误),超过后终止轮询 */
const MAX_RETRYABLE_ERRORS = 10
/**
* 生成状态轮询 Hook
* 轮询生成状态,更新进度,处理完成/失败
* 生成状态轮询 Hookv2 — 改用 /generation/tasks/{task_id}
*
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
* 返回的 task_id 轮询任务详情,不再依赖 plan。
*
* 错误处理:
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
*/
export const useGenerationPolling = ({
templateId,
onProgress,
onComplete,
onFailed,
}: UseGenerationPollingOptions) => {
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
const cancelledRef = useRef(false)
const clearTimer = useCallback(() => {
cancelledRef.current = true
if (progressTimer.current) {
clearTimeout(progressTimer.current)
progressTimer.current = undefined
}
}, [])
const startPolling = useCallback(() => {
const poll = async () => {
try {
const data = await getGenerationStatus(templateId)
const startPolling = useCallback(
(taskId: string) => {
cancelledRef.current = false
let consecutiveErrors = 0
if (data.plan_status === "completed") {
onProgress(100)
// 获取生成的视频结果
let videos: unknown[] = []
if (data.generation_task_id) {
const poll = async () => {
if (cancelledRef.current) return
try {
const task = await getGenerationTask(taskId)
consecutiveErrors = 0
if (task.status === "completed") {
onProgress(100)
let videos: unknown[] = []
try {
videos = await getGenerationTaskResults(data.generation_task_id)
videos = await getGenerationTaskResults(taskId)
} catch (err) {
console.error("[获取生成结果失败]", err)
}
onComplete(videos)
message.success("视频生成完成!")
return
}
onComplete(videos)
message.success("视频生成完成!")
return
}
if (data.plan_status === "failed") {
const dataAny = data as unknown as Record<string, unknown>
const rawMsg =
dataAny.error_message ||
dataAny.error ||
dataAny.message ||
(Array.isArray(data.clips)
? (data.clips as { status: string; error_message?: string }[]).find(
(c) => c.status === "failed",
)?.error_message
: undefined) ||
"视频生成失败,请联系管理员或重试"
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] templateId:", templateId, "响应:", data)
onFailed(errorMsg)
message.error(errorMsg)
return
}
const clips = data.clips || []
const total = clips.length || 1
const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length
onProgress(Math.round((done / total) * 100))
if (task.status === "failed" || task.status === "cancelled") {
const rawMsg =
task.error_info?.error_message ||
task.error_message ||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
const errorMsg = safeExtractError(rawMsg)
console.error("[生成失败] taskId:", taskId, "响应:", task)
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
console.error("[轮询出错] templateId:", templateId, pollErr)
progressTimer.current = setTimeout(poll, 3000)
// pending / waiting / running — 继续轮询
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
onProgress(pct)
progressTimer.current = setTimeout(poll, 2000)
} catch (pollErr) {
if (cancelledRef.current) return
console.error("[轮询出错] taskId:", taskId, pollErr)
// 4xx 不可恢复,立即失败
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
if (status && status >= 400 && status < 500) {
const msg =
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.detail) ||
(axios.isAxiosError(pollErr) &&
(pollErr.response?.data as { detail?: string; message?: string } | undefined)
?.message) ||
`查询任务失败 (${status})`
const errorMsg = safeExtractError(msg)
onFailed(errorMsg)
message.error(errorMsg)
return
}
consecutiveErrors += 1
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
const errorMsg = "任务状态查询连续失败,请稍后在任务列表查看结果"
onFailed(errorMsg)
message.error(errorMsg)
return
}
progressTimer.current = setTimeout(poll, 3000)
}
}
}
progressTimer.current = setTimeout(poll, 2000)
}, [templateId, onProgress, onComplete, onFailed])
progressTimer.current = setTimeout(poll, 1500)
},
[onProgress, onComplete, onFailed],
)
return { startPolling, clearTimer }
}
@@ -0,0 +1,97 @@
/**
* 草稿自动保存工具 Hook
*
* 背景:后端 PUT /templates/{id}/editor 的 config 是「整体替换」语义,
* 直接发送 { config: { asset_ids } } 会把 title 等其他字段覆盖掉。
* 本 Hook 统一执行「GET 当前 config → 浅合并新字段 → PUT 回去」,
* 并用串行队列 + AbortController 保证:
* - 同一时刻只有一个保存请求在飞
* - 快速连续变化时只提交最后一次
* - 组件卸载时取消未完成请求
*
* 保存失败只 console.warn,不弹窗、不阻塞。
*/
import { useCallback, useEffect, useRef } from "react"
import { getEditPlan, updateEditPlan } from "@/api/template-editor"
type ConfigPatch = Record<string, unknown>
export function useDraftAutoSave(templateId?: string) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const abortRef = useRef<AbortController | null>(null)
// 待合并的补丁队列(解决「保存进行中又来了新变化」)
const pendingPatchRef = useRef<ConfigPatch | null>(null)
const savingRef = useRef(false)
const templateIdRef = useRef(templateId)
templateIdRef.current = templateId
const flush = useCallback(async () => {
const tid = templateIdRef.current
if (!tid) return
// 已有保存在飞:把新补丁暂存,等当前请求结束后再合并一次
if (savingRef.current) return
savingRef.current = true
// 把暂存的所有补丁合到一起
const patch = pendingPatchRef.current
pendingPatchRef.current = null
if (!patch) {
savingRef.current = false
return
}
const controller = new AbortController()
abortRef.current = controller
try {
// 1. 读当前 config(拿最新,避免覆盖别人/别的步骤写入的字段)
const current = await getEditPlan(tid)
if (controller.signal.aborted) return
const merged = { ...(current.config || {}), ...patch }
// 2. 写回完整合并后的 config
await updateEditPlan(tid, { config: merged }, controller.signal)
} catch (err) {
const name = (err as { name?: string })?.name
if (name !== "CanceledError" && name !== "AbortError") {
console.warn("[useDraftAutoSave] 自动保存草稿失败:", err)
}
} finally {
savingRef.current = false
// 如果保存期间又积累了新的变化,再触发一次
if (pendingPatchRef.current && !controller.signal.aborted) {
timerRef.current = setTimeout(() => {
void flush()
}, 50)
}
}
}, [])
/**
* 调度一次自动保存(防抖)
* @param patch 要合并进 config 的局部字段
* @param delay 防抖毫秒数
*/
const scheduleSave = useCallback(
(patch: ConfigPatch, delay = 500) => {
const tid = templateIdRef.current
if (!tid) return
if (timerRef.current) clearTimeout(timerRef.current)
// 累计补丁(同一周期内多次变化合并成一次写入)
pendingPatchRef.current = { ...(pendingPatchRef.current || {}), ...patch }
timerRef.current = setTimeout(() => {
void flush()
}, delay)
},
[flush],
)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (abortRef.current) abortRef.current.abort()
}
}, [])
return { scheduleSave }
}
export default useDraftAutoSave
@@ -34,7 +34,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}, [])
const { startPolling, clearTimer } = useGenerationPolling({
templateId: selectedTemplate,
onProgress: handleProgress,
onComplete: handleComplete,
onFailed: handleFailed,
@@ -90,16 +89,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// 直接创建正式生成任务
await createGenerationTask({
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: props.coverSettings?.upload_url || "",
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -116,7 +119,12 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
: {}),
})
startPolling()
// 从创建响应直接拿 task_id,改用新接口轮询
const taskId = taskResp.items?.[0]?.id
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
startPolling(taskId)
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef } from "react"
import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
import { useDraftAutoSave } from "./useDraftAutoSave"
interface UseStep2MaterialsProps {
materialMode: "manual" | "auto"
@@ -14,6 +15,8 @@ interface UseStep2MaterialsProps {
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
export function useStep2Materials({
@@ -23,6 +26,7 @@ export function useStep2Materials({
onSelectedMaterialsChange,
smartSelectedIds,
onSmartSelectedIdsChange,
selectedTemplate,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
@@ -53,6 +57,14 @@ export function useStep2Materials({
handleSmartMatch()
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
/* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
if (!selectedTemplate) return
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
scheduleSave({ asset_ids: ids }, 500)
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
/* ── 手动选择素材 ── */
const handleToggleMaterial = useCallback(
(materialId: string) => {
@@ -4,17 +4,24 @@ import { getTitles } from "@/api/titles"
import type { TitleSettings } from "../../types"
import { useAiTitleGenerator } from "./useAiTitleGenerator"
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
import { useDraftAutoSave } from "../useDraftAutoSave"
interface UseStep4TitleProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
}
/**
* Step 4 标题设置 Hook
* 封装 AI 标题生成、标题样式设置等逻辑
*/
export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4TitleProps) {
export function useStep4Title({
titleSettings,
onTitleSettingsChange,
selectedTemplate,
}: UseStep4TitleProps) {
// 标题库数据
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
@@ -42,6 +49,38 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
const isFirstMount = useRef(true)
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
useEffect(() => {
if (!selectedTemplate) return
scheduleTitleSave(
{
title: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
},
800,
)
}, [
selectedTemplate,
titleSettings.title,
titleSettings.font,
titleSettings.size,
titleSettings.color,
titleSettings.position,
titleSettings.bold,
titleSettings.stroke,
titleSettings.shadow,
scheduleTitleSave,
])
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
useEffect(() => {
@@ -97,6 +97,20 @@ export function useStep6Cover({
const response = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
clearTimeout(timeoutId)
const thumbnailUrl = response.cover?.image_url || ""
@@ -205,6 +219,20 @@ export function useStep6Cover({
const retryResp = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
const retryUrl = retryResp.cover?.image_url || ""
if (retryUrl) {
+2 -2
View File
@@ -32,7 +32,7 @@ describe("bgm API", () => {
describe("getBgmPresets", () => {
it("should resolve successfully", async () => {
await expect(getBgmPresets("test-params?")).resolves.not.toThrow()
await expect(getBgmPresets("test-template", { category: "test" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
@@ -42,7 +42,7 @@ describe("bgm API", () => {
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getBgmPresets("test-params?")).rejects.toThrow()
await expect(getBgmPresets("test-template", { category: "test" })).rejects.toThrow()
})
})
})
-85
View File
@@ -1,16 +1,12 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
aiRecommendClips,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
getEditPlanClips,
getEditPlanClip,
createEditPlanClip,
@@ -19,7 +15,6 @@ import {
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
copyEditPlan,
getMediaAssets,
getMediaAsset,
} from "@/api/template-editor"
@@ -53,22 +48,6 @@ describe("editPlans API", () => {
mockPatch.mockResolvedValue({ data: { success: true, items: [] } })
})
describe("getEditPlans", () => {
it("should resolve successfully", async () => {
await expect(getEditPlans("test-params?")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(getEditPlans("test-params?")).rejects.toThrow()
})
})
describe("getEditPlan", () => {
it("should resolve successfully", async () => {
await expect(getEditPlan("test-planId")).resolves.not.toThrow()
@@ -85,22 +64,6 @@ describe("editPlans API", () => {
})
})
describe("createEditPlan", () => {
it("should resolve successfully", async () => {
await expect(createEditPlan({ name: "test-item" })).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(createEditPlan({ name: "test-item" })).rejects.toThrow()
})
})
describe("updateEditPlan", () => {
it("should resolve successfully", async () => {
await expect(updateEditPlan("test-planId")).resolves.not.toThrow()
@@ -117,22 +80,6 @@ describe("editPlans API", () => {
})
})
describe("deleteEditPlan", () => {
it("should resolve successfully", async () => {
await expect(deleteEditPlan("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(deleteEditPlan("test-planId")).rejects.toThrow()
})
})
describe("generateEditPlan", () => {
it("should resolve successfully", async () => {
await expect(generateEditPlan("test-planId")).resolves.not.toThrow()
@@ -213,22 +160,6 @@ describe("editPlans API", () => {
})
})
describe("cancelGeneration", () => {
it("should resolve successfully", async () => {
await expect(cancelGeneration("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(cancelGeneration("test-planId")).rejects.toThrow()
})
})
describe("getEditPlanClips", () => {
it("should resolve successfully", async () => {
await expect(getEditPlanClips("test-planId")).resolves.not.toThrow()
@@ -357,22 +288,6 @@ describe("editPlans API", () => {
})
})
describe("copyEditPlan", () => {
it("should resolve successfully", async () => {
await expect(copyEditPlan("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(copyEditPlan("test-planId")).rejects.toThrow()
})
})
describe("getMediaAssets", () => {
it("should resolve successfully", async () => {
await expect(getMediaAssets("test-libraryId?")).resolves.not.toThrow()
-17
View File
@@ -5,7 +5,6 @@ import {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "@/api/templates"
const mockGet = vi.fn()
@@ -116,20 +115,4 @@ describe("templates API", () => {
await expect(copyTemplate("test-templateId")).rejects.toThrow()
})
})
describe("generateFromTemplate", () => {
it("should resolve successfully", async () => {
await expect(generateFromTemplate("test-templateId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(generateFromTemplate("test-templateId")).rejects.toThrow()
})
})
})
@@ -150,12 +150,10 @@ vi.mock("@/api/template-editor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
getEditPlan: vi.fn().mockResolvedValue({}),
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
cancelGeneration: vi.fn().mockResolvedValue({}),
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
@@ -109,7 +109,6 @@ vi.mock("@/api/templates", () => ({
getTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
toggleFavoriteTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
copyTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
generateFromTemplate: vi.fn().mockResolvedValue({ items: [], total: 0, success: true }),
}))
vi.mock("@/pages/templates/TemplateLibrary.css", () => ({}))