Compare commits

...

10 Commits

Author SHA1 Message Date
CI Bot 96d4a42169 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 56s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m13s
AI Code Review / AI Code Review (pull_request) Failing after 1m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m57s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 14s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 39s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 8s
2026-08-15 09:52:19 +08:00
xiaoxia 71ca3a0d6e fix(test): add autouse fixture to reset worker_app.db module between tests
Add _reset_worker_app_db pytest fixture with autouse=True to prevent
mock pollution from leaking between tests. This fixes the flaky
test_batch_download_session_closed test that would fail when running
the full test suite due to worker_app.db being replaced with a
MagicMock from a previous test.
2026-08-15 09:52:19 +08:00
xiaoxia 0301370dd8 refactor: 统一封面生成管道 — 从渲染后视频抽帧作为封面 (#1371)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m32s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m2s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m19s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Unit Tests (push) Successful in 9m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m53s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-14 22:16:00 +08:00
xiaoxia 85bfe58f39 Merge pull request 'fix: 修复封面标题叠加根因 - custom_title 未传入渲染流程' (#1368) from fix/custom-title-render into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m2s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m51s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Unit Tests (push) Successful in 9m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-14 15:05:23 +08:00
xiaoxia 2ff2798c97 fix: 修复封面标题叠加根因 - custom_title 未传入渲染流程
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m0s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 49s
- _render_video 函数签名新增 custom_title 参数
- 函数体内注入 custom_title 到 plan_config 覆盖模板标题
- 调用处传入 task_info.get('custom_title', '')
2026-08-14 14:54:37 +08:00
xiaoxia 00348a2154 fix(e2e): align confirm generation response assertion with BatchGenerationTaskResponse (#1366)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m12s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m34s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI/CD Pipeline / Unit Tests (push) Successful in 8m56s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-14 13:41:00 +08:00
xiaoxia c00c56a742 fix: overlay title text on auto-generated cover frames (#1365)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m6s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m31s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Unit Tests (push) Successful in 9m31s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 19m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-14 12:11:00 +08:00
xiaoxia 8e4a8a8184 fix: videoRatio aspect ratio parsed as resolution causing scale=9:1920 (#1364)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m32s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m32s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 16m49s
CI/CD Pipeline / Unit Tests (push) Successful in 20m10s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m22s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m7s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 11m8s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-14 00:56:54 +08:00
xiaoxia 6a4085452d fix: cover_templates config double-encoded JSON causing 500 (#1363)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m12s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m18s
CI/CD Pipeline / Unit Tests (push) Successful in 13m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 21m39s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 21m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
2026-08-13 23:55:40 +08:00
xiaoxia 0896f3e161 fix: add ffmpeg to API runtime image for cover fallback (#1362)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m55s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m18s
CI/CD Pipeline / Unit Tests (push) Successful in 10m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 22m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m49s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix: add ffmpeg to API runtime image for cover fallback (#1362)
2026-08-13 21:40:22 +08:00
22 changed files with 595 additions and 1068 deletions
+1 -3
View File
@@ -10,8 +10,6 @@ Changes:
3. config 为 JSON 字段,存储封面配置信息
"""
import json
import sqlalchemy as sa
from alembic import context, op
@@ -73,7 +71,7 @@ def upgrade() -> None:
name=name,
thumbnail_url="",
is_system=True,
config=json.dumps(config),
config=config,
created_at=sa.func.now(),
updated_at=sa.func.now(),
)
@@ -0,0 +1,39 @@
"""修复 cover_templates.config 双重序列化
Revision ID: 056_fix_cover_templates_config
Revises: 055_cover_templates
Create Date: 2026-08-13
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
"""
import sqlalchemy as sa
from alembic import op
revision = "056_fix_cover_templates_config"
down_revision = "055_cover_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
if conn.dialect.name == "postgresql":
conn.execute(
sa.text(
"UPDATE cover_templates SET config = (config#>>'{}')::json "
"WHERE jsonb_typeof(config::jsonb) = 'string'"
)
)
def downgrade() -> None:
# No safe rollback — the original data was incorrect
pass
+33 -21
View File
@@ -185,27 +185,39 @@ def generate_cover(
detail=f"获取预览视频URL失败: {e}",
) from e
# 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回)
cover_candidates = (plan.config or {}).get("cover_candidates", [])
if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"):
logger.info(
"[封面生成] 使用预存封面候选帧: plan_id=%s count=%d",
plan_id,
len(cover_candidates),
)
first_frame = cover_candidates[0]
cover_data = {
"type": "ai_frame",
"image_url": first_frame.get("image_url", ""),
"frame_time": first_frame.get("frame_time", 0.0),
"confidence": 0.9,
}
if cover_data["image_url"]:
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
if body.cover_type in ("ai_frame", "ai_regenerate"):
# 尝试从 GenerationTask 读取已持久化的封面 URL
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""):
cover_data = {
"type": "ai_frame",
"image_url": task.cover_url,
"frame_time": 0.0,
"confidence": 0.95,
}
logger.info(
"[封面生成] 使用统一管道封面: plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
task.cover_url[:80],
)
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
except Exception:
logger.warning(
"[封面生成] 读取 GenerationTask.cover_url 失败: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
exc_info=True,
)
from packages.shared.ai_service import run_generate_cover
+2 -2
View File
@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field, field_validator, model_validator
class ConfirmGenerationRequest(BaseModel):
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
output_width: int = Field(default=1080, description="输出视频宽度")
output_height: int = Field(default=1920, description="输出视频高度")
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="自定义视频标题")
+6 -3
View File
@@ -258,9 +258,12 @@ test.describe("Core generation flow", () => {
// Generate API may return 400 in test env if template has no ready segments
// That is OK for a wizard flow smoke test
if (genResp.ok()) {
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
expect(genData.plan_id).toBeTruthy()
expect(genData.generation_task_id).toBeTruthy()
const genData = (await genResp.json()) as {
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
}
@@ -56,10 +56,41 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
try {
// 使用确认生成 API(基于预览任务)
// 解析分辨率
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
const outputWidth = parseInt(widthStr, 10) || 1080
const outputHeight = parseInt(heightStr, 10) || 1920
// 解析分辨率videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
const ratio = props.videoRatio || "9:16"
let outputWidth: number
let outputHeight: number
if (ratio.includes(":")) {
// 宽高比格式,如 "9:16" → 基于基准高度 1920 计算
const [rw, rh] = ratio.split(":").map(Number)
if (rw > 0 && rh > 0) {
// 基准:长边 1920,短边按比例计算
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
const baseLong = 1920
const baseShort = Math.round((baseLong * shortSide) / longSide)
// 确保偶数(FFmpeg 要求)
const evenShort = baseShort - (baseShort % 2)
if (rw < rh) {
outputWidth = evenShort
outputHeight = baseLong
} else {
outputWidth = baseLong
outputHeight = evenShort
}
} else {
outputWidth = 1080
outputHeight = 1920
}
} else if (ratio.includes("x")) {
// 分辨率格式,如 "1080x1920"
const [wStr, hStr] = ratio.split("x")
outputWidth = parseInt(wStr, 10) || 1080
outputHeight = parseInt(hStr, 10) || 1920
} else {
outputWidth = 1080
outputHeight = 1920
}
await confirmGeneration(props.previewTaskId, {
output_width: outputWidth,
+2 -12
View File
@@ -85,19 +85,9 @@ def create_video_record_and_dedup(
if thumbnail_url:
generated_video.thumbnail_url = thumbnail_url
video_repo.update_thumbnail(video_id, thumbnail_url)
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "")
else:
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
if _thumbnail_url:
generated_video.thumbnail_url = _thumbnail_url
video_repo.update_thumbnail(video_id, _thumbnail_url)
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
except Exception as thumb_err:
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
logger.debug("No thumbnail_url provided for video %s, skipping", video_id)
# 计算视频指纹
deduplicator = VideoDeduplicator()
+30 -29
View File
@@ -47,7 +47,14 @@ def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
w, h = resolution_str.lower().split("x", 1)
width = int(w.strip())
height = int(h.strip())
if width <= 0 or height <= 0:
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
if width < 100 or height < 100:
logger.warning(
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
width,
height,
resolution_str,
)
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
return width, height
except (ValueError, TypeError):
@@ -74,9 +81,7 @@ class RenderAdapterResult:
failed_clip_ids: list[str] = None # 失败的 clip id 列表
error_message: str = ""
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
cover_candidates: list[dict] | None = (
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
cover_url: str = "" # 封面图片 URL(从渲染后视频抽帧,天然带标题)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -555,37 +560,33 @@ class RenderAdapter:
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
output_url = upload_to_oss(result.output_path, storage_key)
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
self._report_progress(progress_cb, 90.0, "抽取封面帧")
# 6. 生成封面缩略图
thumbnail_url = ""
# 6. 从已渲染视频抽取封面帧(标题已通过 ASS 字幕烧录,封面天然带标题)
cover_url = ""
cover_frame_path = None
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
from video_processing.thumbnail_generator import extract_first_frame
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
except Exception as thumb_err:
logger.warning(
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
thumb_err,
)
# 7. 抽取封面候选帧并上传 OSS(失败不阻断主流程)
cover_candidates = None
try:
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
cover_candidates = extract_and_upload_cover_frames(str(result.output_path), plan_id, num_frames=3)
if cover_candidates:
cover_frame_path = extract_first_frame(str(result.output_path), width=640)
cover_storage_key = f"rendered/{plan_id}/cover.jpg"
try:
cover_url = upload_to_oss(cover_frame_path, cover_storage_key) or ""
finally:
if cover_frame_path:
try:
Path(cover_frame_path).unlink(missing_ok=True)
except Exception:
pass
if cover_url:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
"[render-adapter] 封面帧提取成功: plan_id=%s url=%s",
plan_id,
len(cover_candidates),
cover_url[:80],
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
"[render-adapter] 封面帧提取失败(不影响主流程): plan_id=%s error=%s",
plan_id,
cover_err,
)
@@ -613,7 +614,7 @@ class RenderAdapter:
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=thumbnail_url,
thumbnail_url=cover_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
@@ -621,7 +622,7 @@ class RenderAdapter:
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
cover_url=cover_url,
)
def render_from_memory(
@@ -1,4 +1,8 @@
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。
统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频,
渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。
"""
from __future__ import annotations
@@ -13,28 +17,30 @@ def extract_first_frame(
video_path: str,
output_path: str | None = None,
*,
width: int = 640,
width: int = -1,
height: int = -1,
timeout: int = 30,
seek_ratio: float = 0.15,
min_seek_seconds: float = 1.0,
) -> str:
"""抽取视频封面(默认取视频时长 15% 处的帧,避开片头纯色画面)。
"""抽取视频封面(默认取视频时长 15% 处的帧,避开片头纯色画面)。
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
Args:
video_path: 视频文件路径
output_path: 输出图片路径,不传则用临时文件
width: 输出宽度(默认 640-1 表示按比例缩放
height: 输出高度(默认 -1按比例缩放
width: 输出宽度(默认 -1,保持原始分辨率
height: 输出高度(默认 -1保持原始分辨率
timeout: 超时时间(秒)
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
Returns:
生成的缩略图文件路径
生成的封面帧文件路径
Raises:
subprocess.CalledProcessError: ffmpeg 执行失败
RuntimeError: ffmpeg 执行失败或输出文件为空
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
@@ -57,10 +63,20 @@ def extract_first_frame(
# 格式化为 HH:MM:SS.xx
seek_str = _format_seek_time(seek_time)
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
if width > 0 or height > 0:
w_str = str(width) if width > 0 else "-1"
h_str = str(height) if height > 0 else "-1"
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
else:
# 保持原始分辨率,只确保格式兼容
scale_filter = "format=yuvj420p"
# -ss 放在 -i 前面(input seeking,更快)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p"
cmd = [
FFMPEG_BIN,
"-y",
@@ -99,7 +115,7 @@ def extract_first_frame(
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
return output_path
except Exception:
@@ -118,171 +134,3 @@ def _format_seek_time(seconds: float) -> str:
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:05.2f}"
def generate_and_upload_thumbnail(
video_path: str,
storage_key: str,
) -> str | None:
"""生成缩略图并上传到 OSS,返回 URL。
Args:
video_path: 本地视频路径
storage_key: OSS 存储 key(如 generated/projects/xxx/thumbnails/yyy.jpg
Returns:
上传成功返回 URL,失败返回 None
"""
thumbnail_path = None
try:
thumbnail_path = extract_first_frame(video_path)
except Exception as e:
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
return None
try:
from video_processing.oss_helpers import upload_to_oss
url = upload_to_oss(thumbnail_path, storage_key)
return url
except Exception as e:
logger.warning("Failed to upload thumbnail to OSS: %s", e)
return None
finally:
# 清理临时文件
if thumbnail_path:
try:
Path(thumbnail_path).unlink(missing_ok=True)
except Exception:
pass
def extract_cover_candidates(
video_path: str,
num_frames: int = 3,
*,
width: int = 640,
timeout: int = 30,
) -> list[dict]:
"""在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。
Args:
video_path: 视频文件路径
num_frames: 抽帧数量(默认 3
width: 输出宽度
timeout: 单帧超时(秒)
Returns:
[{"local_path": "...", "frame_time": 5.0}, ...]
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
try:
duration = probe_duration(video_path)
except Exception:
duration = 0.0
if duration <= 0:
duration = 5.0 # fallback
# 计算抽帧时间点:25%, 50%, 75%
ratios = []
for i in range(1, num_frames + 1):
ratios.append(i / (num_frames + 1))
results = []
for _idx, ratio in enumerate(ratios):
frame_time = max(0.5, duration * ratio)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
try:
seek_str = _format_seek_time(frame_time)
scale_filter = f"scale={width}:-1:force_original_aspect_ratio=decrease,format=yuvj420p"
cmd = [
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_path,
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
if Path(output_path).exists() and Path(output_path).stat().st_size > 0:
results.append(
{
"local_path": output_path,
"frame_time": round(frame_time, 2),
}
)
else:
Path(output_path).unlink(missing_ok=True)
except Exception as e:
logger.warning("封面候选帧抽取失败 ratio=%.2f: %s", ratio, e)
Path(output_path).unlink(missing_ok=True)
return results
def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
num_frames: int = 3,
) -> list[dict]:
"""抽取封面候选帧并上传到 OSS。
Args:
video_path: 本地视频路径
plan_id: 剪辑计划 ID(用于 OSS 路径)
num_frames: 抽帧数量
Returns:
[{"image_url": "https://...", "frame_time": 5.0, "storage_key": "covers/xxx/frame_0.jpg"}, ...]
"""
candidates = extract_cover_candidates(video_path, num_frames=num_frames)
if not candidates:
logger.warning("封面候选帧抽取为空: plan_id=%s", plan_id)
return []
results = []
for idx, cand in enumerate(candidates):
local_path = cand["local_path"]
frame_time = cand["frame_time"]
storage_key = f"covers/{plan_id}/frame_{idx}.jpg"
try:
from video_processing.oss_helpers import upload_to_oss
url = upload_to_oss(local_path, storage_key)
if url:
results.append(
{
"image_url": url,
"frame_time": frame_time,
"storage_key": storage_key,
}
)
logger.info(
"封面候选帧上传成功: plan_id=%s idx=%d frame_time=%.2f",
plan_id,
idx,
frame_time,
)
except Exception as e:
logger.warning("封面候选帧上传失败: plan_id=%s idx=%d error=%s", plan_id, idx, e)
finally:
try:
Path(local_path).unlink(missing_ok=True)
except Exception:
pass
return results
+63 -5
View File
@@ -1123,14 +1123,15 @@ def _render_video(
resolution: str = "",
bgm_config: dict | None = None,
voice_ids: list[str] | None = None,
) -> tuple[Path, float]:
custom_title: str = "",
) -> tuple[Path, float, str]:
"""渲染视频(含配音混音)。
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/封面抽取逻辑。
Args:
Returns:
(output_path, render_duration)
(output_path, render_duration, cover_url)
"""
if not downloaded_videos:
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
@@ -1169,6 +1170,20 @@ def _render_video(
merged_bgm.get("source", ""),
)
# 用户自定义标题覆盖模板标题(用户指定优先级最高)
if custom_title and custom_title.strip():
plan_cfg = dict(virtual_plan.config or {})
title_cfg = dict(plan_cfg.get("title", {}) or {})
title_cfg["text"] = custom_title.strip()
title_cfg["enabled"] = True
plan_cfg["title"] = title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 用户自定义标题已注入: title=%s",
task_id,
custom_title[:50],
)
# 确保输出分辨率配置存在
# 优先级:用户指定 > 模板配置 > 默认 1280x720
# 预览模式:强制 854x480 + 低码率
@@ -1244,8 +1259,9 @@ def _render_video(
# 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理
output_path = render_output_path
cover_url = getattr(render_result, "cover_url", "") or ""
return output_path, render_duration
return output_path, render_duration, cover_url
def _upload_and_record(
@@ -1256,6 +1272,7 @@ def _upload_and_record(
editing_mode,
user_id: str = "",
video_name: str = "",
thumbnail_url: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
@@ -1501,12 +1518,22 @@ def generate_video(self, task_id: str) -> dict:
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
# 防护:前端可能误传宽高比(如 parseInt("9:16") = 9),宽度 < 100 时忽略
if _ow < 100 or _oh < 100:
logger.warning(
"[task_id=%s] output_width/output_height 异常 (%dx%d),回退到默认",
task_id,
_ow,
_oh,
)
_ow = OUTPUT_WIDTH
_oh = OUTPUT_HEIGHT
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
_resolved_resolution = f"{_ow}x{_oh}"
else:
_resolved_resolution = task_info.get("resolution", "")
output_path, render_duration = _render_video(
output_path, render_duration, cover_url = _render_video(
task_id=task_id,
downloaded_videos=downloaded_videos,
voice_path=audio_path,
@@ -1519,12 +1546,42 @@ def generate_video(self, task_id: str) -> dict:
resolution=_resolved_resolution,
bgm_config=task_info.get("bgm_config", {}),
voice_ids=task_info.get("voice_ids", []),
custom_title=task_info.get("custom_title", ""),
)
if gen_task:
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
_flush_logs(task_id, gen_task)
# 持久化封面 URL 到 GenerationTask(统一封面管道:从渲染后视频抽帧)
if cover_url:
_cover_session = None
try:
_cover_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
_cover_model = (
_cover_session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
)
if _cover_model:
_cover_model.cover_url = cover_url
_cover_session.commit()
logger.info(
"[task_id=%s] 封面URL已持久化: %s",
task_id,
cover_url[:80],
)
finally:
if _cover_session:
_cover_session.close()
except Exception as cover_err:
logger.warning(
"[task_id=%s] 封面URL持久化失败(不影响主流程): %s",
task_id,
cover_err,
)
_update_task_progress(task_id, 80, "渲染完成")
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────────────
@@ -1537,6 +1594,7 @@ def generate_video(self, task_id: str) -> dict:
editing_mode=editing_mode,
user_id=user_id,
video_name=task_info.get("video_title", ""),
thumbnail_url=cover_url,
)
if gen_task:
+12 -2
View File
@@ -206,11 +206,21 @@ def ingest_asset(job_id: str) -> dict:
# 视频类型:生成缩略图(文件还在的时候生成)
thumbnail_url = None
if media_type == "video" and extract_success:
frame_path = None
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
from video_processing.oss_helpers import upload_to_oss
from video_processing.thumbnail_generator import extract_first_frame
frame_path = extract_first_frame(str(local_file), width=640)
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
try:
thumbnail_url = upload_to_oss(frame_path, thumb_storage_key)
finally:
if frame_path:
try:
Path(frame_path).unlink(missing_ok=True)
except Exception:
pass
if thumbnail_url:
logger.info(
"素材缩略图生成成功: job_id=%s url=%s",
+2 -1
View File
@@ -53,9 +53,10 @@ ARG APP_VERSION=dev
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖)
# 只装运行时需要的库(libpq5 是 psycopg2 运行时依赖ffmpeg 用于封面兜底取帧
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制虚拟环境
+11 -174
View File
@@ -13,8 +13,6 @@ import random
import time
from typing import Any, Dict, List, Optional
import requests as http_requests
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
from packages.shared.ai_client import get_doubao_client
@@ -354,93 +352,6 @@ def _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str:
return frame_url
def _extract_frames_with_ffmpeg(
video_url: str,
num_frames: int = 3,
timeout: int = 30,
) -> list[dict]:
"""用 FFmpeg 从远程视频 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)。
Args:
video_url: 视频 URL
num_frames: 抽帧数量
timeout: 单帧超时(秒)
Returns:
[{"local_path": "...", "frame_time": 5.0}, ...]
"""
import re as _re
import tempfile
from pathlib import Path as _Path
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
video_url = _re.sub(r"(?<!:)//", "/", video_url)
# 先用 ffprobe 获取视频时长
import subprocess as _subprocess
from packages.shared.ffmpeg_utils import FFPROBE_BIN
duration = 30.0 # 默认假设 30 秒
try:
probe_result = _subprocess.run(
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_url,
],
capture_output=True,
text=True,
timeout=15,
)
if probe_result.returncode == 0 and probe_result.stdout.strip():
duration = float(probe_result.stdout.strip())
except Exception as e:
logger.warning("FFprobe 远程视频时长失败,使用默认值: %s", e)
ratios = [i / (num_frames + 1) for i in range(1, num_frames + 1)]
results = []
for _idx, ratio in enumerate(ratios):
frame_time = max(0.5, duration * ratio)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
try:
seek_str = f"{int(frame_time // 3600):02d}:{int((frame_time % 3600) // 60):02d}:{frame_time % 60:05.2f}"
cmd = [
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_url,
"-vframes",
"1",
"-q:v",
"2",
output_path,
]
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
if _Path(output_path).exists() and _Path(output_path).stat().st_size > 0:
results.append({"local_path": output_path, "frame_time": round(frame_time, 2)})
else:
_Path(output_path).unlink(missing_ok=True)
except Exception as e:
logger.warning("FFmpeg 远程抽帧失败 ratio=%.2f: %s", ratio, e)
_Path(output_path).unlink(missing_ok=True)
return results
def _call_ai_cover_service(
plan_id: str,
asset_ids: List[str],
@@ -450,9 +361,9 @@ def _call_ai_cover_service(
) -> Dict[str, Any]:
"""调用 AI 封面生成服务.
优先级:
1. 检查 plan.config 中的 cover_candidates(渲染时预抽帧)——由调用方处理
2. FFmpeg 本地从 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)
统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。
此函数仅处理 manual/upload 等需要前端交互的类型,
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。
失败时抛出 RuntimeError。
@@ -484,88 +395,14 @@ def _call_ai_cover_service(
"frame_time": frame_time,
}
# ai_frame / ai_regenerate - 使用 FFmpeg 本地抽帧
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
# 先检查视频 URL 是否可访问
try:
head_resp = http_requests.head(primary_video_url, timeout=10, allow_redirects=True)
if head_resp.status_code != 200:
logger.error(
"封面视频URL不可访问: plan_id=%s url=%s status=%d",
plan_id,
primary_video_url,
head_resp.status_code,
)
raise RuntimeError(
f"封面生成失败: 预览视频URL不可访问 (HTTP {head_resp.status_code})。" f"请重新生成预览视频后再试。"
)
except http_requests.RequestException as e:
logger.error("封面视频URL连通性检查失败: plan_id=%s url=%s error=%s", plan_id, primary_video_url, e)
raise RuntimeError(
f"封面生成失败: 无法访问预览视频 ({e.__class__.__name__})。请重新生成预览视频后再试。"
) from e
# 使用 FFmpeg 从 URL 流式 seek 抽帧
try:
logger.info("FFmpeg 远程抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = _extract_frames_with_ffmpeg(primary_video_url, num_frames=3)
if frames:
best_frame = frames[0]
local_path = best_frame["local_path"]
frame_time_val = best_frame["frame_time"]
# 上传到 OSS
try:
import uuid
from pathlib import Path
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/ffmpeg_frame_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
file_or_path=local_path,
storage_key=cover_key,
content_type="image/jpeg",
)
public_url = storage.get_url(cover_key)
logger.info(
"FFmpeg 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id,
frame_time_val,
public_url[:80],
)
return {
"type": "ai_frame",
"image_url": public_url,
"frame_time": round(frame_time_val, 1),
"confidence": 0.85,
}
finally:
# 清理所有临时文件
for frame in frames:
try:
Path(frame["local_path"]).unlink(missing_ok=True)
except Exception:
pass
except RuntimeError:
raise
except Exception as e:
logger.exception("FFmpeg 远程抽帧失败: %s", str(e))
# 封面生成失败
raise RuntimeError(f"封面生成失败: plan_id={plan_id},无法从视频抽帧。请检查 primary_video_url 是否可访问。")
# ── 公共入口 ────────────────────────────────────────────────────────────────
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
# 如果调用方传入了持久化的封面 URL,直接使用
logger.warning(
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
plan_id,
cover_type,
)
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
def run_ai_recommend(
@@ -158,7 +158,7 @@ class TestRenderVideoVoiceInjection:
from packages.domain import EditingMode
output_path, render_duration = _render_video(
output_path, render_duration, _cover_url = _render_video(
task_id="test_task_123",
downloaded_videos=[Path("/tmp/video1.mp4")],
voice_path=None,
+15
View File
@@ -13,6 +13,21 @@ from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(autouse=True)
def _reset_worker_app_db():
"""Prevent mock pollution from leaking between tests."""
import importlib
import sys
# Save original module reference
original = sys.modules.get("worker_app.db")
yield
# Restore original module after each test
if original is not None:
sys.modules["worker_app.db"] = original
# ── Fake repository ─────────────────────────────────────────────────────────
@@ -237,7 +237,7 @@ class TestAIRunTasks:
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
with pytest.raises(RuntimeError, match="封面数据不可用"):
run_generate_cover(
plan_id="plan-001",
asset_ids=["asset-1"],
+113 -450
View File
@@ -1,486 +1,149 @@
"""Tests for cover frame pre-extraction during rendering.
"""Tests for unified cover frame extraction pipeline.
Tests:
- extract_cover_candidates: FFmpeg frame extraction at 25%/50%/75%
- extract_and_upload_cover_frames: extraction + OSS upload
- RenderAdapterResult.cover_candidates field
- generation_cover route uses pre-stored candidates
- ai_service FFmpeg fallback
统一封面管道测试:
- extract_first_frame: 从已渲染视频抽取封面帧
- 封面天然带标题(ASS 字幕已烧录到视频中)
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, call, patch
import pytest
from unittest.mock import patch
class TestExtractCoverCandidates:
"""extract_cover_candidates 测试."""
class TestExtractFirstFrame(unittest.TestCase):
"""extract_first_frame 单元测试."""
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=20.0)
def test_extracts_3_frames_at_correct_positions(self, mock_probe, mock_run):
"""在 25%/50%/75% 处抽取 3 帧."""
import tempfile
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_extracts_frame_at_default_ratio(self, mock_probe, mock_run):
"""默认在视频 15% 处抽帧."""
from video_processing.thumbnail_generator import extract_first_frame
from video_processing.thumbnail_generator import extract_cover_candidates
# Mock run_ffmpeg 创建输出文件(ffmpeg 真实行为)
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
# Create temp files that look like they were created
def fake_run(cmd, **kwargs):
# Find the output path (last arg)
output_path = cmd[-1]
Path(output_path).write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
return ("", "")
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
result = extract_first_frame(video.name)
mock_run.side_effect = fake_run
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake video")
video_path = tmp.name
try:
results = extract_cover_candidates(video_path, num_frames=3)
assert len(results) == 3
# Check frame times: 20*0.25=5.0, 20*0.5=10.0, 20*0.75=15.0
assert results[0]["frame_time"] == 5.0
assert results[1]["frame_time"] == 10.0
assert results[2]["frame_time"] == 15.0
# Check local paths exist
for r in results:
assert Path(r["local_path"]).exists()
# Clean up
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
finally:
Path(video_path).unlink(missing_ok=True)
self.assertTrue(Path(result).exists())
# 验证 ffmpeg 被调用
mock_run.assert_called()
cmd = mock_run.call_args[0][0]
self.assertIn("-vframes", cmd)
self.assertIn("1", cmd)
Path(result).unlink(missing_ok=True)
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=20.0)
def test_handles_ffmpeg_failure_gracefully(self, mock_probe, mock_run):
"""FFmpeg 失败时跳过该帧,继续抽取其他帧."""
import tempfile
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_custom_seek_ratio(self, mock_probe, mock_run):
"""自定义抽帧位置."""
from video_processing.thumbnail_generator import extract_first_frame
from video_processing.thumbnail_generator import extract_cover_candidates
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
call_count = 0
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
result = extract_first_frame(video.name, seek_ratio=0.5)
def fake_run(cmd, **kwargs):
nonlocal call_count
call_count += 1
output_path = cmd[-1]
if call_count == 2:
# Second frame fails - don't create file
raise RuntimeError("ffmpeg error")
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return ("", "")
self.assertTrue(Path(result).exists())
# 50% of 10s = 5s
cmd = mock_run.call_args[0][0]
ss_idx = cmd.index("-ss") + 1
seek_val = cmd[ss_idx]
# Should be around 5 seconds
self.assertIn("05", seek_val)
Path(result).unlink(missing_ok=True)
mock_run.side_effect = fake_run
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_output_path_parameter(self, mock_probe, mock_run):
"""指定输出路径."""
from video_processing.thumbnail_generator import extract_first_frame
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake video")
video_path = tmp.name
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as out:
pass # just get a path
try:
results = extract_cover_candidates(video_path, num_frames=3)
# Should get 2 frames (1st and 3rd), 2nd failed
assert len(results) == 2
finally:
Path(video_path).unlink(missing_ok=True)
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
# Create the file so ffmpeg "succeeds"
mock_run.side_effect = lambda *a, **k: Path(out.name).write_bytes(b"fake image")
@patch("video_processing.ffmpeg_utils.probe_duration", side_effect=Exception("probe failed"))
def test_fallback_duration_when_probe_fails(self, mock_probe):
"""probe 失败时使用默认时长."""
import tempfile
result = extract_first_frame(video.name, output_path=out.name)
self.assertEqual(result, out.name)
Path(out.name).unlink(missing_ok=True)
from video_processing.thumbnail_generator import extract_cover_candidates
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_keeps_original_resolution_by_default(self, mock_probe, mock_run):
"""默认保持原始分辨率(width=-1, height=-1."""
from video_processing.thumbnail_generator import extract_first_frame
# Mock run_ffmpeg to create output files
def fake_run(cmd, **kwargs):
output_path = cmd[-1]
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return ("", "")
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
with patch("video_processing.ffmpeg_utils.run_ffmpeg", side_effect=fake_run):
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
tmp.write(b"fake")
video_path = tmp.name
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
result = extract_first_frame(video.name)
try:
results = extract_cover_candidates(video_path, num_frames=3)
assert len(results) == 3
# Default duration is 5.0, so times should be 5*0.25=1.25, 5*0.5=2.5, 5*0.75=3.75
assert results[0]["frame_time"] == 1.25
assert results[1]["frame_time"] == 2.5
assert results[2]["frame_time"] == 3.75
finally:
Path(video_path).unlink(missing_ok=True)
for r in results:
Path(r["local_path"]).unlink(missing_ok=True)
cmd = mock_run.call_args[0][0]
vf_idx = cmd.index("-vf") + 1
vf_filter = cmd[vf_idx]
# Should NOT have scale filter (only format)
self.assertNotIn("scale", vf_filter)
self.assertIn("format", vf_filter)
Path(result).unlink(missing_ok=True)
@patch("video_processing.ffmpeg_utils.run_ffmpeg")
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_custom_width_triggers_scale(self, mock_probe, mock_run):
"""指定宽度时添加 scale 滤镜."""
from video_processing.thumbnail_generator import extract_first_frame
mock_run.side_effect = lambda cmd, **kw: Path(cmd[-1]).write_bytes(b"\xff\xd8\xff\xe0fake")
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
result = extract_first_frame(video.name, width=640)
cmd = mock_run.call_args[0][0]
vf_idx = cmd.index("-vf") + 1
vf_filter = cmd[vf_idx]
self.assertIn("scale=640", vf_filter)
Path(result).unlink(missing_ok=True)
@patch("video_processing.ffmpeg_utils.run_ffmpeg", side_effect=RuntimeError("fail"))
@patch("video_processing.ffmpeg_utils.probe_duration", return_value=10.0)
def test_cleanup_temp_file_on_failure(self, mock_probe, mock_run):
"""失败时清理临时文件."""
from video_processing.thumbnail_generator import extract_first_frame
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
Path(video.name).write_bytes(b"fake video")
with self.assertRaises(RuntimeError):
extract_first_frame(video.name)
class TestExtractAndUploadCoverFrames:
"""extract_and_upload_cover_frames 测试."""
class TestRenderAdapterCoverUrl(unittest.TestCase):
"""RenderAdapterResult.cover_url 字段测试."""
@patch("video_processing.oss_helpers.upload_to_oss")
@patch("video_processing.thumbnail_generator.extract_cover_candidates")
def test_uploads_and_returns_correct_format(self, mock_extract, mock_upload):
"""上传帧到 OSS 并返回正确格式."""
import tempfile
def test_result_has_cover_url_field(self):
"""RenderAdapterResult 包含 cover_url 字段."""
from video_processing.render_adapter import RenderAdapterResult
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
result = RenderAdapterResult(success=True, cover_url="https://example.com/cover.jpg")
self.assertEqual(result.cover_url, "https://example.com/cover.jpg")
# Create actual temp files
tmp1 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp1.write(b"\xff\xd8" + b"\x00" * 50)
tmp1.close()
tmp2 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp2.write(b"\xff\xd8" + b"\x00" * 50)
tmp2.close()
mock_extract.return_value = [
{"local_path": tmp1.name, "frame_time": 5.0},
{"local_path": tmp2.name, "frame_time": 10.0},
]
mock_upload.side_effect = [
"https://oss.example.com/covers/plan1/frame_0.jpg",
"https://oss.example.com/covers/plan1/frame_1.jpg",
]
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert len(results) == 2
assert results[0]["image_url"] == "https://oss.example.com/covers/plan1/frame_0.jpg"
assert results[0]["frame_time"] == 5.0
assert results[0]["storage_key"] == "covers/plan1/frame_0.jpg"
assert results[1]["image_url"] == "https://oss.example.com/covers/plan1/frame_1.jpg"
assert results[1]["frame_time"] == 10.0
@patch("video_processing.thumbnail_generator.extract_cover_candidates", return_value=[])
def test_returns_empty_when_no_candidates(self, mock_extract):
"""没有候选帧时返回空列表."""
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert results == []
@patch("video_processing.oss_helpers.upload_to_oss", side_effect=Exception("OSS error"))
@patch("video_processing.thumbnail_generator.extract_cover_candidates")
def test_handles_upload_failure_gracefully(self, mock_extract, mock_upload):
"""上传失败时跳过该帧."""
import tempfile
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
tmp1 = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp1.write(b"\xff\xd8" + b"\x00" * 50)
tmp1.close()
mock_extract.return_value = [
{"local_path": tmp1.name, "frame_time": 5.0},
]
results = extract_and_upload_cover_frames("/tmp/video.mp4", "plan1")
assert results == []
class TestRenderAdapterResultCoverCandidates:
"""RenderAdapterResult 的 cover_candidates 字段."""
def test_default_none(self):
"""默认为 None."""
def test_result_cover_url_defaults_empty(self):
"""cover_url 默认为空字符串."""
from video_processing.render_adapter import RenderAdapterResult
result = RenderAdapterResult(success=True)
assert result.cover_candidates is None
def test_can_set_candidates(self):
"""可以设置候选帧列表."""
from video_processing.render_adapter import RenderAdapterResult
candidates = [
{"image_url": "https://example.com/frame_0.jpg", "frame_time": 5.0, "storage_key": "covers/p1/frame_0.jpg"},
]
result = RenderAdapterResult(success=True, cover_candidates=candidates)
assert len(result.cover_candidates) == 1
assert result.cover_candidates[0]["frame_time"] == 5.0
self.assertEqual(result.cover_url, "")
class TestAICoverServiceFFmpegFallback:
"""AI 封面服务 FFmpeg 兜底测试."""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_ffmpeg_fallback_success(self, mock_ffmpeg, mock_head):
"""FFmpeg 兜底抽帧成功."""
import tempfile
mock_head.return_value.status_code = 200
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 5.0}]
# Mock storage
with patch("packages.shared.storage.get_shared_storage_service") as mock_storage_fn:
mock_storage = Mock()
mock_storage.upload_file = Mock()
mock_storage.get_url.return_value = "https://oss.example.com/covers/plan1/ffmpeg_frame_abc.jpg"
mock_storage_fn.return_value = mock_storage
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "ai_frame"
assert result["image_url"] == "https://oss.example.com/covers/plan1/ffmpeg_frame_abc.jpg"
assert result["frame_time"] == 5.0
assert result["confidence"] == 0.85
Path(tmp.name).unlink(missing_ok=True)
@patch("packages.shared.ai_service.http_requests.head")
def test_ffmpeg_no_video_url_raises(self, mock_head):
"""没有视频 URL 时抛出 RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url=None,
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_ffmpeg_no_frames_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 抽帧为空时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
def test_upload_type_returns_immediately(self):
"""upload 类型直接返回."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="upload",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "upload"
def test_manual_type_returns_immediately(self):
"""manual 类型直接返回."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="manual",
frame_time=5.0,
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "manual"
assert result["frame_time"] == 5.0
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_video_url_unreachable_raises(self, mock_ffmpeg, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
mock_head.return_value.status_code = 404
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
_call_ai_cover_service(
plan_id="plan1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
class TestCoverTemplatesFix:
"""CoverTemplateResponse config=None 修复测试."""
def test_config_none_becomes_empty_dict(self):
"""config=None 时 CoverTemplateResponse 不报 ValidationError."""
from datetime import datetime
from app.schemas.cover_template import CoverTemplateResponse
# This should not raise
resp = CoverTemplateResponse(
id="1",
name="test",
thumbnail_url="",
is_system=True,
created_at=datetime.now(),
config={},
)
assert resp.config == {}
class TestExtractFramesWithFFmpeg:
"""_extract_frames_with_ffmpeg 单元测试."""
def test_extracts_frames_with_correct_seek_times(self):
"""抽帧时间点正确计算."""
import subprocess
import tempfile
# Mock ffprobe to return duration
mock_probe_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="20.0\n", stderr="")
with patch("subprocess.run", return_value=mock_probe_result) as mock_subproc:
# First call is ffprobe, rest are ffmpeg
call_count = 0
def side_effect(cmd, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# ffprobe call
return mock_probe_result
else:
# ffmpeg call - create output file
output_path = cmd[-1]
from pathlib import Path
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
mock_subproc.side_effect = side_effect
from packages.shared.ai_service import _extract_frames_with_ffmpeg
results = _extract_frames_with_ffmpeg("https://example.com/video.mp4", num_frames=3)
assert len(results) == 3
# 20 * 0.25 = 5.0, 20 * 0.5 = 10.0, 20 * 0.75 = 15.0
assert results[0]["frame_time"] == 5.0
assert results[1]["frame_time"] == 10.0
assert results[2]["frame_time"] == 15.0
# Clean up
for r in results:
from pathlib import Path
Path(r["local_path"]).unlink(missing_ok=True)
def test_handles_ffmpeg_failure(self):
"""FFmpeg 失败时跳过该帧."""
import subprocess
import tempfile
from pathlib import Path
mock_probe_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="10.0\n", stderr="")
call_count = 0
def side_effect(cmd, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return mock_probe_result
output_path = cmd[-1]
if call_count == 2:
# First frame succeeds
Path(output_path).write_bytes(b"\xff\xd8" + b"\x00" * 50)
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
else:
# Other frames fail
raise subprocess.CalledProcessError(1, cmd)
with patch("subprocess.run", side_effect=side_effect):
from packages.shared.ai_service import _extract_frames_with_ffmpeg
results = _extract_frames_with_ffmpeg("https://example.com/video.mp4", num_frames=3)
assert len(results) == 1
Path(results[0]["local_path"]).unlink(missing_ok=True)
class TestGenerationCoverPreStored:
"""generation_cover.py 预存帧逻辑测试."""
def test_pre_stored_candidates_used_when_available(self):
"""有预存帧时直接使用,不调用 AI 服务."""
from unittest.mock import patch
# Mock the dependencies
mock_plan = MagicMock()
mock_plan.config = {
"cover_candidates": [
{
"image_url": "https://oss.example.com/covers/p1/frame_0.jpg",
"frame_time": 5.0,
"storage_key": "covers/p1/frame_0.jpg",
},
{
"image_url": "https://oss.example.com/covers/p1/frame_1.jpg",
"frame_time": 10.0,
"storage_key": "covers/p1/frame_1.jpg",
},
],
"rendered_storage_key": "rendered/p1/video.mp4",
}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_body = MagicMock()
mock_body.asset_ids = ["a1"]
mock_body.cover_type = "ai_frame"
mock_body.frame_time = None
with (
patch("app.api.routes.generation_cover.get_editor_services") as mock_services,
patch("app.api.routes.generation_cover.get_db_session"),
patch("app.api.routes.generation_cover.get_current_user"),
patch("app.api.routes.generation_cover.get_draft_plan_id", return_value="p1"),
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_services.return_value = (MagicMock(), mock_plan_svc)
mock_normalize.side_effect = lambda c: c
from app.api.routes.generation_cover import GenerateCoverRequest, generate_cover
result = generate_cover(
body=mock_body,
template_id="t1",
plan_id="p1",
services=(MagicMock(), mock_plan_svc),
db=MagicMock(),
current_user=MagicMock(),
)
assert result.plan_id == "p1"
assert result.cover["type"] == "ai_frame"
assert result.cover["image_url"] == "https://oss.example.com/covers/p1/frame_0.jpg"
assert result.cover["frame_time"] == 5.0
if __name__ == "__main__":
unittest.main()
@@ -310,9 +310,9 @@ class TestThumbnailInDedupHelpers:
mock_dedup.VideoDeduplicator = MagicMock()
sys.modules["video_processing.dedup"] = mock_dedup
# mock video_processing.thumbnail_generator
# mock video_processing.thumbnail_generator (统一封面管道: 仅保留 extract_first_frame)
mock_thumb = MagicMock()
mock_thumb.generate_and_upload_thumbnail = MagicMock()
mock_thumb.extract_first_frame = MagicMock()
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
# 关键:给 video_processing 包设置子模块属性,让 patch() 能通过属性访问找到
@@ -322,7 +322,7 @@ class TestThumbnailInDedupHelpers:
video_processing.thumbnail_generator = mock_thumb
def test_pre_generated_thumbnail_url_is_reused(self):
"""传入 thumbnail_url 时直接复用,不调用 generate_and_upload_thumbnail"""
"""传入 thumbnail_url 时直接复用,统一封面管道不再自动生成缩略图"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
@@ -339,26 +339,23 @@ class TestThumbnailInDedupHelpers:
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail") as mock_gen:
result = create_video_record_and_dedup(
generation_task_id="task-thumb-reuse",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
thumbnail_url=pre_thumb_url,
)
result = create_video_record_and_dedup(
generation_task_id="task-thumb-reuse",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
thumbnail_url=pre_thumb_url,
)
assert result == 1
# 预生成缩略图时不应调用 generate_and_upload_thumbnail
mock_gen.assert_not_called()
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
@@ -368,8 +365,8 @@ class TestThumbnailInDedupHelpers:
finally:
session.close()
def test_thumbnail_generated_when_not_provided(self):
"""未传 thumbnail_url 时调用 generate_and_upload_thumbnail 生成。"""
def test_no_thumbnail_when_not_provided(self):
"""未传 thumbnail_url 时不生成缩略图(统一封面管道已移除自动缩略图生成"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
@@ -377,8 +374,6 @@ class TestThumbnailInDedupHelpers:
Session = sessionmaker(bind=engine)
session = Session()
generated_thumb_url = "https://oss.example.com/generated-thumb.jpg"
try:
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
mock_dedup = mock_dedup_cls.return_value
@@ -386,43 +381,34 @@ class TestThumbnailInDedupHelpers:
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
return_value=generated_thumb_url,
) as mock_gen:
result = create_video_record_and_dedup(
generation_task_id="task-thumb-gen",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
result = create_video_record_and_dedup(
generation_task_id="task-thumb-gen",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
assert result == 1
# 应调用一次缩略图生成
mock_gen.assert_called_once()
# 验证参数:video_path 和 storage_key
call_args = mock_gen.call_args
assert call_args[0][0] == "/tmp/fake.mp4"
assert "thumbnails" in call_args[0][1]
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-gen").first()
assert video is not None
assert video.thumbnail_url == generated_thumb_url
# 统一封面管道下,不传 thumbnail_url 时不自动生成
assert not video.thumbnail_url
finally:
session.close()
def test_thumbnail_generation_failure_does_not_block(self):
"""缩略图生成失败不影响主流程"""
def test_no_thumbnail_does_not_block(self):
"""统一封面管道下,缩略图不再在 dedup 阶段生成"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
@@ -437,24 +423,20 @@ class TestThumbnailInDedupHelpers:
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
side_effect=RuntimeError("cv2 not found"),
):
result = create_video_record_and_dedup(
generation_task_id="task-thumb-fail",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
result = create_video_record_and_dedup(
generation_task_id="task-thumb-fail",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
assert result == 1 # 不阻断
+117
View File
@@ -92,3 +92,120 @@ def test_generation_cover_request_validation():
req2 = GenerateCoverRequest(cover_type="upload", asset_ids=["a1", "a2"])
assert req2.cover_type == "upload"
assert req2.asset_ids == ["a1", "a2"]
class TestUnifiedCoverPipelineEndpoint:
"""测试统一封面管道在 generate_cover endpoint 中的逻辑 (lines 189-215)."""
def test_cover_url_from_generation_task(self):
"""当 GenerationTask 有 cover_url 时,直接返回该 URL 作为封面。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse
# Mock plan with rendered_storage_key (so we skip the 3-step lookup)
mock_plan = MagicMock()
mock_plan.config = {
"generation_task_id": "task-123",
"rendered_storage_key": "rendered/plan-1/video.mp4",
}
# Mock plan_svc
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
# Mock template_svc
mock_template_svc = MagicMock()
# Mock generation task with cover_url
mock_task = MagicMock()
mock_task.cover_url = "https://oss.example.com/rendered/plan-1/cover.jpg"
mock_db = MagicMock()
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_task
mock_repo_cls.return_value = mock_repo
# normalize_plan_config should return the config with cover
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/rendered/plan-1/cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="template-1",
plan_id="plan-1",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=MagicMock(),
)
# 验证返回的封面数据来自 GenerationTask.cover_url
assert result.plan_id == "plan-1"
assert result.cover["image_url"] == "https://oss.example.com/rendered/plan-1/cover.jpg"
assert result.cover["type"] == "ai_frame"
def test_cover_url_fallback_when_no_cover_url(self):
"""当 GenerationTask 没有 cover_url 时,跳过统一管道走 run_generate_cover。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {
"generation_task_id": "task-456",
"rendered_storage_key": "rendered/plan-2/video.mp4",
}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Task has no cover_url
mock_task = MagicMock()
mock_task.cover_url = ""
mock_db = MagicMock()
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.ai_service.run_generate_cover") as mock_run,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_task
mock_repo_cls.return_value = mock_repo
# Mock storage service
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/plan-2/video.mp4"
mock_storage_getter.return_value = mock_storage_svc
mock_run.return_value = {"type": "ai_frame", "image_url": "https://fallback.com/cover.jpg"}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="template-2",
plan_id="plan-2",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=MagicMock(),
)
# 应该走 run_generate_cover 回退
mock_run.assert_called_once()
assert result.cover["image_url"] == "https://fallback.com/cover.jpg"
+17 -103
View File
@@ -141,93 +141,13 @@ class TestMediaKitClient:
class TestAICoverService:
"""AI 封面服务测试(已迁移到 FFmpeg 本地抽帧)。"""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_with_ffmpeg_success(self, mock_ffmpeg, mock_head):
"""FFmpeg 本地抽帧成功."""
import tempfile
mock_head.return_value.status_code = 200
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 3.5}]
with patch("packages.shared.storage.get_shared_storage_service") as mock_storage_fn:
mock_storage = Mock()
mock_storage.upload_file = Mock()
mock_storage.get_url.return_value = "https://example.com/frame.jpg"
mock_storage_fn.return_value = mock_storage
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "ai_frame"
assert result["image_url"] == "https://example.com/frame.jpg"
assert result["frame_time"] == 3.5
assert result["confidence"] == 0.85
Path(tmp.name).unlink(missing_ok=True)
@patch("packages.shared.ai_service.http_requests.head")
def test_call_ai_cover_video_url_unreachable(self, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
mock_head.return_value.status_code = 404
"""AI 封面服务测试(统一封面管道后)。"""
def test_call_ai_cover_ai_frame_raises(self):
"""ai_frame type raises RuntimeError in unified pipeline."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/nonexistent.mp4",
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_url_double_slash_normalized(self, mock_ffmpeg, mock_head):
"""URL 路径中的双斜杠应被规范化."""
dirty_url = "https://oss.example.com/generated/projects//tasks/abc123/rendered.mp4"
clean_url = "https://oss.example.com/generated/projects/tasks/abc123/rendered.mp4"
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError):
_call_ai_cover_service(
plan_id="plan-1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url=dirty_url,
)
# HEAD 请求使用规范化后的 URL
mock_head.assert_called_once()
assert mock_head.call_args[0][0] == clean_url
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_ffmpeg_failure_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 抽帧失败时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.side_effect = Exception("ffmpeg error")
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
with pytest.raises(RuntimeError, match="封面数据不可用"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -235,11 +155,22 @@ class TestAICoverService:
primary_video_url="https://example.com/video.mp4",
)
def test_call_ai_cover_ai_regenerate_raises(self):
"""ai_regenerate type raises RuntimeError in unified pipeline."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="封面数据不可用"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_regenerate",
)
def test_call_ai_cover_without_video_url_raises(self):
"""没有视频 URL 时抛出 RuntimeError."""
"""ai_frame without video URL still raises RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
with pytest.raises(RuntimeError, match="封面数据不可用"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -276,23 +207,6 @@ class TestAICoverService:
assert result["type"] == "manual"
assert result["frame_time"] == 5.0
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_empty_frames_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 返回空帧列表时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
class TestGenerateCover:
"""run_generate_cover 测试."""
+15 -7
View File
@@ -348,24 +348,32 @@ class TestRenderPlan:
mock_render_cls.return_value = mock_render
mock_upload.return_value = "https://oss.example.com/out.mp4"
fake_thumb = "https://oss.example.com/rendered/plan_thumb/thumbnail.jpg"
plan = FakePlan(id="plan_thumb")
clips = [_make_clip("c1", order=0, duration=5.0)]
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
# Mock extract_first_frame to return a temp file path
import tempfile as _tf
_fake_frame = _tf.NamedTemporaryFile(suffix=".jpg", delete=False)
_fake_frame.write(b"fake frame")
_fake_frame.close()
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
return_value=fake_thumb,
"video_processing.thumbnail_generator.extract_first_frame",
return_value=_fake_frame.name,
):
result = adapter.render_plan(
"plan_thumb",
work_dir=tmp_path / "work",
)
from pathlib import Path as _P
_P(_fake_frame.name).unlink(missing_ok=True)
assert result.success
assert result.thumbnail_url == fake_thumb
# cover_url from upload_to_oss (mocked globally)
assert result.thumbnail_url == "https://oss.example.com/out.mp4"
@patch("video_processing.render_adapter.upload_to_oss")
@patch("video_processing.render_adapter.UnifiedRenderService")
@@ -397,8 +405,8 @@ class TestRenderPlan:
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
side_effect=RuntimeError("cv2 not available"),
"video_processing.thumbnail_generator.extract_first_frame",
side_effect=RuntimeError("ffmpeg not available"),
):
result = adapter.render_plan(
"plan_thumb_fail",
+2 -2
View File
@@ -436,12 +436,12 @@ class TestAiCoverService:
def test_cover_type_ai_frame_raises_without_mediakit(self):
"""ai_frame mode raises RuntimeError when MediaKit is unavailable."""
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
with pytest.raises(RuntimeError, match="封面数据不可用"):
_call_ai_cover_service("plan1", ["a1"], "ai_frame")
def test_cover_type_ai_regenerate_raises_without_mediakit(self):
"""ai_regenerate mode raises RuntimeError when MediaKit is unavailable."""
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
with pytest.raises(RuntimeError, match="封面数据不可用"):
_call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
def test_cover_type_manual_still_works(self):