Files
xiaoxia-saas/apps/web/src/pages/generate/hooks/useBatchCovers.ts
T
xiaoxia 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
fix(generation): #2028 awaiting_cover 预览可见 + clips/from-assets 零时长容错(rebase on #2030)
- 后端 _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 个新增/更新,本地全过
2026-09-24 17:48:05 +08:00

249 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 批量封面 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