cb4f076119
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
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 / Frontend Lint (pull_request) Successful in 1m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m2s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 5m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m51s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m54s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 33s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m7s
- 后端 _get_task_video_url:DB 查不到 GeneratedVideo 时回退读 task.extra_meta.rendered_output.file_url,封面生成不再 404
- 后端 results:awaiting_cover 且无 DB 记录时,从 extra_meta 合成 preview-{task_id} 响应
- 后端 finalize 服务:支持 custom_title 自定义标题,幂等分支也更新名称
- 后端 clips/from-assets:asset=None 或 duration≤0 跳过;score_asset/scene_points 异常兜底不阻断;全素材无效返友好 400「素材尚未完成分析,请稍后重试」
- 前端轮询:pollSingleTask 把 awaiting_cover 视为可预览终态;BatchTaskState.status 加 awaiting_cover;7 处 completed 过滤加 OR awaiting_cover
- 前端 finalize:单步调 finalizeGeneration(custom_title 透传),不再先 confirm 再 finalize;响应字段补全 project_id/name/file_size/duration/thumbnail_url/width/height/fps
- 单测 11 个新增/更新,本地全过
249 lines
8.8 KiB
TypeScript
249 lines
8.8 KiB
TypeScript
/**
|
||
* 批量封面 Hook(Issue #1677 / #1750 修复)
|
||
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
|
||
*
|
||
* #1750 修复:
|
||
* - patchCover 改为函数式 updater 回写(onCoversChange 支持 (prev)=>next),
|
||
* 修复串行 generateAll 时闭包捕获旧 covers 数组导致回写互相覆盖的 bug;
|
||
* - loading/uploading 状态改数组(busyIndexes/uploadingIndexes),支持多个卡片在途;
|
||
* - generateOne 返回 boolean,generateAll 据此统计成功/失败数量分级提示,单个失败不阻塞;
|
||
* - generateAll 一次点击即处理所有未生成封面(无需点 3 次)。
|
||
*/
|
||
import { useCallback, useState } from "react"
|
||
import { message } from "antd"
|
||
import { generateCover } from "@/api/generation"
|
||
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
|
||
import type { GeneratedVideo } from "@/api/template-editor"
|
||
|
||
/** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */
|
||
type CoversChangeFn = (updater: string[] | ((prev: string[]) => string[])) => void
|
||
|
||
interface UseBatchCoversOptions {
|
||
selectedTemplate: string
|
||
generatedVideos: GeneratedVideo[]
|
||
/** 每个变体的标题文字 */
|
||
titles: string[]
|
||
/** 标题样式(全局共用) */
|
||
titleStyle: {
|
||
font: string
|
||
size: number
|
||
color: string
|
||
position: string
|
||
bold: boolean
|
||
italic?: boolean
|
||
stroke: boolean
|
||
strokeWidth?: number
|
||
strokeColor?: string
|
||
shadow: boolean
|
||
shadowOffsetX?: number
|
||
shadowOffsetY?: number
|
||
shadowBlur?: number
|
||
shadowColor?: string
|
||
lineHeight?: number
|
||
marginTop?: number
|
||
maxCharsPerLine?: number
|
||
bgEnabled?: boolean
|
||
bgColor?: string
|
||
bgPadding?: number
|
||
bgRadius?: number
|
||
lineOverrides?: unknown[]
|
||
}
|
||
covers: string[]
|
||
onCoversChange: CoversChangeFn
|
||
}
|
||
|
||
export function useBatchCovers({
|
||
selectedTemplate: _selectedTemplate,
|
||
generatedVideos,
|
||
titles,
|
||
titleStyle,
|
||
covers,
|
||
onCoversChange,
|
||
}: UseBatchCoversOptions) {
|
||
/** 正在自动生成封面的变体序号集合(数组支持多个卡片同时在途) */
|
||
const [busyIndexes, setBusyIndexes] = useState<number[]>([])
|
||
/** 正在上传自定义封面的变体序号集合 */
|
||
const [uploadingIndexes, setUploadingIndexes] = useState<number[]>([])
|
||
|
||
const addBusy = useCallback((i: number) => {
|
||
setBusyIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
|
||
}, [])
|
||
const removeBusy = useCallback((i: number) => {
|
||
setBusyIndexes((prev) => prev.filter((x) => x !== i))
|
||
}, [])
|
||
const addUploading = useCallback((i: number) => {
|
||
setUploadingIndexes((prev) => (prev.includes(i) ? prev : [...prev, i]))
|
||
}, [])
|
||
const removeUploading = useCallback((i: number) => {
|
||
setUploadingIndexes((prev) => prev.filter((x) => x !== i))
|
||
}, [])
|
||
|
||
/** 函数式回写单个封面位置(基于最新 prev,不受闭包旧值影响) */
|
||
const patchCover = useCallback(
|
||
(index: number, url: string) => {
|
||
onCoversChange((prev) => {
|
||
const next = [...prev]
|
||
next[index] = url
|
||
return next
|
||
})
|
||
},
|
||
[onCoversChange],
|
||
)
|
||
|
||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||
const generateOne = useCallback(
|
||
async (index: number): Promise<boolean> => {
|
||
const finalVideos = generatedVideos.filter(
|
||
(v) =>
|
||
v.status === "completed" ||
|
||
v.status === "awaiting_cover" ||
|
||
v.status === "awaiting_cover",
|
||
)
|
||
const target = finalVideos[index] || generatedVideos[index]
|
||
if (!target) {
|
||
message.warning("该视频尚未生成完成")
|
||
return false
|
||
}
|
||
addBusy(index)
|
||
try {
|
||
const titleText = titles[index] || ""
|
||
const response = await generateCover("default", {
|
||
generated_video_id: target.id,
|
||
video_url: target.file_url || target.download_url || "",
|
||
cover_type: "ai_frame",
|
||
...(titleText
|
||
? {
|
||
title_config: {
|
||
text: titleText,
|
||
font: titleStyle.font,
|
||
font_size: titleStyle.size,
|
||
font_color: titleStyle.color,
|
||
position: titleStyle.position,
|
||
bold: titleStyle.bold,
|
||
italic: titleStyle.italic,
|
||
stroke: titleStyle.stroke
|
||
? {
|
||
enabled: true,
|
||
width: titleStyle.strokeWidth ?? 4,
|
||
color: titleStyle.strokeColor ?? "#000000",
|
||
}
|
||
: { enabled: false },
|
||
shadow: titleStyle.shadow
|
||
? {
|
||
enabled: true,
|
||
offset_x: titleStyle.shadowOffsetX ?? 2,
|
||
offset_y: titleStyle.shadowOffsetY ?? 2,
|
||
blur: titleStyle.shadowBlur ?? 4,
|
||
color: titleStyle.shadowColor ?? "rgba(0,0,0,0.8)",
|
||
}
|
||
: { enabled: false },
|
||
line_height: titleStyle.lineHeight ?? 1.2,
|
||
margin_top: titleStyle.marginTop ?? 24,
|
||
max_chars_per_line: titleStyle.maxCharsPerLine ?? 0,
|
||
background: titleStyle.bgEnabled
|
||
? {
|
||
enabled: true,
|
||
color: titleStyle.bgColor,
|
||
padding: titleStyle.bgPadding,
|
||
radius: titleStyle.bgRadius,
|
||
}
|
||
: { enabled: false },
|
||
line_overrides: (titleStyle.lineOverrides ?? []) as Array<
|
||
Record<string, unknown>
|
||
>,
|
||
},
|
||
}
|
||
: {}),
|
||
})
|
||
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
|
||
if (url) {
|
||
patchCover(index, url)
|
||
message.success(`视频 ${index + 1} 封面生成成功`)
|
||
return true
|
||
}
|
||
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
|
||
return false
|
||
} catch (err) {
|
||
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
|
||
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
|
||
return false
|
||
} finally {
|
||
removeBusy(index)
|
||
}
|
||
},
|
||
[generatedVideos, titles, titleStyle, patchCover, addBusy, removeBusy],
|
||
)
|
||
|
||
/** 为第 index 个视频上传自定义封面 */
|
||
const uploadOne = useCallback(
|
||
async (index: number, file: File) => {
|
||
addUploading(index)
|
||
try {
|
||
const libs = await getAssetLibraries()
|
||
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
|
||
if (!imageLib) {
|
||
message.error("未找到素材库,请先创建")
|
||
return
|
||
}
|
||
const result = await uploadAssetDirect({
|
||
file,
|
||
library_id: imageLib.id,
|
||
})
|
||
const url = result?.url || ""
|
||
if (url) {
|
||
patchCover(index, url)
|
||
message.success(`视频 ${index + 1} 封面已上传`)
|
||
} else {
|
||
message.warning("上传完成但未获取到图片URL,请重试")
|
||
}
|
||
} catch (err) {
|
||
console.error(`[封面] 视频 ${index + 1} 上传失败:`, err)
|
||
message.error("封面上传失败,请重试")
|
||
} finally {
|
||
removeUploading(index)
|
||
}
|
||
},
|
||
[patchCover, addUploading, removeUploading],
|
||
)
|
||
|
||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||
const generateAll = useCallback(async () => {
|
||
const finalVideos = generatedVideos.filter(
|
||
(v) =>
|
||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||
)
|
||
const total = finalVideos.length
|
||
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
|
||
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
|
||
const pending = finalVideos.map((_, i) => i).filter((i) => !covers[i])
|
||
if (pending.length === 0) {
|
||
message.info("所有视频封面已生成")
|
||
return
|
||
}
|
||
let okCount = 0
|
||
let failCount = 0
|
||
for (const i of pending) {
|
||
const ok = await generateOne(i)
|
||
if (ok) okCount += 1
|
||
else failCount += 1
|
||
}
|
||
if (failCount === 0) {
|
||
message.success(`全部 ${total} 个封面已生成`)
|
||
} else if (okCount === 0) {
|
||
message.error("封面生成全部失败,请重试")
|
||
} else {
|
||
message.warning(`已生成 ${okCount} 个封面,${failCount} 个失败,请对失败项重试`)
|
||
}
|
||
}, [generatedVideos, covers, generateOne])
|
||
|
||
return {
|
||
busyIndexes,
|
||
uploadingIndexes,
|
||
generateOne,
|
||
uploadOne,
|
||
generateAll,
|
||
}
|
||
}
|
||
|
||
export default useBatchCovers
|