Compare commits

...

5 Commits

Author SHA1 Message Date
CI Bot 7eab4bc508 fix: add ffmpeg to API runtime image for cover fallback
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 API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m19s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m9s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m59s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 53s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m14s
PR #1360 introduced FFmpeg-based frame extraction as fallback for cover
generation, but the API Docker image did not include FFmpeg binary.
Worker image already has it via worker-base-runtime, but API image only
had libpq5 in its runtime stage.

This caused subprocess calls to fail with FileNotFoundError when the
cover generation tried to use _extract_frames_with_ffmpeg fallback.

Fix: add ffmpeg to apt-get install in runtime stage of api.Dockerfile.
2026-08-13 21:25:30 +08:00
xiaoxia 21e84c71c4 perf: render-time cover frame pre-extraction + FFmpeg fallback, remove MediaKit (#1360)
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 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m2s
CI/CD Pipeline / Unit Tests (push) Successful in 9m47s
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 13m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Integration Tests (push) Successful in 7m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
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-13 20:51:02 +08:00
xiaoxia 17174e2cf5 refactor(cover): 封面生成API从templates_editor解耦到generation路由 (#1359)
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 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m26s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m44s
CI/CD Pipeline / Integration Tests (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Successful in 9m0s
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 12m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 32s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m14s
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-13 18:56:00 +08:00
xiaoxia 0a00870ab6 fix(cover): 修复预览视频存储路径错误导致封面生成 500 (#1358)
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 - Type Check (mypy) (push) Successful in 2m30s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m7s
CI/CD Pipeline / Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Unit Tests (push) Successful in 9m23s
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 13m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(cover): 修复预览视频存储路径错误导致封面生成500 (#1358)
2026-08-13 18:17:09 +08:00
xiaoxia 68fa7fd163 fix(cover): 修复 OSS 路径双斜杠导致 MediaKit 404 (#1357)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (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 / Validate - Migration (alembic) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m42s
CI/CD Pipeline / Unit Tests (push) Successful in 10m9s
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 3m38s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
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-13 17:51:00 +08:00
24 changed files with 1120 additions and 241 deletions
+6
View File
@@ -8,6 +8,7 @@ from app.api.routes.classification_jobs import router as classification_jobs_rou
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router
@@ -98,6 +99,11 @@ api_router.include_router(
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
titles_router,
prefix="/titles",
+1 -2
View File
@@ -7,7 +7,6 @@ API:
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
"""
import logging
from typing import Any
@@ -55,7 +54,7 @@ def list_cover_templates(
thumbnail_url=t.thumbnail_url,
is_system=t.is_system,
created_at=t.created_at,
config=t.config,
config=t.config or {},
)
for t in items
],
@@ -1,18 +1,22 @@
"""封面管理路由.
"""封面生成路由 — Generation 模块.
端点
- POST /generate-cover AI 生成封面从预览视频中抽帧
挂载路径: /api/v1/generation/generate-cover
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
@@ -21,20 +25,44 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
GenerateCoverRequest,
GenerateCoverResponse,
)
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
router = APIRouter(tags=["Generation"])
# ── Schemas ──────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── Route ────────────────────────────────────────────────────────────────
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def editor_generate_cover(
template_id: str,
def generate_cover(
body: GenerateCoverRequest,
template_id: str = Query(..., description="模板 ID"),
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
@@ -58,7 +86,9 @@ def editor_generate_cover(
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
logger.info("[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id)
logger.info(
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
)
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
@@ -137,6 +167,13 @@ def editor_generate_cover(
storage_svc = get_shared_storage_service()
primary_video_url = storage_svc.get_url(rendered_storage_key)
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
logger.info(
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
plan_id,
@@ -148,6 +185,28 @@ def editor_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)
from packages.shared.ai_service import run_generate_cover
try:
@@ -11,7 +11,6 @@
- bgm.py: BGM 管理
- effects.py: 转场 + 滤镜
- export.py: 导出配置
- cover.py: 封面管理 + AI 生成封面
- subtitles.py: 字幕管理
- ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
@@ -31,7 +30,6 @@ from .adjustments import router as adjustments_router
from .ai_features import router as ai_features_router
from .bgm import router as bgm_router
from .clips import router as clips_router
from .cover import router as cover_router
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router
from .effects import router as effects_router
@@ -51,7 +49,6 @@ _sub_routers = [
bgm_router,
effects_router,
export_router,
cover_router,
subtitles_router,
ai_features_router,
generation_router,
@@ -223,7 +223,12 @@ def _get_task_output_url(task, gen_task_repo, db) -> str:
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
return getattr(videos[0], "file_url", "") or ""
url = getattr(videos[0], "file_url", "") or ""
# 规范化:合并路径中的双斜杠(保留协议头 ://)
if url:
import re as _re
url = _re.sub(r"(?<!:)//", "/", url)
return url
except Exception:
pass
return ""
@@ -99,29 +99,6 @@ class AIRecommendResponse(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── 封面生成 ────────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── BGM ────────────────────────────────────────────────────────────────────
+35
View File
@@ -0,0 +1,35 @@
import apiClient from "../client"
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
export interface GenerateCoverResponse {
plan_id: string
cover: {
scheme?: string
asset_id?: string
frame_time?: number
image_url?: string
thumbnail_url?: string
[key: string]: unknown
}
}
/** AI 生成封面 — 从预览视频中抽帧 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post<GenerateCoverResponse>(
"/generation/generate-cover",
{ ...data, template_id: templateId },
{
timeout: 300000,
params: { template_id: templateId },
},
)
return response.data
}
+3
View File
@@ -10,3 +10,6 @@ export type {
export { createPreview, getPreviewStatus } from "./preview"
export { confirmGeneration } from "./confirm"
export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
+2 -18
View File
@@ -1,13 +1,8 @@
/**
* AI 推荐 + 封面生成 API
* AI 推荐 API
*/
import apiClient from "../client"
import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
import type { AIRecommendRequest, AIRecommendResponse } from "./types"
/** AI 推荐片段方案 */
export async function aiRecommendClips(
@@ -17,14 +12,3 @@ export async function aiRecommendClips(
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data, {
timeout: 300000, // 封面生成涉及 MediaKit 抽帧,最长 300 秒
})
return response.data
}
+2 -5
View File
@@ -27,9 +27,6 @@ export type {
AIRecommendRequest,
AIRecommendClipItem,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus,
EditPlanClip,
CreateEditPlanClipRequest,
@@ -81,8 +78,8 @@ export {
createClipsFromAssets,
} from "./clips"
// AI 推荐 + 封面生成
export { aiRecommendClips, generateCover } from "./aiFeatures"
// AI 推荐
export { aiRecommendClips } from "./aiFeatures"
// 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
+1 -23
View File
@@ -244,7 +244,7 @@ export interface GeneratedVideo {
updated_at?: string
}
/* ── AI 推荐 & 封面生成 ── */
/* ── AI 推荐 ── */
/** AI 推荐请求 */
export interface AIRecommendRequest {
@@ -274,28 +274,6 @@ export interface AIRecommendResponse {
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
image_url?: string
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */
/** 片段状态 */
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/template-editor"
import { generateCover } from "@/api/generation"
import {
fetchCoverTemplates,
createCoverTemplate,
-17
View File
@@ -8,7 +8,6 @@ import {
generateEditPlan,
getGenerationStatus,
aiRecommendClips,
generateCover,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
@@ -182,22 +181,6 @@ describe("editPlans API", () => {
})
})
describe("generateCover", () => {
it("should resolve successfully", async () => {
await expect(generateCover("test-planId")).resolves.not.toThrow()
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
mockPut.mockRejectedValue(new Error("Network error"))
mockDelete.mockRejectedValue(new Error("Network error"))
mockPatch.mockRejectedValue(new Error("Network error"))
await expect(generateCover("test-planId")).rejects.toThrow()
})
})
describe("getEditPlanGenerations", () => {
it("should resolve successfully", async () => {
await expect(getEditPlanGenerations("test-planId")).resolves.not.toThrow()
@@ -74,6 +74,9 @@ 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": "..."}]
)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -568,6 +571,25 @@ class RenderAdapter:
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:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
plan_id,
len(cover_candidates),
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
cover_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
@@ -599,6 +621,7 @@ class RenderAdapter:
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
)
def render_from_memory(
@@ -155,3 +155,134 @@ def generate_and_upload_thumbnail(
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
@@ -241,14 +241,32 @@ def _render_with_unified(
return {"status": "error", "message": result.error_message or "渲染失败"}
output_path = result.output_path or Path("")
output_url = result.output_url
output_url = result.output_url or ""
thumbnail_url = result.thumbnail_url or ""
storage_key = f"rendered/{plan_id}/output.mp4"
# adapter 上传到 rendered/{plan_id}/{job_id}.mp4,从 URL 提取实际 key
# 不能用 output.mp4 硬编码,否则 cover 等下游通过 key 构造的 URL 指向不存在的文件
if output_url:
from packages.shared.storage import get_shared_storage_service
storage_key = get_shared_storage_service().normalize_storage_key(output_url)
else:
storage_key = f"rendered/{plan_id}/{generation_task_id or plan_id}.mp4"
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
rendered_clip_ids = result.rendered_clip_ids or []
failed_clip_ids = result.failed_clip_ids or []
# 将封面候选帧写入 plan.config(供封面 API 直接使用,跳过 MediaKit 抽帧)
if result.cover_candidates:
plan_config = plan.config or {}
plan_config["cover_candidates"] = result.cover_candidates
plan.config = plan_config
logger.info(
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
plan_id,
len(result.cover_candidates),
)
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
+3 -1
View File
@@ -1262,7 +1262,9 @@ def _upload_and_record(
Returns:
(file_url, duration, file_size, video_count)
"""
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
storage_key = "/".join(path_parts)
file_size = output_path.stat().st_size
# 上传 OSS
+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 复制虚拟环境
+163 -62
View File
@@ -354,6 +354,93 @@ 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],
@@ -363,7 +450,10 @@ def _call_ai_cover_service(
) -> Dict[str, Any]:
"""调用 AI 封面生成服务.
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
优先级:
1. 检查 plan.config 中的 cover_candidates(渲染时预抽帧)——由调用方处理
2. FFmpeg 本地从 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)
失败时抛出 RuntimeError。
Args:
@@ -371,7 +461,7 @@ def _call_ai_cover_service(
asset_ids: 素材 ID 列表
cover_type: 封面类型
frame_time: 手动选帧时间点
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
primary_video_url: 主视频的可访问 URL
"""
if cover_type == "upload":
return {
@@ -394,74 +484,85 @@ def _call_ai_cover_service(
"frame_time": frame_time,
}
# ai_frame / ai_regenerate - 尝试调用 MediaKit
# ai_frame / ai_regenerate - 使用 FFmpeg 本地抽帧
if primary_video_url:
from packages.shared.mediakit_client import get_mediakit_client
import re as _re
client = get_mediakit_client()
if client.is_available:
# 先检查视频 URL 是否可访问,避免 MediaKit 下载失败后超时
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__})。" f"请重新生成预览视频后再试。"
) from e
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
try:
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = client.extract_frames(
video_url=primary_video_url,
strategy="TimeInterval", # 按固定时间间隔,稳定性好,不易 OOM
max_frames=3, # 减少到 3 帧,平衡速度和质量
poll_interval=2.0, # 缩短轮询间隔
max_poll_attempts=60, # 120秒超时
# 先检查视频 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
if frames and len(frames) > 0:
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
best_frame = frames[0]
image_url = best_frame.get("image_url", "")
timestamp = best_frame.get("timestamp", 0.0)
# 使用 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 image_url:
logger.info(
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id,
timestamp,
image_url[:80],
)
# MediaKit 返回的 URL 是临时内部 URL,浏览器无法直接访问
# 需要下载到本地并重新上传到 OSS,返回公开可访问的 URL
public_url = _transfer_cover_frame_to_storage(image_url, plan_id)
return {
"type": "ai_frame",
"image_url": public_url,
"frame_time": round(timestamp, 1),
"confidence": 0.85,
}
else:
logger.warning("MediaKit 返回的帧无 image_url")
if frames:
best_frame = frames[0]
local_path = best_frame["local_path"]
frame_time_val = best_frame["frame_time"]
except Exception as e:
logger.exception("MediaKit 抽帧失败: %s", str(e))
# 上传到 OSS
try:
import uuid
from pathlib import Path
# 封面生成失败 - 不再降级到 stub,直接报错
raise RuntimeError(
f"封面生成失败: plan_id={plan_id}, MediaKit 不可用或抽帧失败。" f"请检查 primary_video_url 是否可访问。"
)
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 是否可访问。")
# ── 公共入口 ────────────────────────────────────────────────────────────────
@@ -237,7 +237,7 @@ class TestAIRunTasks:
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
run_generate_cover(
plan_id="plan-001",
asset_ids=["asset-1"],
+486
View File
@@ -0,0 +1,486 @@
"""Tests for cover frame pre-extraction during rendering.
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
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, Mock, call, patch
import pytest
class TestExtractCoverCandidates:
"""extract_cover_candidates 测试."""
@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
from video_processing.thumbnail_generator import extract_cover_candidates
# 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 ("", "")
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)
@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
from video_processing.thumbnail_generator import extract_cover_candidates
call_count = 0
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 ("", "")
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)
# 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)
@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
from video_processing.thumbnail_generator import extract_cover_candidates
# 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 ("", "")
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
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)
class TestExtractAndUploadCoverFrames:
"""extract_and_upload_cover_frames 测试."""
@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
from video_processing.thumbnail_generator import extract_and_upload_cover_frames
# 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."""
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
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
+94
View File
@@ -0,0 +1,94 @@
"""Tests for generation cover route — schema validation and import checks."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
def test_generation_cover_router_importable():
"""新路由模块可以正确导入"""
from app.api.routes.generation_cover import router
assert router is not None
# tags 应该是 Generation
assert "Generation" in router.tags
def test_generation_cover_route_path():
"""路由路径应为 /generate-cover"""
from app.api.routes.generation_cover import router
paths = [route.path for route in router.routes]
assert "/generate-cover" in paths
def test_generation_cover_schemas_importable():
"""Schema 可以从新模块导入"""
from app.api.routes.generation_cover import GenerateCoverRequest, GenerateCoverResponse
# 验证请求 schema 默认值
req = GenerateCoverRequest()
assert req.asset_ids == []
assert req.cover_type == "ai_frame"
assert req.frame_time is None
# 验证响应 schema
resp = GenerateCoverResponse(plan_id="p1", cover={"image_url": "http://x"})
assert resp.plan_id == "p1"
assert resp.cover["image_url"] == "http://x"
def test_generation_cover_schemas_not_in_templates_editor():
"""旧的 templates_editor/schemas.py 不再包含封面 schema"""
from app.api.routes.templates_editor import schemas as te_schemas
assert not hasattr(te_schemas, "GenerateCoverRequest")
assert not hasattr(te_schemas, "GenerateCoverResponse")
def test_templates_editor_no_cover_router():
"""templates_editor 不再包含 cover_router"""
from app.api.routes.templates_editor import _sub_routers
# cover_router 应该已被移除
for sub in _sub_routers:
for route in sub.routes:
assert "generate-cover" not in getattr(route, "path", ""), "templates_editor 不应再有 generate-cover 路由"
def test_api_router_has_generation_cover():
"""api_router 应该包含 /api/v1/generation/generate-cover 路径"""
from app.api.router import api_router
all_paths = []
for route in api_router.routes:
if hasattr(route, "path"):
all_paths.append(route.path)
# 嵌套 router
if hasattr(route, "routes"):
for sub_route in route.routes:
if hasattr(sub_route, "path"):
all_paths.append(sub_route.path)
# 应该能找到 generate-cover 路径
cover_paths = [p for p in all_paths if "generate-cover" in p]
assert len(cover_paths) > 0, f"未找到 generate-cover 路由, 所有路径: {all_paths[:20]}"
def test_generation_cover_request_validation():
"""验证请求 schema 的字段约束"""
from app.api.routes.generation_cover import GenerateCoverRequest
# frame_time 不允许负数
with pytest.raises(ValidationError):
GenerateCoverRequest(frame_time=-1.0)
# 合法的 frame_time
req = GenerateCoverRequest(frame_time=5.5)
assert req.frame_time == 5.5
# 自定义 cover_type
req2 = GenerateCoverRequest(cover_type="upload", asset_ids=["a1", "a2"])
assert req2.cover_type == "upload"
assert req2.asset_ids == ["a1", "a2"]
+68 -68
View File
@@ -3,6 +3,7 @@
测试 #1208: AI封面接入MediaKit视频截帧
"""
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -140,47 +141,49 @@ class TestMediaKitClient:
class TestAICoverService:
"""AI 封面服务测试."""
"""AI 封面服务测试(已迁移到 FFmpeg 本地抽帧)。"""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_with_mediakit_success(self, mock_get_client, mock_head):
"""MediaKit 抽帧成功."""
# Mock HEAD request to verify URL is accessible
@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
mock_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.return_value = [{"image_url": "https://example.com/frame.jpg", "timestamp": 3.5}]
mock_get_client.return_value = mock_client
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
from packages.shared.ai_service import _call_ai_cover_service
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 3.5}]
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",
)
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
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
from packages.shared.ai_service import _call_ai_cover_service
mock_client.extract_frames.assert_called_once()
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")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_video_url_unreachable(self, mock_get_client, mock_head):
def test_call_ai_cover_video_url_unreachable(self, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
# Mock HEAD request to return 404
mock_head.return_value.status_code = 404
mock_client = Mock()
mock_client.is_available = True
mock_get_client.return_value = mock_client
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
@@ -192,20 +195,39 @@ class TestAICoverService:
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_with_mediakit_failure_raises(self, mock_get_client, mock_head):
"""MediaKit 失败时抛出 RuntimeError(不再降级到 stub."""
# Mock HEAD request to return 200 (URL is accessible, but MediaKit fails)
mock_head.return_value.status_code = 200
@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_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.side_effect = Exception("API error")
mock_get_client.return_value = mock_client
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="MediaKit"):
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="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -214,11 +236,10 @@ class TestAICoverService:
)
def test_call_ai_cover_without_video_url_raises(self):
"""没有视频 URL 时抛出 RuntimeError(不再降级到 stub."""
"""没有视频 URL 时抛出 RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
@@ -255,37 +276,16 @@ class TestAICoverService:
assert result["type"] == "manual"
assert result["frame_time"] == 5.0
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_mediakit_not_available_raises(self, mock_get_client):
"""MediaKit 未配置时抛出 RuntimeError(不再降级到 stub."""
mock_client = Mock()
mock_client.is_available = False
mock_get_client.return_value = mock_client
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="MediaKit"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.mediakit_client.get_mediakit_client")
def test_call_ai_cover_empty_frames_raises(self, mock_get_client, mock_head):
"""MediaKit 返回空帧列表时抛出 RuntimeError(不再降级)."""
mock_head.return_value.status_code = 200 # URL accessible
mock_client = Mock()
mock_client.is_available = True
mock_client.extract_frames.return_value = []
mock_get_client.return_value = mock_client
@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="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
+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="MediaKit"):
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="MediaKit"):
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service("plan1", ["a1"], "ai_regenerate")
def test_cover_type_manual_still_works(self):