perf: pre-extract cover frames during render + FFmpeg fallback
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Failing after 2m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 811h30m37s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 811h31m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 811h31m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 811h30m37s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 811h31m25s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 811h30m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 811h31m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 811h31m25s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 812h4m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 812h4m55s

- Add extract_cover_candidates() and extract_and_upload_cover_frames()
  to thumbnail_generator.py: extract 3 frames at 25%/50%/75% of video
- Add cover_candidates field to RenderAdapterResult
- Call frame extraction after thumbnail gen in render_adapter._do_render()
- Write cover_candidates to plan.config in edit_plan_generation worker
- Check pre-stored frames in generation_cover.py before calling AI service
- Replace MediaKit remote API with FFmpeg HTTP range-request in
  _call_ai_cover_service (no full video download needed)
- Fix CoverTemplateResponse ValidationError when config is None
- Add unit tests for all new code paths
- Update existing tests to match new FFmpeg-based behavior
This commit is contained in:
CI Bot
2026-08-13 19:54:46 +08:00
parent 17174e2cf5
commit bbf3006d0c
9 changed files with 710 additions and 143 deletions
@@ -155,3 +155,122 @@ 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