0ef4e1d633
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m47s
CI/CD Pipeline / Integration Tests (push) Successful in 1m56s
CI/CD Pipeline / Unit Tests (push) Successful in 9m42s
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 12m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m39s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
- 新增 video_validation 模块(moov atom 检测 + ffprobe 验证 + 退出码映射) - render_adapter 渲染后自动校验输出再上传 OSS - 三个渲染任务加 10 分钟 soft_time_limit - Worker 启动时清理 GenerationTask + Job 两张表的孤儿任务 - 21 个新单元测试,全量 13713 测试通过
199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
"""视频合成 Celery 任务 — Phase 8 任务 2.10.
|
||
|
||
使用 JobService 管理任务生命周期,通过 RenderAdapter 调用 UnifiedRenderService 执行合成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess # pragma: no cover
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from celery.exceptions import SoftTimeLimitExceeded # pragma: no cover
|
||
from celery.utils.log import get_task_logger
|
||
from worker_app.celery_app import celery_app
|
||
from worker_app.db import SessionLocal
|
||
|
||
logger = get_task_logger(__name__)
|
||
|
||
# 任务超时时间(秒):超过此时间 Celery 会抛出 SoftTimeLimitExceeded
|
||
RENDER_TASK_SOFT_TIME_LIMIT = 600 # pragma: no cover # 10 分钟
|
||
# 硬超时:超过此时间进程会被强制 kill
|
||
RENDER_TASK_TIME_LIMIT = 660 # pragma: no cover # 10 分钟 + 1 分钟清理缓冲
|
||
|
||
|
||
def _get_job_service():
|
||
"""延迟导入 JobService,避免循环依赖。"""
|
||
from apps.api.app.services.job_service import JobService
|
||
from packages.adapters.sqlalchemy_impl.job_repository import SQLAlchemyJobRepository
|
||
|
||
db = SessionLocal()
|
||
repo = SQLAlchemyJobRepository(db)
|
||
return JobService(repo), db
|
||
|
||
|
||
@celery_app.task(
|
||
name="worker.compose_video",
|
||
bind=True,
|
||
max_retries=3,
|
||
default_retry_delay=60,
|
||
soft_time_limit=RENDER_TASK_SOFT_TIME_LIMIT,
|
||
time_limit=RENDER_TASK_TIME_LIMIT,
|
||
)
|
||
def compose_video(self, job_id: str, **kwargs): # pragma: no cover
|
||
"""视频合成任务。
|
||
|
||
使用 UnifiedRenderService(图层架构)进行渲染。
|
||
|
||
Args:
|
||
job_id: JobService 中的任务 ID
|
||
**kwargs: 来自 Job.payload 的额外参数(plan_id, output_path 等)
|
||
"""
|
||
job_service, db = _get_job_service()
|
||
|
||
try:
|
||
job = job_service.get_job(job_id)
|
||
if job is None:
|
||
logger.error("Job not found: %s", job_id)
|
||
return {"status": "error", "message": f"Job {job_id} not found"}
|
||
|
||
plan_id = job.payload.get("plan_id", "")
|
||
if not plan_id:
|
||
job_service.fail_job(job_id, "Missing plan_id in job payload")
|
||
return {"status": "error", "message": "Missing plan_id"}
|
||
|
||
# 使用 unified 渲染引擎
|
||
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
|
||
|
||
except SoftTimeLimitExceeded:
|
||
# Celery 软超时:任务执行超过 soft_time_limit
|
||
error_msg = f"渲染任务超时(超过 {RENDER_TASK_SOFT_TIME_LIMIT // 60} 分钟)"
|
||
logger.error("视频合成超时: job_id=%s", job_id)
|
||
try:
|
||
job_service.fail_job(job_id, error_msg)
|
||
except Exception:
|
||
logger.exception("更新 Job 超时失败状态时出错")
|
||
# 超时不重试
|
||
return {"status": "error", "message": error_msg, "error_type": "timeout"}
|
||
|
||
except subprocess.TimeoutExpired as exc:
|
||
# FFmpeg 子进程超时
|
||
error_msg = f"FFmpeg 渲染超时({exc.timeout}s)"
|
||
logger.error("视频合成 FFmpeg 超时: job_id=%s timeout=%s", job_id, exc.timeout)
|
||
try:
|
||
job_service.fail_job(job_id, error_msg)
|
||
except Exception:
|
||
logger.exception("更新 Job 超时失败状态时出错")
|
||
# 超时不重试
|
||
return {"status": "error", "message": error_msg, "error_type": "ffmpeg_timeout"}
|
||
|
||
except self.retry_exc as exc:
|
||
logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc)
|
||
raise
|
||
|
||
except subprocess.CalledProcessError as exc:
|
||
# FFmpeg 执行失败,提取有意义的错误信息
|
||
from video_processing.video_validation import get_exit_code_message
|
||
|
||
exit_msg = get_exit_code_message(exc.returncode)
|
||
stderr_text = (exc.stderr or "").strip()
|
||
stderr_tail = stderr_text[-300:] if len(stderr_text) > 300 else stderr_text
|
||
error_msg = f"渲染失败: {exit_msg}"
|
||
if stderr_tail:
|
||
error_msg += f" | {stderr_tail[:200]}"
|
||
|
||
logger.error("视频合成 FFmpeg 失败: job_id=%s %s", job_id, exit_msg)
|
||
try:
|
||
job_service.fail_job(job_id, error_msg[:500])
|
||
except Exception:
|
||
logger.exception("更新 Job 失败状态时出错")
|
||
# FFmpeg 错误不重试(通常是素材或配置问题)
|
||
return {"status": "error", "message": error_msg, "error_type": "ffmpeg_error", "exit_code": exc.returncode}
|
||
|
||
except Exception as exc:
|
||
logger.exception("视频合成异常: job_id=%s", job_id)
|
||
try:
|
||
job_service.fail_job(job_id, str(exc)[:500])
|
||
except Exception:
|
||
logger.exception("更新 Job 失败状态时出错")
|
||
raise self.retry(exc=exc, countdown=60) from exc
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict: # pragma: no cover
|
||
"""新引擎渲染路径(UnifiedRenderService + RenderAdapter)。"""
|
||
job_id = job.id
|
||
|
||
# 标记为 running
|
||
job_service.update_progress(job_id, progress=10.0, current_stage="初始化统一渲染引擎")
|
||
|
||
from video_processing.render_adapter import RenderAdapter
|
||
|
||
adapter = RenderAdapter(db)
|
||
|
||
# 校验合成条件
|
||
job_service.update_progress(job_id, progress=15.0, current_stage="校验合成条件")
|
||
valid, errors, warnings, ready_count, total_count = adapter.validate_plan(plan_id)
|
||
if not valid:
|
||
error_msg = "; ".join(errors)
|
||
job_service.fail_job(job_id, f"合成校验失败: {error_msg}")
|
||
return {"status": "error", "message": error_msg}
|
||
|
||
# 进度回调
|
||
def progress_cb(progress: float, stage: str) -> None:
|
||
try:
|
||
job_service.update_progress(job_id, progress=progress, current_stage=stage)
|
||
except Exception:
|
||
logger.exception("更新进度失败")
|
||
|
||
# 执行渲染
|
||
job_service.update_progress(job_id, progress=20.0, current_stage="开始渲染")
|
||
logger.info("统一渲染引擎开始: job_id=%s plan_id=%s", job_id, plan_id)
|
||
|
||
result = adapter.render_plan(
|
||
plan_id=plan_id,
|
||
job_id=job_id,
|
||
progress_cb=progress_cb,
|
||
)
|
||
|
||
if not result.success:
|
||
error_msg = f"渲染失败: {result.error_message}"
|
||
job_service.fail_job(job_id, error_msg[:500])
|
||
raise RuntimeError(result.error_message)
|
||
|
||
# 更新 Job 状态为完成
|
||
result_data = {
|
||
"plan_id": plan_id,
|
||
"output_path": str(result.output_path) if result.output_path else "",
|
||
"storage_key": f"rendered/{plan_id}/{job_id}.mp4",
|
||
"output_url": result.output_url,
|
||
"estimated_duration": result.duration,
|
||
"clip_count": result.clip_count,
|
||
"engine": "unified",
|
||
"width": result.width,
|
||
"height": result.height,
|
||
"file_size": result.file_size,
|
||
}
|
||
job_service.complete_job(job_id, result=result_data)
|
||
|
||
logger.info(
|
||
"视频合成完成(unified): job_id=%s plan_id=%s duration=%.2fs",
|
||
job_id,
|
||
plan_id,
|
||
result.duration,
|
||
)
|
||
return {"status": "completed", "job_id": job_id, "result": result_data}
|
||
|
||
|
||
def _cleanup_output(job_id: str) -> None:
|
||
"""清理临时输出文件。"""
|
||
try:
|
||
_output_dir = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output"))
|
||
output_path = os.path.join(_output_dir, f"{job_id}.mp4")
|
||
if Path(output_path).exists():
|
||
Path(output_path).unlink()
|
||
except Exception as e:
|
||
logger.warning(f"清理输出文件失败: {e}", exc_info=True)
|