Files
xiaoxia-saas/packages/shared/ai_service.py
T
xiaoxia 3e24f8de4a
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m11s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m25s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m52s
CI/CD Pipeline / Integration Tests (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 9m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 13m58s
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 / CI Gate (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 API Image (push) Successful in 14m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 33s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 17s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 44s
fix: MediaKit 重试逻辑 + TimeInterval默认策略 + 错误日志增强 (#1227)
2026-08-03 13:51:27 +08:00

473 lines
16 KiB
Python
Executable File

"""AI 服务层 — 智能推荐 & 封面生成.
提供 AI 推荐片段编排方案和封面生成的核心业务逻辑。
API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代码。
"""
from __future__ import annotations
import copy
import json
import logging
import random
import time
from typing import Any, Dict, List, Optional
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
def _fallback_recommend_clips(
plan_id: str,
template_id: str,
asset_ids: List[str],
editing_mode: str,
target_duration: float,
) -> Dict[str, Any]:
"""本地降级推荐方案(原 stub 逻辑).
当豆包 API 不可用或调用失败时使用,基于模板规则生成模拟推荐数据。
"""
# 模拟 AI 分析耗时
time.sleep(0.5)
# 根据素材数量生成推荐片段
clips: List[Dict[str, Any]] = []
order = 0
# 开场片段
clips.append(
{
"clip_type": "intro",
"order": order,
"text_content": "精彩看点",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": asset_ids[0] if asset_ids else "",
"start_time": 0.0,
"config": {},
}
)
order += 1
# 为每个素材生成展示片段
per_clip_duration = max(2.0, (target_duration - 6.0) / max(len(asset_ids), 1))
for i, asset_id in enumerate(asset_ids):
clips.append(
{
"clip_type": "showcase",
"order": order,
"text_content": f"展示片段 {i + 1}",
"duration": round(per_clip_duration, 1),
"transition_effect": "cut",
"asset_id": asset_id,
"start_time": 0.0,
"config": {},
}
)
order += 1
# 结尾 CTA
clips.append(
{
"clip_type": "outro",
"order": order,
"text_content": "感谢观看",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": "",
"start_time": 0.0,
"config": {},
}
)
order += 1
# 生成推荐 config
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
config["title"]["ai_auto"] = True
return {
"clips": clips,
"config": config,
"total_duration": round(sum(c["duration"] for c in clips), 1),
"confidence": round(random.uniform(0.75, 0.95), 2),
}
def _parse_recommend_response(
content: str,
asset_ids: List[str],
target_duration: float,
) -> Optional[Dict[str, Any]]:
"""解析豆包返回的推荐方案.
期望返回结构:
{
"clips": [
{"clip_type": "intro/showcase/outro", "order": 0,
"text_content": "...", "duration": 3.0,
"transition_effect": "fade/cut", "asset_id": "...",
"start_time": 0.0, "config": {}}
],
"title": "视频标题",
"confidence": 0.85
}
"""
if not content:
return None
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if not isinstance(data, dict):
return None
clips_data = data.get("clips", [])
if not isinstance(clips_data, list) or len(clips_data) == 0:
return None
clips: List[Dict[str, Any]] = []
for _, clip in enumerate(clips_data):
if not isinstance(clip, dict):
continue
asset_id = str(clip.get("asset_id", ""))
# 校验 asset_id 是否在输入列表中
if asset_id and asset_id not in asset_ids:
asset_id = ""
clips.append(
{
"clip_type": clip.get("clip_type", "showcase"),
"order": clip.get("order", len(clips)),
"text_content": str(clip.get("text_content", "")),
"duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))),
"transition_effect": clip.get("transition_effect", "cut"),
"asset_id": asset_id,
"start_time": max(0.0, float(clip.get("start_time", 0.0))),
"config": clip.get("config", {}) or {},
}
)
if not clips:
return None
# 按 order 排序
clips.sort(key=lambda c: c["order"])
# 重新编号 order 保证连续
for i, clip in enumerate(clips):
clip["order"] = i
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
config["title"]["ai_auto"] = True
confidence = float(data.get("confidence", 0.7))
confidence = max(0.0, min(1.0, confidence))
total_duration = round(sum(c["duration"] for c in clips), 1)
return {
"clips": clips,
"config": config,
"total_duration": total_duration,
"confidence": round(confidence, 2),
}
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
return None
def _call_ai_recommend_service(
plan_id: str,
template_id: str,
asset_ids: List[str],
editing_mode: str,
target_duration: float,
asset_analyses: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""调用 AI 推荐服务生成片段编排方案.
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
当提供 asset_analyses 时,会将每个素材的视频理解结果注入 prompt,
让 LLM 能基于视频实际内容做智能编排。
Args:
plan_id: 剪辑计划 ID
template_id: 模板 ID
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式
target_duration: 目标时长(秒)
asset_analyses: 可选,{asset_id: 视频理解文本} 映射
"""
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成AI推荐方案")
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# 构建素材描述(含视频理解结果)
asset_analyses = asset_analyses or {}
asset_lines = []
for aid in asset_ids[:30]:
analysis = asset_analyses.get(aid, "")
if analysis:
# 截断过长的分析结果,避免 token 爆炸
analysis_truncated = analysis[:300] + ("..." if len(analysis) > 300 else "")
asset_lines.append(f" - 素材ID: {aid}\n 内容描述: {analysis_truncated}")
else:
asset_lines.append(f" - 素材ID: {aid}")
assets_desc = "\n".join(asset_lines)
has_analysis = any(aid in asset_analyses for aid in asset_ids[:30])
# 构建 prompt
system_prompt = (
"你是一个专业的视频剪辑导演助手。" "根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
)
if has_analysis:
system_prompt += (
"每个素材附带了 AI 视频理解的内容描述,请根据素材的实际内容来决策编排:\n"
"- 将内容相关的素材放在一起,保持叙事连贯\n"
"- 根据素材内容合理安排片段顺序(如开场用吸引人的画面、高潮部分紧凑切换等)\n"
"- 为每个片段选择最匹配的素材,并在 text_content 中体现素材主题\n"
)
system_prompt += (
"要求:\n"
"1. 片段类型分为三类:intro(开场)、showcase(展示)、outro(结尾)\n"
"2. 每个片段包含:clip_type、order、text_content(字幕/标题文字)、"
"duration(时长秒)、transition_effect(转场效果:fade/cut/dissolve)、"
"asset_id(使用的素材ID)、start_time(素材起始时间秒)\n"
"3. 总时长接近 target_duration,每个素材至少用一次\n"
"4. 转场效果合理分配,不要全用cut\n"
"5. 返回纯JSON,不要其他文字\n"
'返回格式:{"clips": [...], "title": "视频标题", "confidence": 0.85}'
)
user_prompt = (
f"剪辑计划ID: {plan_id}\n"
f"模板ID: {template_id}\n"
f"剪辑模式: {editing_mode}\n"
f"目标时长: {target_duration}\n"
f"素材列表(共{len(asset_ids)}个):\n{assets_desc}\n\n"
f"请设计完整的片段编排方案:"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.7,
max_tokens=2048,
)
if result:
parsed = _parse_recommend_response(result, asset_ids, target_duration)
if parsed and len(parsed["clips"]) >= 2:
logger.info(
"豆包AI推荐生成成功: plan_id=%s clips=%d duration=%.1f confidence=%.2f has_analysis=%s",
plan_id,
len(parsed["clips"]),
parsed["total_duration"],
parsed["confidence"],
has_analysis,
)
return parsed
logger.warning("豆包AI推荐返回解析失败,降级到本地方案: %s", result[:100])
# 降级
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
def _call_ai_cover_service(
plan_id: str,
asset_ids: List[str],
cover_type: str,
frame_time: float | None = None,
primary_video_url: str | None = None,
) -> Dict[str, Any]:
"""调用 AI 封面生成服务.
当 cover_type 为 ai_frame 或 ai_regenerate 时,调用 MediaKit 视频截帧。
失败或未配置时降级为 stub 行为。
Args:
plan_id: 剪辑计划 ID
asset_ids: 素材 ID 列表
cover_type: 封面类型
frame_time: 手动选帧时间点
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
"""
if cover_type == "upload":
return {
"type": "upload",
"image_url": "",
"message": "请上传封面图片",
}
if cover_type == "manual" and frame_time is not None:
return {
"type": "manual",
"image_url": f"/api/v1/assets/placeholder/cover?time={frame_time}",
"frame_time": frame_time,
}
# ai_frame / ai_regenerate - 尝试调用 MediaKit
if primary_video_url:
from packages.shared.mediakit_client import get_mediakit_client
client = get_mediakit_client()
if client.is_available:
try:
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = client.extract_frames(
video_url=primary_video_url,
strategy="TimeInterval",
max_frames=5,
)
if frames and len(frames) > 0:
# 选择第一帧(SceneChange 策略的第一帧通常是最佳画面)
best_frame = frames[0]
image_url = best_frame.get("image_url", "")
timestamp = best_frame.get("timestamp", 0.0)
if image_url:
logger.info(
"MediaKit 抽帧成功: plan_id=%s frame_time=%.2f url=%s",
plan_id,
timestamp,
image_url[:80],
)
return {
"type": "ai_frame",
"image_url": image_url,
"frame_time": round(timestamp, 1),
"confidence": 0.85,
}
else:
logger.warning("MediaKit 返回的帧无 image_url")
except Exception as e:
logger.exception("MediaKit 抽帧失败,降级到 stub: %s", str(e))
# 降级:stub 行为
logger.info("使用 stub 封面: plan_id=%s", plan_id)
time.sleep(0.3)
return {
"type": "ai_frame",
"image_url": f"/api/v1/assets/placeholder/cover?plan={plan_id}",
"frame_time": round(random.uniform(1.0, 10.0), 1),
"confidence": round(random.uniform(0.80, 0.98), 2),
}
# ── 公共入口 ────────────────────────────────────────────────────────────────
def run_ai_recommend(
plan_id: str,
template_id: str,
asset_ids: List[str],
editing_mode: str = "one_take",
target_duration: float = 30.0,
asset_analyses: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""执行 AI 推荐片段方案
Args:
plan_id: 剪辑计划 ID
template_id: 模板 ID
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式 (one_take / pip / voice_over / voice_pip)
target_duration: 目标时长(秒)
asset_analyses: 可选,{asset_id: 视频理解文本} 映射
Returns:
推荐方案 dict,包含 clips / config / total_duration / confidence
"""
logger.info(
"AI 推荐片段方案: plan_id=%s template_id=%s assets=%d mode=%s duration=%.1f has_analysis=%s",
plan_id,
template_id,
len(asset_ids),
editing_mode,
target_duration,
bool(asset_analyses),
)
result = _call_ai_recommend_service(
plan_id=plan_id,
template_id=template_id,
asset_ids=asset_ids,
editing_mode=editing_mode,
target_duration=target_duration,
asset_analyses=asset_analyses,
)
logger.info(
"AI 推荐完成: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
plan_id,
len(result["clips"]),
result["total_duration"],
result["confidence"],
)
return result
def run_generate_cover(
plan_id: str,
asset_ids: List[str],
cover_type: str = "ai_frame",
frame_time: float | None = None,
primary_video_url: str | None = None,
) -> Dict[str, Any]:
"""执行 AI 封面生成
Args:
plan_id: 剪辑计划 ID
asset_ids: 素材 ID 列表(用于确定视频来源)
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
frame_time: 手动选帧时间点(仅 manual 模式使用)
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
Returns:
封面数据 dict,包含 type / image_url / frame_time
"""
logger.info(
"AI 封面生成: plan_id=%s type=%s assets=%d has_video_url=%s",
plan_id,
cover_type,
len(asset_ids),
bool(primary_video_url),
)
result = _call_ai_cover_service(
plan_id=plan_id,
asset_ids=asset_ids,
cover_type=cover_type,
frame_time=frame_time,
primary_video_url=primary_video_url,
)
logger.info(
"AI 封面生成完成: plan_id=%s type=%s url=%s",
plan_id,
result.get("type"),
result.get("image_url", "")[:60],
)
return result