"""视频合成 Celery 任务 — Phase 8 任务 2.10. 使用 JobService 管理任务生命周期,集成 VideoComposeService 执行合成。 """ from __future__ import annotations import os import tempfile from pathlib import Path 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__) 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, ) def compose_video(self, job_id: str, **kwargs): """视频合成任务。 根据 RENDER_ENGINE 配置选择渲染引擎: - legacy: 旧 VideoComposeService(filter_complex 模式) - unified: 新 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"} # 判断使用哪个渲染引擎 # 优先级:Redis Feature Flag(白名单 > 百分比) > 环境变量默认 from video_processing.render_engine_resolver import get_render_engine_resolver resolver = get_render_engine_resolver() user_id = job.created_by_user_id or None engine = resolver.get_engine(user_id=user_id) # 灰度期间打印详细 flag 配置,便于排查 config = resolver.get_config_snapshot() logger.info( "compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s", job_id, engine, user_id, config.get("enabled"), config.get("percentage"), len(config.get("whitelist", [])), config.get("default_engine"), ) if engine == "unified": return _compose_with_unified_engine(self, job_service, job, plan_id, db) else: return _compose_with_legacy_engine(self, job_service, job, plan_id, db) except self.retry_exc as exc: logger.warning("视频合成重试中: job_id=%s, exc=%s", job_id, exc) raise 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_legacy_engine(task, job_service, job, plan_id: str, db) -> dict: """旧引擎渲染路径(VideoComposeService)。""" job_id = job.id # 标记为 running job_service.update_progress(job_id, progress=10.0, current_stage="初始化合成环境") # 延迟导入 VideoComposeService from apps.api.app.services.video_compose_service import VideoComposeService compose_svc = VideoComposeService(db) # 校验合成条件 job_service.update_progress(job_id, progress=20.0, current_stage="校验合成条件") validation = compose_svc.validate_compose(plan_id) if not validation.valid: error_msg = "; ".join(validation.errors) job_service.fail_job(job_id, f"合成校验失败: {error_msg}") return {"status": "error", "message": error_msg} # 构建合成命令 job_service.update_progress(job_id, progress=30.0, current_stage="构建 FFmpeg 命令") _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") compose_cmd = compose_svc.build_compose_command(plan_id, output_path) # 执行 FFmpeg job_service.update_progress(job_id, progress=50.0, current_stage="正在执行视频合成") logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id) try: from video_processing.ffmpeg_utils import run_ffmpeg run_ffmpeg(compose_cmd.command, timeout=3600) except Exception as e: error_msg = f"FFmpeg 执行失败: {str(e)[:500]}" job_service.fail_job(job_id, error_msg) raise # 上传结果 job_service.update_progress(job_id, progress=80.0, current_stage="上传合成结果") storage_key = f"rendered/{plan_id}/{job_id}.mp4" from worker_app.tasks.edit_plan_generation import _upload_to_oss output_url = _upload_to_oss(Path(output_path), storage_key) # 更新 Job 状态为完成 result_data = { "plan_id": plan_id, "output_path": output_path, "storage_key": storage_key, "output_url": output_url or "", "estimated_duration": compose_cmd.estimated_duration, "clip_count": len(compose_cmd.clip_chains), "engine": "legacy", } job_service.complete_job(job_id, result=result_data) logger.info("视频合成完成(legacy): job_id=%s, plan_id=%s", job_id, plan_id) return {"status": "completed", "job_id": job_id, "result": result_data} def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> dict: """新引擎渲染路径(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: job_service.fail_job(job_id, f"渲染失败: {result.error_message}") 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)