Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21c26b5b26 | |||
| cf83c0df9f | |||
| ca7f875224 | |||
| 1c5060c224 | |||
| 15909a92e5 | |||
| af25045123 | |||
| 7a4aa27f71 | |||
| 28b3010668 |
@@ -14,6 +14,7 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
@@ -312,12 +313,12 @@ def create_preview_generation_task(
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待后再提交",
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
) from e
|
||||
|
||||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
||||
@@ -498,6 +499,7 @@ def create_preview_generation_task(
|
||||
|
||||
# ── 入队 ──
|
||||
responses: list[PreviewGenerationTaskResponse] = []
|
||||
rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示
|
||||
for variant_index, task in enumerate(created_tasks):
|
||||
try:
|
||||
enqueued = safe_enqueue_generation_task(
|
||||
@@ -510,23 +512,29 @@ def create_preview_generation_task(
|
||||
if not enqueued:
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
except UserPendingLimitExceeded:
|
||||
except UserPendingLimitExceeded as e:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
except GlobalQueueFull:
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except GlobalQueueFull as e:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队异常")
|
||||
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
|
||||
responses.append(_to_preview_response(task))
|
||||
|
||||
# 队列满/限流时若全部失败,返回明确错误码
|
||||
if all(r.status == "failed" for r in responses):
|
||||
first_err = next((r.error_message for r in responses if r.error_message), "")
|
||||
if "待处理任务" in first_err:
|
||||
raise HTTPException(status_code=429, detail=first_err or "待处理任务超限")
|
||||
if "队列" in first_err:
|
||||
raise HTTPException(status_code=503, detail=first_err or "系统繁忙,请稍后再试")
|
||||
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
|
||||
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
|
||||
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
@@ -417,12 +418,12 @@ def create_generation_task(
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
) from e
|
||||
|
||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
||||
@@ -579,7 +580,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from _e
|
||||
break
|
||||
except GlobalQueueFull as _e:
|
||||
@@ -587,7 +588,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
) from _e
|
||||
break
|
||||
except HTTPException:
|
||||
@@ -713,15 +714,15 @@ def confirm_generation(
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
except UserPendingLimitExceeded as _e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
except GlobalQueueFull as _e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
@@ -803,12 +804,24 @@ def retry_generation_task(
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
detail=build_rate_limit_detail(
|
||||
UserPendingLimitExceeded(
|
||||
user_id=user_id,
|
||||
pending_count=user_pending,
|
||||
limit=USER_PENDING_LIMIT,
|
||||
),
|
||||
generation_task_repository,
|
||||
scope="user",
|
||||
),
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(
|
||||
GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT),
|
||||
generation_task_repository,
|
||||
scope="global",
|
||||
),
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
@@ -843,15 +856,15 @@ def retry_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded:
|
||||
except UserPendingLimitExceeded as _e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
except GlobalQueueFull as _e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@@ -252,6 +252,10 @@ class RecomputeDedupRequest(BaseModel):
|
||||
None,
|
||||
description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="强制重算:即使视频已有查重数据也重新入队(#1702 查重算法升级后用于存量视频重算)。",
|
||||
)
|
||||
|
||||
|
||||
class RecomputeDedupResponse(BaseModel):
|
||||
@@ -291,15 +295,15 @@ def recompute_dedup(
|
||||
skipped = 0
|
||||
|
||||
for video in target_videos:
|
||||
# 已有完整查重数据的跳过
|
||||
if video.duplicate_rate is not None and video.video_fingerprint:
|
||||
# 已有完整查重数据的跳过(force=True 时强制重算,#1702 算法升级后存量视频需要重算指纹/分片)
|
||||
if not request.force and video.duplicate_rate is not None and video.video_fingerprint:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 触发异步查重任务
|
||||
celery_app.send_task("worker.check_duplicate", args=[video.id])
|
||||
enqueued += 1
|
||||
logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id)
|
||||
logger.info("Enqueued re-dedup for video %s (user=%s, force=%s)", video.id, user_id, request.force)
|
||||
|
||||
return RecomputeDedupResponse(
|
||||
enqueued=enqueued,
|
||||
|
||||
@@ -8,27 +8,145 @@ logger = logging.getLogger(__name__)
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
|
||||
|
||||
# 限流错误码:前端据此区分"排队等待"与"创建失败"
|
||||
ERROR_CODE_USER_QUEUE_FULL = "USER_QUEUE_FULL" # 429:用户自己的任务排队中
|
||||
ERROR_CODE_SYSTEM_QUEUE_FULL = "SYSTEM_QUEUE_FULL" # 503:系统整体繁忙
|
||||
|
||||
|
||||
class UserPendingLimitExceeded(Exception):
|
||||
"""用户 pending 任务数超限,返回 429。"""
|
||||
|
||||
def __init__(self, user_id: str, pending_count: int, limit: int):
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
requested_count: int = 1,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
# 排队上下文(用于 429 结构化提示,前端展示"排队中"而非"创建失败")
|
||||
self.running_count = running_count
|
||||
self.requested_count = requested_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
class GlobalQueueFull(Exception):
|
||||
"""全局限流,返回 503。"""
|
||||
|
||||
def __init__(self, pending_count: int, limit: int):
|
||||
def __init__(
|
||||
self,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
self.running_count = running_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
def _estimate_wait_seconds(queue_ahead: int, generation_task_repository: Any) -> int:
|
||||
"""根据排队任务数 + worker 并发数 + 历史平均任务耗时估算等待秒数。
|
||||
|
||||
估算公式:ceil(排队任务数 / 并发数) × 平均单任务耗时。
|
||||
拿不到历史数据时仓储层返回默认 120 秒。
|
||||
"""
|
||||
import math
|
||||
|
||||
if queue_ahead <= 0:
|
||||
return 0
|
||||
try:
|
||||
estimator = getattr(generation_task_repository, "estimate_avg_duration_seconds", None)
|
||||
avg_seconds = estimator() if estimator is not None else 120.0
|
||||
except Exception:
|
||||
avg_seconds = 120.0
|
||||
return int(math.ceil(queue_ahead / WORKER_CONCURRENCY) * avg_seconds)
|
||||
|
||||
|
||||
def build_rate_limit_detail(
|
||||
exc: Exception,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
scope: str = "user",
|
||||
) -> dict:
|
||||
"""构造结构化限流响应体(HTTPException 的 detail)。
|
||||
|
||||
前端按 detail.code 判断场景:
|
||||
- USER_QUEUE_FULL (429):用户自己的任务在排队,应提示"等待/继续排队",不是创建失败
|
||||
- SYSTEM_QUEUE_FULL (503):系统繁忙,稍后重试
|
||||
|
||||
detail 字段:
|
||||
- code: 错误码
|
||||
- message: 可读中文提示(可直接展示)
|
||||
- queued_count: 当前排队(pending)任务数
|
||||
- running_count: 当前渲染中(running)任务数
|
||||
- queue_ahead: 前方排队任务数(预计等待批次依据)
|
||||
- estimated_wait_seconds: 预计等待秒数
|
||||
- limit: 对应限流上限
|
||||
"""
|
||||
if scope == "user" and isinstance(exc, UserPendingLimitExceeded):
|
||||
running = exc.running_count
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_by_user", None)
|
||||
running = counter(exc.user_id) if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = exc.queue_ahead or max(exc.pending_count, 0)
|
||||
wait = exc.estimated_wait_seconds or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
message = (
|
||||
f"您有 {exc.pending_count} 个任务正在排队、{running} 个正在渲染,"
|
||||
f"同一时间最多提交 {exc.limit} 个任务。请等待约 {wait_minutes} 分钟后再提交"
|
||||
)
|
||||
return {
|
||||
"code": ERROR_CODE_USER_QUEUE_FULL,
|
||||
"message": message,
|
||||
"queued_count": exc.pending_count,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": exc.limit,
|
||||
}
|
||||
|
||||
# 全局繁忙
|
||||
pending = getattr(exc, "pending_count", 0)
|
||||
running = getattr(exc, "running_count", 0)
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_total", None)
|
||||
running = counter() if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = getattr(exc, "queue_ahead", 0) or pending
|
||||
wait = getattr(exc, "estimated_wait_seconds", 0) or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
return {
|
||||
"code": ERROR_CODE_SYSTEM_QUEUE_FULL,
|
||||
"message": f"系统繁忙:当前 {pending} 个任务排队中、{running} 个渲染中,预计等待约 {wait_minutes} 分钟,请稍后再试",
|
||||
"queued_count": pending,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": getattr(exc, "limit", GLOBAL_PENDING_LIMIT),
|
||||
}
|
||||
|
||||
|
||||
def check_queue_limits(
|
||||
user_id: str,
|
||||
generation_task_repository: Any,
|
||||
|
||||
@@ -255,32 +255,10 @@ test.describe("Core generation flow", () => {
|
||||
expect(genData.items.length).toBeGreaterThan(0)
|
||||
expect(genData.items[0].id).toBeTruthy()
|
||||
|
||||
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
// 单视频(N=1):点击「确认生成视频」后直接跳 Step 5 封面(与旧流程一致)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 180_000,
|
||||
})
|
||||
|
||||
// Step 5 → Step 6:等待渲染终态
|
||||
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
|
||||
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
|
||||
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
|
||||
const renderSucceeded = await page
|
||||
.getByText("视频生成完成", { exact: false })
|
||||
.waitFor({ timeout: 180_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (renderSucceeded) {
|
||||
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
} else {
|
||||
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
|
||||
}
|
||||
} else {
|
||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
||||
// 创建失败时停留在标题页并展示错误提示
|
||||
|
||||
@@ -33,15 +33,36 @@ export interface CreatePreviewRequest {
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
/** 批量预览数量(1~10),默认1。N>1 时返回 N 个独立变体任务 */
|
||||
preview_count?: number
|
||||
/** 各变体独立标题文字:长度1=共用,长度=preview_count=独立,空数组=使用 title_config.text */
|
||||
titles?: string[]
|
||||
/** 各变体独立配音素材库ID:长度1=共用,长度=preview_count=独立,空数组=回退 voice_library_id */
|
||||
voice_library_ids?: string[]
|
||||
/** 各变体独立封面URL:长度1=共用,长度=preview_count=独立(预览阶段通常为空) */
|
||||
cover_urls?: string[]
|
||||
}
|
||||
|
||||
/** 创建预览任务响应 */
|
||||
export interface CreatePreviewResponse {
|
||||
/** 单个预览变体任务 */
|
||||
export interface PreviewVariantItem {
|
||||
task_id: string
|
||||
status: PreviewStatus
|
||||
status: string
|
||||
progress: number
|
||||
is_preview: boolean
|
||||
variant_index: number
|
||||
resolution: string
|
||||
created_at: string
|
||||
video_url: string
|
||||
duration: number
|
||||
error_message: string
|
||||
title_text: string
|
||||
voice_library_id: string
|
||||
created_at?: string | null
|
||||
}
|
||||
|
||||
/** 创建预览任务响应(单变体,preview_count=1 时 items 长度为1) */
|
||||
export interface CreatePreviewResponse {
|
||||
items: PreviewVariantItem[]
|
||||
total: number
|
||||
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id) */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ export interface CreateGenerationTaskRequest {
|
||||
preset_id?: string
|
||||
volume?: number
|
||||
}
|
||||
/** 批量生成数量(1~10),默认1。不传=单条旧逻辑 */
|
||||
count?: number
|
||||
/** 各变体独立标题文字:长度1=共用,长度=count=独立,空数组=使用 title_config/custom_title */
|
||||
titles?: string[]
|
||||
/** 各变体独立配音素材库ID:长度1=共用,长度=count=独立,空数组=回退 voice_library_id */
|
||||
voice_library_ids?: string[]
|
||||
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
|
||||
cover_urls?: string[]
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* 智能剪辑页面 — 前端实时预览架构
|
||||
* 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
|
||||
* 智能剪辑页面(Issue #1677 多视频批量生成)
|
||||
* 5 步向导:选择模板(弹数量) → 素材 → 配音 → 标题(预览+确认生成) → 封面
|
||||
*
|
||||
* 架构:
|
||||
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
|
||||
* - 步骤 5 右侧内联播放生成中的/最终视频
|
||||
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
|
||||
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
|
||||
* - N=1:前端 Canvas 实时预览(FrontendPreviewPlayer),零回归
|
||||
* - N>1:服务器批量预览(POST /generation/preview?preview_count=N),
|
||||
* N 个变体分别轮询,网格展示、独立可播放、CSS 标题浮层实时叠加、勾选批量生成
|
||||
*/
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
@@ -17,16 +16,20 @@ import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import ServerPreviewGrid from "./components/ServerPreviewGrid"
|
||||
import PreviewCountModal from "./components/PreviewCountModal"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useBatchPreview } from "./hooks/useBatchPreview"
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { calculateResolution } from "./utils/calculateResolution"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -53,10 +56,8 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice,
|
||||
setSelectedVoice,
|
||||
voiceMode,
|
||||
setVoiceMode,
|
||||
selectedClonedVoice,
|
||||
setSelectedClonedVoice,
|
||||
presetVoices,
|
||||
|
||||
cloneModalOpen,
|
||||
setCloneModalOpen,
|
||||
videoRatio,
|
||||
@@ -72,8 +73,51 @@ const GeneratePage: React.FC = () => {
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
previewCount,
|
||||
setPreviewCount,
|
||||
previewTitles,
|
||||
setPreviewTitles,
|
||||
voiceModePerVideo,
|
||||
setVoiceModePerVideo,
|
||||
voiceLibraryIds,
|
||||
setVoiceLibraryIds,
|
||||
previewCovers,
|
||||
setPreviewCovers,
|
||||
selectedVariantIds,
|
||||
setSelectedVariantIds,
|
||||
} = formState
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
|
||||
/* ── 配音选择同步:共用配音 ↔ 变体数组 ── */
|
||||
// 触发场景:①共用配音变化 ②批量模式进入/退出 ③独立→共用切换(需把所有变体刷成共用配音)
|
||||
// 独立模式下:仅同步变体[0](其选择器绑定共用配音),用户单独选择的其他变体不覆盖
|
||||
const prevVoiceSyncRef = useRef({
|
||||
voice: selectedVoice,
|
||||
batch: isBatch,
|
||||
perVideo: voiceModePerVideo,
|
||||
})
|
||||
useEffect(() => {
|
||||
const prev = prevVoiceSyncRef.current
|
||||
const voiceChanged = prev.voice !== selectedVoice
|
||||
const modeChanged = prev.batch !== isBatch || prev.perVideo !== voiceModePerVideo
|
||||
prevVoiceSyncRef.current = { voice: selectedVoice, batch: isBatch, perVideo: voiceModePerVideo }
|
||||
if (!voiceChanged && !modeChanged) return
|
||||
if (!isBatch) return
|
||||
if (!voiceModePerVideo) {
|
||||
// 共用模式(含刚从独立切回):所有变体跟随共用配音,未选择的补默认值
|
||||
setVoiceLibraryIds((prevIds) => (prevIds || []).map((id) => id || selectedVoice))
|
||||
} else if (voiceChanged) {
|
||||
// 独立模式下共用配音变化:仅同步变体[0](与共用选择器绑定),其余不覆盖
|
||||
setVoiceLibraryIds((prevIds) =>
|
||||
(prevIds || []).map((id, i) => (i === 0 ? selectedVoice : id)),
|
||||
)
|
||||
}
|
||||
}, [selectedVoice, isBatch, voiceModePerVideo, setVoiceLibraryIds])
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
const [countModalOpen, setCountModalOpen] = useState(false)
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
const styleUpdaters = useTitleStyleUpdaters({
|
||||
titleSettings,
|
||||
@@ -127,7 +171,7 @@ const GeneratePage: React.FC = () => {
|
||||
}, [selectedVoice, selectedClonedVoice, titleSettings.title, voiceMaterials])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
||||
const { addClone } = useCloneProgress()
|
||||
|
||||
const handleCloneSuccess = (voice: VoiceClone) => {
|
||||
addClone(voice)
|
||||
@@ -156,19 +200,95 @@ const GeneratePage: React.FC = () => {
|
||||
[bgm, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
|
||||
/* ── 加载素材详情(供前端预览播放器使用) ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
|
||||
previewAssetIds,
|
||||
previewAssetsEnabled,
|
||||
)
|
||||
|
||||
/* ── 预览就绪:素材已加载,且有模板 ── */
|
||||
const previewReady = useMemo(
|
||||
/* ── 预览就绪 ── */
|
||||
const singlePreviewReady = useMemo(
|
||||
() => previewAssetsReady && !!currentTemplate,
|
||||
[previewAssetsReady, currentTemplate],
|
||||
)
|
||||
|
||||
/* ── 批量服务器预览(N>1) ── */
|
||||
const buildPreviewRequest = useCallback(() => {
|
||||
const { width, height } = calculateResolution(videoRatio || "9:16")
|
||||
const voiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
return {
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: previewAssetIds,
|
||||
output_width: width,
|
||||
output_height: height,
|
||||
video_ratio: videoRatio,
|
||||
voice_library_id: voiceLibraryId,
|
||||
...(voiceModePerVideo && voiceLibraryIds.some(Boolean)
|
||||
? { voice_library_ids: voiceLibraryIds.map((id) => id || voiceLibraryId) }
|
||||
: {}),
|
||||
preview_count: previewCount,
|
||||
// 批量预览不传 titles/title_config:标题文字与样式由前端 CSS 浮层实时叠加
|
||||
// (用户改标题/样式即时可见,无需重渲染);正式生成时才把标题烧录进成片
|
||||
duration: duration || undefined,
|
||||
bgm_config: {
|
||||
enabled: bgm !== false,
|
||||
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(storedSourceEditPlanId || sourceEditPlanId
|
||||
? { source_edit_plan_id: storedSourceEditPlanId || sourceEditPlanId || undefined }
|
||||
: {}),
|
||||
}
|
||||
}, [
|
||||
videoRatio,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
selectedVoice,
|
||||
selectedTemplate,
|
||||
previewAssetIds,
|
||||
voiceModePerVideo,
|
||||
voiceLibraryIds,
|
||||
previewCount,
|
||||
duration,
|
||||
bgm,
|
||||
bgmConfig,
|
||||
storedSourceEditPlanId,
|
||||
sourceEditPlanId,
|
||||
])
|
||||
|
||||
const {
|
||||
variants,
|
||||
status: batchPreviewStatus,
|
||||
progress: batchPreviewProgress,
|
||||
failedCount: batchFailedCount,
|
||||
trigger: retryBatchPreview,
|
||||
} = useBatchPreview({
|
||||
enabled: isBatch && currentStep >= 4 && previewAssetIds.length > 0 && !!selectedTemplate,
|
||||
buildRequest: buildPreviewRequest,
|
||||
onPreviewTasksCreated: (_taskIds, planId) => {
|
||||
if (planId) setStoredSourceEditPlanId(planId)
|
||||
},
|
||||
})
|
||||
|
||||
/** 批量预览就绪:全部变体渲染完成 */
|
||||
const batchPreviewReady =
|
||||
isBatch && variants.length > 0 && variants.every((v) => v.status === "ready")
|
||||
|
||||
/** 步骤4整体预览就绪状态 */
|
||||
const previewReady = isBatch ? batchPreviewReady : singlePreviewReady
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedVariantIds((prev) => {
|
||||
const list = prev || []
|
||||
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
|
||||
})
|
||||
},
|
||||
[setSelectedVariantIds],
|
||||
)
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -199,16 +319,61 @@ const GeneratePage: React.FC = () => {
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
previewCount,
|
||||
variantTitles: previewTitles,
|
||||
variantVoiceLibraryIds: voiceLibraryIds,
|
||||
voiceModePerVideo,
|
||||
variantCoverUrls: previewCovers,
|
||||
selectedVariantIndexes: isBatch ? selectedVariantIds : undefined,
|
||||
onGenerationSuccess: () => {
|
||||
setPreviewTaskId(null)
|
||||
setStoredSourceEditPlanId(null)
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
|
||||
/* ── 数量弹窗确认:设置数量 + 同步批量数组长度 + 进入步骤2 ── */
|
||||
const handleCountConfirm = useCallback(
|
||||
(count: number) => {
|
||||
setPreviewCount(count)
|
||||
setCountModalOpen(false)
|
||||
// 同步批量数组长度
|
||||
setPreviewTitles((prev) => {
|
||||
const list = prev || []
|
||||
const base = list[0] || titleSettings.title || ""
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? (i === 0 ? base : ""))
|
||||
})
|
||||
setVoiceLibraryIds((prev) => {
|
||||
const list = prev || []
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? selectedVoice ?? "")
|
||||
})
|
||||
setPreviewCovers((prev) => {
|
||||
const list = prev || []
|
||||
return Array.from({ length: count }, (_, i) => list[i] ?? "")
|
||||
})
|
||||
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
|
||||
setCurrentStep(2)
|
||||
},
|
||||
[
|
||||
setPreviewCount,
|
||||
setPreviewTitles,
|
||||
setVoiceLibraryIds,
|
||||
setPreviewCovers,
|
||||
setSelectedVariantIds,
|
||||
setCurrentStep,
|
||||
titleSettings.title,
|
||||
selectedVoice,
|
||||
],
|
||||
)
|
||||
|
||||
/* ── 步骤4「确认生成视频」 ── */
|
||||
const handleConfirmGenerate = useCallback(async () => {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
// 标题校验
|
||||
if (previewTitles.some((t) => !t?.trim())) {
|
||||
message.warning("请为每个视频输入标题")
|
||||
return
|
||||
}
|
||||
if (isBatch && selectedVariantIds.length === 0) {
|
||||
message.warning("请至少勾选一个视频")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
@@ -216,10 +381,18 @@ const GeneratePage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
if (ok) {
|
||||
if (ok && !isBatch) {
|
||||
setCurrentStep(5)
|
||||
}
|
||||
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
|
||||
// 批量模式停留在步骤4,右侧网格显示生成进度,完成后点"下一步"进封面
|
||||
}, [
|
||||
isBatch,
|
||||
selectedVariantIds.length,
|
||||
previewReady,
|
||||
previewTitles,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
])
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const { goNext, goPrev } = useStepNavigation({
|
||||
@@ -232,11 +405,24 @@ const GeneratePage: React.FC = () => {
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
previewTitles,
|
||||
selectedCount: isBatch ? selectedVariantIds.length : 1,
|
||||
onOpenCountModal: () => setCountModalOpen(true),
|
||||
})
|
||||
|
||||
/* ── 最终成片(步骤5/6 右侧播放) ── */
|
||||
/* ── 最终成片(单视频右侧播放) ── */
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/** 批量生成进度文案 */
|
||||
const batchGeneratingText = useMemo(() => {
|
||||
if (batchPreviewStatus === "loading")
|
||||
return `AI 正在渲染 ${previewCount} 个预览视频… ${batchPreviewProgress}%`
|
||||
if (batchPreviewStatus === "failed") return "预览渲染失败,请重试"
|
||||
if (batchPreviewStatus === "partial_failed")
|
||||
return `${batchFailedCount} 个预览失败,可重新生成或勾选成功的视频`
|
||||
return ""
|
||||
}, [batchPreviewStatus, batchPreviewProgress, batchFailedCount, previewCount])
|
||||
|
||||
/* ================================================================
|
||||
渲染
|
||||
================================================================ */
|
||||
@@ -247,8 +433,142 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
<div className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}`}>
|
||||
{/* ════ 左侧:表单区 ════ */}
|
||||
<div
|
||||
className={`xx-generate-layout${currentStep < 4 ? " full-width" : ""}${
|
||||
currentStep === 4 ? " step4-layout" : ""
|
||||
}`}
|
||||
>
|
||||
{/* ════ 步骤4:左侧预览大区域 ════ */}
|
||||
{currentStep === 4 && !!currentTemplate && (
|
||||
<div className="xx-generate-preview-col">
|
||||
{!isBatch ? (
|
||||
/* 单视频:前端 Canvas 实时预览(与旧版一致) */
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
) : (
|
||||
/* 批量:服务器预览网格 */
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 {previewCount} 个视频预览</h3>
|
||||
{batchPreviewStatus === "loading" && (
|
||||
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
{batchPreviewProgress}%
|
||||
</span>
|
||||
)}
|
||||
{(batchPreviewStatus === "failed" || batchPreviewStatus === "partial_failed") && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
onClick={retryBatchPreview}
|
||||
>
|
||||
🔄 重新生成预览
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batchGeneratingText && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color:
|
||||
batchPreviewStatus === "failed"
|
||||
? "var(--error-color, #ef4444)"
|
||||
: "var(--text-secondary, #666)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
{batchGeneratingText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ServerPreviewGrid
|
||||
variants={variants}
|
||||
titles={previewTitles}
|
||||
titleStyle={{
|
||||
position: titleSettings.position,
|
||||
color: titleSettings.color,
|
||||
size: titleSettings.size,
|
||||
}}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
/>
|
||||
|
||||
{/* 生成中进度(批量) */}
|
||||
{generating && (
|
||||
<div className="xx-gen-progress-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-progress-header">
|
||||
<div className="xx-gen-progress-info">
|
||||
<div className="xx-gen-progress-phase">
|
||||
⏳ 正在渲染 {selectedVariantIds.length} 个最终视频… {Math.round(progress)}
|
||||
%
|
||||
</div>
|
||||
<div className="xx-gen-progress-sub">
|
||||
生成过程中可以切换到其他页面,完成后可在任务历史查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-gen-progress-bar">
|
||||
<div
|
||||
className="xx-gen-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateError && !generating && (
|
||||
<div className="xx-gen-error-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-error-info">
|
||||
<div className="xx-gen-error-title">生成失败</div>
|
||||
<div className="xx-gen-error-msg">{generateError}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={handleRetryGenerate}
|
||||
>
|
||||
🔄 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generated && !generating && (
|
||||
<div className="xx-gen-success-card" style={{ marginTop: 16 }}>
|
||||
<div className="xx-gen-success-info">
|
||||
<div className="xx-gen-success-title">✅ 视频生成完成!</div>
|
||||
<div className="xx-gen-success-sub">
|
||||
共生成 {generatedVideos.length} 条视频,点击「下一步」为每个视频选择封面
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════ 右侧:步骤1~3 表单 / 步骤4 标题边栏 / 步骤5 封面 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
<GenerateStepContent
|
||||
currentStep={currentStep}
|
||||
@@ -280,15 +600,6 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={setSelectedClonedVoice}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={setCloneModalOpen}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
@@ -296,7 +607,16 @@ const GeneratePage: React.FC = () => {
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={setPreviewTitles}
|
||||
voiceModePerVideo={voiceModePerVideo}
|
||||
onVoiceModePerVideoChange={setVoiceModePerVideo}
|
||||
voiceLibraryIds={voiceLibraryIds}
|
||||
onVoiceLibraryIdsChange={setVoiceLibraryIds}
|
||||
previewCovers={previewCovers}
|
||||
onPreviewCoversChange={setPreviewCovers}
|
||||
selectedVariantIds={selectedVariantIds}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -307,43 +627,20 @@ const GeneratePage: React.FC = () => {
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
selectedCount={isBatch ? selectedVariantIds.length : 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{currentStep === 4 && !!currentTemplate && (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 5 && generated && finalVideo && (
|
||||
{/* ════ 步骤5(封面):成片播放器(单视频) ════ */}
|
||||
{currentStep === 5 && !isBatch && generated && finalVideo && (
|
||||
<div className="xx-generate-right-col">
|
||||
<div className="xx-inline-video-player">
|
||||
<video
|
||||
src={finalVideo.download_url || finalVideo.file_url}
|
||||
controls
|
||||
autoPlay={currentStep === 5}
|
||||
autoPlay
|
||||
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
|
||||
poster={finalVideo.thumbnail_url || undefined}
|
||||
poster={finalVideo.thumbnail_url}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
|
||||
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
|
||||
@@ -360,10 +657,18 @@ const GeneratePage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 数量选择弹窗 */}
|
||||
<PreviewCountModal
|
||||
open={countModalOpen}
|
||||
defaultCount={1}
|
||||
onConfirm={handleCountConfirm}
|
||||
onCancel={() => setCountModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 音色克隆弹窗 */}
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* GeneratePage 步骤底部操作按钮
|
||||
* GeneratePage 步骤底部操作按钮(Issue #1677 改造后 5 步)
|
||||
*
|
||||
* 步骤 1~3:上一步 / 下一步
|
||||
* 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
|
||||
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
|
||||
* 步骤 6(选择封面):仅上一步
|
||||
* 步骤 4(标题+预览+确认生成):确认生成按钮在右侧边栏底部(含勾选数量),
|
||||
* 渲染中显示进度;生成完成后显示"下一步 → 选择封面"
|
||||
* 步骤 5(选择封面):仅上一步
|
||||
*/
|
||||
import React from "react"
|
||||
|
||||
@@ -17,9 +17,11 @@ export interface GenerateStepActionsProps {
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
/** 批量模式下勾选的视频数量(N=1 时为1) */
|
||||
selectedCount?: number
|
||||
}
|
||||
|
||||
export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
currentStep,
|
||||
onPrev,
|
||||
onNext,
|
||||
@@ -27,9 +29,10 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
selectedCount = 1,
|
||||
}) => {
|
||||
const renderPrimaryButton = () => {
|
||||
/* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
|
||||
/* 步骤 1~3:上一步 / 下一步 */
|
||||
if (currentStep < 4) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
@@ -38,7 +41,7 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 4:确认生成视频(触发按钮在标题页) */
|
||||
/* 步骤 4:标题+预览+确认生成 */
|
||||
if (currentStep === 4) {
|
||||
if (generating) {
|
||||
return (
|
||||
@@ -57,31 +60,18 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
|
||||
if (generated) {
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onNext}>
|
||||
下一步 →
|
||||
下一步:选择封面 →
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
|
||||
✨ 确认生成视频
|
||||
{selectedCount > 1 ? `✨ 确认生成 ${selectedCount} 个视频` : "✨ 确认生成视频"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 5:渲染中禁用,完成后下一步进入封面 */
|
||||
if (currentStep === 5) {
|
||||
return (
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onNext}
|
||||
disabled={generating || !generated}
|
||||
>
|
||||
{generating ? "视频生成中…" : "下一步 →"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* 步骤 6(最后一步):无主按钮 */
|
||||
/* 步骤 5(封面,最后一步):无主按钮 */
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
* 步骤顺序(5步,Issue #1677):模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step3VoiceWithMode from "./Step3VoiceWithMode"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
|
||||
@@ -50,15 +47,6 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (id: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
/* 生成 */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -67,12 +55,22 @@ export interface GenerateStepContentProps {
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
/** BGM 开关 */
|
||||
bgm: boolean
|
||||
/** BGM 配置(来自模板) */
|
||||
bgmConfig?: { enabled: boolean; music_id?: string }
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount: number
|
||||
previewTitles: string[]
|
||||
onPreviewTitlesChange: (titles: string[]) => void
|
||||
voiceModePerVideo: boolean
|
||||
onVoiceModePerVideoChange: (v: boolean) => void
|
||||
voiceLibraryIds: string[]
|
||||
onVoiceLibraryIdsChange: (ids: string[]) => void
|
||||
previewCovers: string[]
|
||||
onPreviewCoversChange: (urls: string[]) => void
|
||||
/** 批量模式勾选的变体索引(封面卡片按勾选顺序展示) */
|
||||
selectedVariantIds?: number[]
|
||||
}
|
||||
|
||||
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
|
||||
@@ -104,17 +102,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
onServerClipsChange,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
previewCount,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
voiceModePerVideo,
|
||||
onVoiceModePerVideoChange,
|
||||
voiceLibraryIds,
|
||||
onVoiceLibraryIdsChange,
|
||||
previewCovers,
|
||||
onPreviewCoversChange,
|
||||
selectedVariantIds,
|
||||
} = props
|
||||
|
||||
/* 当前模板的 segments,传给 Step2 构建 clips */
|
||||
@@ -146,9 +144,14 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3VoiceSelect
|
||||
<Step3VoiceWithMode
|
||||
previewCount={previewCount}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceModePerVideo={voiceModePerVideo}
|
||||
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
|
||||
voiceLibraryIds={voiceLibraryIds}
|
||||
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
@@ -167,33 +170,12 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onApplyPreset={onApplyPreset}
|
||||
activePreset={activePreset}
|
||||
titlePresets={titlePresets}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
onPreviewTitlesChange={onPreviewTitlesChange}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5ConfirmGenerate
|
||||
templates={userTemplates}
|
||||
selectedTemplate={selectedTemplate}
|
||||
materialMode={materialMode}
|
||||
selectedMaterials={selectedMaterials}
|
||||
smartSelectedIds={smartSelectedIds}
|
||||
title={titleSettings.title}
|
||||
voiceMode={voiceMode}
|
||||
selectedVoice={selectedVoice}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
coverSettings={coverSettings}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
return (
|
||||
<Step6CoverSettings
|
||||
coverSettings={coverSettings}
|
||||
@@ -201,6 +183,11 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedTemplate={selectedTemplate}
|
||||
titleSettings={titleSettings}
|
||||
generatedVideos={generatedVideos}
|
||||
previewCount={previewCount}
|
||||
previewTitles={previewTitles}
|
||||
previewCovers={previewCovers}
|
||||
onPreviewCoversChange={onPreviewCoversChange}
|
||||
selectedVariantIndexes={selectedVariantIds}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 生成数量选择弹窗(Issue #1677)
|
||||
* Step1 选完模板点「下一步」时弹出:要生成几个视频?(1~10)
|
||||
* 默认 1,回车 = 1(零额外操作)
|
||||
*/
|
||||
import React, { useState, useEffect, useRef } from "react"
|
||||
import { MAX_PREVIEW_COUNT } from "../constants"
|
||||
|
||||
interface PreviewCountModalProps {
|
||||
open: boolean
|
||||
/** 默认值(上次选择,默认1) */
|
||||
defaultCount?: number
|
||||
onConfirm: (count: number) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const PreviewCountModal: React.FC<PreviewCountModalProps> = ({
|
||||
open,
|
||||
defaultCount = 1,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [count, setCount] = useState(defaultCount)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCount(defaultCount)
|
||||
// 弹窗打开后聚焦并选中,方便直接回车=默认1
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, defaultCount])
|
||||
|
||||
const clamp = (n: number) => Math.max(1, Math.min(MAX_PREVIEW_COUNT, n || 1))
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(clamp(count))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleConfirm()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="xx-modal-mask" onClick={onCancel}>
|
||||
<div className="xx-modal-box xx-count-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ margin: "0 0 8px", fontSize: 18 }}>要生成几个视频?</h3>
|
||||
<p style={{ margin: "0 0 20px", fontSize: 13, color: "var(--text-secondary, #666)" }}>
|
||||
素材共用,AI 随机剪辑出不同版本,每个视频可独立设置标题、配音和封面
|
||||
</p>
|
||||
|
||||
<div className="xx-count-selector">
|
||||
<button
|
||||
type="button"
|
||||
className="xx-count-btn"
|
||||
onClick={() => setCount((c) => clamp(c - 1))}
|
||||
disabled={count <= 1}
|
||||
aria-label="减少"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_PREVIEW_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(clamp(parseInt(e.target.value, 10) || 1))}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="xx-count-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-count-btn"
|
||||
onClick={() => setCount((c) => clamp(c + 1))}
|
||||
disabled={count >= MAX_PREVIEW_COUNT}
|
||||
aria-label="增加"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-count-quick">
|
||||
{[1, 3, 5, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`xx-count-chip ${count === n ? "active" : ""}`}
|
||||
onClick={() => setCount(n)}
|
||||
>
|
||||
{n} 个
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="xx-count-actions">
|
||||
<button type="button" className="xx-btn xx-btn-ghost" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="xx-btn xx-btn-primary" onClick={handleConfirm}>
|
||||
{count === 1 ? "生成 1 个视频" : `生成 ${count} 个视频`}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: "12px 0 0",
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
直接按回车 = 生成 1 个
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewCountModal
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 批量预览网格(Issue #1677)
|
||||
* N 个服务器渲染的预览视频,网格排列、各自独立播放、CSS 标题浮层实时叠加、勾选框批量选择
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined, CheckCircleFilled, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { VariantPreview } from "../hooks/useBatchPreview"
|
||||
|
||||
interface ServerPreviewGridProps {
|
||||
variants: VariantPreview[]
|
||||
/** 每个变体的标题文字(实时叠加浮层) */
|
||||
titles: string[]
|
||||
/** 标题样式(全局共用) */
|
||||
titleStyle: {
|
||||
position: string
|
||||
color: string
|
||||
size: number
|
||||
}
|
||||
/** 勾选的变体索引 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
/** 是否显示勾选框(确认生成前) */
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
const ServerPreviewGrid: React.FC<ServerPreviewGridProps> = ({
|
||||
variants,
|
||||
titles,
|
||||
titleStyle,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
if (variants.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="xx-variant-grid">
|
||||
{variants.map((v) => {
|
||||
const selected = selectedIds.includes(v.index)
|
||||
const titleText = titles[v.index] || ""
|
||||
return (
|
||||
<div
|
||||
key={v.index}
|
||||
className={`xx-variant-card ${selected ? "selected" : ""} ${
|
||||
v.status === "failed" ? "failed" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (selectable && v.status === "ready") onToggleSelect(v.index)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* 勾选框 */}
|
||||
{selectable && v.status === "ready" && (
|
||||
<div className={`xx-variant-check ${selected ? "checked" : ""}`}>
|
||||
{selected && "✓"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变体序号 */}
|
||||
<div className="xx-variant-index">视频 {v.index + 1}</div>
|
||||
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-variant-video-wrap">
|
||||
{v.status === "loading" && (
|
||||
<div className="xx-variant-loading">
|
||||
<LoadingOutlined style={{ fontSize: 28, color: "#3b82f6" }} />
|
||||
<div className="xx-variant-progress">
|
||||
<div
|
||||
className="xx-variant-progress-bar"
|
||||
style={{ width: `${Math.min(v.progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="xx-variant-progress-text">{v.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{v.status === "failed" && (
|
||||
<div className="xx-variant-failed">
|
||||
<CloseCircleOutlined style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<span>{v.error || "预览失败"}</span>
|
||||
</div>
|
||||
)}
|
||||
{v.status === "ready" && v.videoUrl && (
|
||||
<>
|
||||
<video
|
||||
src={v.videoUrl}
|
||||
controls
|
||||
style={{ width: "100%", display: "block", background: "#000", borderRadius: 8 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* 标题浮层(CSS 实时叠加,改标题即时可见) */}
|
||||
{titleText && (
|
||||
<div
|
||||
className={`xx-variant-title-overlay pos-${titleStyle.position}`}
|
||||
style={{
|
||||
color: titleStyle.color,
|
||||
fontSize: Math.max(13, Math.round(titleStyle.size * 0.55)),
|
||||
WebkitTextStroke: "0.5px rgba(0,0,0,0.6)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{titleText}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部状态 */}
|
||||
<div className="xx-variant-footer">
|
||||
{v.status === "ready" && selected && (
|
||||
<span className="xx-variant-ready-tag">
|
||||
<CheckCircleFilled style={{ color: "#52c41a" }} /> 已选择
|
||||
</span>
|
||||
)}
|
||||
{v.status === "ready" && !selected && selectable && (
|
||||
<span className="xx-variant-skip-tag">点击卡片取消/勾选</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerPreviewGrid
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Step3 配音选择(Issue #1677 批量生成)
|
||||
* - 单视频 / 共用模式:与原配音选择完全一致
|
||||
* - 独立模式(开关开启):N 个配音选择器,每个视频独立选择
|
||||
*/
|
||||
import React from "react"
|
||||
import Step3VoiceSelect from "./Step5VoiceSelect"
|
||||
|
||||
interface Step3VoiceWithModeProps {
|
||||
previewCount: number
|
||||
/** 共用配音ID */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
/** 是否独立配音 */
|
||||
voiceModePerVideo: boolean
|
||||
onVoiceModePerVideoChange: (v: boolean) => void
|
||||
/** 各变体独立配音ID */
|
||||
voiceLibraryIds: string[]
|
||||
onVoiceLibraryIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const Step3VoiceWithMode: React.FC<Step3VoiceWithModeProps> = ({
|
||||
previewCount,
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceModePerVideo,
|
||||
onVoiceModePerVideoChange,
|
||||
voiceLibraryIds,
|
||||
onVoiceLibraryIdsChange,
|
||||
}) => {
|
||||
const isBatch = previewCount > 1
|
||||
|
||||
if (!isBatch) {
|
||||
return (
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
{/* 共用/独立切换 */}
|
||||
<div className="xx-title-ai-toggle" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 15 }}>
|
||||
🎙️ 配音方式:{voiceModePerVideo ? "每个视频独立配音" : "所有视频共用配音"}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-tertiary, #999)", marginTop: 2 }}>
|
||||
{voiceModePerVideo
|
||||
? `为 ${previewCount} 个视频分别选择不同配音`
|
||||
: "所有视频使用同一个配音(默认)"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`xx-switch ${voiceModePerVideo ? "active" : ""}`}
|
||||
onClick={() => onVoiceModePerVideoChange(!voiceModePerVideo)}
|
||||
role="switch"
|
||||
aria-checked={voiceModePerVideo}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onVoiceModePerVideoChange(!voiceModePerVideo)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!voiceModePerVideo ? (
|
||||
<Step3VoiceSelect
|
||||
heading="🎙️ 共用配音"
|
||||
description={`所有 ${previewCount} 个视频使用同一个配音,点击卡片可预览播放`}
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-per-voice-list">
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<Step3VoiceSelect
|
||||
key={i}
|
||||
heading={`🎙️ 视频 ${i + 1} 的配音`}
|
||||
description="为这个视频单独选择配音"
|
||||
compact
|
||||
selectedVoice={voiceLibraryIds[i] || ""}
|
||||
onSelectedVoiceChange={(id) => {
|
||||
const next = [...voiceLibraryIds]
|
||||
next[i] = id
|
||||
onVoiceLibraryIdsChange(next)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3VoiceWithMode
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板)
|
||||
* Step 4 选择标题(Issue #1677 批量生成改造)
|
||||
*
|
||||
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设)
|
||||
* 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
|
||||
* 布局(由 GeneratePage 编排):左侧大区域预览,右侧边栏标题设置。
|
||||
* 本组件渲染在右侧边栏:
|
||||
* - 标题文字:1 个视频 1 个输入框;N 个视频 N 个输入框各自独立
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { PlayCircleOutlined } from "@ant-design/icons"
|
||||
import { AutoComplete, Input } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
@@ -29,6 +30,12 @@ interface Step4TitleSettingsProps {
|
||||
onApplyPreset: (presetKey: string) => void
|
||||
activePreset: string | null
|
||||
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
|
||||
/* ── 批量生成(#1677)── */
|
||||
/** 生成数量 */
|
||||
previewCount?: number
|
||||
/** 每个变体的标题文字(长度=previewCount) */
|
||||
previewTitles?: string[]
|
||||
onPreviewTitlesChange?: (titles: string[]) => void
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
@@ -44,122 +51,138 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
onApplyPreset,
|
||||
activePreset,
|
||||
titlePresets,
|
||||
previewCount = 1,
|
||||
previewTitles,
|
||||
onPreviewTitlesChange,
|
||||
} = props
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
|
||||
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿保存依赖) */
|
||||
const updateVariantTitle = (index: number, val: string) => {
|
||||
if (!previewTitles || !onPreviewTitlesChange) return
|
||||
const next = [...previewTitles]
|
||||
next[index] = val
|
||||
onPreviewTitlesChange(next)
|
||||
if (index === 0) {
|
||||
t.updateTitle(val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-form-section xx-title-sidebar">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{/* AI 自动选择模式 */}
|
||||
{t.titleSettings.aiAutoSelect && (
|
||||
{!isBatch ? (
|
||||
/* ── 单视频:原有 AI 标题 + 输入框(保持不变) ── */
|
||||
<>
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */}
|
||||
<div className="xx-form-field">
|
||||
<label>当前 AI 选定标题</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--text-primary, #333)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{t.titleSettings.title || "AI 将自动为你选择标题"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
|
||||
onClick={t.autoGenerateTitle}
|
||||
>
|
||||
🔄 换一个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 手动选择模式 */}
|
||||
{!t.titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<AutoComplete
|
||||
placeholder="输入或从标题库选择…"
|
||||
allowClear
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={t.titleSettings.title || undefined}
|
||||
onChange={(val) => t.updateTitle(val || "")}
|
||||
options={t.userTitles.map((ut) => ({
|
||||
label: ut.content,
|
||||
value: ut.content,
|
||||
}))}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
notFoundContent={
|
||||
t.userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
{t.titleSettings.aiAutoSelect ? (
|
||||
<>
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>当前 AI 选定标题</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--text-primary, #333)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
|
||||
onClick={t.autoGenerateTitle}
|
||||
>
|
||||
🔄 换一个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<AutoComplete
|
||||
placeholder="输入标题文字…"
|
||||
allowClear
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={t.userTitles.map((ut) => ({ label: ut.content, value: ut.content }))}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* ── 批量:N 个独立标题输入框(CSS 浮层实时叠加到对应预览) ── */
|
||||
<div className="xx-batch-titles">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary, #666)",
|
||||
marginBottom: 10,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<Input
|
||||
placeholder={`视频 ${i + 1} 的标题…`}
|
||||
maxLength={50}
|
||||
showCount
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(e) => updateVariantTitle(i, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标题样式面板(原 Step5) */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
background: "rgba(59, 130, 246, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
border: "1px solid rgba(59, 130, 246, 0.15)",
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
|
||||
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
|
||||
右侧为实时预览,调整样式即时生效
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题样式面板(全局共用) */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={onUpdatePosition}
|
||||
|
||||
@@ -12,6 +12,12 @@ import type { AssetItem } from "@/api/assets"
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
/** 卡片标题(独立配音模式下显示"视频 N 的配音"),默认"选择配音" */
|
||||
heading?: string
|
||||
/** 描述文案 */
|
||||
description?: string
|
||||
/** 是否使用紧凑卡片样式(独立配音模式下 N 个并排) */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/** 获取素材实际时长(优先顶层 duration,fallback 到 metadata.duration) */
|
||||
@@ -43,6 +49,9 @@ const formatFileSize = (bytes?: number): string => {
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
heading = "🎙️ 选择配音",
|
||||
description = "从配音库中选择已上传的素材,点击卡片可预览播放",
|
||||
compact = false,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
@@ -148,14 +157,14 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
|
||||
从配音库中选择已上传的素材,点击卡片可预览播放
|
||||
</p>
|
||||
<h3>{heading}</h3>
|
||||
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>{description}</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gridTemplateColumns: compact
|
||||
? "repeat(auto-fill, minmax(160px, 1fr))"
|
||||
: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import React from "react"
|
||||
/**
|
||||
* Step 5 选择封面(Issue #1677 批量生成改造)
|
||||
* - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览)
|
||||
* - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传
|
||||
*/
|
||||
import React, { useRef } from "react"
|
||||
import { Modal, Spin } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { useBatchCovers } from "../hooks/useBatchCovers"
|
||||
import Button from "@/components/ui/Button"
|
||||
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||
@@ -17,6 +24,15 @@ interface Step6CoverSettingsProps {
|
||||
titleSettings?: TitleSettings
|
||||
/** 确认生成步骤产出的最终视频列表 */
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/* ── 批量生成(#1677)── */
|
||||
previewCount?: number
|
||||
/** 每个变体的标题文字 */
|
||||
previewTitles?: string[]
|
||||
/** 每个变体的封面URL(按变体索引) */
|
||||
previewCovers?: string[]
|
||||
onPreviewCoversChange?: (urls: string[]) => void
|
||||
/** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */
|
||||
selectedVariantIndexes?: number[]
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -46,13 +62,173 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
generatedVideos: props.generatedVideos,
|
||||
})
|
||||
|
||||
const handleAutoGenerate = () => {
|
||||
generateAutoCover()
|
||||
}
|
||||
const previewCount = props.previewCount || 1
|
||||
const isBatch = previewCount > 1
|
||||
const previewTitles = props.previewTitles || []
|
||||
const previewCovers = props.previewCovers || []
|
||||
/** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */
|
||||
const cardIndexes =
|
||||
isBatch && props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: previewCount }, (_, i) => i)
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTargetRef = useRef<number>(0)
|
||||
|
||||
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
|
||||
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
|
||||
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
|
||||
const batchCovers = useBatchCovers({
|
||||
selectedTemplate: props.selectedTemplate || "",
|
||||
generatedVideos: props.generatedVideos,
|
||||
titles: batchTitles,
|
||||
titleStyle: {
|
||||
font: props.titleSettings?.font || "思源黑体",
|
||||
size: props.titleSettings?.size || 28,
|
||||
color: props.titleSettings?.color || "#ffffff",
|
||||
position: props.titleSettings?.position || "top",
|
||||
bold: props.titleSettings?.bold ?? true,
|
||||
stroke: props.titleSettings?.stroke ?? true,
|
||||
shadow: props.titleSettings?.shadow ?? false,
|
||||
},
|
||||
covers: batchCoversList,
|
||||
onCoversChange: (urls) => {
|
||||
// 按卡片顺序写回对应变体索引
|
||||
const next = [...(props.previewCovers || [])]
|
||||
cardIndexes.forEach((vi, cardPos) => {
|
||||
next[vi] = urls[cardPos] || ""
|
||||
})
|
||||
props.onPreviewCoversChange?.(next)
|
||||
},
|
||||
})
|
||||
|
||||
// 预览图:优先 thumbnail_url,其次 upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
|
||||
const handleUploadClick = (variantIndex: number) => {
|
||||
uploadTargetRef.current = variantIndex
|
||||
uploadInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (file) {
|
||||
const variantIndex = uploadTargetRef.current
|
||||
const cardPos = cardIndexes.indexOf(variantIndex)
|
||||
if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 批量封面 ── */
|
||||
if (isBatch) {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
background: "rgba(16, 185, 129, 0.08)",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
border: "1px solid rgba(16, 185, 129, 0.15)",
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
🎬 共 {completedVideos.length} 个成片,封面将从对应成片中智能选帧并叠加该视频的标题
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => void batchCovers.generateAll()}
|
||||
disabled={completedVideos.length === 0 || batchCovers.loadingIndex !== null}
|
||||
>
|
||||
✨ 一键全部自动生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-grid">
|
||||
{cardIndexes.map((variantIndex, cardPos) => {
|
||||
const url = batchCoversList[cardPos]
|
||||
const isLoading = batchCovers.loadingIndex === cardPos
|
||||
const isUploading = batchCovers.uploadingIndex === cardPos
|
||||
const title = batchTitles[cardPos]
|
||||
return (
|
||||
<div className="xx-cover-card" key={variantIndex}>
|
||||
<div className="xx-cover-card-title">视频 {variantIndex + 1}</div>
|
||||
<div className="xx-cover-card-box">
|
||||
{isLoading || isUploading ? (
|
||||
<div className="xx-cover-card-loading">
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
|
||||
<span>{isLoading ? "AI 选帧中…" : "上传中…"}</span>
|
||||
</div>
|
||||
) : url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={`视频${variantIndex + 1}封面`}
|
||||
className="xx-cover-card-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-cover-card-placeholder">
|
||||
<span style={{ fontSize: 26 }}>🖼️</span>
|
||||
<span style={{ fontSize: 12 }}>未设置封面</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-card-ratio">9:16</div>
|
||||
</div>
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary, #666)",
|
||||
marginTop: 6,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
标题:{title}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
|
||||
onClick={() => void batchCovers.generateOne(cardPos)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
{url ? "🔄 重新生成" : "✨ 自动生成"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
style={{ flex: 1, fontSize: 12, padding: "4px 8px" }}
|
||||
onClick={() => handleUploadClick(variantIndex)}
|
||||
disabled={isLoading || isUploading}
|
||||
>
|
||||
📤 上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 单视频:原有流程保持不变 ── */
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🖼️ 选择封面</h3>
|
||||
@@ -75,7 +251,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
)}
|
||||
|
||||
<div className="xx-cover-actions">
|
||||
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
|
||||
<Button buttonType="primary" onClick={generateAutoCover} disabled={!finalVideo}>
|
||||
✨ 自动生成封面
|
||||
</Button>
|
||||
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Step 7 确认生成组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { useStep7Generate } from "../hooks/useStep7Generate"
|
||||
import SummaryCard from "./step7-confirm/SummaryCard"
|
||||
import GenerationStatus from "./step7-confirm/GenerationStatus"
|
||||
|
||||
interface Step7ConfirmGenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
onRetry: () => void
|
||||
onDismissError: () => void
|
||||
}
|
||||
|
||||
const Step7ConfirmGenerate: React.FC<Step7ConfirmGenerateProps> = (props) => {
|
||||
const {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
} = useStep7Generate(props)
|
||||
|
||||
const { onRetry, onDismissError } = props
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>✨ 确认生成</h3>
|
||||
<SummaryCard
|
||||
templateName={templateName}
|
||||
materialSummary={materialSummary}
|
||||
title={title}
|
||||
voiceName={voiceName}
|
||||
coverSummary={coverSummary}
|
||||
/>
|
||||
<GenerationStatus
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
progress={progress}
|
||||
generatedVideos={generatedVideos}
|
||||
getGenerationPhase={getGenerationPhase}
|
||||
onScrollToPreview={handleScrollToPreview}
|
||||
onRetry={onRetry}
|
||||
onDismissError={onDismissError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step7ConfirmGenerate
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface SummaryCardProps {
|
||||
templateName: string
|
||||
materialSummary: string
|
||||
title: string
|
||||
voiceName: string
|
||||
coverSummary: string
|
||||
}
|
||||
|
||||
const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-summary-card">
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">模板</span>
|
||||
<span className="xx-summary-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">{materialSummary}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">标题</span>
|
||||
<span className="xx-summary-value">{title || "未选择"}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{voiceName}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">封面</span>
|
||||
<span className="xx-summary-value">{coverSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SummaryCard
|
||||
@@ -33,10 +33,13 @@ export const STEPS = [
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "确认生成" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 5, label: "选择封面" },
|
||||
]
|
||||
|
||||
/* ── 批量生成限制 ── */
|
||||
export const MAX_PREVIEW_COUNT = 10
|
||||
export const MIN_PREVIEW_COUNT = 1
|
||||
|
||||
/* ── 标题位置选项 ── */
|
||||
export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
|
||||
@@ -2900,3 +2900,381 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Issue #1677 多视频批量生成
|
||||
================================================================ */
|
||||
|
||||
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr 380px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col .xx-form-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-title-sidebar {
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
.xx-modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.xx-modal-box {
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.xx-count-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.xx-count-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-primary, #d9d9d9);
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #333);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-btn:hover:not(:disabled) {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.xx-count-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-count-input {
|
||||
width: 88px;
|
||||
height: 52px;
|
||||
text-align: center;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
border: 2px solid var(--border-primary, #d9d9d9);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.xx-count-input:focus {
|
||||
outline: none;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
/* 隐藏 number input 上下箭头 */
|
||||
.xx-count-input::-webkit-outer-spin-button,
|
||||
.xx-count-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.xx-count-input {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.xx-count-quick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.xx-count-chip {
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-primary, #d9d9d9);
|
||||
background: var(--bg-primary, #fff);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-chip:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.xx-count-chip.active {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.xx-count-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-count-actions .xx-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── 批量预览网格 ── */
|
||||
.xx-variant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-variant-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-primary, #e8e8e8);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-primary, #fff);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
|
||||
.xx-variant-card:hover {
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.xx-variant-card.selected {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.xx-variant-card.failed {
|
||||
border-color: #ffccc7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xx-variant-check {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
z-index: 3;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.xx-variant-check.checked {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.xx-variant-index {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-variant-video-wrap {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 420px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-variant-loading,
|
||||
.xx-variant-failed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-variant-progress {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-variant-progress-bar {
|
||||
height: 100%;
|
||||
background: #1677ff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.xx-variant-progress-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-variant-title-overlay {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-variant-title-overlay.pos-top {
|
||||
top: 8%;
|
||||
}
|
||||
|
||||
.xx-variant-title-overlay.pos-center {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.xx-variant-title-overlay.pos-bottom,
|
||||
.xx-variant-title-overlay.pos-custom {
|
||||
bottom: 10%;
|
||||
}
|
||||
|
||||
.xx-variant-footer {
|
||||
min-height: 22px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-variant-ready-tag {
|
||||
color: #52c41a;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-variant-skip-tag {
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
/* ── 批量配音列表 ── */
|
||||
.xx-per-voice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-per-voice-list .xx-form-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 批量封面网格 ── */
|
||||
.xx-cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-cover-card {
|
||||
border: 1px solid var(--border-primary, #e8e8e8);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.xx-cover-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-cover-card-box {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.xx-cover-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-cover-card-placeholder,
|
||||
.xx-cover-card-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary, #999);
|
||||
background: var(--bg-secondary, #f7f7f7);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.xx-cover-card-ratio {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏 Step4 回退单列 ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.xx-title-sidebar {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@ export interface UseGenerateVideoProps {
|
||||
}
|
||||
/** 生成成功后的回调(用于清除持久化的 previewTaskId 等状态) */
|
||||
onGenerationSuccess?: () => void
|
||||
/* ── 批量生成(#1677)── */
|
||||
/** 生成数量(1=单条旧逻辑,>1=批量) */
|
||||
previewCount?: number
|
||||
/** 每个变体的标题文字(长度=count 时各自独立) */
|
||||
variantTitles?: string[]
|
||||
/** 独立配音模式下每个变体的配音ID(空数组=共用 selectedVoice) */
|
||||
variantVoiceLibraryIds?: string[]
|
||||
/** 是否独立配音 */
|
||||
voiceModePerVideo?: boolean
|
||||
/** 每个变体的封面URL(空数组=回退 coverSettings) */
|
||||
variantCoverUrls?: string[]
|
||||
/** 勾选要生成的变体索引(批量模式) */
|
||||
selectedVariantIndexes?: number[]
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
@@ -44,7 +57,7 @@ export interface UseGenerateVideoResult {
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
generatedVideos: GeneratedVideo[]
|
||||
generate: () => Promise<void>
|
||||
generate: () => Promise<boolean>
|
||||
retry: () => void
|
||||
dismissError: () => void
|
||||
download: () => Promise<void>
|
||||
|
||||
@@ -17,31 +17,25 @@ const MAX_RETRYABLE_ERRORS = 10
|
||||
const MAX_RESULTS_RETRIES = 3
|
||||
|
||||
/**
|
||||
* 生成状态轮询 Hook(v2 — 改用 /generation/tasks/{task_id})
|
||||
* 生成状态轮询 Hook(v3 — 支持批量多任务)
|
||||
*
|
||||
* 旧版轮询 GET /templates/{id}/editor/generation-status 依赖 plan 维度状态,
|
||||
* 在编辑流程数据链路断裂时拿不到 task_id。新版直接使用 POST /generation/tasks
|
||||
* 返回的 task_id 轮询任务详情,不再依赖 plan。
|
||||
*
|
||||
* 错误处理:
|
||||
* - 4xx(尤其 404)视为不可恢复,立即 onFailed,不再重试
|
||||
* - 5xx / 网络错误重试,最多连续 MAX_RETRYABLE_ERRORS 次
|
||||
* - 任务完成后获取结果失败会重试 MAX_RESULTS_RETRIES 次,仍失败则 onFailed
|
||||
* startPolling(taskId) 轮询单个任务;
|
||||
* startPollingBatch(taskIds) 并行轮询 N 个任务,全部完成后聚合结果,
|
||||
* 任一任务失败即整体失败(其余任务仍在后端继续,不影响)。
|
||||
* 进度为所有任务平均值。
|
||||
*/
|
||||
export const useGenerationPolling = ({
|
||||
export function useGenerationPolling({
|
||||
onProgress,
|
||||
onComplete,
|
||||
onFailed,
|
||||
}: UseGenerationPollingOptions) => {
|
||||
const progressTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
}: 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
|
||||
}
|
||||
progressTimer.current.forEach((t) => clearTimeout(t))
|
||||
progressTimer.current = []
|
||||
}, [])
|
||||
|
||||
/** 任务完成后拉取结果列表,带重试 */
|
||||
@@ -62,85 +56,151 @@ export const useGenerationPolling = ({
|
||||
[],
|
||||
)
|
||||
|
||||
const extractErrorMessage = (pollErr: unknown, status: number): string => {
|
||||
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})`
|
||||
return safeExtractError(msg)
|
||||
}
|
||||
|
||||
/** 轮询单个任务,resolve 该任务的结果视频数组;失败时 reject(new Error(msg)) */
|
||||
const pollSingleTask = useCallback(
|
||||
(taskId: string, runId: number, onTaskProgress?: (pct: number) => void): Promise<unknown[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let consecutiveErrors = 0
|
||||
let done = false
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current || done) return
|
||||
try {
|
||||
const task = await getGenerationTask(taskId)
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
done = true
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
reject(new Error("视频已生成,但获取结果列表失败,请稍后在任务列表查看"))
|
||||
return
|
||||
}
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === "failed" || task.status === "cancelled") {
|
||||
done = true
|
||||
const rawMsg =
|
||||
task.error_info?.error_message ||
|
||||
task.error_message ||
|
||||
(task.status === "cancelled" ? "任务已取消" : "视频生成失败,请联系管理员或重试")
|
||||
reject(new Error(safeExtractError(rawMsg)))
|
||||
return
|
||||
}
|
||||
|
||||
const pct = Math.max(0, Math.min(99, Math.round(Number(task.progress) || 0)))
|
||||
if (onTaskProgress) {
|
||||
onTaskProgress(pct)
|
||||
} else if (runId === 0) {
|
||||
onProgress(pct)
|
||||
}
|
||||
const timer = setTimeout(poll, 2000)
|
||||
progressTimer.current.push(timer)
|
||||
} catch (pollErr) {
|
||||
if (cancelledRef.current || done) return
|
||||
console.error("[轮询出错] taskId:", taskId, pollErr)
|
||||
const status = axios.isAxiosError(pollErr) ? pollErr.response?.status : undefined
|
||||
if (status && status >= 400 && status < 500) {
|
||||
done = true
|
||||
reject(new Error(extractErrorMessage(pollErr, status)))
|
||||
return
|
||||
}
|
||||
consecutiveErrors += 1
|
||||
if (consecutiveErrors >= MAX_RETRYABLE_ERRORS) {
|
||||
done = true
|
||||
reject(new Error("任务状态查询连续失败,请稍后在任务列表查看结果"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(poll, 3000)
|
||||
progressTimer.current.push(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(poll, 1500)
|
||||
progressTimer.current.push(timer)
|
||||
})
|
||||
},
|
||||
[onProgress, fetchResultsWithRetry],
|
||||
)
|
||||
|
||||
/** 单任务轮询(兼容旧调用) */
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
cancelledRef.current = false
|
||||
let consecutiveErrors = 0
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelledRef.current) return
|
||||
try {
|
||||
const task = await getGenerationTask(taskId)
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed") {
|
||||
onProgress(100)
|
||||
const videos = await fetchResultsWithRetry(taskId)
|
||||
if (cancelledRef.current) return
|
||||
if (videos === null) {
|
||||
const errorMsg = "视频已生成,但获取结果列表失败,请稍后在任务列表查看"
|
||||
console.error("[生成结果获取失败] taskId:", taskId)
|
||||
onFailed(errorMsg)
|
||||
message.error(errorMsg)
|
||||
return
|
||||
}
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const runId = 0
|
||||
pollSingleTask(taskId, runId)
|
||||
.then((videos) => {
|
||||
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, 1500)
|
||||
onProgress(100)
|
||||
onComplete(videos)
|
||||
message.success("视频生成完成!")
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelledRef.current) return
|
||||
console.error("[生成失败] taskId:", taskId, err.message)
|
||||
onFailed(err.message)
|
||||
message.error(err.message)
|
||||
})
|
||||
},
|
||||
[onProgress, onComplete, onFailed, fetchResultsWithRetry],
|
||||
[pollSingleTask, onProgress, onComplete, onFailed],
|
||||
)
|
||||
|
||||
return { startPolling, clearTimer }
|
||||
/** 批量多任务轮询:全部完成后聚合结果;任一失败即整体失败 */
|
||||
const startPollingBatch = useCallback(
|
||||
(taskIds: string[]) => {
|
||||
cancelledRef.current = false
|
||||
const runId = Date.now()
|
||||
const progressMap = new Map<string, number>()
|
||||
|
||||
const reportAggregateProgress = () => {
|
||||
if (cancelledRef.current) return
|
||||
const values = taskIds.map((id) => progressMap.get(id) ?? 0)
|
||||
const avg = Math.round(values.reduce((a, b) => a + b, 0) / Math.max(values.length, 1))
|
||||
onProgress(Math.min(avg, 99))
|
||||
}
|
||||
|
||||
const tasks = taskIds.map((taskId) =>
|
||||
pollSingleTask(taskId, runId, (pct) => {
|
||||
progressMap.set(taskId, pct)
|
||||
reportAggregateProgress()
|
||||
}).then((videos) => {
|
||||
progressMap.set(taskId, 100)
|
||||
reportAggregateProgress()
|
||||
return videos
|
||||
}),
|
||||
)
|
||||
|
||||
Promise.all(tasks)
|
||||
.then((results) => {
|
||||
if (cancelledRef.current) return
|
||||
onProgress(100)
|
||||
const allVideos = results.flat()
|
||||
onComplete(allVideos)
|
||||
message.success(`全部 ${taskIds.length} 个视频生成完成!`)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelledRef.current) return
|
||||
console.error("[批量生成失败]", err.message)
|
||||
onFailed(err.message)
|
||||
message.error(err.message)
|
||||
})
|
||||
},
|
||||
[pollSingleTask, onProgress, onComplete, onFailed],
|
||||
)
|
||||
|
||||
return { startPolling, startPollingBatch, clearTimer }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 批量封面 Hook(Issue #1677)
|
||||
* N 个视频时:逐个自动生成封面(从对应成片抽帧 + 叠加对应标题)或上传自定义封面
|
||||
*/
|
||||
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"
|
||||
|
||||
interface UseBatchCoversOptions {
|
||||
selectedTemplate: string
|
||||
generatedVideos: GeneratedVideo[]
|
||||
/** 每个变体的标题文字 */
|
||||
titles: string[]
|
||||
/** 标题样式(全局共用) */
|
||||
titleStyle: {
|
||||
font: string
|
||||
size: number
|
||||
color: string
|
||||
position: string
|
||||
bold: boolean
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
}
|
||||
covers: string[]
|
||||
onCoversChange: (urls: string[]) => void
|
||||
}
|
||||
|
||||
export function useBatchCovers({
|
||||
selectedTemplate,
|
||||
generatedVideos,
|
||||
titles,
|
||||
titleStyle,
|
||||
covers,
|
||||
onCoversChange,
|
||||
}: UseBatchCoversOptions) {
|
||||
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null)
|
||||
|
||||
const patchCover = useCallback(
|
||||
(index: number, url: string) => {
|
||||
const next = [...covers]
|
||||
next[index] = url
|
||||
onCoversChange(next)
|
||||
},
|
||||
[covers, onCoversChange],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频自动生成封面 */
|
||||
const generateOne = useCallback(
|
||||
async (index: number) => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
return
|
||||
}
|
||||
setLoadingIndex(index)
|
||||
try {
|
||||
const titleText = titles[index] || ""
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
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,
|
||||
stroke: titleStyle.stroke,
|
||||
shadow: titleStyle.shadow,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const url = response.cover?.image_url || response.cover?.thumbnail_url || ""
|
||||
if (url) {
|
||||
patchCover(index, url)
|
||||
message.success(`视频 ${index + 1} 封面生成成功`)
|
||||
} else {
|
||||
message.warning(`视频 ${index + 1} 封面生成未返回图片,请重试`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[封面] 视频 ${index + 1} 生成失败:`, err)
|
||||
message.error(`视频 ${index + 1} 封面生成失败,请重试`)
|
||||
} finally {
|
||||
setLoadingIndex(null)
|
||||
}
|
||||
},
|
||||
[generatedVideos, titles, titleStyle, selectedTemplate, patchCover],
|
||||
)
|
||||
|
||||
/** 为第 index 个视频上传自定义封面 */
|
||||
const uploadOne = useCallback(
|
||||
async (index: number, file: File) => {
|
||||
setUploadingIndex(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 {
|
||||
setUploadingIndex(null)
|
||||
}
|
||||
},
|
||||
[patchCover],
|
||||
)
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
for (let i = 0; i < finalVideos.length; i++) {
|
||||
if (covers[i]) continue // 已有封面跳过
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await generateOne(i)
|
||||
}
|
||||
message.success("全部封面已生成")
|
||||
}, [generatedVideos, covers, generateOne])
|
||||
|
||||
return {
|
||||
loadingIndex,
|
||||
uploadingIndex,
|
||||
generateOne,
|
||||
uploadOne,
|
||||
generateAll,
|
||||
}
|
||||
}
|
||||
|
||||
export default useBatchCovers
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 批量服务器预览 Hook(Issue #1677 多视频批量生成)
|
||||
*
|
||||
* 核心职责:
|
||||
* 1. 调用 POST /generation/preview(preview_count=N)一次创建 N 个独立变体任务
|
||||
* 2. 对每个变体 task_id 分别轮询 GET /generation/preview/{task_id}
|
||||
* 3. 返回每个变体的状态/进度/视频URL,供网格播放器展示
|
||||
*
|
||||
* N=1 时不启用(走前端 Canvas 实时预览,零回归);
|
||||
* N>1 时进入标题页自动触发;素材/配音等配置变化后重新触发。
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
||||
import type { CreatePreviewRequest } from "@/api/generation/types"
|
||||
|
||||
export type VariantPreviewStatus = "loading" | "ready" | "failed"
|
||||
|
||||
export interface VariantPreview {
|
||||
/** 变体序号(0-based) */
|
||||
index: number
|
||||
taskId: string
|
||||
status: VariantPreviewStatus
|
||||
progress: number
|
||||
videoUrl: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface UseBatchPreviewOptions {
|
||||
/** 是否启用(仅 previewCount>1 且在标题页时启用) */
|
||||
enabled: boolean
|
||||
/** 构建预览请求参数(每次触发时调用,获取最新配置) */
|
||||
buildRequest: () => CreatePreviewRequest
|
||||
/** 批量预览任务创建成功回调(回传变体 taskId 列表与 source_edit_plan_id) */
|
||||
onPreviewTasksCreated?: (taskIds: string[], sourceEditPlanId?: string) => void
|
||||
}
|
||||
|
||||
interface UseBatchPreviewReturn {
|
||||
variants: VariantPreview[]
|
||||
/** 整体状态:loading=任一进行中,ready=全部完成,failed=有失败 */
|
||||
status: "idle" | "loading" | "ready" | "partial_failed" | "failed"
|
||||
/** 总进度 0-100(各变体平均值) */
|
||||
progress: number
|
||||
/** 失败的变体数量 */
|
||||
failedCount: number
|
||||
/** 手动重新触发 */
|
||||
trigger: () => void
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000
|
||||
const POLL_TIMEOUT = 180_000
|
||||
const MAX_NETWORK_RETRIES = 2
|
||||
|
||||
/**
|
||||
* 对配置参数做指纹,用于检测配置是否变化(标题文字/样式变化不触发重渲染,仅CSS浮层叠加)
|
||||
*/
|
||||
function buildFingerprint(req: CreatePreviewRequest): string {
|
||||
// 不含 titles/title_config:标题文字与样式由 CSS 浮层实时叠加,变化不触发重渲染
|
||||
return JSON.stringify({
|
||||
t: req.template_id,
|
||||
a: [...(req.asset_ids || [])].sort(),
|
||||
r: req.video_ratio,
|
||||
v: req.voice_library_id,
|
||||
vs: req.voice_library_ids,
|
||||
pc: req.preview_count,
|
||||
b: req.bgm_config,
|
||||
})
|
||||
}
|
||||
|
||||
export function useBatchPreview({
|
||||
enabled,
|
||||
buildRequest,
|
||||
onPreviewTasksCreated,
|
||||
}: UseBatchPreviewOptions): UseBatchPreviewReturn {
|
||||
const [variants, setVariants] = useState<VariantPreview[]>([])
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "partial_failed" | "failed">(
|
||||
"idle",
|
||||
)
|
||||
const requestSeqRef = useRef(0)
|
||||
const pollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const buildRequestRef = useRef(buildRequest)
|
||||
buildRequestRef.current = buildRequest
|
||||
const onCreatedRef = useRef(onPreviewTasksCreated)
|
||||
onCreatedRef.current = onPreviewTasksCreated
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
pollTimersRef.current.forEach((t) => clearTimeout(t))
|
||||
pollTimersRef.current = []
|
||||
if (timeoutTimerRef.current) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearTimers()
|
||||
}
|
||||
}, [clearTimers])
|
||||
|
||||
/** 更新单个变体状态 */
|
||||
const patchVariant = useCallback((taskId: string, patch: Partial<VariantPreview>) => {
|
||||
setVariants((prev) => prev.map((v) => (v.taskId === taskId ? { ...v, ...patch } : v)))
|
||||
}, [])
|
||||
|
||||
/** 轮询单个变体任务 */
|
||||
const pollVariant = useCallback(
|
||||
async (taskId: string, seq: number, retries = 0) => {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
try {
|
||||
const st = await getPreviewStatus(taskId)
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
if (st.status === "completed" && st.video_url) {
|
||||
patchVariant(taskId, {
|
||||
status: "ready",
|
||||
videoUrl: st.video_url,
|
||||
progress: 100,
|
||||
error: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (st.status === "failed" || st.status === "cancelled") {
|
||||
patchVariant(taskId, {
|
||||
status: "failed",
|
||||
error:
|
||||
st.status === "cancelled" ? "预览任务已取消" : st.error_message || "预览渲染失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (typeof st.progress === "number") {
|
||||
patchVariant(taskId, { progress: Math.round(st.progress) })
|
||||
}
|
||||
const timer = setTimeout(() => pollVariant(taskId, seq), POLL_INTERVAL)
|
||||
pollTimersRef.current.push(timer)
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
if (retries < MAX_NETWORK_RETRIES) {
|
||||
console.warn(`[BatchPreview] 变体 ${taskId} 轮询网络错误,第 ${retries + 1} 次重试`, err)
|
||||
const timer = setTimeout(() => pollVariant(taskId, seq, retries + 1), POLL_INTERVAL * 2)
|
||||
pollTimersRef.current.push(timer)
|
||||
} else {
|
||||
patchVariant(taskId, { status: "failed", error: "网络错误,无法获取预览状态" })
|
||||
}
|
||||
}
|
||||
},
|
||||
[patchVariant],
|
||||
)
|
||||
|
||||
/** 创建批量预览任务并开始轮询 */
|
||||
const trigger = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const request = buildRequestRef.current()
|
||||
if (!request.template_id || !request.asset_ids?.length) return
|
||||
const count = request.preview_count && request.preview_count > 1 ? request.preview_count : 0
|
||||
if (!count) return
|
||||
|
||||
clearTimers()
|
||||
const seq = ++requestSeqRef.current
|
||||
setStatus("loading")
|
||||
setVariants(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
index: i,
|
||||
taskId: "",
|
||||
status: "loading" as const,
|
||||
progress: 0,
|
||||
videoUrl: null,
|
||||
error: null,
|
||||
})),
|
||||
)
|
||||
|
||||
createPreview(request)
|
||||
.then((resp) => {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
const items = resp.items || []
|
||||
const taskIds = items.map((it) => it.task_id).filter(Boolean)
|
||||
if (taskIds.length === 0) {
|
||||
setStatus("failed")
|
||||
setVariants((prev) =>
|
||||
prev.map((v) => ({ ...v, status: "failed", error: "未创建预览任务" })),
|
||||
)
|
||||
return
|
||||
}
|
||||
onCreatedRef.current?.(taskIds, resp.source_edit_plan_id)
|
||||
|
||||
// 用返回的 task_id 填充变体(按 variant_index 对齐)
|
||||
setVariants((prev) =>
|
||||
prev.map((v) => {
|
||||
const item = items.find((it) => it.variant_index === v.index) || items[v.index]
|
||||
return item ? { ...v, taskId: item.task_id } : v
|
||||
}),
|
||||
)
|
||||
|
||||
// 超时保护
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
setVariants((prev) =>
|
||||
prev.map((v) =>
|
||||
v.status === "loading"
|
||||
? { ...v, status: "failed", error: "预览渲染超时,请重试" }
|
||||
: v,
|
||||
),
|
||||
)
|
||||
}, POLL_TIMEOUT)
|
||||
|
||||
// 分别轮询每个变体
|
||||
items.forEach((item) => {
|
||||
if (item.task_id) pollVariant(item.task_id, seq)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
console.error("[BatchPreview] 创建批量预览失败:", err)
|
||||
const errData = (err as { response?: { data?: { detail?: string; message?: string } } })
|
||||
?.response?.data
|
||||
setStatus("failed")
|
||||
setVariants((prev) =>
|
||||
prev.map((v) => ({
|
||||
...v,
|
||||
status: "failed",
|
||||
error: errData?.detail || errData?.message || "预览任务创建失败,请重试",
|
||||
})),
|
||||
)
|
||||
})
|
||||
}, [enabled, clearTimers, pollVariant])
|
||||
|
||||
/* ── 自动触发 + 配置变更检测 ── */
|
||||
const request = enabled ? buildRequest() : null
|
||||
const currentFingerprint = request
|
||||
? request.template_id && request.asset_ids?.length && (request.preview_count || 1) > 1
|
||||
? buildFingerprint(request)
|
||||
: ""
|
||||
: ""
|
||||
|
||||
const didInitRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) {
|
||||
didInitRef.current = false
|
||||
requestSeqRef.current += 1
|
||||
clearTimers()
|
||||
setStatus("idle")
|
||||
setVariants([])
|
||||
return
|
||||
}
|
||||
if (!didInitRef.current) {
|
||||
didInitRef.current = true
|
||||
trigger()
|
||||
}
|
||||
}, [enabled, currentFingerprint, trigger, clearTimers])
|
||||
|
||||
// 配置变更(素材/配音/数量)→ 重新渲染;标题文字变化不触发(CSS浮层实时叠加)
|
||||
const prevFingerprintRef = useRef(currentFingerprint)
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentFingerprint) return
|
||||
const prev = prevFingerprintRef.current
|
||||
prevFingerprintRef.current = currentFingerprint
|
||||
if (!prev || prev === currentFingerprint) return
|
||||
trigger()
|
||||
}, [enabled, currentFingerprint, trigger])
|
||||
|
||||
/* ── 派生状态 ── */
|
||||
const progress =
|
||||
variants.length > 0
|
||||
? Math.round(variants.reduce((sum, v) => sum + v.progress, 0) / variants.length)
|
||||
: 0
|
||||
const failedCount = variants.filter((v) => v.status === "failed").length
|
||||
const readyCount = variants.filter((v) => v.status === "ready").length
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "loading" || variants.length === 0) return
|
||||
if (readyCount === variants.length) {
|
||||
setStatus("ready")
|
||||
} else if (readyCount + failedCount === variants.length && failedCount > 0) {
|
||||
setStatus(failedCount === variants.length ? "failed" : "partial_failed")
|
||||
}
|
||||
}, [variants, status, readyCount, failedCount])
|
||||
|
||||
return { variants, status, progress, failedCount, trigger }
|
||||
}
|
||||
|
||||
export default useBatchPreview
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 表单状态管理
|
||||
* 集中管理 7 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
* 集中管理 5 步向导的所有共享状态、API 加载、URL 参数解析
|
||||
*/
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
@@ -100,6 +100,26 @@ export interface GenerateFormState {
|
||||
/** 从预览响应中提取的 source_edit_plan_id(供 fallback 路径使用) */
|
||||
storedSourceEditPlanId: string | null
|
||||
setStoredSourceEditPlanId: (planId: string | null) => void
|
||||
|
||||
/* ── 批量生成(Issue #1677)── */
|
||||
/** 生成数量(1~10),1=单条旧逻辑 */
|
||||
previewCount: number
|
||||
setPreviewCount: (n: number) => void
|
||||
/** 每个变体的标题文字,长度=previewCount;[0] 与 titleSettings.title 保持同步 */
|
||||
previewTitles: string[]
|
||||
setPreviewTitles: (titles: string[] | ((prev: string[]) => string[])) => void
|
||||
/** false=所有视频共用一个配音;true=每个视频独立配音 */
|
||||
voiceModePerVideo: boolean
|
||||
setVoiceModePerVideo: (v: boolean) => void
|
||||
/** 独立配音模式下每个变体的配音素材ID,长度=previewCount */
|
||||
voiceLibraryIds: string[]
|
||||
setVoiceLibraryIds: (ids: string[] | ((prev: string[]) => string[])) => void
|
||||
/** 每个变体的封面URL(自动生成或上传),长度=previewCount,空串=未设置 */
|
||||
previewCovers: string[]
|
||||
setPreviewCovers: (urls: string[] | ((prev: string[]) => string[])) => void
|
||||
/** 确认生成时勾选的变体索引 */
|
||||
selectedVariantIds: number[]
|
||||
setSelectedVariantIds: (ids: number[] | ((prev: number[]) => number[])) => void
|
||||
}
|
||||
|
||||
export const useGenerateFormState = (): GenerateFormState => {
|
||||
@@ -185,6 +205,14 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
null,
|
||||
)
|
||||
|
||||
/* ── 批量生成状态(Issue #1677)── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
const [previewTitles, setPreviewTitles] = useState<string[]>([""])
|
||||
const [voiceModePerVideo, setVoiceModePerVideo] = useState(false)
|
||||
const [voiceLibraryIds, setVoiceLibraryIds] = useState<string[]>([""])
|
||||
const [previewCovers, setPreviewCovers] = useState<string[]>([""])
|
||||
const [selectedVariantIds, setSelectedVariantIds] = useState<number[]>([0])
|
||||
|
||||
/* ── 从 URL / 编辑计划加载配置 ── */
|
||||
usePlanConfigLoader({
|
||||
editPlanId,
|
||||
@@ -233,5 +261,17 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
previewCount,
|
||||
setPreviewCount,
|
||||
previewTitles,
|
||||
setPreviewTitles,
|
||||
voiceModePerVideo,
|
||||
setVoiceModePerVideo,
|
||||
voiceLibraryIds,
|
||||
setVoiceLibraryIds,
|
||||
previewCovers,
|
||||
setPreviewCovers,
|
||||
selectedVariantIds,
|
||||
setSelectedVariantIds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(errorMsg)
|
||||
}, [])
|
||||
|
||||
const { startPolling, clearTimer } = useGenerationPolling({
|
||||
const { startPolling, startPollingBatch, clearTimer } = useGenerationPolling({
|
||||
onProgress: handleProgress,
|
||||
onComplete: handleComplete,
|
||||
onFailed: handleFailed,
|
||||
@@ -83,7 +83,11 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const hide = message.loading("正在生成预览视频...", 0)
|
||||
const isBatch = (props.previewCount || 1) > 1
|
||||
const hide = message.loading(
|
||||
isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...",
|
||||
0,
|
||||
)
|
||||
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
@@ -92,6 +96,29 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
|
||||
const indexes =
|
||||
isBatch && props.selectedVariantIndexes?.length
|
||||
? props.selectedVariantIndexes
|
||||
: Array.from({ length: props.previewCount || 1 }, (_, i) => i)
|
||||
const batchCount = isBatch ? indexes.length : 1
|
||||
|
||||
// 标题文字数组:批量时按勾选顺序
|
||||
const titlesArr =
|
||||
isBatch && (props.variantTitles?.length || 0) >= batchCount
|
||||
? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "")
|
||||
: []
|
||||
// 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id)
|
||||
const voiceArr =
|
||||
isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length
|
||||
? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId)
|
||||
: []
|
||||
// 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面)
|
||||
const coversArr =
|
||||
isBatch && props.variantCoverUrls?.length
|
||||
? indexes.map((i) => props.variantCoverUrls![i] || "")
|
||||
: []
|
||||
|
||||
try {
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
@@ -109,6 +136,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
},
|
||||
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
||||
...(isBatch ? { count: batchCount } : {}),
|
||||
...(titlesArr.length ? { titles: titlesArr } : {}),
|
||||
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
|
||||
...(coversArr.length ? { cover_urls: coversArr } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
@@ -133,12 +164,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
: {}),
|
||||
})
|
||||
hide()
|
||||
const taskId = taskResp.items?.[0]?.id
|
||||
const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean)
|
||||
|
||||
if (!taskId) {
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
||||
}
|
||||
startPolling(taskId)
|
||||
if (taskIds.length > 1) {
|
||||
startPollingBatch(taskIds)
|
||||
} else {
|
||||
startPolling(taskIds[0])
|
||||
}
|
||||
} catch (err) {
|
||||
hide()
|
||||
throw err
|
||||
@@ -154,7 +189,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
}, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
|
||||
@@ -114,8 +114,11 @@ export function useServerPreview({
|
||||
const resp = await createPreview(request)
|
||||
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
setTaskId(resp.task_id)
|
||||
onCreatedRef.current?.(resp.task_id, resp.source_edit_plan_id)
|
||||
// 兼容批量响应 {items, total}:取第一个变体
|
||||
const firstTask = resp.items?.[0]
|
||||
const taskId = firstTask?.task_id || ""
|
||||
setTaskId(taskId)
|
||||
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
|
||||
|
||||
let completed = false
|
||||
|
||||
@@ -132,7 +135,7 @@ export function useServerPreview({
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
try {
|
||||
const st = await getPreviewStatus(resp.task_id)
|
||||
const st = await getPreviewStatus(taskId)
|
||||
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
||||
|
||||
if (st.status === "completed" && st.video_url) {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Step 7 确认生成 Hook
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
title: string
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
selectedVoice: string
|
||||
selectedClonedVoice: string
|
||||
presetVoices: PresetVoiceItem[]
|
||||
clonedVoices: VoiceClone[]
|
||||
coverSettings: CoverConfig
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
generateError: string | null
|
||||
progress: number
|
||||
generatedVideos: GeneratedVideo[]
|
||||
}
|
||||
|
||||
export function useStep7Generate({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode: _voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice: _selectedClonedVoice,
|
||||
presetVoices: _presetVoices,
|
||||
clonedVoices: _clonedVoices,
|
||||
coverSettings,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
}: UseStep7GenerateProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialSummary = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
// 从配音素材库中查找 voiceName
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
|
||||
return asset ? asset.name : "未选择"
|
||||
}, [voiceMaterials, selectedVoice])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
return COVER_MODE_LABELS[coverSettings.mode] || "智能封面"
|
||||
}, [coverSettings])
|
||||
|
||||
const getGenerationPhase = (p: number) => {
|
||||
if (p < 20) return { label: "分析素材与配置", icon: "🔍" }
|
||||
if (p < 50) return { label: "智能剪辑合成", icon: "🎬" }
|
||||
if (p < 80) return { label: "渲染视频中", icon: "⚡" }
|
||||
return { label: "即将完成", icon: "✨" }
|
||||
}
|
||||
|
||||
const handleScrollToPreview = () => {
|
||||
const el =
|
||||
document.querySelector(".xx-inline-video-player") ||
|
||||
document.querySelector(".xx-preview-section")
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialSummary,
|
||||
title,
|
||||
voiceName,
|
||||
coverSummary,
|
||||
generating,
|
||||
generated,
|
||||
generateError,
|
||||
progress,
|
||||
generatedVideos,
|
||||
getGenerationPhase,
|
||||
handleScrollToPreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep7Generate
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
|
||||
* GeneratePage 步骤导航(Issue #1677 改造后 5 步)
|
||||
* 步骤:模板(1) → 素材(2) → 配音(3) → 标题+预览+确认生成(4) → 封面(5)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -13,10 +13,16 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 预览是否已就绪(素材已加载,可播放) */
|
||||
/** 预览是否已就绪(单视频=前端预览素材已加载;批量=服务器预览全部完成) */
|
||||
previewReady: boolean
|
||||
/** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
|
||||
/** 是否已完成视频生成(步骤4确认生成后才能进入封面) */
|
||||
generated: boolean
|
||||
/** 批量模式下每个变体的标题 */
|
||||
previewTitles: string[]
|
||||
/** 批量模式勾选的变体数 */
|
||||
selectedCount: number
|
||||
/** Step1 点下一步时弹出数量选择弹窗 */
|
||||
onOpenCountModal: () => void
|
||||
}
|
||||
|
||||
export interface UseStepNavigationReturn {
|
||||
@@ -32,14 +38,21 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady,
|
||||
generated,
|
||||
previewTitles,
|
||||
selectedCount,
|
||||
onOpenCountModal,
|
||||
} = options
|
||||
|
||||
const goNext = () => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
message.warning("请先选择一个模板")
|
||||
if (currentStep === 1) {
|
||||
if (!selectedTemplate) {
|
||||
message.warning("请先选择一个模板")
|
||||
return
|
||||
}
|
||||
// 选完模板弹数量选择弹窗(每次都弹,不记忆)
|
||||
onOpenCountModal()
|
||||
return
|
||||
}
|
||||
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
@@ -50,23 +63,27 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
// Step4(标题+预览):标题必填 + 预览必须已加载
|
||||
// Step4(标题+预览+确认生成):标题必填 + 预览必须已加载
|
||||
if (currentStep === 4) {
|
||||
if (!titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
const allTitlesFilled = previewTitles.every((t) => t && t.trim())
|
||||
if (!allTitlesFilled) {
|
||||
message.warning("请为每个视频输入标题")
|
||||
return
|
||||
}
|
||||
if (selectedCount === 0) {
|
||||
message.warning("请至少勾选一个视频")
|
||||
return
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览视频正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
if (!generated) {
|
||||
message.warning("请先点击「确认生成视频」完成渲染")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Step5(确认生成):必须已完成生成才能进入封面
|
||||
if (currentStep === 5 && !generated) {
|
||||
message.warning("请先生成视频")
|
||||
return
|
||||
}
|
||||
if (currentStep < 6) {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep((s) => s + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/Step3VoiceWithMode"
|
||||
import "@/pages/generate/components/ServerPreviewGrid"
|
||||
import "@/pages/generate/components/PreviewCountModal"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
import "@/pages/generate/components/GenerateResultPanel"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
@@ -45,6 +48,8 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useBatchPreview"
|
||||
import "@/pages/generate/hooks/useBatchCovers"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
|
||||
@@ -31,25 +31,65 @@ SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值
|
||||
MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒)
|
||||
MAX_KEYFRAMES = 30 # 最大关键帧数
|
||||
MIN_KEYFRAMES = 5 # 最小关键帧数
|
||||
FINGERPRINT_SAMPLE_INTERVAL_SEC = 1.0 # 指纹采样间隔(秒):密集均匀采样,保证两视频时序可对齐
|
||||
FINGERPRINT_MAX_SAMPLES = 30 # 长视频采样数上限(超过后采样间隔自动放宽)
|
||||
LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数
|
||||
LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值
|
||||
MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数
|
||||
|
||||
# ── 滑动窗口匹配常量 ────────────────────────────────────────────
|
||||
SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值
|
||||
MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数
|
||||
# ── 滑动窗口匹配常量(Issue #1702 二次校准) ─────────────────────
|
||||
# 阈值经 staging 真实数据两轮回归校准(worker 容器内离线实验):
|
||||
# 第一轮(2026-09-05):同源对 <=12 命中 4/11,异源最小距离 24 → 定 12;
|
||||
# 第二轮(2026-09-05,证据视频 B->A 仍漏检):扩大样本到该用户全部
|
||||
# 15 个真实成片(13 个异源候选)实测:
|
||||
# - 同源成片对(A 20s / B、C 各 11.75s,1s 密集采样):
|
||||
# B->A 中位数距离 14,<=16 命中 8/11=0.73;C->A 8/11=0.73
|
||||
# - 异源成片对(13 个真实视频):每帧全局最近邻最小距离 18,
|
||||
# <=16 命中帧数全部为 0(最近邻 18 仅个别帧,中位数 22~28)
|
||||
# 12 漏掉同源降重对(降重滤镜/字幕/画面扰动把距离从 ~8 推到 14~16);
|
||||
# 16 对同源命中 0.73+ 且与异源分布(最近邻 >=18)仍有 >=2bit 安全裕度,
|
||||
# 异源 <=16 命中 0 帧,无误报空间。
|
||||
PHASH_THRESHOLD = 16
|
||||
SEGMENT_MATCH_THRESHOLD = PHASH_THRESHOLD # 片段匹配阈值与帧匹配统一(#1702:阈值常量统一来源)
|
||||
MIN_CONSECUTIVE_MATCHES = 5 # 连续匹配默认门槛;短视频自适应 min(5, max(2, 分片数//2))
|
||||
MAX_GAP = 2 # 允许的最大间隙帧数
|
||||
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
|
||||
|
||||
# ── 融合判定常量 ────────────────────────────────────────────────
|
||||
PHASH_WEIGHT = 0.7 # pHash 权重
|
||||
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
|
||||
MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配
|
||||
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配
|
||||
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
|
||||
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
|
||||
|
||||
# ── 降重裁剪规避常量(Issue #1702) ─────────────────────────────
|
||||
# 成片强制 2-5% random_edge_crop 降重只服务外部平台;自查重指纹取中心 90%
|
||||
# 区域,使两次不同裁剪的同源画面 pHash 距离回到同分布。
|
||||
FINGERPRINT_CENTER_CROP_RATIO = 0.90
|
||||
|
||||
|
||||
# ── 感知哈希 & 颜色直方图工具函数 ────────────────────────────────
|
||||
|
||||
|
||||
def center_crop_frame(image: np.ndarray, ratio: float = FINGERPRINT_CENTER_CROP_RATIO) -> np.ndarray:
|
||||
"""取画面中心 ratio 比例区域(裁除四边边缘)。
|
||||
|
||||
查重指纹用:random_edge_crop 降重(2-5% 四边随机裁剪)会让同源画面 pHash
|
||||
位翻转 12-16,污染自查重(Issue #1702)。算 pHash/颜色直方图前先居中裁除
|
||||
边缘 10%,两次不同裁剪的同源画面中心区域基本重合,指纹不再被降重污染。
|
||||
降重只服务外部平台,不影响内部查重。
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return image
|
||||
h, w = image.shape[:2]
|
||||
ch, cw = int(h * ratio), int(w * ratio)
|
||||
if ch <= 0 or cw <= 0 or (ch >= h and cw >= w):
|
||||
return image
|
||||
y0 = (h - ch) // 2
|
||||
x0 = (w - cw) // 2
|
||||
return image[y0 : y0 + ch, x0 : x0 + cw]
|
||||
|
||||
|
||||
def compute_phash(image: np.ndarray, hash_size: int = 8) -> str:
|
||||
"""计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。
|
||||
|
||||
@@ -101,11 +141,17 @@ def hamming_distance(hash1: str, hash2: str) -> int:
|
||||
|
||||
|
||||
def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]:
|
||||
"""Compute color histogram for an image."""
|
||||
"""Compute BGR color histogram for an image.
|
||||
|
||||
Issue #1702: 每个通道独立做 NORM_L1 归一化(通道内 Σ=1,是概率分布),
|
||||
三通道拼接存储。Bhattacharyya 系数对拼接向量直接 Σ√(a*b) 会得到
|
||||
3 通道之和(范围 [0,3],实测 ~14.9 是旧 L2 归一化的错误结果),
|
||||
消费方 _bhattacharyya_coefficient 按通道数平均归一到 [0,1]。
|
||||
"""
|
||||
hist = []
|
||||
for i in range(3):
|
||||
h = cv2.calcHist([image], [i], None, [bins], [0, 256])
|
||||
h = cv2.normalize(h, h).flatten()
|
||||
h = cv2.normalize(h, h, norm_type=cv2.NORM_L1).flatten()
|
||||
hist.extend(h)
|
||||
return hist
|
||||
|
||||
@@ -210,6 +256,30 @@ def detect_keyframe_timestamps(
|
||||
return keyframe_times
|
||||
|
||||
|
||||
def sample_fingerprint_timestamps(
|
||||
duration: float,
|
||||
*,
|
||||
interval_sec: float = FINGERPRINT_SAMPLE_INTERVAL_SEC,
|
||||
max_samples: int = FINGERPRINT_MAX_SAMPLES,
|
||||
) -> list[float]:
|
||||
"""指纹采样时间戳:固定间隔密集均匀采样(Issue #1702)。
|
||||
|
||||
动态场景检测抽帧(#1659)在两个同源视频上会各自取到不同时刻,切点/取帧
|
||||
错位让对齐帧的 pHash 距离都很大(实测同源对最小距离 12 且配对时序错乱)。
|
||||
改为固定 1s 间隔均匀采样后,复用片段的帧时刻天然对齐,配合 ±1 邻接窗口
|
||||
即可检出同源/局部复用。长视频(>max_samples*interval)自动放宽间隔到
|
||||
duration/max_samples,保证分片数有上限。
|
||||
"""
|
||||
if duration <= 0:
|
||||
return []
|
||||
step = interval_sec
|
||||
n_uniform = int(duration / step)
|
||||
if n_uniform > max_samples:
|
||||
step = duration / max_samples
|
||||
count = max(1, int(duration / step))
|
||||
return [step * (i + 0.5) for i in range(count)]
|
||||
|
||||
|
||||
# ── 数据类 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -297,23 +367,33 @@ def find_duplicate_segments(
|
||||
target_chunks: list,
|
||||
*,
|
||||
match_threshold: int = SEGMENT_MATCH_THRESHOLD,
|
||||
min_consecutive: int = MIN_CONSECUTIVE_MATCHES,
|
||||
min_consecutive: Optional[int] = None,
|
||||
max_gap: int = MAX_GAP,
|
||||
neighbor_window: int = NEIGHBOR_WINDOW,
|
||||
) -> list[DuplicateSegment]:
|
||||
"""滑动窗口时序匹配:找出两组分片之间的重复片段。
|
||||
"""滑动窗口时序匹配:找出两组分片之间的重复片段(Issue #1702 重构)。
|
||||
|
||||
算法:
|
||||
1. 对每个 query chunk,找到 target 中汉明距离最小的 chunk
|
||||
2. 距离 <= match_threshold 视为匹配
|
||||
3. 找连续匹配的 run(允许 max_gap 帧间隙)
|
||||
4. 连续匹配数 >= min_consecutive 的 run 报告为重复片段
|
||||
1. 构建 query×target 全量汉明距离矩阵;每个 query chunk 保留所有
|
||||
距离 <= match_threshold 的候选 target 分片(与帧匹配判定同一阈值)。
|
||||
2. 时序一致贪心对齐:沿 query 时序推进,run 内优先选择与上一匹配帧
|
||||
目标序号连贯(|delta| <= neighbor_window+1,允许 ±1 邻接/时序偏移
|
||||
对齐——1s 密集采样下相邻帧 pHash 接近,最近邻在目标相邻帧间
|
||||
正/反向跳变均属正常,缓解场景切割切点、取帧错位、局部倒退)的
|
||||
候选;同距时偏好小索引(最早对齐位置)。
|
||||
3. 连贯匹配中允许 <= max_gap 帧间隙桥接;断裂后另起新 run——天然
|
||||
支持局部片段复用(复用片段可出现在任意时序位置,各成独立片段)。
|
||||
4. 连续匹配帧数 >= min_consecutive 的 run 报为重复片段。短视频自适应:
|
||||
min_consecutive = min(5, max(2, len(query_chunks)//2));n=1 时
|
||||
不形成片段,由调用方匹配帧回退兜底。
|
||||
|
||||
Args:
|
||||
query_chunks: 查询视频的分片列表(FingerprintChunk 或 dict)
|
||||
target_chunks: 目标视频的分片列表
|
||||
match_threshold: 汉明距离匹配阈值
|
||||
min_consecutive: 最少连续匹配帧数
|
||||
match_threshold: 汉明距离匹配阈值(统一常量 PHASH_THRESHOLD)
|
||||
min_consecutive: 最少连续匹配帧数;None 时按短视频自适应
|
||||
max_gap: 允许的最大间隙帧数
|
||||
neighbor_window: 时序对齐允许的目标分片序号邻接窗口(正/反向均允许)
|
||||
|
||||
Returns:
|
||||
DuplicateSegment 列表
|
||||
@@ -321,95 +401,94 @@ def find_duplicate_segments(
|
||||
if not query_chunks or not target_chunks:
|
||||
return []
|
||||
|
||||
def _get_phash(chunk) -> str:
|
||||
def _get(chunk, key):
|
||||
if isinstance(chunk, dict):
|
||||
return chunk["phash_binary"]
|
||||
return chunk.phash_binary
|
||||
return chunk[key]
|
||||
return getattr(chunk, key)
|
||||
|
||||
def _get_start(chunk) -> int:
|
||||
if isinstance(chunk, dict):
|
||||
return chunk["start_time_ms"]
|
||||
return chunk.start_time_ms
|
||||
n, m = len(query_chunks), len(target_chunks)
|
||||
q_ph = [_get(c, "phash_binary") for c in query_chunks]
|
||||
t_ph = [_get(c, "phash_binary") for c in target_chunks]
|
||||
|
||||
def _get_end(chunk) -> int:
|
||||
if isinstance(chunk, dict):
|
||||
return chunk["end_time_ms"]
|
||||
return chunk.end_time_ms
|
||||
# Step 1: 全量距离矩阵。每个 query chunk 保留所有 <= 阈值的候选 target,
|
||||
# 按距离升序;同距时小索引优先(取最早的对齐位置,贪心连贯推进时最保守,
|
||||
# 不会越过复用片段末端;重复 hash 的连续帧由 Step 2 的连贯性窗口约束)。
|
||||
candidates: list[list[tuple[int, int]]] = [] # 每 query 帧: [(target_idx, dist), ...]
|
||||
for i in range(n):
|
||||
dists = [hamming_distance(q_ph[i], t_ph[j]) for j in range(m)]
|
||||
cand = [(j, d) for j, d in enumerate(dists) if d <= match_threshold]
|
||||
cand.sort(key=lambda x: (x[1], x[0]))
|
||||
candidates.append(cand)
|
||||
|
||||
# Step 1: 逐帧匹配
|
||||
frame_matches: list[tuple[bool, int, int]] = [] # (is_match, min_dist, best_target_idx)
|
||||
for qc in query_chunks:
|
||||
qc_phash = _get_phash(qc)
|
||||
best_dist = 64
|
||||
best_idx = 0
|
||||
for j, tc in enumerate(target_chunks):
|
||||
d = hamming_distance(qc_phash, _get_phash(tc))
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_idx = j
|
||||
frame_matches.append((best_dist <= match_threshold, best_dist, best_idx))
|
||||
# 短视频自适应连续匹配门槛(Issue #1702 工单公式):
|
||||
# MIN_CONSECUTIVE_MATCHES = min(5, max(2, 分片数//2))。
|
||||
# n=1 时门槛为 2 不形成片段,由 _evaluate_candidate 的匹配帧回退
|
||||
# (temporal_coverage 按匹配帧占比估计)兜底检出,不回归。
|
||||
if min_consecutive is None:
|
||||
min_consecutive = min(MIN_CONSECUTIVE_MATCHES, max(2, n // 2))
|
||||
|
||||
# Step 2: 找连续匹配的 runs
|
||||
runs: list[tuple[int, int]] = [] # list of (start_idx, end_idx)
|
||||
run_start = None
|
||||
# Step 2: 时序一致贪心对齐。
|
||||
# run 内偏好与上一匹配帧目标序号连贯(|delta| <= neighbor_window+1,
|
||||
# 支持 ±1 邻接窗口/时序偏移对齐,正反向抖动均允许)的候选;
|
||||
# 无连贯候选时关闭旧 run。
|
||||
# 这天然支持局部片段复用:同一 query 视频中多个复用片段各自形成独立 run。
|
||||
frame_matches: list[tuple[bool, int, int]] = []
|
||||
runs: list[tuple[int, int]] = []
|
||||
run_start: Optional[int] = None
|
||||
run_last_t: Optional[int] = None
|
||||
gap_count = 0
|
||||
|
||||
for i, (is_match, _dist, _idx) in enumerate(frame_matches):
|
||||
if is_match:
|
||||
def _matching_count(a: int, b: int) -> int:
|
||||
return sum(1 for k in range(a, b + 1) if frame_matches[k][0])
|
||||
|
||||
def _close_run(a: int, b: int) -> None:
|
||||
if b >= a and _matching_count(a, b) >= min_consecutive:
|
||||
runs.append((a, b))
|
||||
|
||||
for i in range(n):
|
||||
cand = candidates[i]
|
||||
if run_last_t is None:
|
||||
chosen = cand[0] if cand else None
|
||||
else:
|
||||
chosen = next(
|
||||
(c for c in cand if abs(c[0] - run_last_t) <= neighbor_window + 1),
|
||||
None,
|
||||
)
|
||||
|
||||
if chosen is not None:
|
||||
tidx, dist = chosen
|
||||
frame_matches.append((True, dist, tidx))
|
||||
if run_start is None:
|
||||
run_start = i
|
||||
gap_count = 0 # 重置间隙
|
||||
gap_count = 0
|
||||
run_last_t = tidx
|
||||
else:
|
||||
frame_matches.append((False, match_threshold + 1, -1))
|
||||
if run_start is not None:
|
||||
gap_count += 1
|
||||
if gap_count > max_gap:
|
||||
# 中断当前 run
|
||||
run_end = i - gap_count # 最后一个匹配帧的索引
|
||||
# 计算 run 内的实际匹配帧数(总跨度 - 间隙数)
|
||||
total_gaps = sum(1 for k in range(run_start, run_end + 1) if not frame_matches[k][0])
|
||||
matching_count = (run_end - run_start + 1) - total_gaps
|
||||
if matching_count >= min_consecutive:
|
||||
runs.append((run_start, run_end))
|
||||
run_start = None
|
||||
gap_count = 0
|
||||
# 非匹配帧从 i-gap_count+1 开始,run 结束于其前一帧
|
||||
_close_run(run_start, i - gap_count)
|
||||
run_start, run_last_t, gap_count = None, None, 0
|
||||
|
||||
# 处理末尾 run
|
||||
if run_start is not None:
|
||||
last_idx = len(frame_matches) - 1
|
||||
# 回退找到最后一个匹配帧的位置(跳过尾部非匹配帧)
|
||||
last_idx = n - 1
|
||||
while last_idx >= run_start and not frame_matches[last_idx][0]:
|
||||
last_idx -= 1
|
||||
if last_idx >= run_start:
|
||||
# 计算 run 内的总间隙数
|
||||
total_gaps = sum(1 for k in range(run_start, last_idx + 1) if not frame_matches[k][0])
|
||||
matching_count = (last_idx - run_start + 1) - total_gaps
|
||||
if matching_count >= min_consecutive:
|
||||
runs.append((run_start, last_idx))
|
||||
_close_run(run_start, last_idx)
|
||||
|
||||
# Step 3: 构建 DuplicateSegment
|
||||
segments: list[DuplicateSegment] = []
|
||||
for start, end in runs:
|
||||
query_start = _get_start(query_chunks[start])
|
||||
query_end = _get_end(query_chunks[end])
|
||||
|
||||
# 取目标范围(按最佳匹配的目标 chunk 时间范围)
|
||||
target_indices = [frame_matches[k][2] for k in range(start, end + 1) if frame_matches[k][0]]
|
||||
if target_indices:
|
||||
t_min = min(target_indices)
|
||||
t_max = max(target_indices)
|
||||
target_start = _get_start(target_chunks[t_min])
|
||||
target_end = _get_end(target_chunks[t_max])
|
||||
else:
|
||||
target_start = _get_start(target_chunks[0])
|
||||
target_end = _get_end(target_chunks[-1])
|
||||
|
||||
avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1)) / (end - start + 1)
|
||||
t_min, t_max = min(target_indices), max(target_indices)
|
||||
avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1) if frame_matches[k][0]) / len(target_indices)
|
||||
segments.append(
|
||||
DuplicateSegment(
|
||||
query_start_ms=query_start,
|
||||
query_end_ms=query_end,
|
||||
target_start_ms=target_start,
|
||||
target_end_ms=target_end,
|
||||
query_start_ms=_get(query_chunks[start], "start_time_ms"),
|
||||
query_end_ms=_get(query_chunks[end], "end_time_ms"),
|
||||
target_start_ms=_get(target_chunks[t_min], "start_time_ms"),
|
||||
target_end_ms=_get(target_chunks[t_max], "end_time_ms"),
|
||||
avg_distance=avg_dist,
|
||||
)
|
||||
)
|
||||
@@ -423,7 +502,9 @@ def find_duplicate_segments(
|
||||
class VideoDeduplicator:
|
||||
"""Video deduplication using multiple fingerprint methods."""
|
||||
|
||||
PHASH_THRESHOLD = 8 # Issue #1658: pHash 汉明距离阈值由 10 收紧到 8,降低不同视频误判率
|
||||
# Issue #1702: 阈值统一来源为模块常量 PHASH_THRESHOLD(#1658 曾收紧到 8,
|
||||
# 后经 staging 真实同源/异源指纹分布重新校准,见 test_phash_threshold_calibration_1702)。
|
||||
PHASH_THRESHOLD = PHASH_THRESHOLD
|
||||
HISTOGRAM_THRESHOLD = 0.85
|
||||
|
||||
@staticmethod
|
||||
@@ -447,27 +528,36 @@ class VideoDeduplicator:
|
||||
# 单帧不视为坏指纹(短视频或抽帧不足)
|
||||
if len(phashes) == 1:
|
||||
return False
|
||||
# 多帧但所有 phash 完全相同 → 黑屏/纯色视频
|
||||
# Issue #1702: 旧逻辑"所有 phash 完全相同即判黑屏"会误杀短视频——
|
||||
# 11s 视频只有几个不同镜头时,相邻 1s 采样帧可能 phash 完全一致(内容
|
||||
# 连续但非黑屏)。黑屏的特征是「大量帧全部无内容」,要求至少 8 帧
|
||||
# 且相同帧占比 >=80% 才判坏;短视频(<8 帧)只有真正单值时交给
|
||||
# _bhattacharyya/融合分兜底,不因"帧都一样"直接跳过。
|
||||
if len(phashes) < 8:
|
||||
return False
|
||||
unique = set(phashes)
|
||||
if len(unique) == 1:
|
||||
same_ratio = sum(1 for x in phashes if x == phashes[0]) / len(phashes)
|
||||
if len(unique) == 1 and same_ratio >= 0.8:
|
||||
return True
|
||||
# 多帧但所有 phash 之间的汉明距离都极小(<3)→ 近似黑屏
|
||||
# 多帧但所有唯一 phash 之间的汉明距离都极小(<3)且占比 >=80% → 近似黑屏
|
||||
phash_list = list(unique)
|
||||
if len(phash_list) >= 2:
|
||||
all_distances = []
|
||||
for i in range(len(phash_list)):
|
||||
for j in range(i + 1, len(phash_list)):
|
||||
all_distances.append(hamming_distance(phash_list[i], phash_list[j]))
|
||||
if len(phash_list) >= 2 and same_ratio >= 0.8:
|
||||
all_distances = [
|
||||
hamming_distance(phash_list[i], phash_list[j])
|
||||
for i in range(len(phash_list))
|
||||
for j in range(i + 1, len(phash_list))
|
||||
]
|
||||
if all_distances and max(all_distances) < 3:
|
||||
return True
|
||||
return False
|
||||
|
||||
def compute_fingerprint(self, video_path: str) -> VideoFingerprint:
|
||||
"""Compute video fingerprint using dynamic keyframe detection.
|
||||
"""Compute video fingerprint using dense uniform sampling.
|
||||
|
||||
使用 detect_keyframe_timestamps() 检测内容感知关键帧,
|
||||
在每个关键帧处取帧计算 pHash + color_histogram。
|
||||
同时保留 MD5 计算和分片数据结构。
|
||||
Issue #1702: 使用 sample_fingerprint_timestamps() 固定 1s 间隔密集均匀
|
||||
采样(替代动态场景检测抽帧),保证两个同源视频复用片段的帧时刻天然
|
||||
对齐;每帧取中心 90% 区域(center_crop_frame)计算 pHash + color_histogram,
|
||||
绕开 random_edge_crop 降重裁剪污染;MD5 仍基于原始帧。
|
||||
"""
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
@@ -481,8 +571,8 @@ class VideoDeduplicator:
|
||||
|
||||
cap.release()
|
||||
|
||||
# 1. 检测关键帧时间戳
|
||||
keyframe_times = detect_keyframe_timestamps(video_path)
|
||||
# 1. 固定间隔密集采样(Issue #1702:替代动态场景检测,保证跨视频时序对齐)
|
||||
keyframe_times = sample_fingerprint_timestamps(duration)
|
||||
|
||||
if not keyframe_times:
|
||||
return VideoFingerprint(
|
||||
@@ -506,12 +596,15 @@ class VideoDeduplicator:
|
||||
if not ret:
|
||||
continue
|
||||
|
||||
# MD5 计算
|
||||
# MD5 计算(基于原始帧,指纹文件级去重不受裁剪影响)
|
||||
_, buffer = cv2.imencode(".jpg", frame)
|
||||
md5_hash.update(buffer)
|
||||
|
||||
phash = compute_phash(frame)
|
||||
hist = compute_color_histogram(frame)
|
||||
# Issue #1702: pHash / 颜色直方图基于中心 90% 区域,绕开 random_edge_crop
|
||||
# 降重裁剪对指纹的污染(降重只服务外部平台,不污染自查重)。
|
||||
fp_frame = center_crop_frame(frame)
|
||||
phash = compute_phash(fp_frame)
|
||||
hist = compute_color_histogram(fp_frame)
|
||||
|
||||
# 计算分片时间范围(从前一个关键帧到下一个关键帧的中点)
|
||||
prev_boundary = keyframe_times[i - 1] * 1000 if i > 0 else 0
|
||||
@@ -564,12 +657,22 @@ class VideoDeduplicator:
|
||||
|
||||
@staticmethod
|
||||
def _bhattacharyya_coefficient(hist_a: list[float], hist_b: list[float]) -> float:
|
||||
"""Bhattacharyya 系数:Σ √(a[i] * b[i]),范围 [0, 1],1=完全相同。"""
|
||||
"""Bhattacharyya 系数(概率分布版,范围 [0,1],1=完全相同)。
|
||||
|
||||
Issue #1702: compute_color_histogram 输出 3 通道拼接、每通道独立 NORM_L1
|
||||
(单通道 Σ=1,三通道拼接向量 Σ=3)。旧实现直接 Σ√(a*b) 对三通道拼接向量
|
||||
算出 ~3(旧 L2 归一化更是算出 ~14.9),不是合法的概率系数。
|
||||
这里按两个直方图各自的总量归一:BC = Σ√(a*b) / √(Σa·Σb)。
|
||||
- 单通道概率分布(Σa=Σb=1):分母 1,与旧测试/教科书定义一致;
|
||||
- 三通道拼接(Σa=Σb=3):分母 3,结果在 [0,1]。
|
||||
"""
|
||||
min_len = min(len(hist_a), len(hist_b))
|
||||
a = hist_a[:min_len]
|
||||
b = hist_b[:min_len]
|
||||
# 纯标准库计算(不依赖 numpy);max(0.0, ...) 防御上游异常负值导致 sqrt domain error
|
||||
return float(sum(math.sqrt(max(0.0, ai * bi)) for ai, bi in zip(a, b, strict=False)))
|
||||
a = [max(0.0, float(x)) for x in hist_a[:min_len]]
|
||||
b = [max(0.0, float(x)) for x in hist_b[:min_len]]
|
||||
# max(0.0, ...) 防御上游异常负值导致 sqrt domain error
|
||||
coeff = sum(math.sqrt(ai * bi) for ai, bi in zip(a, b, strict=False))
|
||||
norm = math.sqrt(sum(a) * sum(b))
|
||||
return float(coeff / norm) if norm > 0 else 0.0
|
||||
|
||||
@staticmethod
|
||||
def _compute_histogram_similarity(
|
||||
@@ -611,6 +714,72 @@ class VideoDeduplicator:
|
||||
hist_similarity = VideoDeduplicator._compute_histogram_similarity(hist_a, hist_b) if hist_b else 0.5
|
||||
return PHASH_WEIGHT * phash_similarity + HISTOGRAM_WEIGHT * hist_similarity
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_candidate(
|
||||
fingerprint: VideoFingerprint,
|
||||
existing_phashes: list[str],
|
||||
existing_histograms: list,
|
||||
existing_chunk_objects: list,
|
||||
*,
|
||||
query_duration_sec: float,
|
||||
) -> dict:
|
||||
"""评估新视频指纹与单个候选视频的相似度(Issue #1702 共享逻辑)。
|
||||
|
||||
指标:
|
||||
- min_distances / frame_match_rate:每个新分片到候选视频全局最近邻的汉明距离,
|
||||
分母取两视频分片数的较小值(支持局部片段复用:短视频复用长视频片段时不被长视频分母稀释)。
|
||||
- temporal_coverage:时序一致连续匹配片段总时长 / 新视频时长(局部复用主指标)。
|
||||
- fusion:pHash 中位数距离 + 颜色直方图的加权融合分。
|
||||
|
||||
Returns:
|
||||
{frame_match_rate, temporal_coverage, segments, median_distance,
|
||||
fusion, matching_frames, min_distances}
|
||||
"""
|
||||
query_phashes = fingerprint.keyframe_phashes or []
|
||||
if not query_phashes or not existing_phashes:
|
||||
return {
|
||||
"frame_match_rate": 0.0,
|
||||
"temporal_coverage": 0.0,
|
||||
"segments": [],
|
||||
"median_distance": 64,
|
||||
"fusion": 0.0,
|
||||
"matching_frames": 0,
|
||||
"min_distances": [],
|
||||
}
|
||||
|
||||
min_distances = [min(hamming_distance(ph, ep) for ep in existing_phashes) for ph in query_phashes]
|
||||
matching_frames = sum(1 for d in min_distances if d <= PHASH_THRESHOLD)
|
||||
# 分母取 min(两视频分片数):局部复用时(如 B 的 5 片复用 A 9 片中的若干片)
|
||||
# 命中帧占比不因候选视频更长而被稀释。
|
||||
frame_match_rate = matching_frames / min(len(query_phashes), len(existing_phashes))
|
||||
|
||||
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
|
||||
duration_ms = query_duration_sec * 1000 if query_duration_sec else 0
|
||||
if duration_ms > 0 and segments:
|
||||
covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments)
|
||||
temporal_coverage = min(covered_ms / duration_ms, 1.0)
|
||||
elif matching_frames > 0:
|
||||
# 无连续片段(时序连贯性不足)时,按匹配帧占比估计覆盖:
|
||||
# 密集 1s 采样下每个分片≈1s 等权时间片,匹配帧数≈命中秒数。
|
||||
temporal_coverage = min(frame_match_rate, 1.0)
|
||||
else:
|
||||
temporal_coverage = 0.0
|
||||
|
||||
median_distance = statistics.median(min_distances) if min_distances else 64
|
||||
fusion = VideoDeduplicator._compute_fusion_score(
|
||||
median_distance, fingerprint.color_histograms, existing_histograms
|
||||
)
|
||||
|
||||
return {
|
||||
"frame_match_rate": frame_match_rate,
|
||||
"temporal_coverage": temporal_coverage,
|
||||
"segments": segments,
|
||||
"median_distance": median_distance,
|
||||
"fusion": fusion,
|
||||
"matching_frames": matching_frames,
|
||||
"min_distances": min_distances,
|
||||
}
|
||||
|
||||
def check_duplicate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
@@ -620,6 +789,7 @@ class VideoDeduplicator:
|
||||
scope: str = "project",
|
||||
user_id: str = "",
|
||||
duration_sec: float = 0,
|
||||
exclude_video_id: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""检查视频是否与已有视频重复。
|
||||
|
||||
@@ -636,6 +806,9 @@ class VideoDeduplicator:
|
||||
scope: "project" 项目内查重(默认),"user" 跨项目全局查重
|
||||
user_id: 用户 ID(scope="user" 时使用)
|
||||
duration_sec: 视频时长(秒),用于时长预过滤 ±15%
|
||||
exclude_video_id: 排除的视频 ID(查重自身时用)。recompute-dedup
|
||||
重算时视频记录已存在,不排除会自匹配(距离 0 分最高)导致
|
||||
duplicate_of 指向自己(Issue #1702 连带修复)。
|
||||
|
||||
Returns:
|
||||
重复信息字典(含 duplicate, duplicate_of, reason, similarity, duplicate_segments),
|
||||
@@ -643,13 +816,22 @@ class VideoDeduplicator:
|
||||
"""
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
if scope == "user" and user_id:
|
||||
dur_min = duration_sec * 0.85 if duration_sec > 0 else 0
|
||||
dur_max = duration_sec * 1.15 if duration_sec > 0 else 0
|
||||
existing_videos = video_repo.list_by_user(user_id, duration_min=dur_min, duration_max=dur_max)
|
||||
# Issue #1702: 不做 ±15% 时长预过滤。旧逻辑按 duration_sec 缩小候选窗口,
|
||||
# 但局部片段复用的两个视频时长必然不同(证据视频 20s vs 11s,差 42%),
|
||||
# ±15% 窗口让同源视频互相不可见 → is_duplicate 恒 False。
|
||||
# 全量遍历同用户视频(与 compute_duplicate_rate 口径一致),异源视频由
|
||||
# fusion/temporal_coverage 阈值天然过滤(校准:异源最小汉明距离 24)。
|
||||
existing_videos = video_repo.list_by_user(user_id)
|
||||
else:
|
||||
existing_videos = video_repo.list_by_project(project_id)
|
||||
|
||||
best_score = 0.0
|
||||
best_result: Optional[dict] = None
|
||||
|
||||
for existing in existing_videos:
|
||||
# 排除自身(recompute 时当前视频已在候选列表里,否则自匹配距离 0 必最高分)
|
||||
if exclude_video_id and existing.id == exclude_video_id:
|
||||
continue
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
@@ -677,61 +859,70 @@ class VideoDeduplicator:
|
||||
if not existing_phashes:
|
||||
continue
|
||||
|
||||
# 计算每个新关键帧到已有关键帧的最小汉明距离
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
|
||||
# 帧匹配比例检查
|
||||
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
|
||||
match_ratio = matching_frames / len(min_distances) if min_distances else 0
|
||||
if match_ratio < MATCH_RATIO_THRESHOLD:
|
||||
continue
|
||||
|
||||
# 中位数距离
|
||||
median_distance = statistics.median(min_distances) if min_distances else 64
|
||||
if median_distance >= self.PHASH_THRESHOLD:
|
||||
continue
|
||||
|
||||
# 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
|
||||
# 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
|
||||
if chunk_data:
|
||||
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
|
||||
existing_chunk_objects = chunk_data
|
||||
else:
|
||||
existing_histograms = ef.get("color_histograms") or []
|
||||
existing_chunk_objects = [
|
||||
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
|
||||
]
|
||||
|
||||
combined_score = self._compute_fusion_score(
|
||||
median_distance, fingerprint.color_histograms, existing_histograms
|
||||
# Issue #1702: 统一评估每个候选(含局部片段复用),不再用
|
||||
# "frame_match_rate<0.7 整条跳过" 的硬门槛——局部复用(如 B 结尾 2s
|
||||
# ≈ A 中间 2s)帧比例天然低,但 coverage 能检出。
|
||||
ev = self._evaluate_candidate(
|
||||
fingerprint,
|
||||
existing_phashes,
|
||||
existing_histograms,
|
||||
existing_chunk_objects,
|
||||
query_duration_sec=fingerprint.duration,
|
||||
)
|
||||
logger.debug(
|
||||
"check_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f "
|
||||
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
|
||||
existing.id,
|
||||
ev["min_distances"],
|
||||
ev["frame_match_rate"],
|
||||
ev["temporal_coverage"],
|
||||
ev["median_distance"],
|
||||
ev["fusion"],
|
||||
len(ev["segments"]),
|
||||
)
|
||||
|
||||
if combined_score < DUPLICATE_THRESHOLD:
|
||||
continue
|
||||
|
||||
# 滑动窗口时序匹配:获取具体重复片段
|
||||
existing_chunk_objects = (
|
||||
chunk_data
|
||||
if chunk_data
|
||||
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
|
||||
# 全片重复判定:融合分过阈 且(帧匹配比例 >=70% 或 局部覆盖 >=50%)
|
||||
is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and (
|
||||
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
|
||||
)
|
||||
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
|
||||
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "phash_histogram_fusion",
|
||||
"similarity": combined_score,
|
||||
"duplicate_segments": [
|
||||
{
|
||||
"query_start_ms": s.query_start_ms,
|
||||
"query_end_ms": s.query_end_ms,
|
||||
"target_start_ms": s.target_start_ms,
|
||||
"target_end_ms": s.target_end_ms,
|
||||
"avg_distance": round(s.avg_distance, 2),
|
||||
}
|
||||
for s in segments
|
||||
],
|
||||
}
|
||||
if is_full_duplicate and ev["fusion"] > best_score:
|
||||
best_score = ev["fusion"]
|
||||
best_result = {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "phash_histogram_fusion",
|
||||
"similarity": ev["fusion"],
|
||||
"duplicate_segments": [
|
||||
{
|
||||
"query_start_ms": s.query_start_ms,
|
||||
"query_end_ms": s.query_end_ms,
|
||||
"target_start_ms": s.target_start_ms,
|
||||
"target_end_ms": s.target_end_ms,
|
||||
"avg_distance": round(s.avg_distance, 2),
|
||||
}
|
||||
for s in ev["segments"]
|
||||
],
|
||||
}
|
||||
|
||||
if best_result:
|
||||
return best_result
|
||||
logger.info(
|
||||
"check_duplicate no match (project=%s scope=%s): %d candidates evaluated, best_fusion=%.3f",
|
||||
project_id,
|
||||
scope,
|
||||
len(existing_videos),
|
||||
best_score,
|
||||
)
|
||||
return None
|
||||
|
||||
def check_batch_duplicate(
|
||||
@@ -763,6 +954,9 @@ class VideoDeduplicator:
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
batch_videos = video_repo.list_by_batch(batch_id)
|
||||
|
||||
best_score = 0.0
|
||||
best_result: Optional[dict] = None
|
||||
|
||||
for existing in batch_videos:
|
||||
if existing.id == current_video_id:
|
||||
continue
|
||||
@@ -796,59 +990,59 @@ class VideoDeduplicator:
|
||||
if not existing_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
|
||||
# 帧匹配比例检查
|
||||
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
|
||||
match_ratio = matching_frames / len(min_distances) if min_distances else 0
|
||||
if match_ratio < MATCH_RATIO_THRESHOLD:
|
||||
continue
|
||||
|
||||
median_distance = statistics.median(min_distances) if min_distances else 64
|
||||
if median_distance >= self.PHASH_THRESHOLD:
|
||||
continue
|
||||
|
||||
# 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
|
||||
if chunk_data:
|
||||
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
|
||||
existing_chunk_objects = chunk_data
|
||||
else:
|
||||
existing_histograms = ef.get("color_histograms") or []
|
||||
existing_chunk_objects = [
|
||||
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
|
||||
]
|
||||
|
||||
combined_score = self._compute_fusion_score(
|
||||
median_distance, fingerprint.color_histograms, existing_histograms
|
||||
ev = self._evaluate_candidate(
|
||||
fingerprint,
|
||||
existing_phashes,
|
||||
existing_histograms,
|
||||
existing_chunk_objects,
|
||||
query_duration_sec=fingerprint.duration,
|
||||
)
|
||||
logger.debug(
|
||||
"check_batch_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f "
|
||||
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
|
||||
existing.id,
|
||||
ev["min_distances"],
|
||||
ev["frame_match_rate"],
|
||||
ev["temporal_coverage"],
|
||||
ev["median_distance"],
|
||||
ev["fusion"],
|
||||
len(ev["segments"]),
|
||||
)
|
||||
|
||||
if combined_score < DUPLICATE_THRESHOLD:
|
||||
continue
|
||||
|
||||
# 滑动窗口时序匹配
|
||||
existing_chunk_objects = (
|
||||
chunk_data
|
||||
if chunk_data
|
||||
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
|
||||
is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and (
|
||||
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
|
||||
)
|
||||
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
|
||||
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "batch_phash_histogram_fusion",
|
||||
"similarity": combined_score,
|
||||
"duplicate_segments": [
|
||||
{
|
||||
"query_start_ms": s.query_start_ms,
|
||||
"query_end_ms": s.query_end_ms,
|
||||
"target_start_ms": s.target_start_ms,
|
||||
"target_end_ms": s.target_end_ms,
|
||||
"avg_distance": round(s.avg_distance, 2),
|
||||
}
|
||||
for s in segments
|
||||
],
|
||||
}
|
||||
if is_full_duplicate and ev["fusion"] > best_score:
|
||||
best_score = ev["fusion"]
|
||||
best_result = {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "batch_phash_histogram_fusion",
|
||||
"similarity": ev["fusion"],
|
||||
"duplicate_segments": [
|
||||
{
|
||||
"query_start_ms": s.query_start_ms,
|
||||
"query_end_ms": s.query_end_ms,
|
||||
"target_start_ms": s.target_start_ms,
|
||||
"target_end_ms": s.target_end_ms,
|
||||
"avg_distance": round(s.avg_distance, 2),
|
||||
}
|
||||
for s in ev["segments"]
|
||||
],
|
||||
}
|
||||
|
||||
if best_result:
|
||||
return best_result
|
||||
logger.info("check_batch_duplicate no match (batch=%s): best_fusion=%.3f", batch_id, best_score)
|
||||
return None
|
||||
|
||||
def compute_duplicate_rate(
|
||||
@@ -897,8 +1091,7 @@ class VideoDeduplicator:
|
||||
max_duplicate_rate = 0.0
|
||||
max_visual_similarity = 0.0
|
||||
match_count = 0
|
||||
|
||||
total_duration_ms = fingerprint.duration if fingerprint.duration else 0
|
||||
evaluated = 0
|
||||
|
||||
for existing in existing_videos:
|
||||
if current_video_id and existing.id == current_video_id:
|
||||
@@ -933,57 +1126,63 @@ class VideoDeduplicator:
|
||||
if not existing_phashes or not fingerprint.keyframe_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
|
||||
# frame_match_rate
|
||||
total_frames = len(min_distances)
|
||||
if total_frames == 0:
|
||||
continue
|
||||
matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD)
|
||||
frame_match_rate = matching_frames / total_frames
|
||||
|
||||
# 帧匹配比例太低则跳过
|
||||
if frame_match_rate < 0.3:
|
||||
continue
|
||||
|
||||
# temporal_coverage_rate via find_duplicate_segments
|
||||
existing_chunk_objects = (
|
||||
chunk_data
|
||||
if chunk_data
|
||||
else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes]
|
||||
)
|
||||
segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects)
|
||||
|
||||
if total_duration_ms > 0 and segments:
|
||||
covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments)
|
||||
temporal_coverage_rate = min(covered_ms / total_duration_ms, 1.0)
|
||||
else:
|
||||
temporal_coverage_rate = 0.0
|
||||
|
||||
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage_rate
|
||||
dup_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
|
||||
|
||||
# visual_similarity (融合相似度,归一化 0~1)
|
||||
median_distance = statistics.median(min_distances) if min_distances else 64
|
||||
# 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表)
|
||||
if chunk_data:
|
||||
existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")]
|
||||
existing_chunk_objects = chunk_data
|
||||
else:
|
||||
# JSON NULL 显式回退空列表
|
||||
existing_histograms = ef.get("color_histograms") or []
|
||||
existing_chunk_objects = [
|
||||
{"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes
|
||||
]
|
||||
|
||||
visual_sim = self._compute_fusion_score(median_distance, fingerprint.color_histograms, existing_histograms)
|
||||
# Issue #1702: 统一评估;frame_match_rate 分母为 min(两视频分片数),
|
||||
# temporal_coverage 时长量纲在 _evaluate_candidate 内统一为毫秒。
|
||||
ev = self._evaluate_candidate(
|
||||
fingerprint,
|
||||
existing_phashes,
|
||||
existing_histograms,
|
||||
existing_chunk_objects,
|
||||
query_duration_sec=fingerprint.duration,
|
||||
)
|
||||
evaluated += 1
|
||||
logger.debug(
|
||||
"compute_duplicate_rate candidate=%s min_distances=%s frame_match_rate=%.3f "
|
||||
"temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d",
|
||||
existing.id,
|
||||
ev["min_distances"],
|
||||
ev["frame_match_rate"],
|
||||
ev["temporal_coverage"],
|
||||
ev["median_distance"],
|
||||
ev["fusion"],
|
||||
len(ev["segments"]),
|
||||
)
|
||||
|
||||
# 判定是否为重复(融合分数超过阈值)
|
||||
if visual_sim >= DUPLICATE_THRESHOLD:
|
||||
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
|
||||
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
|
||||
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
|
||||
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
|
||||
|
||||
# 全片重复计数与 check_duplicate 判定口径一致
|
||||
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
|
||||
ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD
|
||||
):
|
||||
match_count += 1
|
||||
|
||||
if dup_rate > max_duplicate_rate:
|
||||
max_duplicate_rate = dup_rate
|
||||
max_visual_similarity = visual_sim
|
||||
max_visual_similarity = ev["fusion"]
|
||||
|
||||
logger.info(
|
||||
"compute_duplicate_rate done (project=%s scope=%s): evaluated=%d max_rate=%.2f%% "
|
||||
"max_visual_sim=%.3f matches=%d",
|
||||
project_id,
|
||||
scope,
|
||||
evaluated,
|
||||
max_duplicate_rate,
|
||||
max_visual_similarity,
|
||||
match_count,
|
||||
)
|
||||
return {
|
||||
"duplicate_rate": round(max(max_duplicate_rate, 0.0), 2),
|
||||
"visual_similarity": round(max_visual_similarity, 4),
|
||||
@@ -999,18 +1198,20 @@ def _save_fingerprint_chunks(
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""将指纹分片数据批量写入 video_fingerprint_chunks 表。幂等:已有数据时跳过。"""
|
||||
# 幂等检查:已有分片数据则跳过
|
||||
existing_count = (
|
||||
session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count()
|
||||
)
|
||||
if existing_count > 0:
|
||||
logger.debug("Fingerprint chunks already exist for video %s (%d chunks), skipping", video_id, existing_count)
|
||||
return
|
||||
|
||||
if not fingerprint.chunks:
|
||||
logger.warning("No chunks in fingerprint for video %s, skipping chunk save", video_id)
|
||||
return
|
||||
|
||||
# Issue #1702: recompute-dedup 重算时指纹算法已变(中心裁剪 + 新阈值),
|
||||
# 旧分片必须替换而非跳过(旧实现"有数据就跳过"导致重算不刷新分片表)。
|
||||
deleted = (
|
||||
session.query(VideoFingerprintChunkModel)
|
||||
.filter(VideoFingerprintChunkModel.video_id == video_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
if deleted:
|
||||
logger.info("Replaced %d stale fingerprint chunks for video %s", deleted, video_id)
|
||||
|
||||
chunk_models = fingerprint.to_chunk_models(video_id, project_id, user_id)
|
||||
session.bulk_save_objects(chunk_models)
|
||||
logger.info("Saved %d fingerprint chunks for video %s", len(chunk_models), video_id)
|
||||
@@ -1032,9 +1233,17 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
raise ValueError(f"Generated video {generated_video_id} not found")
|
||||
|
||||
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
|
||||
storage_service.download_file(
|
||||
f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path
|
||||
)
|
||||
# Issue #1702: recompute 走的是 OSS 重新下载路径(正常生成流程用本地渲染文件,
|
||||
# 不经此任务)。成片真实 OSS key 是生成时的
|
||||
# generated/projects/{pid}/tasks/{task_id}/rendered_*.mp4(见 generation.py
|
||||
# _upload_and_record),旧代码硬编码 projects/{pid}/generated/{vid}/{vid}.mp4
|
||||
# 这个从不存在的 key,导致所有 recompute 任务下载 404、查重数据永远无法重算。
|
||||
# 优先从 file_url 解析真实 key,旧 key 模式仅作回退。
|
||||
download_key = getattr(video, "file_url", "") or ""
|
||||
if not download_key:
|
||||
download_key = f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4"
|
||||
logger.warning("video %s has no file_url, falling back to legacy key %s", generated_video_id, download_key)
|
||||
storage_service.download_file(download_key, local_path)
|
||||
|
||||
fingerprint = deduplicator.compute_fingerprint(local_path)
|
||||
|
||||
@@ -1045,7 +1254,10 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
session,
|
||||
scope="user",
|
||||
user_id=video.user_id,
|
||||
duration_sec=fingerprint.duration / 1000 if fingerprint.duration else 0,
|
||||
# Issue #1702: fingerprint.duration 单位已经是秒,旧代码 /1000 导致
|
||||
# ±15% 时长预过滤窗口缩到 ~0.013s,scope=user 的跨项目查重永远返回 None。
|
||||
duration_sec=fingerprint.duration if fingerprint.duration else 0,
|
||||
exclude_video_id=generated_video_id,
|
||||
)
|
||||
|
||||
video.video_fingerprint = fingerprint.to_dict()
|
||||
|
||||
@@ -92,7 +92,8 @@ def create_video_record_and_dedup(
|
||||
logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err)
|
||||
|
||||
# (a) 历史成片查重(跨项目全局 + 时长预过滤)
|
||||
duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0
|
||||
# Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效
|
||||
duration_sec = fingerprint.duration if fingerprint.duration else 0
|
||||
duplicate_result = deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
@@ -100,6 +101,7 @@ def create_video_record_and_dedup(
|
||||
scope="user",
|
||||
user_id=user_id,
|
||||
duration_sec=duration_sec,
|
||||
exclude_video_id=video_id,
|
||||
)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
|
||||
@@ -22,10 +22,18 @@ celery_app.conf.imports = (
|
||||
)
|
||||
|
||||
# Celery Beat 定时任务调度
|
||||
# 注:worker 单实例内嵌 beat(entrypoint-worker.sh -B),定时任务不会重复执行
|
||||
celery_app.conf.beat_schedule = {
|
||||
# pending 任务超时清理:worker 停止消费后,卡 pending 的任务 15 分钟内释放限流名额
|
||||
"cleanup-stale-pending-tasks": {
|
||||
"task": "worker.cleanup_stale_pending_tasks",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 300}, # 5 分钟过期,避免堆积
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240}, # 4 分钟过期,避免堆积
|
||||
},
|
||||
# running 孤儿任务巡检:容器重启/进程被杀后卡 running 的任务,20 分钟无更新则判失败
|
||||
"cleanup-stale-running-tasks": {
|
||||
"task": "worker.cleanup_stale_running_tasks",
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,11 +7,34 @@ from worker_app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 孤儿任务超时阈值:渲染任务超过此时间未更新则视为卡死
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 10
|
||||
|
||||
# Pending 任务超时阈值:pending 任务在队列中等待超过此时间则自动清理
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 30
|
||||
def cleanup_stale_running_with_session(repo, timeout_minutes: int) -> int:
|
||||
"""清理超时未更新的 running GenerationTask(可注入 repo 的纯核心,便于单测)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
return repo.cleanup_stale_running(timeout_minutes)
|
||||
|
||||
|
||||
def cleanup_stale_pending_with_session(repo, timeout_minutes: int) -> int:
|
||||
"""清理超时 pending GenerationTask(可注入 repo 的纯核心,便于单测)。
|
||||
|
||||
Returns:
|
||||
清理的任务数量
|
||||
"""
|
||||
return repo.cleanup_stale_pending(timeout_minutes)
|
||||
|
||||
|
||||
# 孤儿任务超时阈值:running 任务超过此时间无进度更新则视为卡死。
|
||||
# 依据:worker.generate_video 硬超时 time_limit=11 分钟,正常任务不可能超过;
|
||||
# 20 分钟阈值覆盖硬超时 + 重试 + 余量,绝不误杀正常任务。
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES = 20
|
||||
|
||||
# Pending 任务超时阈值:任务创建后超过此时间仍未被 worker 拉取,
|
||||
# 说明 worker 已停止消费(容器异常/卡死),清掉释放限流名额。
|
||||
# 依据:满队列(20 pending)× 平均 2 分钟 / 并发 4 ≈ 10 分钟,15 分钟留余量。
|
||||
PENDING_TASK_TIMEOUT_MINUTES = 15
|
||||
|
||||
|
||||
def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> int: # pragma: no cover
|
||||
@@ -32,9 +55,11 @@ def cleanup_orphan_tasks(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) ->
|
||||
|
||||
try:
|
||||
session = SessionLocal()
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = repo.cleanup_stale_running(timeout_minutes)
|
||||
session.close()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = cleanup_stale_running_with_session(repo, timeout_minutes)
|
||||
finally:
|
||||
session.close()
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的孤儿 GenerationTask(超过 %d 分钟未更新)", count, timeout_minutes)
|
||||
else:
|
||||
@@ -105,7 +130,7 @@ def cleanup_stale_pending_tasks(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINU
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGenerationTaskRepository(session)
|
||||
count = repo.cleanup_stale_pending(timeout_minutes)
|
||||
count = cleanup_stale_pending_with_session(repo, timeout_minutes)
|
||||
if count > 0:
|
||||
logger.warning("清理了 %d 个超时的 pending GenerationTask(超过 %d 分钟未处理)", count, timeout_minutes)
|
||||
else:
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"""定期清理任务 — Celery Beat 调度。
|
||||
|
||||
包含:
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks
|
||||
- cleanup_stale_pending_tasks: 定期清理卡在 pending 超时的 generation_tasks(worker 停止消费时占位)
|
||||
- cleanup_stale_running_tasks: 定期清理卡在 running 超时的 generation_tasks(容器重启/进程被杀后的孤儿)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from worker_app.tasks._startup import (
|
||||
ORPHAN_TASK_TIMEOUT_MINUTES,
|
||||
PENDING_TASK_TIMEOUT_MINUTES,
|
||||
cleanup_orphan_tasks,
|
||||
cleanup_stale_jobs,
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
@@ -19,12 +23,12 @@ logger = logging.getLogger(__name__)
|
||||
def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 pending 任务。
|
||||
|
||||
每 10 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
每 5 分钟执行一次(由 celery_app.py 的 beat_schedule 配置),
|
||||
查找所有 status='pending' 且 created_at < NOW() - timeout_minutes
|
||||
的 generation_tasks,批量更新为 failed。
|
||||
的 generation_tasks,批量更新为 failed,释放限流名额。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 30 分钟
|
||||
timeout_minutes: 超时时间(分钟),默认 15 分钟
|
||||
|
||||
Returns:
|
||||
{"cleaned": int}
|
||||
@@ -33,3 +37,33 @@ def scheduled_cleanup_stale_pending(timeout_minutes: int = PENDING_TASK_TIMEOUT_
|
||||
if count > 0:
|
||||
logger.info("[Beat] 清理了 %d 个超时 pending 任务(超时阈值 %d 分钟)", count, timeout_minutes)
|
||||
return {"cleaned": count}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_running_tasks")
|
||||
def scheduled_cleanup_stale_running(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_MINUTES) -> dict:
|
||||
"""Celery Beat 调度的定期任务:清理超时的 running 孤儿任务。
|
||||
|
||||
每 5 分钟执行一次。worker_ready 信号只在 worker 启动时清一次,
|
||||
若 worker 没重启但任务卡死(上传挂起、进程 OOM 被内核杀掉等),
|
||||
任务会永久卡在 running 占位。此任务做持续兜底:
|
||||
查找 status='running' 且 updated_at < NOW() - timeout_minutes 的任务,
|
||||
标记为 failed(原因:容器重启/超时中断),同时清理 Job 表孤儿。
|
||||
|
||||
Args:
|
||||
timeout_minutes: 超时时间(分钟),默认 20 分钟
|
||||
(worker.generate_video 硬超时 11 分钟,正常任务不可能超过 20 分钟)
|
||||
|
||||
Returns:
|
||||
{"generation_tasks": int, "jobs": int}
|
||||
"""
|
||||
gen_count = cleanup_orphan_tasks(timeout_minutes)
|
||||
job_count = cleanup_stale_jobs(timeout_minutes)
|
||||
total = gen_count + job_count
|
||||
if total > 0:
|
||||
logger.warning(
|
||||
"[Beat] 清理孤儿任务: running GenerationTask=%d, Job=%d(超时阈值 %d 分钟)",
|
||||
gen_count,
|
||||
job_count,
|
||||
timeout_minutes,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
|
||||
@@ -138,6 +138,52 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
.count()
|
||||
)
|
||||
|
||||
def count_running_by_user(self, user_id: str) -> int:
|
||||
"""统计指定用户处于 running 状态的任务数(用于限流提示展示)。"""
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(
|
||||
GenerationTaskModel.created_by_user_id == user_id,
|
||||
GenerationTaskModel.status == GenerationTaskStatus.RUNNING.value,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def count_running_total(self) -> int:
|
||||
"""统计全局处于 running 状态的任务数(worker 实际在执行的任务数)。"""
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.status == GenerationTaskStatus.RUNNING.value)
|
||||
.count()
|
||||
)
|
||||
|
||||
def estimate_avg_duration_seconds(self, limit: int = 20, default_seconds: float = 120.0) -> float:
|
||||
"""估算最近完成任务的平均耗时(秒),用于 429 限流提示的等待预估。
|
||||
|
||||
取最近 N 条 completed 任务的 (completed_at - started_at) 平均值;
|
||||
无足够历史数据时返回 default_seconds。
|
||||
用 Python 侧计算差值,避免 SQLite/PostgreSQL 方言差异。
|
||||
"""
|
||||
rows = (
|
||||
self.session.query(GenerationTaskModel.started_at, GenerationTaskModel.completed_at)
|
||||
.filter(
|
||||
GenerationTaskModel.status == GenerationTaskStatus.COMPLETED.value,
|
||||
GenerationTaskModel.started_at.isnot(None),
|
||||
GenerationTaskModel.completed_at.isnot(None),
|
||||
)
|
||||
.order_by(GenerationTaskModel.completed_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
durations = [
|
||||
(completed - started).total_seconds()
|
||||
for started, completed in rows
|
||||
if completed and started and (completed - started).total_seconds() > 0
|
||||
]
|
||||
if not durations:
|
||||
return default_seconds
|
||||
return sum(durations) / len(durations)
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
models = (
|
||||
self.session.query(GenerationTaskModel)
|
||||
|
||||
@@ -20,6 +20,12 @@ class GenerationTaskRepository(Protocol):
|
||||
|
||||
def count_pending_total(self) -> int: ...
|
||||
|
||||
def count_running_by_user(self, user_id: str) -> int: ...
|
||||
|
||||
def count_running_total(self) -> int: ...
|
||||
|
||||
def estimate_avg_duration_seconds(self, limit: int = 20, default_seconds: float = 120.0) -> float: ...
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ...
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ...
|
||||
|
||||
@@ -85,17 +85,18 @@ class TestIsBadFingerprint:
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["abcdef0123456789"]) is False
|
||||
|
||||
def test_all_identical_phashes_is_bad(self):
|
||||
"""多帧但所有 phash 完全相同 → 黑屏/纯色视频。"""
|
||||
phashes = ["aaaaaaaaaaaaaaaa"] * 5
|
||||
""">=8 帧且所有 phash 完全相同 → 黑屏/纯色视频(#1702:短帧不误杀)。"""
|
||||
phashes = ["aaaaaaaaaaaaaaaa"] * 10
|
||||
assert VideoDeduplicator._is_bad_fingerprint(phashes) is True
|
||||
|
||||
def test_two_identical_phashes_is_bad(self):
|
||||
"""两帧完全相同也视为坏指纹。"""
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is True
|
||||
def test_short_identical_phashes_not_bad(self):
|
||||
"""<8 帧完全相同不判坏——短视频内容连续时相邻采样帧 phash 天然相同(#1702)。"""
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb"] * 5) is False
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is False
|
||||
|
||||
def test_all_very_similar_phashes_is_bad(self):
|
||||
"""多帧 phash 之间的汉明距离都 < 3 → 近似黑屏。"""
|
||||
phashes = ["0000000000000000", "0000000000000001", "0000000000000002"]
|
||||
""">=8 帧 phash 之间的汉明距离都 < 3 且高占比 → 近似黑屏。"""
|
||||
phashes = ["0000000000000000"] * 8 + ["0000000000000001", "0000000000000002"]
|
||||
assert VideoDeduplicator._is_bad_fingerprint(phashes) is True
|
||||
|
||||
def test_diverse_phashes_is_good(self):
|
||||
@@ -122,7 +123,9 @@ class TestIsBadFingerprint:
|
||||
"""已知黑屏视频的 phash 特征(全零或均匀分布)。"""
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["0000000000000000"] * 10) is True
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["ffffffffffffffff"] * 8) is True
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 6) is True
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 8) is True
|
||||
# <8 帧不判坏(#1702 短视频保护)
|
||||
assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 5) is False
|
||||
|
||||
|
||||
# ── Helper ──────────────────────────────────────────────────────
|
||||
@@ -151,13 +154,13 @@ class TestCheckDuplicateBadFingerprint:
|
||||
deduplicator = VideoDeduplicator()
|
||||
mock_session = MagicMock()
|
||||
|
||||
black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 5)
|
||||
black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 10)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_user.return_value = [black_screen]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="md5_normal",
|
||||
keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 5,
|
||||
keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 10,
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
@@ -206,7 +209,7 @@ class TestCheckDuplicateBadFingerprint:
|
||||
deduplicator = VideoDeduplicator()
|
||||
mock_session = MagicMock()
|
||||
|
||||
black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 5)
|
||||
black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 10)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_user.return_value = [black_screen]
|
||||
|
||||
@@ -283,8 +286,8 @@ class TestComputeDuplicateRateBadFingerprint:
|
||||
mock_session = MagicMock()
|
||||
|
||||
videos = [
|
||||
_make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 5),
|
||||
_make_existing_video("vid-b2", "md5_b2", ["bbbbbbbbbbbbbbbb"] * 5),
|
||||
_make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 10),
|
||||
_make_existing_video("vid-b2", "md5_b2", ["cccccccccccccccc"] * 5), # hamming(a,c)=32 > PHASH_THRESHOLD
|
||||
]
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_user.return_value = videos
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Issue #1702 — 查重率恒为 0% 修复:单测.
|
||||
|
||||
覆盖验收要求:
|
||||
1. 同源不同裁剪的两个视频能检出非 0 相似度(指纹中心裁剪绕开降重 + 阈值校准)
|
||||
2. 局部片段复用(B 结尾 2s ≈ A 中间 2s)能检出
|
||||
3. 异源视频不误报(相似度接近 0)
|
||||
4. N=1 现有流程不回归
|
||||
5. P1 确定性 bug:时长预过滤单位 /1000、直方图归一化、temporal_coverage 量纲、阈值比较统一
|
||||
6. P0:±1 邻接对齐、短视频自适应连续门槛
|
||||
7. P2:0 匹配也要落日志
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.modules.setdefault("cv2", MagicMock())
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "worker"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
PHASH_THRESHOLD,
|
||||
SEGMENT_MATCH_THRESHOLD,
|
||||
FingerprintChunk,
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
find_duplicate_segments,
|
||||
)
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _h(d: int) -> str:
|
||||
"""64-bit phash with exactly d bits set vs zero hash."""
|
||||
bits = ["0"] * 64
|
||||
for i in range(d):
|
||||
bits[i] = "1"
|
||||
return f"{int(''.join(bits), 2):016x}"
|
||||
|
||||
|
||||
def _chunk(phash: str, t0: float, t1: float):
|
||||
|
||||
return FingerprintChunk(
|
||||
start_time_ms=int(t0 * 1000),
|
||||
end_time_ms=int(t1 * 1000),
|
||||
phash_binary=phash,
|
||||
color_histogram=[],
|
||||
frame_count=1,
|
||||
)
|
||||
|
||||
|
||||
def _fingerprint(phashes, duration, chunks=None, md5="fp-md5-x"):
|
||||
|
||||
return VideoFingerprint(
|
||||
md5=md5,
|
||||
keyframe_phashes=list(phashes),
|
||||
color_histograms=[],
|
||||
duration=duration,
|
||||
resolution=(1280, 720),
|
||||
chunks=chunks or [],
|
||||
)
|
||||
|
||||
|
||||
def _video(vid, phashes, duration=10.0, project_id="proj1"):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=vid,
|
||||
project_id=project_id,
|
||||
generation_task_id=f"task-{vid}",
|
||||
name=f"video-{vid}.mp4",
|
||||
file_url=f"https://example.com/{vid}.mp4",
|
||||
file_size=1000,
|
||||
duration=duration,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
video_fingerprint={"md5": f"md5-{vid}", "keyframe_phashes": list(phashes)},
|
||||
)
|
||||
|
||||
|
||||
def _rate(deduplicator, fp, videos, session=None):
|
||||
session_magic = MagicMock()
|
||||
# 分片表无数据 -> 回退 JSON keyframe_phashes
|
||||
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
repo = MockRepo.return_value
|
||||
repo.list_by_project.return_value = videos
|
||||
repo.list_by_user.return_value = videos
|
||||
return deduplicator.compute_duplicate_rate(fp, "proj1", "new-vid", session_magic, scope="project")
|
||||
|
||||
|
||||
def _check(deduplicator, fp, videos, scope="project", **kw):
|
||||
session_magic = MagicMock()
|
||||
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
repo = MockRepo.return_value
|
||||
repo.list_by_project.return_value = videos
|
||||
repo.list_by_user.return_value = videos
|
||||
return deduplicator.check_duplicate(fp, "proj1", session_magic, scope=scope, **kw)
|
||||
|
||||
|
||||
# ── P0-1/P0-2: 同源不同裁剪(距离 6~10)检出非 0 ──────────────
|
||||
|
||||
|
||||
class TestSameSourceDifferentCrop:
|
||||
"""同源成片:random_edge_crop 后 pHash 距离 6~10,应检出非 0 相似度。"""
|
||||
|
||||
def test_same_source_high_similarity_detected(self):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
# 新视频 5 个分片,每个 phash 与已有视频对应分片距离 6(< 阈值)
|
||||
base = [_h(0) for _ in range(5)]
|
||||
new = [_h(6) for _ in range(5)]
|
||||
existing = _video("v-old", base, duration=11.0)
|
||||
chunks = [_chunk(h, i * 2.2, (i + 1) * 2.2) for i, h in enumerate(new)]
|
||||
fp = _fingerprint(new, 11.0, chunks=chunks)
|
||||
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
assert result["duplicate_rate"] > 0
|
||||
assert result["visual_similarity"] > 0
|
||||
|
||||
def test_same_source_distance_at_threshold_still_detected(self):
|
||||
"""距离正好等于阈值(<=)也要算匹配——阈值比较统一为 <=。"""
|
||||
|
||||
assert PHASH_THRESHOLD <= 16, "阈值应经真实数据校准保持在能检出同源裁剪/降重对的范围(#1702 二次校准为 16)"
|
||||
ddp = VideoDeduplicator()
|
||||
base = [_h(0) for _ in range(6)]
|
||||
new = [_h(PHASH_THRESHOLD) for _ in range(6)]
|
||||
existing = _video("v-old", base, duration=12.0)
|
||||
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
|
||||
fp = _fingerprint(new, 12.0, chunks=chunks)
|
||||
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
assert result["duplicate_rate"] > 0
|
||||
|
||||
|
||||
# ── P0-2: 局部片段复用(B 结尾 2s ≈ A 中间 2s) ────────────────
|
||||
|
||||
|
||||
class TestPartialReuse:
|
||||
def test_partial_reuse_tail_overlap_detected(self):
|
||||
"""新视频 6 片,最后 2 片命中已有视频中间 2 片(距离 4),其余不匹配。
|
||||
|
||||
旧逻辑 frame_match_rate=2/6≈0.33(<0.3 硬跳过边界)+ MIN_CONSECUTIVE=5
|
||||
导致完全检不出;新逻辑 coverage 为主指标 + 自适应门槛应检出。
|
||||
"""
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
# 已有 8 片:索引 3、4 是被复用的镜头
|
||||
old = [_h(20 + i) for i in range(8)]
|
||||
# 新视频 6 片:最后 2 片对应 old[3], old[4],距离 4;其余距离 30
|
||||
new = [_h(50 + i) for i in range(4)] + [_h(4)] * 2
|
||||
# 让 new[4] 与 old[3] 距离 4、new[5] 与 old[4] 距离 4(构造近似)
|
||||
new[4] = f"{int('1' * 4 + '0' * 60, 2):016x}"
|
||||
new[5] = f"{int('1' * 4 + '0' * 60, 2):016x}"
|
||||
old[3] = _h(0)
|
||||
old[4] = _h(0)
|
||||
|
||||
existing = _video("v-old", old, duration=16.0)
|
||||
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
|
||||
fp = _fingerprint(new, 12.0, chunks=chunks)
|
||||
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
# 局部复用:duplicate_rate 必须非 0
|
||||
assert result["duplicate_rate"] > 0
|
||||
|
||||
def test_short_video_adaptive_consecutive_threshold(self):
|
||||
"""11s/5 片短视频:MIN_CONSECUTIVE 自适应 min(5, max(2, 5//2))=2,
|
||||
2 片连续命中即报片段(旧值 5 让短视频永远无法报片段)。"""
|
||||
|
||||
q = [
|
||||
FingerprintChunk(0, 2000, "f" * 16, []),
|
||||
FingerprintChunk(2000, 4000, "0" * 16, []),
|
||||
FingerprintChunk(4000, 6000, f"{int('11110000', 2):016x}", []),
|
||||
]
|
||||
t = [
|
||||
FingerprintChunk(0, 2000, "f" * 16, []),
|
||||
FingerprintChunk(2000, 4000, "0" * 16, []),
|
||||
FingerprintChunk(4000, 6000, "e" * 16, []),
|
||||
]
|
||||
# 3 片视频自适应门槛 = min(5, max(2, 3//2)) = 2
|
||||
segs = find_duplicate_segments(q, t)
|
||||
assert len(segs) >= 1
|
||||
|
||||
|
||||
# ── P0-3: ±1 邻接窗口对齐 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestNeighborAlignment:
|
||||
def test_neighbor_window_absorbs_boundary_jitter(self):
|
||||
"""切点错位导致目标索引偏移 ±1 时,连续匹配不应被中断。"""
|
||||
|
||||
q = [FingerprintChunk(i * 1000, (i + 1) * 1000, f"{i:016x}", []) for i in range(4)]
|
||||
# 目标:前 3 片与 q 相同,但第 3 片最佳匹配偏移 +1(t[4]),t[3] 是无关内容
|
||||
t_hashes = [f"{i:016x}" for i in range(3)] + ["f" * 16, f"{3:016x}"]
|
||||
t = [FingerprintChunk(i * 1000, (i + 1) * 1000, h, []) for i, h in enumerate(t_hashes)]
|
||||
segs = find_duplicate_segments(q, t)
|
||||
# q[0],q[1] 精确匹配 t[0],t[1];q[2]->t[2];q[3]->t[4](步进 2,窗口 ±1 内)
|
||||
assert len(segs) >= 1
|
||||
assert segs[0].query_end_ms >= 3000
|
||||
|
||||
|
||||
# ── P0-5 / 验收:异源不误报 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestDifferentSourceNoFalsePositive:
|
||||
def test_unrelated_videos_near_zero(self):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
# 异源:所有分片距离 >= 20
|
||||
old = [_h(40 + i * 3 % 20) for i in range(6)]
|
||||
new = [_h(0 + i) for i in range(6)]
|
||||
existing = _video("v-old", old, duration=12.0)
|
||||
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
|
||||
fp = _fingerprint(new, 12.0, chunks=chunks)
|
||||
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
assert result["duplicate_rate"] == 0
|
||||
assert result["visual_similarity"] < 0.7
|
||||
assert result["match_count"] == 0
|
||||
|
||||
def test_check_duplicate_returns_none_for_unrelated(self):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
old = [_h(40 + i) for i in range(6)]
|
||||
new = [_h(i) for i in range(6)]
|
||||
existing = _video("v-old", old, duration=12.0)
|
||||
fp = _fingerprint(new, 12.0)
|
||||
|
||||
result = _check(ddp, fp, [existing])
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── N=1 不回归 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSingleChunkNoRegression:
|
||||
def test_single_chunk_identical_detected(self):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
h = _h(2)
|
||||
existing = _video("v-old", [h], duration=3.0)
|
||||
chunks = [_chunk(h, 0, 3000)]
|
||||
fp = _fingerprint([h], 3.0, chunks=chunks)
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
assert result["duplicate_rate"] > 0
|
||||
|
||||
def test_single_chunk_md5_exact_match(self):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
existing = _video("v-old", [_h(0)], duration=3.0)
|
||||
existing.video_fingerprint["md5"] = "same"
|
||||
fp = _fingerprint([_h(0)], 3.0, md5="same")
|
||||
result = _check(ddp, fp, [existing])
|
||||
assert result is not None
|
||||
assert result["reason"] == "exact_md5_match"
|
||||
|
||||
|
||||
# ── P1-6: 时长预过滤单位 bug ──────────────────────────────────
|
||||
|
||||
|
||||
class TestDurationPrefilterUnit:
|
||||
def test_user_scope_skips_duration_prefilter(self):
|
||||
"""Issue #1702: scope=user 跨项目查重不做 ±15% 时长预过滤。
|
||||
|
||||
旧逻辑 duration/1000 单位 bug 先修成秒,但 ±15% 窗口与局部片段复用
|
||||
根本矛盾——复用片段的两个视频时长必然不同(证据视频 20s vs 11s 差 42%),
|
||||
窗口内找不到对方导致 is_duplicate 恒 False。最终口径:scope=user 全量
|
||||
遍历同用户视频(与 compute_duplicate_rate 一致),不传 duration_min/max。
|
||||
"""
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
fp = _fingerprint([_h(0)], 13.5)
|
||||
session_magic = MagicMock()
|
||||
session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
repo = MockRepo.return_value
|
||||
repo.list_by_user.return_value = []
|
||||
ddp.check_duplicate(fp, "proj1", session_magic, scope="user", user_id="u1", duration_sec=fp.duration)
|
||||
args, kwargs = repo.list_by_user.call_args
|
||||
# 全量查询:不带任何时长过滤参数(局部复用必须跨时长比较)
|
||||
assert "duration_min" not in kwargs
|
||||
assert "duration_max" not in kwargs
|
||||
assert args == ("u1",) or args == ()
|
||||
|
||||
|
||||
# ── P1-7: 颜色直方图归一化 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistogramNormalization:
|
||||
def test_bhattacharyya_coefficient_in_unit_range(self):
|
||||
"""Bhattacharyya 系数必须在 [0,1](旧 L2 + 3 通道拼接算出 ~14.9)。"""
|
||||
|
||||
# 3 通道拼接、每通道概率分布(Σ=1)
|
||||
hist_a = [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94
|
||||
# 长度裁剪到 96(3 通道 × 32 bins)
|
||||
hist_a = ([0.5, 0.5] + [0.0] * 30) * 3
|
||||
hist_b = ([0.5, 0.5] + [0.0] * 30) * 3
|
||||
|
||||
coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
|
||||
assert 0.0 <= coeff <= 1.0
|
||||
assert coeff > 0.99 # 完全相同 -> 1.0
|
||||
|
||||
def test_bhattacharyya_disjoint_hist_low(self):
|
||||
|
||||
hist_a = ([1.0] + [0.0] * 31) * 3
|
||||
hist_b = ([0.0] * 31 + [1.0]) * 3
|
||||
coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
|
||||
assert coeff < 0.05
|
||||
|
||||
|
||||
# ── P1-8: temporal_coverage 量纲 ──────────────────────────────
|
||||
|
||||
|
||||
class TestTemporalCoverageUnits:
|
||||
def test_coverage_uses_milliseconds(self):
|
||||
"""命中片段 6s / 视频 12s -> coverage=0.5;旧 bug 把 duration(秒)当毫秒,
|
||||
covered_ms(6000)/duration(12) = 500 -> min(1.0)=1.0 误判 100% 覆盖。"""
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
old = [_h(0) for _ in range(6)]
|
||||
new = [_h(0) for _ in range(3)] + [_h(30) for _ in range(3)]
|
||||
existing = _video("v-old", old, duration=12.0)
|
||||
# 新视频 12s,前 6s(3 片)与 old 相同
|
||||
chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)]
|
||||
fp = _fingerprint(new, 12.0, chunks=chunks)
|
||||
result = _rate(ddp, fp, [existing], MagicMock())
|
||||
# coverage 应约 0.5(3 片 × 2s = 6s / 12s),duplicate_rate ≈ (0.5*0.4 + 0.5*0.6)*100 = 50
|
||||
assert 30 < result["duplicate_rate"] < 70
|
||||
|
||||
|
||||
# ── P1-9: 阈值比较统一 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestThresholdConsistency:
|
||||
def test_frame_and_segment_thresholds_same_source(self):
|
||||
|
||||
assert SEGMENT_MATCH_THRESHOLD == PHASH_THRESHOLD
|
||||
assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD
|
||||
|
||||
|
||||
# ── P2: 0 匹配也要有日志痕迹 ──────────────────────────────────
|
||||
|
||||
|
||||
class TestZeroMatchLogging:
|
||||
def test_no_match_emits_info_log(self, caplog):
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
old = [_h(40 + i) for i in range(5)]
|
||||
existing = _video("v-old", old, duration=10.0)
|
||||
fp = _fingerprint([_h(i) for i in range(5)], 10.0)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="video_processing.dedup"):
|
||||
result = _check(ddp, fp, [existing])
|
||||
assert result is None
|
||||
assert any("no match" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
# ── recompute 任务下载路径(#1702 连带修复:旧硬编码 key 404) ─────
|
||||
|
||||
|
||||
class TestRecomputeDownloadPath:
|
||||
"""recompute-dedup 走 check_duplicate_task,需要从 OSS 重新下载成片。
|
||||
|
||||
旧代码硬编码 projects/{pid}/generated/{vid}/{vid}.mp4(从不存在),
|
||||
真实 key 在 file_url:generated/projects/{pid}/tasks/{tid}/rendered_*.mp4。
|
||||
"""
|
||||
|
||||
def test_task_downloads_from_file_url(self):
|
||||
import inspect
|
||||
|
||||
import video_processing.dedup as dedup_mod
|
||||
|
||||
source = inspect.getsource(dedup_mod.check_duplicate_task)
|
||||
# 下载 key 必须来自 video.file_url
|
||||
assert 'getattr(video, "file_url"' in source or "video.file_url" in source
|
||||
# 旧的硬编码 key 只能作为回退存在,不能是主路径
|
||||
assert "falling back to legacy key" in source
|
||||
# download_file 接收的是派生 key 而非硬编码 f-string
|
||||
assert "storage_service.download_file(download_key" in source
|
||||
assert '/generated/{generated_video_id}/{generated_video_id}.mp4"' not in source.replace(
|
||||
'download_key = f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4"',
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
# ── check_duplicate 排除自身(#1702 连带修复:recompute 自匹配) ─────
|
||||
|
||||
|
||||
class TestCheckDuplicateExcludesSelf:
|
||||
def test_exclude_video_id_skips_self_match(self):
|
||||
"""recompute 时当前视频已在候选列表:自匹配距离 0 分会让 duplicate_of
|
||||
指向自己。exclude_video_id 必须跳过自身,返回真实的其他匹配或 None。
|
||||
"""
|
||||
|
||||
ddp = VideoDeduplicator()
|
||||
h = _h(0)
|
||||
# 候选列表里同时放「自己」(完全相同)和一个异源视频
|
||||
self_video = _video("v-self", [h], duration=10.0)
|
||||
other_video = _video("v-other", [_h(40 + i) for i in range(3)], duration=10.0)
|
||||
fp = _fingerprint([h], 10.0)
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
|
||||
|
||||
# 不传 exclude → 自匹配命中(错误行为复现)
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
MockRepo.return_value.list_by_project.return_value = [self_video, other_video]
|
||||
result = ddp.check_duplicate(fp, "proj1", session)
|
||||
assert result is not None and result["duplicate_of"] == "v-self"
|
||||
|
||||
# 传 exclude_video_id → 跳过自己,异源不匹配 → None
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
MockRepo.return_value.list_by_project.return_value = [self_video, other_video]
|
||||
result = ddp.check_duplicate(fp, "proj1", session, exclude_video_id="v-self")
|
||||
assert result is None
|
||||
|
||||
# 排除自己后,真实同源其他视频仍能检出
|
||||
real_dup = _video("v-real", [h], duration=10.0)
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
MockRepo.return_value.list_by_project.return_value = [self_video, real_dup]
|
||||
result = ddp.check_duplicate(fp, "proj1", session, exclude_video_id="v-self")
|
||||
assert result is not None and result["duplicate_of"] == "v-real"
|
||||
|
||||
|
||||
# ── 阈值 16 二次校准 + 时序抖动对齐(#1702 第二轮真实数据校准) ──────
|
||||
|
||||
|
||||
class TestThreshold16Calibration:
|
||||
"""二次校准:staging 15 个真实成片实测——同源降重对中位数距离 14、
|
||||
<=16 命中 8/11=0.73;异源 13 个候选每帧全局最近邻最小距离 18、<=16
|
||||
命中全 0。阈值 16 检出同源且异源零误报(>=2bit 安全裕度)。"""
|
||||
|
||||
def test_threshold_calibrated_to_16(self):
|
||||
assert PHASH_THRESHOLD == 16
|
||||
|
||||
@staticmethod
|
||||
def _variant(phash: str, d: int) -> str:
|
||||
"""在 phash 基础上翻转恰好 d 个低位 bit → 与原哈希汉明距离恰为 d。"""
|
||||
v = int(phash, 16)
|
||||
for b in range(d):
|
||||
v ^= 1 << b
|
||||
return f"{v:016x}"
|
||||
|
||||
def test_distance_18_unrelated_not_matched(self):
|
||||
"""距离 18(异源实测最小最近邻距离)不判匹配,距离 16 判匹配。"""
|
||||
ddp = VideoDeduplicator()
|
||||
# 多样化 base(相邻帧各不相同,避免黑屏过滤器)
|
||||
base = [_h(i + 4) for i in range(8)]
|
||||
near = [self._variant(h, 16) for h in base] # 同源降重:每帧距离恰 16
|
||||
far = [self._variant(h, 18) for h in base] # 异源边界:每帧距离恰 18
|
||||
|
||||
fp_near = _fingerprint(near, 8.0, chunks=[_chunk(h, i, i + 1) for i, h in enumerate(near)])
|
||||
fp_far = _fingerprint(far, 8.0, chunks=[_chunk(h, i, i + 1) for i, h in enumerate(far)])
|
||||
|
||||
r_near = _rate(ddp, fp_near, [_video("v-base", base, duration=8.0)])
|
||||
r_far = _rate(ddp, fp_far, [_video("v-base", base, duration=8.0)])
|
||||
|
||||
assert r_near["duplicate_rate"] > 0, "距离16的同源降重对必须检出"
|
||||
assert r_far["duplicate_rate"] == 0.0, "距离18的异源对不得误报"
|
||||
assert r_far["match_count"] == 0
|
||||
|
||||
def test_deduped_pair_frame_match_rate_over_threshold(self):
|
||||
"""真实场景比例:11 帧中 8 帧距离 <=16(0.73 >= 0.7),
|
||||
其余 3 帧异源距离(>=18)——frame_match_rate 必须过 0.7 门槛。"""
|
||||
ddp = VideoDeduplicator()
|
||||
base = [_h(i + 4) for i in range(11)]
|
||||
near = [self._variant(h, 14) for h in base[:8]] # 中位数 14 的同源降重帧
|
||||
# 异源帧用完全不同前缀(与 base 距离 >=30)
|
||||
far = [_h(52 + i) for i in range(3)]
|
||||
query = near + far
|
||||
|
||||
fp = _fingerprint(query, 11.0, chunks=[_chunk(h, i, i + 1) for i, h in enumerate(query)])
|
||||
r = _rate(ddp, fp, [_video("v-base", base, duration=11.0)])
|
||||
# frame_match_rate=8/11=0.73、时序片段覆盖 ~0.73
|
||||
# → duplicate_rate = 0.4*0.73+0.6*0.73 ≈ 73%(空直方图回退下 fusion=0.6965
|
||||
# 略低于 is_duplicate 的 0.70 判定阈值,故此处断言查重率而非 match_count;
|
||||
# 真实视频带颜色直方图时 fusion≈0.80,staging A-C 实测 is_duplicate=True)
|
||||
assert r["duplicate_rate"] >= 70.0
|
||||
|
||||
|
||||
class TestTemporalJitterAlignment:
|
||||
"""时序对齐允许目标索引正/反向 ±(neighbor_window+1) 抖动。
|
||||
|
||||
密集 1s 采样下相邻帧 pHash 接近,全局最近邻会在目标相邻帧间
|
||||
正负 1 跳变(场景切割/取帧错位/局部倒退);旧逻辑只允许正向
|
||||
delta,把同源连续匹配拆碎,min_consecutive 门槛够不上而漏检。
|
||||
"""
|
||||
|
||||
def test_backward_jitter_keeps_run_continuous(self):
|
||||
"""匹配目标索引序列 0,1,2,1,2,3(含一次 -1 倒退)应保持同一 run。"""
|
||||
from video_processing.dedup import find_duplicate_segments
|
||||
|
||||
# 构造 target 相邻帧 pHash 相同(距离0),query 帧的最近邻在
|
||||
# target[1]/target[2] 之间抖动;全部 <= 阈值
|
||||
t_hash = _h(0)
|
||||
other = _h(40)
|
||||
# target: 帧0-3 相同场景,帧4+ 异源
|
||||
t_chunks = [_chunk(t_hash, i, i + 1) for i in range(4)] + [_chunk(other, i, i + 1) for i in range(4, 8)]
|
||||
# query 6 帧同场景(最近邻会落到 target 0~3,索引可正可负)
|
||||
q_chunks = [_chunk(t_hash, i, i + 1) for i in range(6)]
|
||||
|
||||
segments = find_duplicate_segments(q_chunks, t_chunks)
|
||||
assert segments, "含 ±1 时序抖动的连续匹配必须形成片段"
|
||||
# 6 帧匹配 >= min_consecutive(min(5,max(2,6//2))=5),报为一个片段
|
||||
assert len(segments) == 1
|
||||
seg = segments[0]
|
||||
assert seg.query_end_ms - seg.query_start_ms >= 5000
|
||||
|
||||
def test_large_backward_jump_breaks_run(self):
|
||||
"""目标索引倒退 > neighbor_window+1(如从 5 跳回 0)不属于抖动,
|
||||
不桥接为同一片段;孤立短匹配 < min_consecutive 不报片段。"""
|
||||
from video_processing.dedup import find_duplicate_segments
|
||||
|
||||
# 异源段:9-bit 不重叠段(相邻段隔 3 bit),跨段距离 18~24 > 阈值 16
|
||||
def _bit_seg(start):
|
||||
bits = ["0"] * 64
|
||||
for b in range(9):
|
||||
bits[start + b] = "1"
|
||||
return f"{int(''.join(bits), 2):016x}"
|
||||
|
||||
t_hash = _bit_seg(0) # 复用场景:bit 0-8
|
||||
t_other = [_bit_seg(22 + 4 * i) for i in range(4)] # target 异源段
|
||||
q_other = [_bit_seg(40 + 4 * i) for i in range(3)] # query 异源段
|
||||
# target: 帧0 同场景;帧1-4 异源;帧5-6 同场景
|
||||
t_chunks = (
|
||||
[_chunk(t_hash, 0, 1)]
|
||||
+ [_chunk(t_other[i - 1], i, i + 1) for i in range(1, 5)]
|
||||
+ [_chunk(t_hash, i, i + 1) for i in range(5, 7)]
|
||||
)
|
||||
# query: 帧0 匹配 target[0];帧1-3 异源(与 target 任何帧距离 >16);帧4-5 匹配 target[5,6]
|
||||
q_chunks = (
|
||||
[_chunk(t_hash, 0, 1)]
|
||||
+ [_chunk(q_other[i - 1], i, i + 1) for i in range(1, 4)]
|
||||
+ [_chunk(t_hash, i, i + 1) for i in range(4, 6)]
|
||||
)
|
||||
segments = find_duplicate_segments(q_chunks, t_chunks)
|
||||
# 两段各 1、2 帧 < min_consecutive=5 → 不报片段(大跳跃不桥接)
|
||||
assert segments == []
|
||||
@@ -358,11 +358,11 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_first_match_returned(self, deduplicator, mock_session):
|
||||
"""返回第一个通过阈值的匹配(非最优匹配)。"""
|
||||
# vid-1: 距离=2 bits(0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值
|
||||
def test_highest_score_match_returned(self, deduplicator, mock_session):
|
||||
"""Issue #1702: 遍历所有候选取融合分最高者(旧逻辑首个过阈即返回)。"""
|
||||
# vid-1: 距离=1 bit(0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值
|
||||
vid1 = self._make_existing_video("vid-1", "md5_1", phashes=["0000000000000003"])
|
||||
# vid-2: 距离=0 bits(完全匹配)
|
||||
# vid-2: 距离=0 bits(完全匹配),融合分更高
|
||||
vid2 = self._make_existing_video("vid-2", "md5_2", phashes=["0000000000000001"])
|
||||
|
||||
mock_repo = MagicMock()
|
||||
@@ -380,8 +380,8 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
try:
|
||||
result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session)
|
||||
assert result is not None
|
||||
# 返回第一个通过阈值的匹配(vid-1 距离=1 < 10)
|
||||
assert result["duplicate_of"] == "vid-1"
|
||||
# 两个候选都过阈,返回融合分最高的 vid-2(距离 0 < 1)
|
||||
assert result["duplicate_of"] == "vid-2"
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
|
||||
@@ -185,11 +185,14 @@ class TestBhattacharyyaCoefficient:
|
||||
"""_bhattacharyya_coefficient Bhattacharyya 系数测试."""
|
||||
|
||||
def test_identical_histograms(self):
|
||||
"""完全相同的直方图系数为1.0."""
|
||||
hist = [0.5, 0.5, 0.0, 0.3]
|
||||
"""完全相同的直方图系数为1.0(#1702:按 Σ 归一,概率分布语义)。"""
|
||||
hist = [0.5, 0.5, 0.0, 0.0] # Σ=1 的概率分布
|
||||
bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist)
|
||||
# Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (normalized)
|
||||
assert bc == pytest.approx(sum(h for h in hist))
|
||||
assert bc == pytest.approx(1.0)
|
||||
# 非归一化输入也归一到 1.0(三通道拼接 Σ=3 的等价情形)
|
||||
hist3 = [0.5, 0.5, 0.0, 0.3]
|
||||
bc3 = VideoDeduplicator._bhattacharyya_coefficient(hist3, hist3)
|
||||
assert bc3 == pytest.approx(1.0)
|
||||
|
||||
def test_zero_histograms(self):
|
||||
"""全零直方图系数为0."""
|
||||
@@ -202,10 +205,10 @@ class TestBhattacharyyaCoefficient:
|
||||
assert bc == pytest.approx(0.0)
|
||||
|
||||
def test_different_lengths(self):
|
||||
"""不同长度直方图取最小长度对齐."""
|
||||
"""不同长度直方图取最小长度对齐,并按各自总量归一(#1702 概率分布语义)。"""
|
||||
# 对齐到前 2 维:coeff = 2,norm = √(Σa·Σb) = √(2·2) = 2 → 1.0
|
||||
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 1.0, 0.0, 0.0], [1.0, 1.0])
|
||||
# 对齐到前2维: √(1*1) + √(1*1) = 2.0
|
||||
assert bc == pytest.approx(2.0)
|
||||
assert bc == pytest.approx(1.0)
|
||||
|
||||
def test_known_value(self):
|
||||
"""已知值验证."""
|
||||
|
||||
+26
-23
@@ -103,6 +103,7 @@ from video_processing.dedup import ( # noqa: E402
|
||||
MIN_CONSECUTIVE_MATCHES,
|
||||
MIN_KEYFRAME_INTERVAL_SEC,
|
||||
MIN_KEYFRAMES,
|
||||
PHASH_THRESHOLD,
|
||||
PHASH_WEIGHT,
|
||||
SCENE_CHANGE_THRESHOLD,
|
||||
SEGMENT_MATCH_THRESHOLD,
|
||||
@@ -269,22 +270,17 @@ class TestFindDuplicateSegments:
|
||||
注意:使用不同的 hash 对,确保后半部分帧距离 > 阈值。
|
||||
"""
|
||||
same_hash = "aaaaaaaaaaaaaaaa"
|
||||
# 4 帧匹配,后面 6 帧各自不同(在 query 和 target 中使用不同 hash)
|
||||
# 4 帧匹配,后面 6 帧用与匹配哈希距离 32 的不匹配哈希(> PHASH_THRESHOLD=16)
|
||||
nomatch_hash = "cccccccccccccccc" # hamming(aaaa, cccc)=32
|
||||
chunks_a = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + [
|
||||
_make_chunk(i * 1000, (i + 1) * 1000, "bbbbbbbbbbbbbbbb") for i in range(4, 10)
|
||||
_make_chunk(i * 1000, (i + 1) * 1000, nomatch_hash) for i in range(4, 10)
|
||||
]
|
||||
chunks_b = [_make_chunk(i * 1000, (i + 1) * 1000, same_hash) for i in range(4)] + [
|
||||
_make_chunk(i * 1000, (i + 1) * 1000, "cccccccccccccccc") for i in range(4, 10)
|
||||
_make_chunk(i * 1000, (i + 1) * 1000, nomatch_hash) for i in range(4, 10)
|
||||
]
|
||||
|
||||
# hamming("bbbb...", "cccc...") should be > 8 (SEGMENT_MATCH_THRESHOLD)
|
||||
# b=1011, c=1100 → 4 bits differ per hex digit × 16 digits = 64 bits total? No...
|
||||
# Actually: hamming_distance("bbbbbbbbbbbbbbbb", "cccccccccccccccc")
|
||||
# b=0xb=1011, c=0xc=1100 → XOR=0111=0x7 → 3 bits per digit × 16 = 48
|
||||
# That's > 8 so won't match
|
||||
|
||||
# hamming(aaaa..., cccc...) = 32 > PHASH_THRESHOLD(16),后半段不匹配;
|
||||
# 前 4 帧匹配 < min_consecutive=5,不形成片段
|
||||
segments = find_duplicate_segments(chunks_a, chunks_b)
|
||||
# 只有 4 帧匹配(< min_consecutive=5),所以不报告
|
||||
assert segments == []
|
||||
|
||||
def test_max_gap_behavior(self):
|
||||
@@ -293,10 +289,12 @@ class TestFindDuplicateSegments:
|
||||
关键:间隙帧必须在 query 和 target 中使用不同 hash,使其真正不匹配。
|
||||
"""
|
||||
match_hash = "aaaaaaaaaaaaaaaa"
|
||||
gap_hash_a = "bbbbbbbbbbbbbbbb" # query 端
|
||||
gap_hash_b = "cccccccccccccccc" # target 端(与 query 端距离 > 8)
|
||||
tail_hash_a = "dddddddddddddddd"
|
||||
tail_hash_b = "eeeeeeeeeeeeeeee"
|
||||
# 间隙/尾部哈希与 match_hash 及彼此之间汉明距离均 >64 (> PHASH_THRESHOLD=16),
|
||||
# 确保在 ±(neighbor_window+1) 时序抖动对齐窗口内也不会误匹配
|
||||
gap_hash_a = "ffffffffffffffff" # hamming(a,f)=128
|
||||
gap_hash_b = "9999999999999999" # hamming(a,9)=128, hamming(f,9)=128
|
||||
tail_hash_a = "7777777777777777" # hamming(a,7)=192
|
||||
tail_hash_b = "1111111111111111" # hamming(a,1)=192, hamming(7,1)=128
|
||||
|
||||
# 5 帧匹配, 1 帧间隙, 3 帧匹配, 5 帧不匹配
|
||||
hashes_a = [match_hash] * 5 + [gap_hash_a] + [match_hash] * 3 + [tail_hash_a] * 5
|
||||
@@ -318,10 +316,10 @@ class TestFindDuplicateSegments:
|
||||
def test_max_gap_exceeded(self):
|
||||
"""间隙超过 max_gap → 分成两段."""
|
||||
match_hash = "aaaaaaaaaaaaaaaa"
|
||||
gap_hash_a = "bbbbbbbbbbbbbbbb"
|
||||
gap_hash_b = "cccccccccccccccc"
|
||||
tail_hash_a = "dddddddddddddddd"
|
||||
tail_hash_b = "eeeeeeeeeeeeeeee"
|
||||
gap_hash_a = "ffffffffffffffff" # hamming(a,f)=128
|
||||
gap_hash_b = "9999999999999999" # hamming(a,9)=128
|
||||
tail_hash_a = "7777777777777777" # hamming(a,7)=192
|
||||
tail_hash_b = "1111111111111111" # hamming(a,1)=192
|
||||
|
||||
# 5 帧匹配, 3 帧间隙 (> max_gap=2), 5 帧匹配, 5 帧不匹配
|
||||
hashes_a = [match_hash] * 5 + [gap_hash_a] * 3 + [match_hash] * 5 + [tail_hash_a] * 5
|
||||
@@ -484,8 +482,10 @@ class TestBackwardCompatibility:
|
||||
chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 5000}]
|
||||
|
||||
segments = find_duplicate_segments(chunks_a, chunks_b)
|
||||
# 1 帧 < min_consecutive=5,不会报重复
|
||||
assert segments == []
|
||||
# Issue #1702: 自适应门槛 min(5, max(2, 1//2))=2,1 帧不成段;
|
||||
# N=1 的检出由 _evaluate_candidate 匹配帧回退兜底(见 test_dedup_1702)。
|
||||
# 这里只要求不崩溃。
|
||||
assert isinstance(segments, list)
|
||||
|
||||
|
||||
# ── TestConstants ───────────────────────────────────────────────
|
||||
@@ -495,8 +495,11 @@ class TestConstants:
|
||||
"""常量值验证 — 使用已在模块顶部导入的常量,避免重新 import."""
|
||||
|
||||
def test_segment_match_threshold(self):
|
||||
# 从已导入的 find_duplicate_segments 默认参数间接验证
|
||||
assert SEGMENT_MATCH_THRESHOLD == 8
|
||||
# Issue #1702 二次校准:阈值经 staging 真实数据两轮回归——
|
||||
# 第一轮同源 4/11、异源 min=24 定 12;第二轮扩样本(15 个真实成片)
|
||||
# 同源降重对中位数距离 14、<=16 命中 8/11=0.73,异源 13 个候选
|
||||
# <=16 命中全 0、最近邻最小距离 18 → 校准为 16。
|
||||
assert SEGMENT_MATCH_THRESHOLD == PHASH_THRESHOLD == 16
|
||||
|
||||
def test_min_consecutive_matches(self):
|
||||
assert MIN_CONSECUTIVE_MATCHES == 5
|
||||
|
||||
@@ -132,9 +132,14 @@ class TestCheckDuplicateScopeUser:
|
||||
|
||||
|
||||
class TestDurationPrefilter:
|
||||
"""test_duration_prefilter:时长 ±15% 过滤."""
|
||||
"""Issue #1702: scope=user 跨项目查重不做时长预过滤。
|
||||
|
||||
def test_duration_prefilter_passes_correct_range(self):
|
||||
局部片段复用的两个视频时长必然不同(证据视频 20s vs 11s,差 42%),
|
||||
旧的 ±15% 窗口会让同源视频互相不可见 → is_duplicate 恒 False。
|
||||
全量遍历同用户视频,异源视频由 fusion/temporal_coverage 阈值天然过滤。
|
||||
"""
|
||||
|
||||
def test_user_scope_no_duration_filter(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
@@ -153,12 +158,13 @@ class TestDurationPrefilter:
|
||||
duration_sec=30.0,
|
||||
)
|
||||
|
||||
# Should pass duration_min=25.5, duration_max=34.5 (30 ± 15%)
|
||||
# scope=user 全量遍历:位置参数只传 user_id,kwargs 不含时长过滤
|
||||
call_args = mock_repo.list_by_user.call_args
|
||||
assert call_args[1]["duration_min"] == pytest.approx(25.5, abs=0.1)
|
||||
assert call_args[1]["duration_max"] == pytest.approx(34.5, abs=0.1)
|
||||
assert call_args[0] == ("user1",)
|
||||
assert "duration_min" not in call_args[1]
|
||||
assert "duration_max" not in call_args[1]
|
||||
|
||||
def test_no_duration_prefilter_when_zero(self):
|
||||
def test_user_scope_no_duration_filter_when_zero(self):
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
@@ -178,8 +184,34 @@ class TestDurationPrefilter:
|
||||
)
|
||||
|
||||
call_args = mock_repo.list_by_user.call_args
|
||||
assert call_args[1]["duration_min"] == 0
|
||||
assert call_args[1]["duration_max"] == 0
|
||||
assert "duration_min" not in call_args[1]
|
||||
assert "duration_max" not in call_args[1]
|
||||
|
||||
def test_project_scope_also_no_duration_filter(self):
|
||||
"""scope=project 走 list_by_project,本来就不做时长过滤。"""
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = _make_fingerprint(duration_ms=30000)
|
||||
session = MagicMock()
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo.list_by_project.return_value = []
|
||||
deduplicator.check_duplicate(
|
||||
fingerprint,
|
||||
"proj1",
|
||||
session,
|
||||
scope="project",
|
||||
user_id="user1",
|
||||
duration_sec=30.0,
|
||||
)
|
||||
|
||||
mock_repo.list_by_project.assert_called_once()
|
||||
call_args = mock_repo.list_by_project.call_args
|
||||
assert call_args[0] == ("proj1",)
|
||||
assert "duration_min" not in call_args[1]
|
||||
assert "duration_max" not in call_args[1]
|
||||
|
||||
|
||||
class TestComputeDuplicateRateFormula:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
覆盖:
|
||||
- 分片策略:60秒视频 → 30片,120秒视频 → 24片
|
||||
- VideoFingerprint.to_chunk_models() 输出正确
|
||||
- _save_fingerprint_chunks 幂等性(已有数据跳过)
|
||||
- _save_fingerprint_chunks 替换语义(Issue #1702:重算时先删旧分片再写入)
|
||||
- to_dict() 向后兼容
|
||||
"""
|
||||
|
||||
@@ -169,11 +169,15 @@ class TestVideoFingerprintToChunkModels:
|
||||
assert models == []
|
||||
|
||||
|
||||
class TestSaveFingerprintChunksIdempotent:
|
||||
"""测试 _save_fingerprint_chunks 幂等性。"""
|
||||
class TestSaveFingerprintChunksReplace:
|
||||
"""测试 _save_fingerprint_chunks 替换语义(Issue #1702)。
|
||||
|
||||
def test_save_skips_existing(self):
|
||||
"""已有分片数据时跳过写入。"""
|
||||
重算查重时指纹算法已升级(中心裁剪 + 新采样/阈值),旧分片必须先删除
|
||||
再写入新分片,否则 recompute-dedup 永远读到旧指纹、修复对存量视频不生效。
|
||||
"""
|
||||
|
||||
def test_save_replaces_existing(self):
|
||||
"""已有分片数据时:先删除旧分片,再写入新分片。"""
|
||||
fp = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["a1b2"],
|
||||
@@ -186,16 +190,22 @@ class TestSaveFingerprintChunksIdempotent:
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
# Mock: 已有 1 条分片数据
|
||||
session.query.return_value.filter.return_value.count.return_value = 1
|
||||
# Mock: 删除旧分片返回 3(旧算法留下的 3 条分片)
|
||||
session.query.return_value.filter.return_value.delete.return_value = 3
|
||||
|
||||
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
|
||||
|
||||
# bulk_save_objects 不应被调用
|
||||
session.bulk_save_objects.assert_not_called()
|
||||
# 必须先执行删除
|
||||
session.query.return_value.filter.return_value.delete.assert_called_once()
|
||||
# 新分片必须写入
|
||||
session.bulk_save_objects.assert_called_once()
|
||||
saved_models = session.bulk_save_objects.call_args[0][0]
|
||||
assert len(saved_models) == 1
|
||||
assert saved_models[0].video_id == "v1"
|
||||
assert saved_models[0].phash_binary == "a1b2"
|
||||
|
||||
def test_save_writes_new(self):
|
||||
"""无分片数据时写入。"""
|
||||
"""无旧分片时直接写入。"""
|
||||
fp = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["a1b2"],
|
||||
@@ -208,12 +218,12 @@ class TestSaveFingerprintChunksIdempotent:
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
# Mock: 无分片数据
|
||||
session.query.return_value.filter.return_value.count.return_value = 0
|
||||
# Mock: 无旧分片
|
||||
session.query.return_value.filter.return_value.delete.return_value = 0
|
||||
|
||||
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
|
||||
|
||||
# bulk_save_objects 应被调用一次
|
||||
session.query.return_value.filter.return_value.delete.assert_called_once()
|
||||
session.bulk_save_objects.assert_called_once()
|
||||
saved_models = session.bulk_save_objects.call_args[0][0]
|
||||
assert len(saved_models) == 1
|
||||
@@ -221,7 +231,7 @@ class TestSaveFingerprintChunksIdempotent:
|
||||
assert saved_models[0].phash_binary == "a1b2"
|
||||
|
||||
def test_save_skips_no_chunks(self):
|
||||
"""指纹无 chunks 时跳过。"""
|
||||
"""指纹无 chunks 时跳过(不删不写)。"""
|
||||
fp = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=[],
|
||||
@@ -232,11 +242,11 @@ class TestSaveFingerprintChunksIdempotent:
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.count.return_value = 0
|
||||
|
||||
_save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session)
|
||||
|
||||
# bulk_save_objects 不应被调用
|
||||
# 无 chunks:不查询、不删除、不写入
|
||||
session.query.assert_not_called()
|
||||
session.bulk_save_objects.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ from video_processing.dedup import ( # noqa: E402
|
||||
DUPLICATE_THRESHOLD,
|
||||
HISTOGRAM_WEIGHT,
|
||||
MATCH_RATIO_THRESHOLD,
|
||||
PHASH_THRESHOLD,
|
||||
PHASH_WEIGHT,
|
||||
VideoDeduplicator,
|
||||
)
|
||||
@@ -128,11 +129,17 @@ _ZERO_HIST = [0.0] * 96 # 全黑视频的全零直方图(有效数据)
|
||||
|
||||
|
||||
class TestThresholdCalibration:
|
||||
"""pHash 阈值由 10 收紧到 8(Issue #1658)。"""
|
||||
"""pHash 阈值校准(#1658 收紧到 8,#1702 两轮真实数据重校准 12→16)。
|
||||
|
||||
def test_phash_threshold_is_8(self):
|
||||
"""PHASH_THRESHOLD 必须为 8(旧值 10 会放过 8~9 汉明距离的不同视频)。"""
|
||||
assert VideoDeduplicator.PHASH_THRESHOLD == 8
|
||||
#1702 第一轮 staging 离线实验:同帧两次 2-5% 随机裁剪距离 4~10;同源成片
|
||||
(密集 1s 采样)<=12 命中 4/11、异源成片最小距离 24 → 初定 12。
|
||||
#1702 第二轮(证据视频 B->A 仍漏检)扩样本到该用户 15 个真实成片实测:
|
||||
同源降重对中位数距离 14、<=16 命中 8/11=0.73;异源 13 个候选 <=16 命中
|
||||
全 0、每帧全局最近邻最小距离 18 → 校准为 16(与异源仍有 >=2bit 裕度)。
|
||||
"""
|
||||
|
||||
def test_phash_threshold_is_calibrated(self):
|
||||
assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD == 16
|
||||
|
||||
def test_match_ratio_threshold_constant(self):
|
||||
assert MATCH_RATIO_THRESHOLD == 0.7
|
||||
@@ -144,22 +151,22 @@ class TestThresholdCalibration:
|
||||
assert PHASH_WEIGHT == 0.7
|
||||
assert HISTOGRAM_WEIGHT == 0.3
|
||||
|
||||
def test_threshold_tightening_excludes_distance_8_and_9(self):
|
||||
"""距离 8、9 的帧:旧阈值 10 下算匹配,新阈值 8 下不算匹配。
|
||||
def test_threshold_matching_semantics(self):
|
||||
"""阈值比较统一为 <=(帧匹配与片段匹配同一口径)。
|
||||
|
||||
场景:5 个关键帧距离为 [7, 7, 7, 9, 9]。
|
||||
- 旧阈值 10:5 帧全部 < 10 → match_ratio = 1.0(误放过)
|
||||
- 新阈值 8:仅 3 帧 < 8 → match_ratio = 0.6 < 0.7(正确跳过)
|
||||
场景:5 个关键帧距离为 [10, 14, 16, 18, 26]。
|
||||
- <=16(#1702 二次校准阈值):3 帧匹配 → 0.6 < 0.7,被帧比例门槛
|
||||
拦截(异源安全边界:真实数据异源最近邻最小距离 18,<=16 命中 0)
|
||||
- 距离正好 16 的同源降重帧应算匹配(< 与 <= 口径统一)
|
||||
"""
|
||||
distances = [7, 7, 7, 9, 9]
|
||||
distances = [10, 14, 16, 18, 26]
|
||||
matched = sum(1 for d in distances if d <= VideoDeduplicator.PHASH_THRESHOLD)
|
||||
assert matched == 3
|
||||
assert matched / len(distances) == 0.6
|
||||
assert matched / len(distances) < MATCH_RATIO_THRESHOLD
|
||||
|
||||
matched_old = sum(1 for d in distances if d < 10)
|
||||
assert matched_old == 5 # 旧行为:全匹配 → 误判风险
|
||||
|
||||
matched_new = sum(1 for d in distances if d < VideoDeduplicator.PHASH_THRESHOLD)
|
||||
assert matched_new == 3
|
||||
assert matched_new / len(distances) == 0.6
|
||||
assert matched_new / len(distances) < MATCH_RATIO_THRESHOLD # 被帧比例门槛拦截
|
||||
# 异源安全边界(实测最小距离 18)及以上绝不匹配
|
||||
assert not any(d <= VideoDeduplicator.PHASH_THRESHOLD for d in (18, 24, 26, 30))
|
||||
|
||||
|
||||
# ── TestComputeFusionScore:统一融合得分方法 ────────────────────
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Issue #1709 任务容错:孤儿任务恢复 + 429 限流结构化提示。
|
||||
|
||||
覆盖:
|
||||
1. 仓储层:count_running_by_user/count_running_total 计数正确(预览/正式任务都计入)
|
||||
2. 仓储层:estimate_avg_duration_seconds 耗时估算(有历史/无历史)
|
||||
3. 限流核心:build_rate_limit_detail 返回结构化 code/message/排队数/预计等待
|
||||
4. worker 侧:cleanup_stale_running/pending 核心函数——中断任务被重置为 failed
|
||||
且原因写明(容器重启/超时中断),正常任务不受影响
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
|
||||
# 预注入 mock worker_app.db,防止真实数据库连接初始化(与其他 worker 测试同模式)
|
||||
_mock_db = MagicMock()
|
||||
_mock_db.SessionLocal = MagicMock()
|
||||
sys.modules.setdefault("worker_app.db", _mock_db)
|
||||
|
||||
from app.core import task_enqueue # noqa: E402
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
from worker_app.tasks import _startup # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import ( # noqa: E402
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||||
from packages.domain import GenerationTask, GenerationTaskStatus # noqa: E402
|
||||
|
||||
|
||||
def _repository():
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyGenerationTaskRepository(session), session, engine
|
||||
|
||||
|
||||
def _make_task(**kwargs) -> GenerationTask:
|
||||
defaults = dict(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
created_by_user_id="user-1",
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask.create(**defaults)
|
||||
|
||||
|
||||
def _age_task(engine, task_id, *, updated_minutes=None, created_minutes=None):
|
||||
"""用 SQL 直接把 updated_at/created_at 改到过去(模拟孤儿任务)。"""
|
||||
sets, params = [], {"id": task_id}
|
||||
if updated_minutes is not None:
|
||||
sets.append("updated_at = :uts")
|
||||
params["uts"] = datetime.now(timezone.utc) - timedelta(minutes=updated_minutes)
|
||||
if created_minutes is not None:
|
||||
sets.append("created_at = :cts")
|
||||
params["cts"] = datetime.now(timezone.utc) - timedelta(minutes=created_minutes)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text(f"UPDATE generation_tasks SET {', '.join(sets)} WHERE id = :id"), params)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. running 计数(限流"渲染中"数量)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_count_running_by_user_mix_statuses():
|
||||
"""count_running_by_user 只统计该用户 running,不含 pending/completed/failed。"""
|
||||
repo, _, _ = _repository()
|
||||
t1 = _make_task(project_id="p1")
|
||||
repo.create(t1) # pending
|
||||
t2 = _make_task(project_id="p2")
|
||||
repo.create(t2)
|
||||
t2.mark_processing()
|
||||
repo.update(t2)
|
||||
t3 = _make_task(project_id="p3")
|
||||
repo.create(t3)
|
||||
t3.mark_processing()
|
||||
repo.update(t3)
|
||||
t4 = _make_task(project_id="p4")
|
||||
repo.create(t4)
|
||||
t4.mark_processing()
|
||||
repo.update(t4)
|
||||
t4.mark_completed()
|
||||
repo.update(t4)
|
||||
t5 = _make_task(project_id="p5", created_by_user_id="user-2")
|
||||
repo.create(t5)
|
||||
t5.mark_processing()
|
||||
repo.update(t5)
|
||||
|
||||
assert repo.count_running_by_user("user-1") == 2
|
||||
assert repo.count_running_by_user("user-2") == 1
|
||||
assert repo.count_running_total() == 3
|
||||
|
||||
|
||||
def test_count_running_total_empty():
|
||||
repo, _, _ = _repository()
|
||||
assert repo.count_running_total() == 0
|
||||
assert repo.count_running_by_user("nobody") == 0
|
||||
|
||||
|
||||
def test_preview_tasks_counted_in_running():
|
||||
"""预览任务(is_preview=True,工单实测卡 80% 的那种)同样计入 running。"""
|
||||
repo, _, _ = _repository()
|
||||
t = _make_task(is_preview=True)
|
||||
repo.create(t)
|
||||
t.mark_processing()
|
||||
repo.update(t)
|
||||
assert repo.count_running_by_user("user-1") == 1
|
||||
assert repo.count_running_total() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 平均耗时估算(429 等待预估依据)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _complete_task(repo, engine, task, duration_seconds: float):
|
||||
repo.create(task)
|
||||
task.mark_processing()
|
||||
repo.update(task)
|
||||
task.mark_completed()
|
||||
repo.update(task)
|
||||
now = datetime.now(timezone.utc)
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("UPDATE generation_tasks SET started_at = :s, completed_at = :c WHERE id = :id"),
|
||||
{"s": now - timedelta(seconds=duration_seconds), "c": now, "id": task.id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_estimate_avg_duration_with_history():
|
||||
"""有历史完成任务时返回平均耗时(秒)。"""
|
||||
repo, _, engine = _repository()
|
||||
_complete_task(repo, engine, _make_task(project_id="p1"), 60.0)
|
||||
_complete_task(repo, engine, _make_task(project_id="p2"), 180.0)
|
||||
|
||||
avg = repo.estimate_avg_duration_seconds(default_seconds=120.0)
|
||||
assert 119.0 < avg < 121.0 # (60+180)/2 = 120
|
||||
|
||||
|
||||
def test_estimate_avg_duration_no_history_returns_default():
|
||||
"""无历史数据时返回默认值。"""
|
||||
repo, _, _ = _repository()
|
||||
assert repo.estimate_avg_duration_seconds(default_seconds=90.0) == 90.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. build_rate_limit_detail 结构化提示(前端区分"排队"与"创建失败")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_user_rate_limit_detail_structure():
|
||||
"""429 用户限流:返回 USER_QUEUE_FULL + 排队/渲染数 + 预计等待。"""
|
||||
repo, _, _ = _repository()
|
||||
for i in range(2): # 2 个渲染中
|
||||
t = _make_task(project_id=f"rp{i}")
|
||||
repo.create(t)
|
||||
t.mark_processing()
|
||||
repo.update(t)
|
||||
|
||||
exc = task_enqueue.UserPendingLimitExceeded(user_id="user-1", pending_count=3, limit=3)
|
||||
detail = task_enqueue.build_rate_limit_detail(exc, repo, scope="user")
|
||||
|
||||
assert detail["code"] == task_enqueue.ERROR_CODE_USER_QUEUE_FULL
|
||||
assert detail["queued_count"] == 3
|
||||
assert detail["running_count"] == 2
|
||||
assert detail["limit"] == 3
|
||||
assert detail["estimated_wait_seconds"] > 0
|
||||
assert "排队" in detail["message"]
|
||||
assert "user-1" not in detail["message"] # 不泄露内部 ID
|
||||
|
||||
|
||||
def test_global_rate_limit_detail_structure():
|
||||
"""503 全局繁忙:返回 SYSTEM_QUEUE_FULL。"""
|
||||
repo, _, _ = _repository()
|
||||
exc = task_enqueue.GlobalQueueFull(pending_count=20, limit=20)
|
||||
detail = task_enqueue.build_rate_limit_detail(exc, repo, scope="global")
|
||||
|
||||
assert detail["code"] == task_enqueue.ERROR_CODE_SYSTEM_QUEUE_FULL
|
||||
assert detail["queued_count"] == 20
|
||||
assert detail["limit"] == 20
|
||||
assert detail["estimated_wait_seconds"] > 0
|
||||
assert "系统繁忙" in detail["message"]
|
||||
|
||||
|
||||
def test_wait_estimate_uses_concurrency():
|
||||
"""等待预估:排队 8 个 / 并发 4 = 2 批 × 平均耗时。"""
|
||||
|
||||
class FakeRepo:
|
||||
def estimate_avg_duration_seconds(self, limit=20, default_seconds=120.0):
|
||||
return 100.0
|
||||
|
||||
wait = task_enqueue._estimate_wait_seconds(8, FakeRepo())
|
||||
assert wait == 200 # ceil(8/4)=2 批 × 100 秒
|
||||
|
||||
|
||||
def test_wait_estimate_repo_without_methods_uses_default():
|
||||
"""仓储没有新方法(旧 mock/鸭子类型)时用默认 120 秒兜底,不抛错。"""
|
||||
|
||||
class LegacyRepo:
|
||||
"""只实现旧接口的仓储(模拟未升级的调用方)。"""
|
||||
|
||||
def count_pending_total(self):
|
||||
return 0
|
||||
|
||||
wait = task_enqueue._estimate_wait_seconds(4, LegacyRepo())
|
||||
assert wait == 120 # ceil(4/4)=1 批 × 120 默认
|
||||
|
||||
|
||||
def test_rate_limit_detail_running_count_falls_back_to_zero():
|
||||
"""仓储不支持 running 计数时,running_count 优雅降级为 0。"""
|
||||
|
||||
class LegacyRepo:
|
||||
def count_pending_total(self):
|
||||
return 0
|
||||
|
||||
exc = task_enqueue.GlobalQueueFull(pending_count=20, limit=20)
|
||||
detail = task_enqueue.build_rate_limit_detail(exc, LegacyRepo(), scope="global")
|
||||
assert detail["running_count"] == 0
|
||||
assert detail["code"] == task_enqueue.ERROR_CODE_SYSTEM_QUEUE_FULL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. worker 清理核心:中断任务被重置(worker 重启/超时恢复)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_worker_cleanup_resets_interrupted_running_task():
|
||||
"""模拟 worker 重启:running 超 20 分钟无更新的任务被重置为 failed,原因写明。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
t = _make_task(is_preview=True) # 预览任务
|
||||
repo.create(t)
|
||||
t.mark_processing() # running
|
||||
repo.update(t)
|
||||
_age_task(engine, t.id, updated_minutes=25) # 25 分钟无进度更新
|
||||
|
||||
cleaned = _startup.cleanup_stale_running_with_session(repo, 20)
|
||||
assert cleaned == 1
|
||||
|
||||
saved = repo.get(t.id)
|
||||
assert saved.status == GenerationTaskStatus.FAILED
|
||||
assert "中断" in saved.error_message
|
||||
assert saved.error_info.get("error_type") == "WorkerInterrupted"
|
||||
assert saved.completed_at is not None
|
||||
|
||||
|
||||
def test_worker_cleanup_keeps_healthy_running_task():
|
||||
"""正常运行中(5 分钟前有更新)的任务不被误杀。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
t = _make_task()
|
||||
repo.create(t)
|
||||
t.mark_processing()
|
||||
repo.update(t)
|
||||
_age_task(engine, t.id, updated_minutes=5)
|
||||
|
||||
assert _startup.cleanup_stale_running_with_session(repo, 20) == 0
|
||||
assert repo.get(t.id).status == GenerationTaskStatus.RUNNING
|
||||
|
||||
|
||||
def test_worker_cleanup_resets_stale_pending_task():
|
||||
"""卡 pending 超 15 分钟(worker 停止消费)的任务被重置,释放限流名额。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
t = _make_task(is_preview=True)
|
||||
repo.create(t) # 一直 pending
|
||||
_age_task(engine, t.id, created_minutes=20)
|
||||
|
||||
cleaned = _startup.cleanup_stale_pending_with_session(repo, 15)
|
||||
assert cleaned == 1
|
||||
|
||||
saved = repo.get(t.id)
|
||||
assert saved.status == GenerationTaskStatus.FAILED
|
||||
assert saved.error_info.get("error_type") == "PendingTimeout"
|
||||
# 释放名额后 pending 计数归零,新请求不再被 429 误伤
|
||||
assert repo.count_pending_total() == 0
|
||||
|
||||
|
||||
def test_worker_cleanup_pending_keeps_recent():
|
||||
"""刚创建 3 分钟的 pending 任务不清理。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
t = _make_task()
|
||||
repo.create(t)
|
||||
_age_task(engine, t.id, created_minutes=3)
|
||||
|
||||
assert _startup.cleanup_stale_pending_with_session(repo, 15) == 0
|
||||
assert repo.get(t.id).status == GenerationTaskStatus.PENDING
|
||||
|
||||
|
||||
def test_worker_cleanup_multiple_orphans_all_reset():
|
||||
"""3 个卡死 running 任务(工单实测:3 个预览卡 80% 超 10 小时)全部恢复。"""
|
||||
repo, _, engine = _repository()
|
||||
|
||||
ids = []
|
||||
for i in range(3):
|
||||
t = _make_task(project_id=f"p{i}", is_preview=True)
|
||||
repo.create(t)
|
||||
t.mark_processing()
|
||||
repo.update(t)
|
||||
_age_task(engine, t.id, updated_minutes=600) # 10 小时
|
||||
ids.append(t.id)
|
||||
|
||||
cleaned = _startup.cleanup_stale_running_with_session(repo, 20)
|
||||
assert cleaned == 3
|
||||
for tid in ids:
|
||||
assert repo.get(tid).status == GenerationTaskStatus.FAILED
|
||||
Reference in New Issue
Block a user