""" 图生视频模块 (Volcengine Doubao-SeedDance) 负责将分镜图片转换为视频片段 """ import logging import time import requests import os from typing import Dict, Any, List, Optional from pathlib import Path import config from modules import storage from modules.db_manager import db from modules import path_utils logger = logging.getLogger(__name__) class VideoGenerator: """图生视频生成器""" def __init__(self): self.api_key = config.VOLC_API_KEY self.base_url = config.VOLC_BASE_URL self.model_id = config.VIDEO_MODEL_ID self.headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}" } def submit_scene_video_task( self, project_id: str, scene_id: int, image_path: str, prompt: str ) -> str: """ 提交单场景视频生成任务 Returns: task_id or None """ if not image_path or not os.path.exists(image_path): logger.warning(f"Skipping video generation for Scene {scene_id}: Image not found") return None # 上传图片到 R2 获取 URL logger.info(f"Uploading image for Scene {scene_id}...") image_url = storage.upload_file(image_path) if not image_url: logger.error(f"Failed to upload image for Scene {scene_id}") return None logger.info(f"Submitting video task for Scene {scene_id}...") task_id = self._submit_task(image_url, prompt) if task_id: # 立即保存 task_id 到数据库,状态为 processing db.save_asset( project_id=project_id, scene_id=scene_id, asset_type="video", status="processing", task_id=task_id, local_path=None ) return task_id def recover_video_from_task(self, task_id: str, output_path: str) -> bool: """ 尝试从已有的 task_id 恢复视频 (查询状态并下载) """ try: status, video_url = self._check_task(task_id) logger.info(f"Recovering task {task_id}: status={status}") if status == "succeeded" and video_url: return self._download_video_to(video_url, output_path) return False except Exception as e: logger.error(f"Failed to recover video task {task_id}: {e}") return False def check_task_status(self, task_id: str) -> tuple[str, str]: """ 查询任务状态 Returns: (status, video_url) """ return self._check_task(task_id) def generate_scene_videos( self, project_id: str, script: Dict[str, Any], scene_images: Dict[int, str] ) -> Dict[int, str]: """ 批量生成分镜视频 (Legacy: 阻塞式轮询) """ generated_videos = {} tasks = {} # scene_id -> task_id scenes = script.get("scenes", []) # 1. 提交所有任务 for scene in scenes: scene_id = scene["id"] image_path = scene_images.get(scene_id) prompt = scene.get("video_prompt", "High quality video") # Use new method signature with project_id task_id = self.submit_scene_video_task(project_id, scene_id, image_path, prompt) if task_id: tasks[scene_id] = task_id logger.info(f"Task submitted: {task_id}") else: logger.error(f"Failed to submit task for Scene {scene_id}") # 2. 轮询任务状态 pending_tasks = list(tasks.keys()) # 设置最大轮询时间 (例如 10 分钟) start_time = time.time() timeout = 600 while pending_tasks and (time.time() - start_time < timeout): logger.info(f"Polling status for {len(pending_tasks)} tasks...") still_pending = [] for scene_id in pending_tasks: task_id = tasks[scene_id] status, result_url = self._check_task(task_id) if status == "succeeded": logger.info(f"Scene {scene_id} video generated successfully") # 下载视频 out_dir = path_utils.project_videos_dir(project_id) if project_id else config.TEMP_DIR fname = path_utils.unique_filename( prefix="scene_video", ext="mp4", project_id=project_id, scene_id=scene_id, extra=(task_id[-8:] if isinstance(task_id, str) else None), ) video_path = self._download_video(result_url, fname, output_dir=out_dir) if video_path: generated_videos[scene_id] = video_path # Update DB db.save_asset( project_id=project_id, scene_id=scene_id, asset_type="video", status="completed", local_path=video_path, task_id=task_id ) elif status == "failed" or status == "cancelled": logger.error(f"Scene {scene_id} task failed/cancelled") db.save_asset( project_id=project_id, scene_id=scene_id, asset_type="video", status="failed", task_id=task_id ) else: # running, queued still_pending.append(scene_id) pending_tasks = still_pending if pending_tasks: time.sleep(5) # 间隔 5 秒 return generated_videos def _submit_task(self, image_url: str, prompt: str) -> str: """提交生成任务""" url = f"{self.base_url}/contents/generations/tasks" payload = { "model": self.model_id, "content": [ { "type": "text", "text": f"{prompt} --resolution 1080p --duration 3 --camerafixed false --watermark false" }, { "type": "image_url", "image_url": {"url": image_url} } ] } try: response = requests.post(url, headers=self.headers, json=payload, timeout=30) response.raise_for_status() data = response.json() # ID might be at top level or in data object depending on exact API version response # Document says: { "id": "...", "status": "..." } or similar task_id = data.get("id") if not task_id and "data" in data: task_id = data.get("data", {}).get("id") return task_id except Exception as e: logger.error(f"Task submission failed: {e}") if 'response' in locals(): logger.error(f"Response: {response.text}") return None def _check_task(self, task_id: str) -> tuple[str, str]: """ 检查任务状态 Returns: (status, content_url) Status: queued, running, succeeded, failed, cancelled """ url = f"{self.base_url}/contents/generations/tasks/{task_id}" try: response = requests.get(url, headers=self.headers, timeout=30) response.raise_for_status() data = response.json() # API Response structure: # { "id": "...", "status": "succeeded", "content": [ { "url": "...", "video_url": "..." } ] } # Or nested in "data" key result = data if "data" in data and "status" not in data: # Check if wrapped in data result = data["data"] status = result.get("status") content_url = None if status == "succeeded": # Try multiple known shapes for volcengine response content = result.get("content") # sometimes nested: data.content or data.result.content, etc. if not content and isinstance(result.get("result"), dict): content = result["result"].get("content") def _extract_url(obj): if isinstance(obj, dict): return obj.get("video_url") or obj.get("url") return None if isinstance(content, list) and content: # pick the first item that has a usable url for item in content: u = _extract_url(item) if u: content_url = u break elif isinstance(content, dict): content_url = _extract_url(content) return status, content_url except Exception as e: logger.error(f"Check task failed: {e}") return "unknown", None def _download_video_to(self, url: str, output_path: str) -> bool: """下载视频到指定路径(避免 TEMP_DIR 固定文件名导致覆盖)""" if not url or not output_path: return False try: out_p = Path(output_path) out_p.parent.mkdir(parents=True, exist_ok=True) response = requests.get(url, stream=True, timeout=60) response.raise_for_status() with open(out_p, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) return True except Exception as e: logger.error(f"Download video failed: {e}") return False def _download_video(self, url: str, filename: str, output_dir: Optional[Path] = None) -> str: """下载视频到临时目录(默认使用 config.TEMP_DIR;可指定 output_dir 避免覆盖)""" if not url: return None try: response = requests.get(url, stream=True, timeout=60) response.raise_for_status() out_dir = output_dir or config.TEMP_DIR out_dir.mkdir(parents=True, exist_ok=True) output_path = out_dir / filename with open(output_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) return str(output_path) except Exception as e: logger.error(f"Download video failed: {e}") return None