style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (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 / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 811h25m1s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 811h25m2s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 811h25m44s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 811h25m48s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 811h25m45s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 811h25m48s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 811h25m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 811h25m49s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 811h58m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 811h59m17s

This commit is contained in:
CI Bot
2026-08-13 12:01:01 +00:00
parent bbf3006d0c
commit a59c9db81d
5 changed files with 73 additions and 39 deletions
@@ -74,7 +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": "..."}]
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:
@@ -574,18 +576,18 @@ class RenderAdapter:
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
)
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),
plan_id,
len(cover_candidates),
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
plan_id, cover_err,
plan_id,
cover_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
@@ -201,21 +201,29 @@ def extract_cover_candidates(
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",
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),
})
results.append(
{
"local_path": output_path,
"frame_time": round(frame_time, 2),
}
)
else:
Path(output_path).unlink(missing_ok=True)
except Exception as e:
@@ -256,14 +264,18 @@ def extract_and_upload_cover_frames(
url = upload_to_oss(local_path, storage_key)
if url:
results.append({
"image_url": url,
"frame_time": frame_time,
"storage_key": storage_key,
})
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,
plan_id,
idx,
frame_time,
)
except Exception as e:
logger.warning("封面候选帧上传失败: plan_id=%s idx=%d error=%s", plan_id, idx, e)
@@ -263,7 +263,8 @@ def _render_with_unified(
plan.config = plan_config
logger.info(
"封面候选帧已写入 plan.config: plan_id=%s count=%d",
plan_id, len(result.cover_candidates),
plan_id,
len(result.cover_candidates),
)
return _finalize_render_success(
+30 -16
View File
@@ -379,18 +379,25 @@ def _extract_frames_with_ffmpeg(
# 先用 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",
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_url,
],
capture_output=True, text=True, timeout=15,
capture_output=True,
text=True,
timeout=15,
)
if probe_result.returncode == 0 and probe_result.stdout.strip():
duration = float(probe_result.stdout.strip())
@@ -409,11 +416,16 @@ def _extract_frames_with_ffmpeg(
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",
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_url,
"-vframes",
"1",
"-q:v",
"2",
output_path,
]
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
@@ -475,6 +487,7 @@ def _call_ai_cover_service(
# ai_frame / ai_regenerate - 使用 FFmpeg 本地抽帧
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
# 先检查视频 URL 是否可访问
@@ -483,11 +496,12 @@ def _call_ai_cover_service(
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,
plan_id,
primary_video_url,
head_resp.status_code,
)
raise RuntimeError(
f"封面生成失败: 预览视频URL不可访问 (HTTP {head_resp.status_code})。"
f"请重新生成预览视频后再试。"
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)
@@ -523,7 +537,9 @@ def _call_ai_cover_service(
logger.info(
"FFmpeg 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id, frame_time_val, public_url[:80],
plan_id,
frame_time_val,
public_url[:80],
)
return {
@@ -546,9 +562,7 @@ def _call_ai_cover_service(
logger.exception("FFmpeg 远程抽帧失败: %s", str(e))
# 封面生成失败
raise RuntimeError(
f"封面生成失败: plan_id={plan_id},无法从视频抽帧。请检查 primary_video_url 是否可访问。"
)
raise RuntimeError(f"封面生成失败: plan_id={plan_id},无法从视频抽帧。请检查 primary_video_url 是否可访问。")
# ── 公共入口 ────────────────────────────────────────────────────────────────
@@ -24,6 +24,7 @@ class TestExtractCoverCandidates:
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
@@ -63,6 +64,7 @@ class TestExtractCoverCandidates:
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
@@ -96,6 +98,7 @@ class TestExtractCoverCandidates:
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
@@ -130,6 +133,7 @@ class TestExtractAndUploadCoverFrames:
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
@@ -172,6 +176,7 @@ class TestExtractAndUploadCoverFrames:
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)