Files
xiaoxia-saas/apps/worker/worker_app/tasks/generation.py
T
saas-backend-agent ed09794f4d
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 (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
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (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 / PR Build API Image (pull_request) Successful in 34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m25s
AI Code Review / AI Code Review (pull_request) Successful in 1m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m48s
CI/CD Pipeline / Validate - Security (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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
fix(#1743): smart-match排序注入随机噪声 + 素材使用次数按成片实际片段计数回写
1. smart-match 排序零随机修复(主因):
   - smart_select_assets 排序/多样性分桶注入 0~SCORE_RANDOM_NOISE_MAX 随机噪声,
     同分/近分素材每次选出不同组合与顺序;分差>20的高质量素材保持稳定优先级
   - 噪声以 asset.id 为 key 同次调用内一致;r.score 始终为无噪声原始分
   - 支持 rng 注入(测试可复现);smart-match API/正式生成/模板编辑器三调用点全受益
2. 素材使用次数口径修复:
   - mark_asset_used_for_generation 新增 times 参数,按成片实际渲染片段引用次数累加
   - worker 回写从 task.asset_ids(请求列表,含未被plan选用的素材)改为
     统计最终成片 plan 的 edit_plan_clips(同素材多片段复用按片段数累加)
   - 抽 _count_plan_clip_asset_usage/_record_rendered_asset_usage 纯函数(可单测)
   - plan 无有效片段时兜底 task.asset_ids 单次计数;单素材失败不阻断其他
3. 测试:22 新测试(噪声 10 + 回写计数 12);旧确定性排序断言注入零噪声 rng;
   修复 test_distribute_assets 预存在 flaky(shuffle 未被零噪声 patch 覆盖)
2026-09-06 19:48:08 +08:00

1223 lines
49 KiB
Python
Raw 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 time
from pathlib import Path
from video_processing.ffmpeg_utils import probe_duration
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
from worker_app.tasks.generation_plan_builder import build_error_info as _build_error_info
from packages.shared.celery_orphan_guard import TERMINAL_STATUS_VALUES
OUTPUT_WIDTH = 1280
OUTPUT_HEIGHT = 720
OUTPUT_FPS = 25.0
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 _update_task_progress(task_id: str, progress: float, stage: str = "") -> bool:
"""更新 GenerationTask 进度(独立 session,异常不向外抛出)。
Args:
task_id: 任务 ID
progress: 进度值(0-100
stage: 阶段描述(仅用于日志)
Returns:
True 表示更新成功
"""
try:
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
session = SessionLocal()
try:
model = session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
if model:
model.progress = progress
session.commit()
if stage:
logger.info(
"GenerationTask 进度更新: task_id=%s progress=%.0f%% stage=%s",
task_id,
progress,
stage,
)
return True
return False
finally:
session.close()
except Exception as e:
logger.error("更新任务进度异常: task_id=%s progress=%s error=%s", task_id, progress, e)
return False
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
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.oss_helpers import (
download_asset,
get_signed_download_url,
upload_to_oss,
)
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 _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 _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_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 "",
"resolution": getattr(gen_task, "resolution", "") or "",
"bgm_config": dict(getattr(gen_task, "bgm_config", {}) or {}),
"source_task_id": getattr(gen_task, "source_task_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 "",
"title_config": dict(getattr(gen_task, "title_config", {}) or {}),
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
"source_edit_plan_id": getattr(gen_task, "source_edit_plan_id", "") or "",
}
finally:
session.close()
# ── #1743 批量变体重渲/封面判定(纯函数,便于单测) ──────────────────────
BATCH_RENDER_SIMILARITY_LIMIT = 0.20
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
"""批次内查重后判定是否需要重选 plan 重渲。
条件(全部满足才重渲):批次任务、首版(attempt==0)、查重率已得出、相似度 > 20%。
非批次任务 / 已是重渲版 / 查重率缺失 / 相似度达标 → 不重渲。
"""
if not batch_id:
return False
if render_attempt >= 1:
return False
if batch_similarity is None:
return False
return float(batch_similarity) > BATCH_RENDER_SIMILARITY_LIMIT
def pick_batch_cover_index(task_id: str, candidate_count: int) -> int:
"""批次变体封面帧选取:按 task_id md5 稳定哈希分散到候选帧。
同任务重试结果稳定;批次内不同 task_id 哈希后分散,避免 N 个变体都抽 frame_0
导致封面雷同。非批次调用方应直接取 0(主流程按 batch_id 区分)。
"""
if candidate_count <= 1:
return 0
import hashlib
return int(hashlib.md5(task_id.encode()).hexdigest(), 16) % candidate_count
def _count_plan_clip_asset_usage(session, plan_id: str) -> dict[str, int]:
"""统计最终成片 plan 中每个素材被片段引用的次数。
计数口径(#1743):以成片实际渲染的 edit_plan_clips 为准——
同一素材在多个片段复用按片段数累加;未被 plan 选用的素材(即使
出现在请求 asset_ids 中)不计数。
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
rows = session.query(EditPlanClipModel.asset_id).filter(EditPlanClipModel.plan_id == plan_id).all()
counts: dict[str, int] = {}
for (asset_id,) in rows:
if asset_id:
counts[asset_id] = counts.get(asset_id, 0) + 1
return counts
def _record_rendered_asset_usage(
session,
plan_id: str,
task_id: str,
fallback_asset_ids: list[str] | None = None,
) -> int:
"""按最终成片 plan 的实际片段统计素材使用次数并回写 metadata。
plan 无有效片段素材(异常数据)时退回 fallback_asset_ids 每个计 1 次,
保证使用统计不因数据异常完全丢失。单素材回写失败不影响其他素材。
Returns: 实际回写次数的素材数量。
"""
from worker_app.core.asset_usage import mark_asset_used_for_generation
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
used_counts = _count_plan_clip_asset_usage(session, plan_id)
if not used_counts and fallback_asset_ids:
used_counts = {aid: 1 for aid in fallback_asset_ids if aid}
if not used_counts:
logger.info("[task_id=%s] 素材使用计数: plan=%s 无有效片段素材,跳过", task_id, plan_id)
return 0
asset_repo = SQLAlchemyAssetRepository(session)
written = 0
for aid, times in used_counts.items():
try:
asset = asset_repo.get(aid)
if asset:
mark_asset_used_for_generation(asset, times=times)
asset_repo.update(asset)
written += 1
except Exception:
logger.warning(
"[task_id=%s] 更新素材使用次数失败: asset_id=%s times=%d",
task_id,
aid,
times,
exc_info=True,
)
logger.info(
"[task_id=%s] 素材使用计数回写完成(plan=%s): %d 个素材, 片段引用 %d 次",
task_id,
plan_id,
written,
sum(used_counts.values()),
)
return written
def _upload_rendered_video(
task_id: str,
output_path: Path,
project_id: str,
*,
attempt: int = 0,
) -> tuple[str, str]:
"""上传成片到 OSS(不落库)。attempt>0 时文件名带轮次后缀,避免覆盖首版。
Returns: (file_url, storage_key)
"""
suffix = f"_v{attempt}" if attempt > 0 else ""
stem = output_path.stem
name = f"{stem}{suffix}{output_path.suffix or '.mp4'}"
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, name) if p]
storage_key = "/".join(path_parts)
logger.info("[task_id=%s] [OSS上传] 开始上传(attempt=%d): size=%d", task_id, attempt, output_path.stat().st_size)
file_url = upload_to_oss(output_path, storage_key)
if not file_url:
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
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}, storage_key={storage_key}"
)
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
return file_url, storage_key
def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict) -> str | None:
"""批次内查重超阈值后,为当前任务重新独立选片生成新 plan(#1743 自动重渲)。
复用 API 侧同一套 EditPlanService.reselect_plan_for_variantpackages 层
variant_plan_selector 纯核心),素材池来自任务 asset_ids + 源 plan 素材。
成功返回新 plan_id;失败返回 None(调用方放弃重渲,保留首版)。
"""
try:
from app.services.edit_plan_service import EditPlanService
db = SessionLocal()
try:
svc = EditPlanService(db)
asset_pool = list(task_info.get("task_asset_ids") or [])
new_plan = svc.reselect_plan_for_variant(
plan_id,
asset_pool,
created_by_user_id=task_info.get("user_id", ""),
name_suffix="重渲变体",
)
return new_plan.id
finally:
db.close()
except Exception:
logger.warning("[task_id=%s] 批次重渲前重选 plan 失败,放弃重渲", task_id, exc_info=True)
return None
def _record_video_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
editing_mode,
user_id: str,
file_url: str,
file_size: int,
video_path: str,
video_name: str = "",
thumbnail_url: str = "",
) -> dict:
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
duration = probe_duration(Path(video_path))
dedup_session = SessionLocal()
try:
result = 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=video_path,
mode=editing_mode.value,
session=dedup_session,
name=video_name,
thumbnail_url=thumbnail_url,
)
finally:
dedup_session.close()
result["duration"] = duration
return result
# ── Celery Task ──────────────────────────────────────────────────────────────
# ── Celery Task ──────────────────────────────────────────────────────────────
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
"""将 GenerationTask 的配置同步到 EditPlan.config,返回配音本地路径(如果有)。
包括:title_config、BGM、输出分辨率。配音单独处理(需下载到本地)。
"""
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
plan_repo = SQLAlchemyEditPlanRepository(db)
plan = plan_repo.get(source_edit_plan_id)
if plan is None:
logger.error("[task] EditPlan not found: %s", source_edit_plan_id)
return None
plan_config = dict(plan.config or {})
changed = False
# 标题配置
title_config = task_info.get("title_config") or {}
if title_config and isinstance(title_config, dict) and title_config.get("text", "").strip():
cfg = dict(title_config)
# 字段名归一化
if "font_size" in cfg and "size" not in cfg:
cfg["size"] = cfg["font_size"]
if "font_color" in cfg and "color" not in cfg:
cfg["color"] = cfg["font_color"]
plan_config["title"] = cfg
changed = True
logger.info("[task] title_config synced to plan: %s", cfg.get("text", "")[:30])
# BGM 配置
bgm_config = task_info.get("bgm_config") or {}
if bgm_config:
from packages.domain.bgm_utils import merge_bgm_config
existing_bgm = plan_config.get("bgm", {}) or {}
plan_config["bgm"] = merge_bgm_config(existing_bgm, bgm_config)
changed = True
# 输出分辨率
ow = task_info.get("output_width") or OUTPUT_WIDTH
oh = task_info.get("output_height") or OUTPUT_HEIGHT
if ow >= 100 and oh >= 100:
export_cfg = dict(plan_config.get("export", {}) or {})
export_cfg["resolution"] = f"{ow}x{oh}"
plan_config["export"] = export_cfg
changed = True
if changed:
plan.config = plan_config
plan_repo.update(plan)
logger.info("[task] plan.config synced: plan_id=%s", source_edit_plan_id)
# 配音下载
voiceover_path: str | None = None
voice_library_id = task_info.get("voice_library_id", "")
voice_ids = task_info.get("voice_ids", []) or []
effective_voice_id = voice_library_id or (voice_ids[0] if voice_ids else "")
if effective_voice_id:
import tempfile
voice_tmp = Path(tempfile.gettempdir()) / f"voice_{source_edit_plan_id}_{id(task_info)}.mp3"
try:
if _download_voice_asset(effective_voice_id, voice_tmp):
voiceover_path = str(voice_tmp)
logger.info("[task] voice downloaded: %s -> %s", effective_voice_id, voiceover_path)
except Exception:
logger.warning("[task] voice download failed: %s", effective_voice_id, exc_info=True)
return voiceover_path
def _render_from_edit_plan(
task_id: str,
source_edit_plan_id: str,
task_info: dict,
) -> tuple[Path, float, list[dict] | None, str | None, str | None, str]:
"""从 EditPlan 数据库记录直接渲染(不再内存重建clips)。
Returns:
(output_path, render_duration, cover_candidates, voiceover_path, temp_dir, thumbnail_url)
"""
from video_processing.render_adapter import RenderAdapter
from worker_app.db import SessionLocal
db = SessionLocal()
try:
# 同步配置到 plan.config + 下载配音
voiceover_path = _sync_task_config_to_plan(source_edit_plan_id, task_info, db)
# 进度回调
def _progress_cb(progress: float, stage: str):
mapped = 40.0 + progress * 0.4
_update_task_progress(task_id, min(mapped, 80.0), stage)
adapter = RenderAdapter(db)
render_start = time.monotonic()
logger.info("[task_id=%s] [渲染] RenderAdapter.render_plan 开始 (plan_id=%s)", task_id, source_edit_plan_id)
result = adapter.render_plan(
plan_id=source_edit_plan_id,
job_id=task_id,
progress_cb=_progress_cb,
voiceover_audio_path=voiceover_path,
)
if not result.success:
raise RuntimeError(f"渲染失败: {result.error_message}")
render_elapsed = time.monotonic() - render_start
logger.info(
"[task_id=%s] [渲染] RenderAdapter.render_plan 完成: 耗时=%.1fs, 时长=%.2fs",
task_id,
render_elapsed,
result.duration,
)
output_path = result.output_path
cover_candidates = getattr(result, "cover_candidates", None)
render_temp_dir = getattr(result, "temp_dir", None)
return (
output_path,
result.duration,
cover_candidates,
voiceover_path,
render_temp_dir,
result.thumbnail_url or "",
)
finally:
db.close()
@celery_app.task(
bind=True,
name="worker.generate_video",
max_retries=2,
soft_time_limit=600, # 10 分钟软超时
time_limit=660, # 11 分钟硬超时
)
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, voice_library_id 已在 _render_from_edit_plan 内部重新获取
# (不再需要在 generate_video 顶层解包)
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()
# ── 0. 执行前状态守卫(#1714):任务已被超时清理/孤儿恢复标记为终态时,
# 这是作废消息(worker 崩溃前未 ack 的旧消息重投/重复投递),直接丢弃,
# 不进入渲染,杜绝 failed→running 非法转换后继续跑产出半成品。
if gen_task is not None and gen_task.status.value in TERMINAL_STATUS_VALUES:
logger.warning(
"[task_id=%s] 任务状态已为 %s,丢弃作废消息,不执行渲染",
task_id,
gen_task.status.value,
)
return {
"status": "discarded",
"task_id": task_id,
"reason": f"task already terminal: {gen_task.status.value}",
}
# 标记任务为 running —— 必须成功:状态机非法转换(如 failed→running)说明
# 任务已被作废,安全中止,禁止继续执行。
if not _update_task_status(task_id, "mark_processing"):
logger.error(
"[task_id=%s] 标记 running 失败(任务可能已被作废/取消),安全中止,不执行渲染",
task_id,
)
return {
"status": "discarded",
"task_id": task_id,
"reason": "claim failed (invalid state transition)",
}
# 记录接收任务日志
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)
_update_task_progress(task_id, 10, "任务启动")
try:
editing_mode = EditingMode(mode) if (mode := task_info["mode"]) else EditingMode.ONE_TAKE
except ValueError:
editing_mode = EditingMode.ONE_TAKE
# output_name 不再使用,渲染路径内部自行处理文件名
try:
if template_id:
_validate_template_exists(template_id)
# ── 新路径:有 source_edit_plan_id 时直接从数据库 EditPlan 渲染 ──
source_edit_plan_id = task_info.get("source_edit_plan_id", "")
if source_edit_plan_id:
voiceover_tmp_path: str | None = None
render_temp_dir: str | None = None
try:
logger.info(
"[task_id=%s] 使用 EditPlan 数据库路径渲染: plan_id=%s",
task_id,
source_edit_plan_id,
)
_update_task_progress(task_id, 30, "加载草稿数据")
if gen_task:
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
_flush_logs(task_id, gen_task)
# ── 渲染→上传→查重→(批次超阈值则重选 plan 重渲一次)循环(#1743)──
current_plan_id = source_edit_plan_id
file_url = ""
duration = 0.0
file_size = 0
video_count = 1
file_size_final = 0
for render_attempt in range(2): # 首版 + 最多 1 次重渲
(
output_path,
render_duration,
cover_candidates,
voiceover_tmp_path,
render_temp_dir,
thumbnail_url,
) = _render_from_edit_plan(
task_id=task_id,
source_edit_plan_id=current_plan_id,
task_info=task_info,
)
if gen_task:
gen_task.append_log("渲染", f"渲染完成(第{render_attempt + 1}版), 时长={render_duration:.1f}s")
_flush_logs(task_id, gen_task)
_update_task_progress(task_id, 80, "渲染完成")
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
from video_processing.ffmpeg_utils import random_edge_crop
try:
cropped_path = random_edge_crop(output_path)
if cropped_path != output_path:
output_path = cropped_path
if gen_task and render_attempt == 0:
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
_flush_logs(task_id, gen_task)
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
except Exception as crop_err:
logger.warning(
"[task_id=%s] 随机边缘裁剪失败,使用原始视频继续: %s",
task_id,
crop_err,
exc_info=True,
)
# ── 4. 上传 OSS(不落库) ───────────────────────────────
_update_task_progress(task_id, 85, "开始上传")
file_url, _storage_key = _upload_rendered_video(
task_id=task_id,
output_path=output_path,
project_id=project_id,
attempt=render_attempt,
)
file_size = output_path.stat().st_size
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
dedup_info = _record_video_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
file_url=file_url,
file_size=file_size,
video_path=str(output_path),
video_name=task_info.get("video_title", ""),
thumbnail_url=thumbnail_url,
)
duration = dedup_info.get("duration", render_duration)
video_count = dedup_info.get("video_count", 1)
batch_sim = dedup_info.get("batch_similarity")
if gen_task:
gen_task.append_log(
"OSS上传",
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
file_size=file_size,
file_url=file_url,
)
_flush_logs(task_id, gen_task)
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
if not should_rerender_for_batch_dedup(
batch_id=batch_id,
render_attempt=render_attempt,
batch_similarity=batch_sim,
):
file_size_final = file_size
break
# 批次内相似度过高:重选独立 plan 后重渲一次
logger.warning(
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
task_id,
batch_sim,
BATCH_RENDER_SIMILARITY_LIMIT,
)
if gen_task:
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
_flush_logs(task_id, gen_task)
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
if not new_plan_id:
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
file_size_final = file_size
break
# 回写任务关联的 plan(重渲版以新 plan 渲染)
try:
_ps = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_pr = SQLAlchemyGenerationTaskRepository(_ps)
_gt = _pr.get(task_id)
if _gt:
_gt.source_edit_plan_id = new_plan_id
_pr.update(_gt)
finally:
_ps.close()
except Exception:
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
current_plan_id = new_plan_id
# 清理本轮临时目录,下一轮重新渲染
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
render_temp_dir = None
file_size = file_size_final or file_size
_update_task_progress(task_id, 95, "上传完成")
# 渲染结束后清理临时目录(重渲循环内每轮已清理,此处兜底最后一轮)
if render_temp_dir:
import shutil
shutil.rmtree(render_temp_dir, ignore_errors=True)
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
try:
if cover_candidates:
# #1743:批量变体封面差异化——候选帧按 task_id 稳定哈希分散选取
# (同任务重试稳定,批次内不同任务落在不同帧位),非批次取首帧。
_cover_idx = pick_batch_cover_index(task_id, len(cover_candidates)) if batch_id else 0
first = cover_candidates[_cover_idx]
cover_frame_url = first.get("image_url") or first.get("url") or ""
if cover_frame_url:
_cover_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_cover_model = (
_cover_session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
if _cover_model:
_cover_model.cover_url = cover_frame_url
meta = dict(_cover_model.extra_meta or {})
meta["cover_candidates"] = cover_candidates
_cover_model.extra_meta = meta
_cover_session.commit()
finally:
_cover_session.close()
except Exception:
logger.warning("[task_id=%s] 封面帧持久化失败", task_id, exc_info=True)
# ── 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 更新素材使用次数
# 按「最终成片 plan 实际渲染的片段」计数(#1743):不用请求传入的
# task.asset_ids(可能含未被 plan 选用的素材),同一素材多片段复用
# 按片段数累加,使 unused_bonus / 高频排除与真实渲染强度挂钩。
# current_plan_id 是重渲循环结束后最终成片所用 plan(首版或重渲版)。
try:
_asset_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
_fallback_ids: list[str] = []
_gt_for_assets = SQLAlchemyGenerationTaskRepository(_asset_session).get(task_id)
if _gt_for_assets:
_fallback_ids = list(_gt_for_assets.asset_ids or [])
_record_rendered_asset_usage(_asset_session, current_plan_id, task_id, _fallback_ids)
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 (edit_plan path)",
task_id,
duration,
file_size,
)
return {
"status": "completed",
"task_id": task_id,
"output_path": str(output_path),
"file_size": file_size,
"duration": duration,
"mode": editing_mode.value,
}
finally:
# 无论任务成功或失败,都清理临时配音文件,避免磁盘泄漏
if voiceover_tmp_path:
try:
Path(voiceover_tmp_path).unlink(missing_ok=True)
except OSError:
logger.warning("[task_id=%s] 清理临时配音文件失败: %s", task_id, voiceover_tmp_path)
else:
logger.error(
"[task_id=%s] source_edit_plan_id 为空,无法渲染。所有任务必须通过预览 API 创建并关联 EditPlan。",
task_id,
)
if gen_task:
gen_task.append_log("任务失败", "缺少 source_edit_plan_id", level="ERROR")
_flush_logs(task_id, gen_task)
_update_task_status(
task_id,
"mark_failed",
error_message="source_edit_plan_id is required. Please create a preview task first.",
)
return {
"status": "failed",
"task_id": task_id,
"error": "source_edit_plan_id is required. Please create a preview task first.",
}
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(
"render",
str(error),
level="ERROR",
error_type=type(error).__name__,
)
_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),
}