""" Video 任务处理器 ================ 管理 Vidu 视频生成任务的提交与轮询。 采用提交 + 轮询两阶段设计。 """ import logging from typing import Any from app.core.exceptions import PlatformError, PlatformErrorType from app.core.platform_config import get_platform_config_loader from app.core.redis_client import get_redis_client from app.scheduler.handlers.base import AsyncHandler from app.scheduler.models import StateChange from app.scheduler.registry import TaskRegistry from app.scheduler.slot_manager import SlotManager from app.services.vidu_service import ViduService from app.utils.content_fingerprint import ( compute_content_fingerprint, extract_vidu_error_code, is_vidu_audit_error, ) logger = logging.getLogger(__name__) SLOT_KEY = "vidu:video_slots" _AUDIT_REJECTION_PREFIX = "platform_gateway:audit_rejection" _AUDIT_REJECTION_TTL = 24 * 60 * 60 # 24 小时 def _get_video_max_slots() -> int: """从 platform-config.yaml 读取 rate_limit 配置作为 max_slots""" try: loader = get_platform_config_loader() platform = loader.get_platform("vidu") if platform and "lip_sync" in platform.methods: return int(platform.methods["lip_sync"].rate_limit_qps) if platform: return int(platform.rate_limit_qps) except Exception as e: logger.warning(f"读取视频平台 rate_limit 配置失败: {e}") return 5 class VideoHandler(AsyncHandler): name = "video" slot_key = SLOT_KEY max_slots = _get_video_max_slots() def __init__(self, service: ViduService | None = None): self.service = service def _get_service(self) -> ViduService: if self.service is None: raise RuntimeError("VideoHandler 需要通过构造函数传入 ViduService 实例") return self.service async def _cache_audit_rejection_if_needed( self, params: dict[str, Any], error_message: str | None ) -> None: """如果失败原因是 Vidu 审核类错误,缓存内容指纹防止重复提交。""" err_code = extract_vidu_error_code(error_message) if not err_code or not is_vidu_audit_error(err_code): return fingerprint = compute_content_fingerprint( task_type="lip_sync", video_url=params.get("video_url"), audio_url=params.get("audio_url"), ref_photo_url=params.get("ref_photo_url"), text=params.get("text"), voice_id=params.get("voice_id"), ) if not fingerprint: return try: redis = get_redis_client() key = f"{_AUDIT_REJECTION_PREFIX}:{fingerprint}" await redis.setex(key, _AUDIT_REJECTION_TTL, err_code) logger.info( f"[Video] 审核失败内容已缓存: fingerprint={fingerprint[:16]}..., " f"err_code={err_code}" ) except Exception as e: logger.warning(f"[Video] 缓存审核失败结果出错: {e}") async def tick( self, tasks: list[Any], registry: TaskRegistry, slots: SlotManager ) -> list[StateChange]: changes: list[StateChange] = [] for task in tasks: params = task.params or {} vidu_task_id = params.get("vidu_task_id") if vidu_task_id: # 轮询阶段:优先检查 callback 结果,fallback 主动查询 Vidu API result_data = task.result or {} if result_data.get("video_url") or result_data.get("state") == "success": # callback 已到达,结果已写入 TaskRegistry await registry.remove_running(task.task_id) changes.append( StateChange( task_id=task.task_id, field_path="status", value="completed", ) ) changes.append( StateChange( task_id=task.task_id, field_path="message", value="视频生成完成", ) ) changes.append( StateChange( task_id=task.task_id, field_path="completed", value=1, ) ) changes.append(StateChange(task_id=task.task_id, field_path="total", value=1)) elif task.status == "failed": # callback 已标记失败,移除 running await registry.remove_running(task.task_id) else: # callback 尚未到达,fallback:主动查询 Vidu API try: service = self._get_service() vidu_status = await service.lip_sync_query(task.task_id) vidu_state = vidu_status.get("state", "") creations = vidu_status.get("creations", []) video_url = creations[0].get("url") if creations else None if vidu_state == "success" and video_url: changes.append( StateChange( task_id=task.task_id, field_path="status", value="completed", ) ) changes.append( StateChange( task_id=task.task_id, field_path="message", value="视频生成完成", ) ) changes.append( StateChange( task_id=task.task_id, field_path="completed", value=1, ) ) changes.append( StateChange( task_id=task.task_id, field_path="total", value=1, ) ) changes.append( StateChange( task_id=task.task_id, field_path="result", value={ "video_url": video_url, "state": "success", }, ) ) # 移除 running set await registry.remove_running(task.task_id) logger.info( f"[Video {task.task_id}] 主动查询 Vidu 任务已完成: " f"video_url={video_url[:60]}..." ) elif vidu_state == "failed": error_message = vidu_status.get("message") or "视频生成失败" err_code = extract_vidu_error_code(error_message) is_audit = err_code and is_vidu_audit_error(err_code) message = ( "人物分镜台词未通过安全审核,请修改后重试" if is_audit else "视频生成失败" ) changes.append( StateChange( task_id=task.task_id, field_path="status", value="failed", ) ) changes.append( StateChange( task_id=task.task_id, field_path="message", value=message, ) ) changes.append( StateChange( task_id=task.task_id, field_path="error", value=error_message, ) ) if is_audit: changes.append( StateChange( task_id=task.task_id, field_path="error_code", value="content_violation", ) ) await self._cache_audit_rejection_if_needed(params, error_message) await registry.remove_running(task.task_id) logger.warning( f"[Video {task.task_id}] 主动查询 Vidu 任务失败: " f"{error_message}" ) else: # 仍在处理中,继续等待 logger.debug( f"[Video {task.task_id}] 主动查询 Vidu 状态: {vidu_state},继续等待" ) except Exception as e: logger.warning(f"[Video {task.task_id}] 主动查询 Vidu 失败: {e}") continue # ← 已提交,不再重复提交 # 提交阶段:占用 slot,提交成功后自动释放 async with slots.acquire_ctx(SLOT_KEY, task.task_id, self.max_slots) as acquired: if not acquired: continue try: service = self._get_service() # 自动构建回调地址(前端未传时兜底) callback_url = params.get("callback_url") if not callback_url: from app.config import get_settings base_url = get_settings().app_base_url if base_url: callback_url = f"{base_url}/api/v1/vidu/callback" else: raise ValueError("callback_url 未配置且无法自动推断应用地址") logger.info( f"[Video {task.task_id}] 准备提交 Vidu 视频生成: " f"callback_url={callback_url}, video_url={params.get('video_url', '')[:60]}..." ) vidu_task_id = await service.lip_sync_create( video_url=params.get("video_url", ""), audio_url=params.get("audio_url"), text=params.get("text"), voice_id=params.get("voice_id"), speed=float(params.get("speed", 1.0)), volume=int(params.get("volume", 0)), ref_photo_url=params.get("ref_photo_url"), callback_url=callback_url, task_id=task.task_id, ) if not vidu_task_id: raise ValueError("未返回任务ID") params["vidu_task_id"] = vidu_task_id changes.append( StateChange(task_id=task.task_id, field_path="params", value=params) ) changes.append( StateChange( task_id=task.task_id, field_path="message", value="视频生成任务已提交", ) ) except RuntimeError: logger.error(f"[Video {task.task_id}] service not initialized") changes.append( StateChange(task_id=task.task_id, field_path="status", value="failed") ) changes.append( StateChange( task_id=task.task_id, field_path="message", value="视频处理服务未就绪", ) ) changes.append( StateChange( task_id=task.task_id, field_path="error", value="视频处理服务未就绪", ) ) except PlatformError as e: error_message = str(e) is_audit = e.error_type == PlatformErrorType.CONTENT_VIOLATION if is_audit: message = "人物分镜台词未通过安全审核,请修改后重试" elif e.error_type == PlatformErrorType.AUTH_FAILED: message = "视频服务认证失败,请联系客服" elif e.error_type == PlatformErrorType.RATE_LIMIT: message = "视频生成服务繁忙,请稍后重试" else: message = "视频生成任务提交失败,请稍后重试" logger.error(f"[Video {task.task_id}] submit platform error: {e}") changes.append( StateChange(task_id=task.task_id, field_path="status", value="failed") ) changes.append( StateChange( task_id=task.task_id, field_path="message", value=message, ) ) changes.append( StateChange( task_id=task.task_id, field_path="error", value=error_message, ) ) if is_audit: changes.append( StateChange( task_id=task.task_id, field_path="error_code", value="content_violation", ) ) # 审核类错误在 PlatformGateway 已写缓存,此处幂等补充 await self._cache_audit_rejection_if_needed(params, error_message) except Exception as e: logger.error(f"[Video {task.task_id}] submit error: {e}") changes.append( StateChange(task_id=task.task_id, field_path="status", value="failed") ) changes.append( StateChange( task_id=task.task_id, field_path="message", value="视频生成任务提交失败,请稍后重试", ) ) changes.append( StateChange( task_id=task.task_id, field_path="error", value="视频生成任务提交失败", ) ) return changes