""" 视频生成任务 支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画 """ import logging import os import shutil import subprocess # nosec B404 import tempfile from pathlib import Path from typing import Optional from urllib.parse import urlparse import oss2 from worker_app.celery_app import celery_app OUTPUT_WIDTH = 1280 OUTPUT_HEIGHT = 720 OUTPUT_FPS = 25.0 OUTPUT_DURATION_SECONDS = 5.0 FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg" FFPROBE_BIN = shutil.which("ffprobe") or "ffprobe" GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated")) GENERATED_FILES_URL_PREFIX = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files") PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "https://api.xiaoxiajianji.com").rstrip("/") logger = logging.getLogger(__name__) def _run_ffmpeg(command: list[str]) -> None: """执行 FFmpeg 命令""" subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603 def _oss_settings() -> tuple[str, str, str, str] | None: """获取 OSS 配置""" access_key_id = os.getenv("OSS_ACCESS_KEY_ID") access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET") endpoint = os.getenv("OSS_ENDPOINT") bucket_name = os.getenv("OSS_BUCKET_NAME") if not all([access_key_id, access_key_secret, endpoint, bucket_name]): return None return access_key_id, access_key_secret, endpoint, bucket_name def _oss_bucket() -> oss2.Bucket | None: """获取 OSS Bucket""" settings = _oss_settings() if settings is None: return None access_key_id, access_key_secret, endpoint, bucket_name = settings return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name) def _normalize_storage_key(storage_key_or_url: str) -> str: """标准化存储键""" if storage_key_or_url.startswith(("http://", "https://")): return urlparse(storage_key_or_url).path.lstrip("/") return storage_key_or_url.lstrip("/") def _download_asset(asset_storage_key: str, local_path: Path) -> bool: """下载素材文件""" bucket = _oss_bucket() if bucket is None: return False try: bucket.get_object_to_file(_normalize_storage_key(asset_storage_key), str(local_path)) return local_path.exists() and local_path.stat().st_size > 0 except Exception: return False def _probe_duration(local_path: Path) -> float: """获取视频时长""" try: result = subprocess.run( [ FFPROBE_BIN, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(local_path), ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) # nosec B603 return round(float(result.stdout.strip()), 3) except Exception: return OUTPUT_DURATION_SECONDS def _create_fallback_clip( output_path: Path, title: str, width: int = OUTPUT_WIDTH, height: int = OUTPUT_HEIGHT ) -> None: """创建 fallback 视频(无素材时)""" safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80] _run_ffmpeg( [ FFMPEG_BIN, "-y", "-f", "lavfi", "-i", f"color=c=#111827:s={width}x{height}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}", "-vf", f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(output_path), ] ) def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool: """下载配音文件""" if not voice_library_id: return False bucket = _oss_bucket() if bucket is None: return False storage_key = f"voice/{voice_library_id}.mp3" try: bucket.get_object_to_file(_normalize_storage_key(storage_key), str(local_path)) return local_path.exists() and local_path.stat().st_size > 0 except Exception: return False def _download_library_assets( asset_library_id: str, temp_path: Path, video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"), asset_ids: list[str] | None = None, ) -> list[str]: """ 从素材库下载视频素材 Args: asset_library_id: 素材库 ID temp_path: 临时目录路径 video_extensions: 支持的视频扩展名 asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材 Returns: 下载成功的视频文件路径列表 """ # 导入模型和会话 try: from worker_app.db import SessionLocal from packages.adapters.sqlalchemy_impl.models import AssetModel session = SessionLocal() try: # 查询素材库中的视频素材 query = session.query(AssetModel).filter( AssetModel.asset_library_id == asset_library_id, AssetModel.status == "ready", AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]), ) # 如果指定了 asset_ids,则只下载这些素材 if asset_ids: query = query.filter(AssetModel.id.in_(asset_ids)) assets = query.order_by(AssetModel.created_at).all() if not assets: logger.info(f"No video assets found in library {asset_library_id}") return [] downloaded_videos = [] for i, asset in enumerate(assets): # 获取文件 URL 或 storage_key storage_key = asset.file_url if asset.file_url else None if not storage_key: continue local_file = temp_path / f"asset_{i}_{asset.id}.mp4" if _download_asset(storage_key, local_file): downloaded_videos.append(str(local_file)) logger.info(f"Downloaded asset: {asset.name} -> {local_file}") else: logger.warning(f"Failed to download asset: {asset.name}") return downloaded_videos finally: session.close() except Exception as e: logger.error(f"Error downloading library assets: {e}") return [] def _process_with_editing_mode( video_paths: list[str], audio_path: Optional[str], mode: str, output_path: Path, output_width: int = OUTPUT_WIDTH, output_height: int = OUTPUT_HEIGHT, ) -> None: """根据剪辑模式处理视频""" from video_processing.editing_modes import ( EditingMode, EditingModeConfig, EditingModeProcessor, PIPPosition, ) config = EditingModeConfig( mode=EditingMode(mode), output_width=output_width, output_height=output_height, output_fps=int(OUTPUT_FPS), pip_position=PIPPosition.TOP_RIGHT, pip_scale=0.25, transition_duration=0.5, ) processor = EditingModeProcessor(config=config) processor.process( video_paths=video_paths, audio_path=audio_path, output_path=str(output_path), ) @celery_app.task(bind=True, name="worker.generate_video", max_retries=2) def generate_video(self, task_id: str) -> dict: """ 生成视频任务 Args: task_id: 任务 ID(从数据库加载完整任务信息) Returns: 生成结果字典 """ from worker_app.db import SessionLocal from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) from packages.domain import EditingMode, GeneratedVideo, GenerationTaskStatus # 从数据库加载任务信息 session = SessionLocal() try: task_repo = SQLAlchemyGenerationTaskRepository(session) gen_task = task_repo.get(task_id) if gen_task is None: return {"status": "failed", "error": f"generation task {task_id} not found"} project_id = gen_task.project_id asset_library_id = gen_task.asset_library_id voice_library_id = gen_task.voice_library_id or "" mode = gen_task.strategy_id or "one_take" task_asset_ids = list(gen_task.asset_ids or []) batch_id = getattr(gen_task, "batch_id", "") or "" # 新增字段:分辨率、封面、标题 output_width = getattr(gen_task, "output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH output_height = getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT cover_url = getattr(gen_task, "cover_url", "") or "" custom_title = getattr(gen_task, "custom_title", "") or "" finally: session.close() try: editing_mode = EditingMode(mode) except ValueError: editing_mode = EditingMode.ONE_TAKE output_name = f"generated-{task_id}.mp4" storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_name}" try: with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir: temp_path = Path(temp_dir) output_path = temp_path / output_name # 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些) downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None) audio_path = None if voice_library_id: local_audio = temp_path / "voice.mp3" if _download_voice_asset(voice_library_id, local_audio): audio_path = str(local_audio) if downloaded_videos: _process_with_editing_mode( video_paths=downloaded_videos, audio_path=audio_path, mode=editing_mode.value, output_path=output_path, output_width=output_width, output_height=output_height, ) else: _create_fallback_clip( output_path, f"Generated Video {task_id[:8]}", width=output_width, height=output_height ) file_size = output_path.stat().st_size duration = _probe_duration(output_path) # 上传到 OSS bucket = _oss_bucket() if bucket: try: bucket.put_object_from_file(storage_key, str(output_path)) except Exception as oss_err: logger.warning(f"OSS upload failed: {oss_err}") # 构建视频 URL if bucket: file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}" else: file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}" # 创建 GeneratedVideo 记录 + 查重 _create_video_record_and_dedup( task_id=task_id, project_id=project_id, batch_id=batch_id, file_url=file_url, file_size=file_size, duration=duration, video_path=str(output_path), mode=editing_mode.value, width=output_width, height=output_height, ) return { "status": "completed", "task_id": task_id, "output_path": str(output_path), "file_size": file_size, "duration": duration, "width": output_width, "height": output_height, "mode": editing_mode.value, } except Exception as error: logger.error(f"Video generation failed: {error}") return { "status": "failed", "task_id": task_id, "error": str(error), } def _create_video_record_and_dedup( *, task_id: str, project_id: str, batch_id: str, file_url: str, file_size: int, duration: float, video_path: str, mode: str, width: int = OUTPUT_WIDTH, height: int = OUTPUT_HEIGHT, ) -> None: """创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。""" from uuid import uuid4 from video_processing.dedup import VideoDeduplicator from worker_app.db import SessionLocal from packages.adapters.sqlalchemy_impl.generated_video_repository import ( SQLAlchemyGeneratedVideoRepository, ) from packages.domain import GeneratedVideo session = SessionLocal() try: video_id = uuid4().hex generated_video = GeneratedVideo( id=video_id, project_id=project_id, generation_task_id=task_id, name=f"generated-{task_id[:8]}.mp4", file_url=file_url, file_size=file_size, duration=duration, width=width, height=height, fps=OUTPUT_FPS, status="completed", generation_params={"mode": mode}, ) video_repo = SQLAlchemyGeneratedVideoRepository(session) video_repo.create(generated_video) # 计算视频指纹 deduplicator = VideoDeduplicator() try: fingerprint = deduplicator.compute_fingerprint(video_path) except Exception as fp_err: logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}") session.commit() return generated_video.video_fingerprint = fingerprint.to_dict() # (a) 历史成片查重 duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session) # (b) 批次内查重(仅当有 batch_id 时) if not duplicate_result and batch_id: duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session) if duplicate_result: generated_video.is_duplicate = True generated_video.duplicate_of = duplicate_result["duplicate_of"] logger.info( f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} " f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})" ) else: generated_video.is_duplicate = False generated_video.duplicate_of = None video_repo.update(generated_video) session.commit() logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})") except Exception as e: logger.error(f"Failed to create video record / dedup for task {task_id}: {e}") session.rollback() finally: session.close()