d8dd510cba
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (web-cache, infra/docker/web.Dockerfile, xiaoxia-saas-web, web, Web, 30) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 36s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (Backend) (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (Backend) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
445 lines
15 KiB
Python
Executable File
445 lines
15 KiB
Python
Executable File
"""
|
|
视频生成任务
|
|
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画
|
|
"""
|
|
|
|
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()
|