Files
xiaoxia-saas/apps/worker/worker_app/tasks/generation.py
xiaoxia e5fffe40c1
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m1s
CI/CD Pipeline / Frontend Lint (push) Successful in 4m28s
CI/CD Pipeline / Unit Tests (push) Failing after 5m2s
CI/CD Pipeline / Integration Tests (push) Successful in 1m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 8m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 34s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
refactor(phase1): 统一渲染入口 + 模板系统双读兼容 (#630)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-20 12:39:43 +08:00

1652 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
视频生成任务 — 使用 UnifiedRenderService 统一渲染引擎.
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画。
模式差异体现在虚拟剪辑计划的 clip_type 分布上,渲染引擎不判断模式。
模式 → clip_type 映射:
ONE_TAKE: N 个 main clips
PIP: 1 main + N-1 overlay
VOICE_OVER: N 个 main(config.role=b_roll)
VOICE_PIP: 1 background + 1 corner_voice + N-2 b_roll
"""
from __future__ import annotations
import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
OUTPUT_FPS = 25.0
OUTPUT_DURATION_SECONDS = 5.0
GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
logger = logging.getLogger(__name__)
# ── 状态更新辅助函数 ──────────────────────────────────────────────────────────
def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
"""更新 GenerationTask 状态(独立 session,异常不向外抛出)。
Args:
task_id: 任务 ID
status_action: 状态动作名,如 "mark_processing" / "mark_completed" / "mark_failed"
**kwargs: 传递给对应方法的参数
Returns:
True 表示更新成功,False 表示更新失败
"""
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyGenerationTaskRepository(session)
task = repo.get(task_id)
if task is None:
logger.warning("更新任务状态失败:任务不存在 task_id=%s", task_id)
return False
action = getattr(task, status_action, None)
if action is None:
logger.warning("未知的状态动作: %s", status_action)
return False
action(**kwargs)
repo.update(task)
logger.info(
"GenerationTask 状态更新成功: task_id=%s action=%s",
task_id,
status_action,
)
return True
finally:
session.close()
except Exception as e:
logger.error(
"更新 GenerationTask 状态异常: task_id=%s action=%s error=%s",
task_id,
status_action,
e,
exc_info=True,
)
return False
def _build_error_info(error: Exception, stage: str = "render") -> dict:
"""构建结构化错误信息。
Args:
error: 异常对象
stage: 发生错误的阶段(download/render/merge/upload等)
Returns:
包含 error_type, message, stack_trace, stage, failed_at 的字典
"""
import traceback
from datetime import datetime, timezone
tb_str = traceback.format_exc()
# 截取堆栈前20行,避免字段过大
tb_lines = tb_str.strip().splitlines()
if len(tb_lines) > 20:
tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)"
else:
tb_summary = tb_str
return {
"error_type": type(error).__name__,
"message": str(error),
"stack_trace": tb_summary,
"stage": stage,
"failed_at": datetime.now(timezone.utc).isoformat(),
}
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
def _flush_logs(task_id: str, gen_task) -> None:
"""将 gen_task.logs 持久化到 DB(独立 session,失败不抛异常)。"""
try:
session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
model = session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
if model:
model.logs = gen_task.logs
session.commit()
finally:
session.close()
except Exception:
logger.warning("[task_id=%s] 日志持久化失败", task_id, exc_info=True)
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
from video_processing.dedup_helpers import create_video_record_and_dedup
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.oss_helpers import (
download_asset,
get_signed_download_url,
upload_to_oss,
)
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
@dataclass
class _VirtualPlan:
"""内存中的虚拟剪辑计划,供 UnifiedRenderService 使用。"""
id: str
name: str = ""
config: dict[str, Any] = field(default_factory=dict)
@dataclass
class _VirtualClip:
"""内存中的虚拟剪辑片段,供 UnifiedRenderService 使用。"""
id: str
plan_id: str = ""
clip_type: str = "main"
order: int = 0
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
playback_speed: float = 1.0
status: str = "ready"
config: dict[str, Any] = field(default_factory=dict)
def _load_template_clip_configs(template_id: str) -> list:
"""从数据库读取模板的片段配置列表。
失败返回空列表,不阻断主流程。
"""
if not template_id:
return []
try:
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyTemplateClipConfigRepository(session)
configs = repo.list_by_template(template_id, limit=200)
logger.info("读取模板片段配置: template_id=%s count=%d", template_id, len(configs))
return configs
finally:
session.close()
except Exception as e:
logger.warning("读取模板片段配置失败,跳过效果层映射: template_id=%s error=%s", template_id, e)
return []
def _extract_intro_outro_from_clip_configs(clip_configs: list) -> dict[str, Any]:
"""从模板的 intro/outro 类型 clip_config 中提取 plan 级 intro_outro 配置。
UnifiedRenderService 已支持 plan.config.intro_outro 路径,
这里把 intro/outro 片段配置转为统一格式注入。
"""
intro_configs = [
c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "intro"
]
outro_configs = [
c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "outro"
]
result: dict[str, Any] = {}
if intro_configs:
intro = intro_configs[0]
intro_cfg = intro.config or {}
result["has_intro"] = True
result["intro_type"] = intro_cfg.get("intro_type", "text")
result["intro_duration"] = intro.default_duration or 3.0
if intro.text_template:
result["intro_text"] = intro.text_template
# 透传额外配置
for key in ("intro_text_color", "intro_bg_color", "intro_font_size", "intro_video_url", "intro_video_path"):
if key in intro_cfg:
result[key] = intro_cfg[key]
if outro_configs:
outro = outro_configs[0]
outro_cfg = outro.config or {}
result["has_outro"] = True
result["outro_type"] = outro_cfg.get("outro_type", "text")
result["outro_duration"] = outro.default_duration or 3.0
if outro.text_template:
result["outro_text"] = outro.text_template
for key in ("outro_text_color", "outro_bg_color", "outro_font_size", "outro_follow_text"):
if key in outro_cfg:
result[key] = outro_cfg[key]
return result
def _apply_template_clip_effects(
clips: list[_VirtualClip],
clip_configs: list,
mode: str,
) -> None:
"""将模板的 clip 级效果层映射到素材 clips 上(就地修改)。
映射规则:
- 只对素材主体 clips 做映射(ONE_TAKE: main, PIP: main+overlay, VOICE_OVER: main, VOICE_PIP: background+b_roll
- 从模板中筛选 main 类型的 clip_config 作为效果模板
- 素材 clips 按顺序循环匹配模板 clip_config(素材多的话重复使用最后一个模板配置)
- 映射字段:transition_effect, config.color_grade, config.speed
"""
if not clip_configs or not clips:
return
# 筛选 main 类型的模板配置(作为效果模板池)
main_configs = [
c
for c in clip_configs
if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) in ("main", "showcase", "b_roll")
]
if not main_configs:
return
# 确定需要映射的素材 clips(排除 corner_voice 等特殊层)
target_clips = [c for c in clips if c.clip_type not in ("corner_voice",)]
for i, clip in enumerate(target_clips):
# 循环匹配:素材多了用最后一个模板配置
cfg_idx = min(i, len(main_configs) - 1)
template_cfg = main_configs[cfg_idx]
# 1. 转场效果
transition = (
template_cfg.transition_effect.value
if hasattr(template_cfg.transition_effect, "value")
else template_cfg.transition_effect
)
if transition and transition != "cut":
clip.transition_effect = transition
# 2. clip 级效果配置(滤镜、调速等)
template_clip_config = template_cfg.config or {}
if template_clip_config:
# 合并到 clip.config(保留已有配置如 role 等)
existing_config = clip.config or {}
# 需要从模板复制的效果层 key
effect_keys = ("color_grade", "speed", "playback_speed", "reverse", "chroma_key", "filter")
for key in effect_keys:
if key in template_clip_config:
existing_config[key] = template_clip_config[key]
clip.config = existing_config
# 3. 调速:同步到 clip.playback_speed 顶级字段(渲染引擎读此字段)
template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed")
if template_speed:
try:
speed_val = float(template_speed)
if speed_val > 0:
clip.playback_speed = speed_val
except (ValueError, TypeError):
pass
def _build_plan_and_clips_from_task(
task_id: str,
downloaded_paths: list[Path],
mode: str,
template_id: str = "",
) -> tuple[_VirtualPlan, list[_VirtualClip], dict[str, Path]]:
"""根据模式和下载的素材路径,构建虚拟 plan + clips + asset_path_map。
模式 → clip_type 映射:
ONE_TAKE: N 个 main clips
PIP: 1 main + N-1 overlay
VOICE_OVER: N 个 main(config.role=b_roll)
VOICE_PIP: 1 background + 1 corner_voice + N-2 b_roll
Returns:
(virtual_plan, virtual_clips, asset_path_map)
"""
plan = _VirtualPlan(id=task_id, name=f"Generated-{task_id[:8]}")
# 为每个下载路径生成合成 asset_id,并预探测素材时长
asset_path_map: dict[str, Path] = {}
path_to_asset_id: dict[Path, str] = {}
path_duration: dict[Path, float] = {}
for i, p in enumerate(downloaded_paths):
asset_id = f"gen_{task_id[:8]}_{i:03d}{p.suffix or '.mp4'}"
asset_path_map[asset_id] = p
path_to_asset_id[p] = asset_id
path_duration[p] = probe_duration(p)
clips: list[_VirtualClip] = []
if mode == "pip":
# 1 main + N-1 overlay
for i, p in enumerate(downloaded_paths):
clip_type = "main" if i == 0 else "overlay"
clips.append(
_VirtualClip(
id=f"vc_{i:03d}",
plan_id=task_id,
clip_type=clip_type,
order=i,
asset_id=path_to_asset_id[p],
duration=path_duration[p],
)
)
elif mode == "voice_over":
# N 个 main(config.role=b_roll)
for i, p in enumerate(downloaded_paths):
clips.append(
_VirtualClip(
id=f"vc_{i:03d}",
plan_id=task_id,
clip_type="main",
order=i,
asset_id=path_to_asset_id[p],
duration=path_duration[p],
config={"role": "b_roll"},
)
)
elif mode == "voice_pip":
# 1 background + 1 corner_voice + N-2 b_roll
for i, p in enumerate(downloaded_paths):
if i == 0:
clip_type = "background"
elif i == 1:
clip_type = "corner_voice"
else:
clip_type = "b_roll"
clips.append(
_VirtualClip(
id=f"vc_{i:03d}",
plan_id=task_id,
clip_type=clip_type,
order=i,
asset_id=path_to_asset_id[p],
duration=path_duration[p],
)
)
else:
# ONE_TAKE (default): N 个 main clips
for i, p in enumerate(downloaded_paths):
clips.append(
_VirtualClip(
id=f"vc_{i:03d}",
plan_id=task_id,
clip_type="main",
order=i,
asset_id=path_to_asset_id[p],
duration=path_duration[p],
)
)
# ── P1: 模板效果层映射 ──
if template_id:
clip_configs = _load_template_clip_configs(template_id)
if clip_configs:
# 1. clip级效果层(转场、滤镜、调速等)
_apply_template_clip_effects(clips, clip_configs, mode)
# 2. 片头片尾(从 intro/outro 类型 clip 提取 plan 级配置)
intro_outro_config = _extract_intro_outro_from_clip_configs(clip_configs)
if intro_outro_config:
plan_config = plan.config or {}
plan_config["intro_outro"] = intro_outro_config
plan.config = plan_config
logger.info(
"模板片头片尾配置已注入: has_intro=%s has_outro=%s",
intro_outro_config.get("has_intro", False),
intro_outro_config.get("has_outro", False),
)
return plan, clips, asset_path_map
def _create_fallback_clip(output_path: Path, title: str) -> None:
"""创建 fallback 视频(无素材时)"""
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
run_ffmpeg(
[
FFMPEG_BIN,
"-y",
"-f",
"lavfi",
"-i",
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_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 _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> None:
"""将音频轨混入已渲染的视频(后处理步骤)。
使用 FFmpeg 将视频和音频合并,视频时长为准,音频不足则循环,
音频过长则截断。
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
"-i",
audio_path,
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-movflags",
"+faststart",
str(output_path),
]
run_ffmpeg(command)
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
"""下载配音文件。
支持两种来源(按优先级):
1. 配音素材库 asset — 将 voice_library_id 当 asset_id 查 asset 表,
找到则用 asset.storage_key 下载(用户上传到配音库的音频)
2. 旧版 voice/{id}.mp3 路径 — 向后兼容
"""
if not voice_library_id:
return False
# 方式1:先尝试当 asset_id 查素材库(用户上传到配音库的音频)
try:
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyAssetRepository(session)
asset = repo.find_by_id(voice_library_id)
if asset and asset.storage_key:
# 是素材库的配音 asset,用 storage_key 下载
logger.info(
"配音素材来自素材库: asset_id=%s storage_key=%s",
voice_library_id,
asset.storage_key,
)
ok = download_asset(asset.storage_key, local_path)
if ok and local_path.exists() and local_path.stat().st_size > 0:
return True
finally:
session.close()
except Exception as e:
logger.warning("查询配音asset失败,fallback旧路径: %s", e)
# 方式2:旧版路径(向后兼容)
storage_key = f"voice/{voice_library_id}.mp3"
return download_asset(storage_key, local_path)
def _prepare_bgm_track(
*,
bgm_config: dict,
temp_path: Path,
task_id: str = "",
) -> str | None:
"""准备 BGM 音频文件(下载到本地).
支持 3 种来源(按优先级):
1. audio_url — 外部直链 URL(最高优先级)
2. asset_id — 素材库中的音频素材
3. preset_id — 预设 BGM 库
Returns:
BGM 本地文件路径,准备失败返回 None
"""
from urllib.parse import urlparse
audio_url = bgm_config.get("audio_url", "") or ""
asset_id = bgm_config.get("asset_id", "") or ""
preset_id = bgm_config.get("preset_id", "") or ""
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
# 优先级1:外部直链 URL
if audio_url:
try:
parsed = urlparse(audio_url)
if parsed.scheme in ("http", "https"):
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
safe_download_file(
audio_url,
str(bgm_file),
purpose="bgm_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
# 优先级2:素材库素材
if asset_id:
try:
from app.core.db import SessionLocal
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = SessionLocal()
try:
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model and (model.storage_key or model.file_url):
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
storage_key = model.storage_key or model.file_url
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
ok = download_asset(storage_key, bgm_file)
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
finally:
session.close()
except Exception as e:
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
# 优先级3:预设 BGM 库
if preset_id:
try:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset and preset.audio_url:
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
safe_download_file(
preset.audio_url,
str(bgm_file),
purpose="bgm_preset_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
# 所有来源都失败
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
return None
def _verify_url_accessible(
url: str,
timeout: float = 10.0,
retries: int = 2,
max_redirects: int = 5,
) -> bool:
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
安全增强:
- 请求前先做 SSRF 安全校验(内网IP/回环地址/链路本地地址等)
- scheme 仅允许 http/https
- 端口仅允许 80/443
- 手动跟随重定向,每一跳 URL 都做 SSRF 校验,避免重定向到内网地址绕过
Args:
url: 待校验的 URL
timeout: 单次请求超时时间(秒)
retries: 最大重试次数(默认 2 次,首次失败后间隔 1s 重试)
max_redirects: 最大重定向次数(默认 5 次)
Returns:
True 表示 URL 可访问(HTTP 2xx/3xx),False 表示所有尝试均失败或安全校验不通过。
"""
import time
import urllib.request
from urllib.parse import urljoin
from video_processing.url_security import UrlSecurityError, validate_url_safety
# P0-1 SSRF 防护:请求前先校验 URL 安全性
try:
validate_url_safety(url, purpose="url_verify")
except UrlSecurityError as e:
logger.warning("URL 安全校验失败,拒绝访问: url=%s error=%s", url[:80], e)
return False
last_error: Exception | None = None
def _do_verify(current_url: str) -> bool:
"""单次校验:手动跟随重定向,每跳都做 SSRF 检查."""
redirect_count = 0
url_being_checked = current_url
# 禁止自动重定向的 handler,手动控制每一跳
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
return None
opener = urllib.request.build_opener(NoRedirect())
while redirect_count <= max_redirects:
# 每一跳都做 SSRF 安全校验
try:
safe_url = validate_url_safety(url_being_checked, purpose="url_verify")
except UrlSecurityError as e:
logger.warning(
"URL校验跳转地址不安全: redirect=%d url=%s error=%s",
redirect_count,
url_being_checked,
e,
)
raise
req = urllib.request.Request(safe_url, method="HEAD")
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
with opener.open(req, timeout=timeout) as resp: # noqa: S310
if 200 <= resp.status < 300:
return True
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if not location:
raise Exception(f"HTTP {resp.status} 但无 Location 头")
# 相对路径转绝对
url_being_checked = urljoin(safe_url, location)
redirect_count += 1
continue
if resp.status < 400:
return True
raise Exception(f"HTTP {resp.status}")
raise Exception(f"重定向次数超过上限 ({max_redirects})")
for attempt in range(1 + retries):
try:
if _do_verify(url):
return True
except Exception as e:
last_error = e
if attempt < retries:
logger.warning(
"URL 校验失败,1s 后重试: url=%s attempt=%d/%d error=%s",
url,
attempt + 1,
retries,
last_error,
)
time.sleep(1)
logger.warning("URL 可访问性校验最终失败: url=%s error=%s", url, last_error)
return False
def _download_library_assets(
temp_path: Path,
*,
asset_library_id: str = "",
project_id: str = "",
asset_ids: list[str] | None = None,
strict: bool = True,
task_id: str = "",
gen_task=None,
) -> list[Path]:
"""下载视频素材 — 同时支持素材库模式和项目级模式。
两种查询路径:
- 素材库模式:asset_library_id 非空时,按 asset_library_id + asset_ids 查
- 项目级模式:project_id 非空时,按 project_id + asset_ids 查
- 两者都提供时优先素材库模式;两者都为空时抛异常
归属校验与下载在同一 DB session 中完成,避免多次连接开销(P3-2)。
Args:
temp_path: 临时目录路径
asset_library_id: 素材库 ID(可选,与 project_id 二选一)
project_id: 项目 ID(可选,与 asset_library_id 二选一)
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
strict: 严格模式(默认 True)。
True — 任何素材下载失败立即抛 RuntimeError
False — 跳过失败素材,返回成功列表(调用方可通过日志感知失败)。
Returns:
下载成功的视频文件 Path 列表
Raises:
ValueError: 当 asset_library_id 和 project_id 都为空时
RuntimeError: strict=True 时任何下载失败;或指定了 asset_ids 但全部下载失败
"""
if not asset_library_id and not project_id:
raise ValueError("asset_library_id 和 project_id 至少需要提供一个")
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = SessionLocal()
try:
# 构建查询
query = session.query(AssetModel).filter(
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
if asset_ids:
# 明确指定了 asset_ids:直接按 ID 查,不预先按 library/project 过滤
# 避免项目级素材或跨库素材因为 library_id 不匹配而查不到
# 归属安全由后面的归属校验保证
query = query.filter(AssetModel.id.in_(asset_ids))
logger.info(
"下载指定素材: asset_ids=%d 个, asset_library_id=%s, project_id=%s",
len(asset_ids),
asset_library_id or "none",
project_id or "none",
)
else:
# 未指定 asset_ids:按 library 或 project 下载全部 ready 视频
if asset_library_id:
query = query.filter(AssetModel.asset_library_id == asset_library_id)
logger.info(
"下载素材库全部视频: asset_library_id=%s",
asset_library_id,
)
else:
query = query.filter(AssetModel.project_id == project_id)
logger.info(
"下载项目全部视频: project_id=%s",
project_id,
)
assets = query.order_by(AssetModel.created_at).all()
if not assets:
mode_desc = f"素材库 {asset_library_id}" if asset_library_id else f"项目 {project_id}"
msg = f"未找到视频素材: {mode_desc}, asset_ids={asset_ids or 'all'}"
logger.error(msg)
raise RuntimeError(msg)
# P3-2: 归属校验合并到同一 session
if asset_ids:
found_ids = {a.id for a in assets}
missing_ids = set(asset_ids) - found_ids
if missing_ids:
raise ValueError(f"素材不存在: asset_ids={sorted(missing_ids)}")
for asset in assets:
# 校验素材库归属(只要传了 asset_library_id 就校验)
if asset_library_id and asset.asset_library_id != asset_library_id:
raise ValueError(
f"素材不属于指定素材库: asset_id={asset.id}, "
f"expected_asset_library_id={asset_library_id}, "
f"actual_asset_library_id={asset.asset_library_id}"
)
# 校验项目归属(只要传了 project_id 就校验)
if project_id and asset.project_id != project_id:
raise ValueError(
f"素材不属于指定项目: asset_id={asset.id}, "
f"expected_project_id={project_id}, "
f"actual_project_id={asset.project_id}"
)
logger.info(
"素材归属校验通过 (同 session): %d 个 asset_ids",
len(asset_ids),
)
downloaded: list[Path] = []
failed_assets: list[str] = []
for i, asset in enumerate(assets):
storage_key = asset.file_url if asset.file_url else None
if not storage_key:
failed_assets.append(f"{asset.name}({asset.id})")
logger.warning(
"[task_id=%s] 素材缺少 file_url, 跳过: asset_id=%s name=%s",
task_id,
asset.id,
asset.name,
)
if gen_task:
gen_task.append_log(
"下载素材",
"素材缺少file_url, 跳过",
level="WARN",
asset_id=asset.id,
asset_name=asset.name,
success=False,
file_size=0,
duration=0.0,
)
if strict:
raise RuntimeError(f"素材缺少 file_url: asset_id={asset.id}, name={asset.name}")
continue
ext = Path(storage_key).suffix or ".mp4"
local_file = temp_path / f"asset_{i:03d}_{asset.id}{ext}"
asset_start = time.monotonic()
download_ok = download_asset(storage_key, local_file)
asset_elapsed = time.monotonic() - asset_start
if download_ok:
file_size = local_file.stat().st_size if local_file.exists() else 0
downloaded.append(local_file)
logger.info(
"[task_id=%s] Downloaded asset: %s -> %s (size=%d, time=%.1fs)",
task_id,
asset.name,
local_file,
file_size,
asset_elapsed,
)
if gen_task:
gen_task.append_log(
"下载素材",
f"下载成功: {asset.name}",
asset_id=asset.id,
asset_name=asset.name,
success=True,
file_size=file_size,
duration=round(asset_elapsed, 2),
)
else:
failed_assets.append(f"{asset.name}({asset.id})")
logger.warning(
"[task_id=%s] Failed to download asset: %s (id=%s)",
task_id,
asset.name,
asset.id,
)
if gen_task:
gen_task.append_log(
"下载素材",
f"下载失败: {asset.name}",
level="WARN",
asset_id=asset.id,
asset_name=asset.name,
success=False,
file_size=0,
duration=round(asset_elapsed, 2),
)
if strict:
raise RuntimeError(f"素材下载失败: asset_id={asset.id}, name={asset.name}")
# 指定了 asset_ids 但全部下载失败 → 无论 strict 与否都报错
if asset_ids and not downloaded:
msg = f"指定的 {len(asset_ids)} 个素材全部下载失败, failed={failed_assets}"
logger.error(msg)
raise RuntimeError(msg)
# 非严格模式有部分失败,记录警告
if failed_assets and not strict:
logger.warning(
"素材下载部分失败 (非严格模式): failed=%s, succeeded=%d",
failed_assets,
len(downloaded),
)
return downloaded
finally:
session.close()
except (ValueError, RuntimeError):
raise
except Exception as e:
logger.error("Error downloading library assets: %s", e, exc_info=True)
raise RuntimeError(f"素材下载异常: {e}") from e
# ── P1 校验函数 ──────────────────────────────────────────────────────────────
def _validate_template_exists(template_id: str) -> None:
"""校验 template_id 是否存在且可用。
优先读新模板系统(EditTemplate),找不到 fallback 到旧模板系统(TemplateModel)。
Raises:
ValueError: template_id 不存在或已禁用时抛出
"""
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditTemplateRepository,
)
session = SessionLocal()
try:
# 优先读新模板系统
new_repo = SQLAlchemyEditTemplateRepository(session)
new_template = new_repo.get(template_id)
if new_template is not None:
status_val = new_template.status.value if hasattr(new_template.status, "value") else new_template.status
if status_val == "active":
logger.info("模板校验通过(新系统): template_id=%s name=%s", template_id, new_template.name)
return
else:
raise ValueError(f"模板已停用: template_id={template_id}")
# fallback: 旧模板系统
from packages.adapters.sqlalchemy_impl.models import TemplateModel
template = (
session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.is_active.is_(True),
)
.first()
)
if template:
logger.info("模板校验通过(旧系统): template_id=%s name=%s", template_id, template.name)
return
raise ValueError(f"模板不存在或已禁用: template_id={template_id}")
finally:
session.close()
def _load_template_plan_config(template_id: str) -> dict:
"""从模板加载 plan 级配置(BGM、字幕、标题等效果层)。
优先读新模板系统(EditTemplate.config + TemplateClipConfig),
找不到 fallback 到旧模板系统(TemplateModel 独立字段)。
模板不存在时返回空 dict,不阻塞主流程。
"""
if not template_id:
return {}
try:
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditTemplateRepository,
SQLAlchemyTemplateClipConfigRepository,
)
session = SessionLocal()
try:
# 优先读新模板系统
tpl_repo = SQLAlchemyEditTemplateRepository(session)
clip_repo = SQLAlchemyTemplateClipConfigRepository(session)
template = tpl_repo.get(template_id)
if template is not None:
# 新系统:config 直接就是 plan.config 格式
plan_config = dict(template.config or {})
# 从片段配置中提取 intro/outro 配置
clip_configs = clip_repo.list_by_template(template_id, limit=200)
if clip_configs:
intro_outro = _extract_intro_outro_from_clip_configs(clip_configs)
if intro_outro:
plan_config["intro_outro"] = intro_outro
# 把 editing_mode 也带过去
if template.editing_mode:
plan_config["editing_mode"] = template.editing_mode
logger.info(
"模板配置加载成功(新系统): template_id=%s keys=%s",
template_id,
list(plan_config.keys()),
)
return plan_config
# fallback: 旧模板系统
from packages.adapters.sqlalchemy_impl.models import TemplateModel
template = (
session.query(TemplateModel)
.filter(
TemplateModel.id == template_id,
TemplateModel.is_active.is_(True),
)
.first()
)
if template is None:
logger.warning("模板不存在,跳过配置加载: template_id=%s", template_id)
return {}
# 从独立字段组装成 plan.config 格式
plan_config: dict[str, Any] = {}
title_cfg = template.title_config or {}
subtitle_cfg = template.subtitle_config or {}
bgm_cfg = template.bgm_config or {}
if title_cfg:
plan_config["title"] = title_cfg
if subtitle_cfg:
plan_config["subtitle"] = subtitle_cfg
if bgm_cfg:
plan_config["bgm"] = bgm_cfg
logger.info(
"模板配置加载成功(旧系统): template_id=%s keys=%s",
template_id,
list(plan_config.keys()),
)
return plan_config
finally:
session.close()
except Exception as e:
logger.warning("加载模板配置失败,跳过: template_id=%s err=%s", template_id, e)
return {}
# ── generate_video 阶段子函数 ─────────────────────────────────────────────────
def _load_task_info(task_id: str) -> dict | None:
"""从数据库加载 GenerationTask 元数据。
Returns:
包含任务元数据的字典,任务不存在时返回 None。
"""
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
session = SessionLocal()
try:
task_repo = SQLAlchemyGenerationTaskRepository(session)
gen_task = task_repo.get(task_id)
if gen_task is None:
return None
return {
"project_id": gen_task.project_id,
"asset_library_id": gen_task.asset_library_id,
"voice_library_id": gen_task.voice_library_id or "",
"template_id": getattr(gen_task, "template_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 "",
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
"video_title": getattr(gen_task, "video_title", "") or "",
}
finally:
session.close()
def _download_all_assets(
temp_path: Path,
asset_library_id: str,
project_id: str,
task_asset_ids: list[str],
voice_library_id: str,
task_id: str,
) -> tuple[list[Path], str | None]:
"""下载视频素材和配音素材。
Returns:
(downloaded_videos, audio_path)
Note: gen_task 不传入下载函数(session 已关闭),
主函数在下载前后已有汇总日志。
"""
logger.info("[task_id=%s] [下载素材] 开始下载视频素材", task_id)
download_start = time.monotonic()
downloaded_videos = _download_library_assets(
temp_path,
asset_library_id=asset_library_id,
project_id=project_id,
asset_ids=task_asset_ids or None,
task_id=task_id,
)
download_elapsed = time.monotonic() - download_start
logger.info(
"[task_id=%s] [下载素材] 完成: 成功=%d个, 耗时=%.1fs",
task_id,
len(downloaded_videos),
download_elapsed,
)
audio_path: str | None = 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)
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
return downloaded_videos, audio_path
def _render_video(
task_id: str,
downloaded_videos: list[Path],
voice_path: str | None,
editing_mode,
project_id: str,
template_id: str,
user_id: str,
temp_path: Path,
output_name: str,
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。
Returns:
(output_path, render_duration)
"""
if not downloaded_videos:
raise RuntimeError(f"素材下载结果为空: task_id={task_id}")
# 构建虚拟 plan + clips + asset_path_map
virtual_plan, virtual_clips, asset_path_map = _build_plan_and_clips_from_task(
task_id=task_id,
downloaded_paths=downloaded_videos,
mode=editing_mode.value,
template_id=template_id,
)
# 注入模板配置(BGM、字幕等效果层)
if template_id:
template_config = _load_template_plan_config(template_id)
if template_config:
base_config = virtual_plan.config or {}
virtual_plan.config = {**template_config, **base_config}
logger.info(
"[task_id=%s] [渲染] 模板配置已注入: keys=%s",
task_id,
list(template_config.keys()),
)
# 确保输出分辨率配置存在(一键生成默认横屏 1280x720)
# RenderAdapter 从 plan.config.export.resolution 读取,
# 如果模板没有配置则用默认值,这里显式设置保持和旧逻辑一致
plan_cfg = virtual_plan.config or {}
export_cfg = plan_cfg.get("export", {}) or {}
if not export_cfg.get("resolution"):
export_cfg["resolution"] = f"{OUTPUT_WIDTH}x{OUTPUT_HEIGHT}"
plan_cfg["export"] = export_cfg
virtual_plan.config = plan_cfg
total_duration = sum(c.duration for c in virtual_clips)
logger.info(
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
task_id,
len(virtual_clips),
total_duration,
)
render_start = time.monotonic()
logger.info("[task_id=%s] [渲染] RenderAdapter 统一渲染开始", task_id)
# 使用 RenderAdapter 统一渲染入口(复用 BGM/ASR/分辨率/缩略图逻辑)
from video_processing.render_adapter import RenderAdapter
from worker_app.db import SessionLocal
db = SessionLocal()
try:
adapter = RenderAdapter(db)
render_result = adapter.render_from_memory(
plan=virtual_plan,
clips=virtual_clips,
asset_path_map=asset_path_map,
plan_id=f"gen_{task_id}",
job_id=task_id,
work_dir=temp_path,
)
finally:
db.close()
if not render_result.success:
raise RuntimeError(f"渲染失败: {render_result.error_message}")
render_output_path = render_result.output_path
render_duration = render_result.duration
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] RenderAdapter 完成: 耗时=%.1fs, 时长=%.2fs",
task_id,
render_elapsed,
render_duration,
)
# 配音混音(素材库音频,后处理混音)
if voice_path:
final_path = temp_path / f"final-{task_id}.mp4"
try:
_mux_audio_track(render_output_path, voice_path, final_path)
output_path = final_path
except Exception as mux_err:
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
output_path = render_output_path
else:
output_path = render_output_path
return output_path, render_duration
def _upload_and_record(
task_id: str,
output_path: Path,
project_id: str,
batch_id: str,
editing_mode,
user_id: str = "",
video_name: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
Returns:
(file_url, duration, file_size, video_count)
"""
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_path.name}"
file_size = output_path.stat().st_size
# 上传 OSS
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
upload_start = time.monotonic()
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
if not _verify_url_accessible(verify_url):
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
bucket = oss_bucket()
key = normalize_storage_key(file_url)
if not (bucket and bucket.object_exists(key)):
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
)
logger.info(
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
key,
)
logger.info(
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
task_id,
upload_elapsed,
file_url,
)
# 创建 GeneratedVideo 记录 + 查重
duration = probe_duration(output_path)
dedup_session = SessionLocal()
try:
video_count = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
name=video_name,
)
finally:
dedup_session.close()
return file_url, duration, file_size, video_count or 1
# ── Celery Task ──────────────────────────────────────────────────────────────
@celery_app.task(bind=True, name="worker.generate_video", max_retries=2)
def generate_video(self, task_id: str) -> dict:
"""生成视频任务 — 使用 UnifiedRenderService 统一渲染。
流程:
1. 加载 GenerationTask 信息
2. 从素材库下载视频素材
3. 根据模式构建虚拟 plan + clips
4. 使用 UnifiedRenderService 渲染
5. 如有配音,后处理混音
6. 上传 OSS + 查重
7. 更新 GenerationTask 状态
Args:
task_id: 任务 ID(从数据库加载完整任务信息)
Returns:
生成结果字典
"""
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.domain import EditingMode
logger.info("[task_id=%s] [接收任务] 开始生成视频任务", task_id)
# 调度时清理一次孤儿任务(其他 worker 崩溃留下的 running 任务)
try:
from worker_app.tasks._startup import cleanup_orphan_tasks
orphan_count = cleanup_orphan_tasks()
if orphan_count > 0:
logger.info("[task_id=%s] 调度前清理了 %d 个孤儿任务", task_id, orphan_count)
except Exception:
pass
# ── 1. 加载任务信息 ──────────────────────────────────────────────────────
task_info = _load_task_info(task_id)
if task_info is None:
logger.error("[task_id=%s] [接收任务] 任务不存在", task_id)
return {"status": "failed", "error": f"generation task {task_id} not found"}
project_id = task_info["project_id"]
asset_library_id = task_info["asset_library_id"]
voice_library_id = task_info["voice_library_id"]
template_id = task_info["template_id"]
task_asset_ids = task_info["task_asset_ids"]
batch_id = task_info["batch_id"]
user_id = task_info["user_id"]
# 加载 gen_task(用于全程进度日志;_flush_logs 使用独立 session 持久化)
_session = SessionLocal()
try:
_repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id)
finally:
_session.close()
# 记录接收任务日志
if gen_task:
gen_task.append_log(
"接收任务",
f"模式={task_info['mode']}, 模板={template_id}, 素材数={len(task_asset_ids)}",
mode=task_info["mode"],
template_id=template_id,
asset_count=len(task_asset_ids),
)
_flush_logs(task_id, gen_task)
# 标记任务为 running
_update_task_status(task_id, "mark_processing")
try:
editing_mode = EditingMode(mode) if (mode := task_info["mode"]) else EditingMode.ONE_TAKE
except ValueError:
editing_mode = EditingMode.ONE_TAKE
output_name = f"generated-{task_id}.mp4"
try:
if template_id:
_validate_template_exists(template_id)
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
temp_path = Path(temp_dir)
# ── 2. 下载素材 ──────────────────────────────────────────────────
downloaded_videos, audio_path = _download_all_assets(
temp_path,
asset_library_id=asset_library_id,
project_id=project_id,
task_asset_ids=task_asset_ids,
voice_library_id=voice_library_id,
task_id=task_id,
)
if gen_task:
gen_task.append_log(
"下载素材",
f"成功下载 {len(downloaded_videos)} 个视频素材",
count=len(downloaded_videos),
)
_flush_logs(task_id, gen_task)
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
output_path, render_duration = _render_video(
task_id=task_id,
downloaded_videos=downloaded_videos,
voice_path=audio_path,
editing_mode=editing_mode,
project_id=project_id,
template_id=template_id,
user_id=user_id,
temp_path=temp_path,
output_name=output_name,
)
if gen_task:
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
_flush_logs(task_id, gen_task)
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────────────
file_url, duration, file_size, video_count = _upload_and_record(
task_id=task_id,
output_path=output_path,
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
video_name=task_info.get("video_title", ""),
)
if gen_task:
gen_task.append_log(
"OSS上传",
f"上传成功, 大小={file_size}",
file_size=file_size,
file_url=file_url,
)
_flush_logs(task_id, gen_task)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
# 5.1 更新标题使用次数
try:
_title_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.adapters.sqlalchemy_impl.title_library_repository import (
SQLAlchemyTitleLibraryRepository,
)
_task_repo = SQLAlchemyGenerationTaskRepository(_title_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.title_ids and _gen_task.created_by_user_id:
_title_repo = SQLAlchemyTitleLibraryRepository(_title_session)
for _tid in _gen_task.title_ids:
try:
_title_repo.increment_usage_count(_tid, _gen_task.created_by_user_id)
except Exception:
logger.warning(
"[task_id=%s] 更新标题使用次数失败: title_id=%s",
task_id,
_tid,
exc_info=True,
)
finally:
_title_session.close()
except Exception:
logger.warning("[task_id=%s] 更新标题使用次数异常(不影响主流程)", task_id, exc_info=True)
# 5.2 更新素材使用次数 + 最近使用时间
try:
from worker_app.core.asset_usage import mark_asset_used_for_generation
_asset_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
_gen_task = _task_repo.get(task_id)
if _gen_task and _gen_task.asset_ids:
for _aid in _gen_task.asset_ids:
try:
_asset = _asset_repo.get(_aid)
if _asset:
mark_asset_used_for_generation(_asset)
_asset_repo.update(_asset)
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
task_id,
_aid,
exc_info=True,
)
finally:
_asset_session.close()
except Exception:
logger.warning("[task_id=%s] 更新素材使用次数异常(不影响主流程)", task_id, exc_info=True)
if gen_task:
gen_task.append_log(
"任务完成",
f"视频生成完成: 时长={duration:.2f}s, 大小={file_size}",
duration=round(duration, 2),
file_size=file_size,
video_count=video_count,
)
_flush_logs(task_id, gen_task)
logger.info(
"[task_id=%s] [任务完成] duration=%.2fs file_size=%d",
task_id,
duration,
file_size,
)
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("[task_id=%s] [任务失败] %s", task_id, error, exc_info=True)
# 构建结构化错误信息
error_info = _build_error_info(error, stage="render")
# 记录失败日志
try:
_session = SessionLocal()
try:
_repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id)
if gen_task:
gen_task.append_log( # type: ignore[misc]
"任务失败",
str(error),
level="ERROR",
error_type=type(error).__name__,
stage="render",
)
_flush_logs(task_id, gen_task)
finally:
_session.close()
except Exception:
logger.warning("[task_id=%s] 记录失败日志异常", task_id, exc_info=True)
_update_task_status(
task_id,
"mark_failed",
error_message=str(error),
error_info=error_info,
)
# ── 自动重试逻辑 ──────────────────────────────────────────────────
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_s = SessionLocal()
try:
_r = SQLAlchemyGenerationTaskRepository(_s)
_task = _r.get(task_id)
if _task and _task.auto_retry_enabled and _task.auto_retry_max > 0:
current_retry = _task.retry_count or 0
if current_retry < _task.auto_retry_max:
logger.info(
"[task_id=%s] 触发自动重试: 当前重试次数=%d, 最大重试次数=%d",
task_id,
current_retry,
_task.auto_retry_max,
)
# 计算退避延迟(指数退避,基础5s,最大60s)
backoff_seconds = min(5 * (2**current_retry), 60)
# 原地重试
_task.mark_pending_from_failed()
_r.update(_task)
# 延迟重新入队
celery_app.send_task(
"worker.generate_video",
args=[task_id],
countdown=backoff_seconds,
)
logger.info(
"[task_id=%s] 自动重试已入队: 延迟=%ds, 第%d次重试",
task_id,
backoff_seconds,
current_retry + 1,
)
finally:
_s.close()
except Exception as retry_err:
logger.warning(
"[task_id=%s] 自动重试逻辑执行失败: %s",
task_id,
retry_err,
exc_info=True,
)
return {
"status": "failed",
"task_id": task_id,
"error": str(error),
}