ad686dcd8b
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
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
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 API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m24s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (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
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
P1-1: VLM lite/pro模型ID修正(250915→250315/250328),根因是旧ID不存在导致lite必然超时降级pro - packages/config/base.py: doubao_vision_model/pro/lite 改为火山方舟官方正确ID - packages/shared/ai_client.py docstring同步更新 - apps/worker/worker_app/tasks/viral_video.py 硬编码字符串同步 - .env.example 同步新ID P1-2: worker心跳+僵尸任务超时回收 - packages/domain/viral_video.py: 新增heartbeat_at字段+touch_heartbeat()方法 - packages/adapters/sqlalchemy_impl/models.py: ViralVideoJobModel加heartbeat_at列(index=True) - packages/adapters/sqlalchemy_impl/viral_video_repository.py: to_entity/save/update映射heartbeat_at - alembic/versions/092_viral_video_heartbeat.py: 幂等迁移,加列+index+给已有running任务初始化 - apps/worker/worker_app/tasks/viral_video.py: - 新增_start_heartbeat_thread后台线程,每25s独立session更新heartbeat_at - 新增_recover_stale_jobs(): running>10min且心跳>2min未更新→自动标记failed - pipeline/analyze/generate_copy/render四个celery任务接入心跳启停+启动时回收僵尸 - apps/api/app/api/routes/viral_video.py: retry接口放宽,允许对超时僵尸任务重试 P1-3: 文案生成质量与兜底修复 - 兜底脚本'很近'→'最近'(之前commit已改) - _step_script_generation 三级重试: fast(0.8/2500tok)→fast(0.6/3200tok)→pro(0.7/3500tok) - 新增fallback判定:口播<20字/含兜底特征串/镜头数<1才视为降级,避免正常短脚本误判 - prompt关键要求新增'输出前自检:口播禁止错别字和语病' - 关键路径打info日志(模型/label/口播长度/镜头数/是否兜底),便于后续排查 P2-1: Seedance图生视频ratio不生效导致9:16出1:1 - 根因: 旧逻辑Bug #2110在image_url存在时不传ratio,模型默认adaptive→首帧方图商品图输出1:1 - 官方文档确认图生视频支持ratio参数,模型会自动居中裁剪首帧 - packages/shared/ai_client.py+ai_service.py: 删除image_url判空,始终传ratio - 同步更新docstring注释 P2-2: OSS成片eventual consistency导致completed后NoSuchKey - apps/worker/worker_app/tasks/viral_video.py: 新增_wait_oss_ready(url,timeout=10s) - mark_completed前循环HEAD公网URL,等200+content-length>0或10s超时 测试: viral-video相关97个单测全部通过,仅更新test_passes_reference_params_to_client以反映新ratio透传行为
656 lines
22 KiB
Python
Executable File
656 lines
22 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, 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 _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str:
|
||
"""下载 MediaKit 帧图并上传到 OSS,返回公开可访问的 URL.
|
||
|
||
Args:
|
||
frame_url: MediaKit 返回的帧图 URL(内部/临时 URL)
|
||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||
|
||
Returns:
|
||
公开可访问的 URL;如果下载/上传失败则返回原始 URL
|
||
"""
|
||
import tempfile
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import httpx
|
||
|
||
# 下载帧图
|
||
logger.info("下载 MediaKit 帧图: plan_id=%s url=%s", plan_id, frame_url[:80])
|
||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||
resp.raise_for_status()
|
||
|
||
if not resp.content:
|
||
logger.warning("MediaKit 帧图下载为空,返回原始 URL")
|
||
return frame_url
|
||
|
||
# 写入临时文件
|
||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||
tmp.write(resp.content)
|
||
tmp_path = tmp.name
|
||
|
||
# 上传到 OSS
|
||
from packages.shared.storage import get_shared_storage_service
|
||
|
||
storage = get_shared_storage_service()
|
||
cover_key = f"covers/{plan_id}/mediakit_frame_{uuid.uuid4().hex[:8]}.jpg"
|
||
storage.upload_file(
|
||
file_or_path=tmp_path,
|
||
storage_key=cover_key,
|
||
content_type="image/jpeg",
|
||
)
|
||
|
||
# 获取公开 URL
|
||
public_url = storage.get_url(cover_key)
|
||
logger.info("封面帧图已上传到 OSS: plan_id=%s key=%s url=%s", plan_id, cover_key, public_url[:80])
|
||
|
||
# 清理临时文件
|
||
Path(tmp_path).unlink(missing_ok=True)
|
||
|
||
return public_url
|
||
|
||
except Exception as e:
|
||
logger.warning("封面帧图转存失败,返回原始 URL: %s", str(e))
|
||
return frame_url
|
||
|
||
|
||
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 封面生成服务.
|
||
|
||
统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。
|
||
此函数仅处理 manual/upload 等需要前端交互的类型,
|
||
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。
|
||
|
||
失败时抛出 RuntimeError。
|
||
|
||
Args:
|
||
plan_id: 剪辑计划 ID
|
||
asset_ids: 素材 ID 列表
|
||
cover_type: 封面类型
|
||
frame_time: 手动选帧时间点
|
||
primary_video_url: 主视频的可访问 URL
|
||
"""
|
||
if cover_type == "upload":
|
||
return {
|
||
"type": "upload",
|
||
"image_url": "",
|
||
"message": "请上传封面图片",
|
||
}
|
||
|
||
if cover_type == "manual" and frame_time is not None:
|
||
svg_placeholder = (
|
||
"data:image/svg+xml,"
|
||
"<svg xmlns='http://www.w3.org/2000/svg' width='1080' height='1920'>"
|
||
"<rect width='1080' height='1920' fill='#1a1a2e'/>"
|
||
"<text x='540' y='960' text-anchor='middle' fill='#e0e0e0' font-size='48' font-family='sans-serif'>手动选帧</text>"
|
||
"</svg>"
|
||
)
|
||
return {
|
||
"type": "manual",
|
||
"image_url": svg_placeholder,
|
||
"frame_time": frame_time,
|
||
}
|
||
|
||
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
|
||
# 如果调用方传入了持久化的封面 URL,直接使用
|
||
logger.warning(
|
||
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
|
||
plan_id,
|
||
cover_type,
|
||
)
|
||
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
|
||
|
||
|
||
def call_llm(
|
||
prompt: str,
|
||
temperature: float = 0.7,
|
||
max_tokens: int = 2048,
|
||
model: str | None = None,
|
||
system_prompt: str | None = None,
|
||
) -> object:
|
||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
|
||
|
||
Args:
|
||
prompt: 用户侧提示。
|
||
temperature: 采样温度。
|
||
max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。
|
||
model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。
|
||
system_prompt: 覆盖默认 system prompt。
|
||
"""
|
||
client = get_doubao_client()
|
||
if not client.is_available:
|
||
return None
|
||
if system_prompt is None:
|
||
system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": prompt},
|
||
]
|
||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
|
||
if raw is None:
|
||
return None
|
||
try:
|
||
return json.loads(raw)
|
||
except (json.JSONDecodeError, TypeError):
|
||
return raw
|
||
|
||
|
||
def call_vision(
|
||
image_url: str,
|
||
prompt: str,
|
||
*,
|
||
model: str | None = None,
|
||
max_tokens: int = 1024,
|
||
temperature: float = 0.2,
|
||
timeout: int = 45,
|
||
system_prompt: str | None = None,
|
||
) -> object:
|
||
"""调用豆包视觉大模型分析图片,返回解析后的 JSON 或原文字符串;失败返回 None。
|
||
|
||
Args:
|
||
image_url: 可公网访问的图片 URL(直接传给豆包视觉模型,无需本地下载)。
|
||
prompt: 用户侧文本提示。
|
||
model: 覆盖默认视觉模型(如 vision_lite_model 提速用),None 走配置默认。
|
||
max_tokens: 输出上限,商品识别用 800~1200 足够,避免长输出拖慢首 token。
|
||
temperature: 温度。
|
||
timeout: 单次请求超时(秒)。
|
||
system_prompt: 覆盖默认 system prompt(viral-video 商品分析会传专门的详细 prompt)。
|
||
"""
|
||
client = get_doubao_client()
|
||
if not client.is_available:
|
||
logger.warning("[call_vision] 豆包客户端未配置 (DOUBAO_API_KEY 缺失)")
|
||
return None
|
||
if not image_url:
|
||
logger.warning("[call_vision] 空 image_url,跳过视觉分析")
|
||
return None
|
||
|
||
if system_prompt is None:
|
||
system_prompt = (
|
||
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
|
||
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
|
||
"「无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
|
||
)
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": prompt},
|
||
]
|
||
|
||
used_model = model or getattr(client, "vision_model", "?")
|
||
logger.info(
|
||
"[call_vision] 调用豆包视觉模型 model=%s image_url=%s prompt_len=%d max_tokens=%d timeout=%d",
|
||
used_model,
|
||
image_url[:120],
|
||
len(prompt),
|
||
max_tokens,
|
||
timeout,
|
||
)
|
||
raw = client.vision_completion(
|
||
messages=messages,
|
||
images=[image_url],
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
timeout=timeout,
|
||
model=model,
|
||
)
|
||
if raw is None:
|
||
logger.warning("[call_vision] 视觉模型返回 None (image_url=%s)", image_url[:80])
|
||
return None
|
||
logger.info("[call_vision] 视觉模型原始返回 (前400字): %s", raw[:400])
|
||
# 剥离 ```json ... ``` 包裹
|
||
stripped = raw.strip()
|
||
if stripped.startswith("```"):
|
||
stripped = stripped.strip("`")
|
||
if stripped.startswith("json"):
|
||
stripped = stripped[4:].lstrip()
|
||
try:
|
||
return json.loads(stripped)
|
||
except (json.JSONDecodeError, TypeError) as e:
|
||
logger.warning("[call_vision] JSON 解析失败(%s),返回原始文本: %s", e, raw[:200])
|
||
return raw
|
||
|
||
|
||
def call_video_generation(
|
||
prompt: str,
|
||
*,
|
||
image_url: str | None = None,
|
||
duration: int = 15,
|
||
ratio: str | None = "9:16",
|
||
resolution: str = "720p",
|
||
output_dir: str | None = None,
|
||
model: str | None = None,
|
||
generate_audio: bool = True,
|
||
reference_images: list[str] | None = None,
|
||
reference_audios: list[str] | None = None,
|
||
reference_videos: list[str] | None = None,
|
||
) -> str | None:
|
||
"""调用 Seedance 2.5 生成视频(v1.6 单次出片版),返回本地 MP4 路径;失败返回 None。
|
||
|
||
v1.6:
|
||
- 默认 generate_audio=True,模型原生合成环境音效/BGM;
|
||
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
|
||
- reference_images 传产品素材 URL 数组做视觉参考;
|
||
- 单次最长 30 秒,不分段不拼接;
|
||
- 图生视频(image_url 存在)也强制传 ratio,避免商品方图导致默认输出 1:1。
|
||
官方文档:图生视频时 ratio 由参数决定,模型会居中裁剪首帧到目标比例。
|
||
"""
|
||
client = get_doubao_client()
|
||
if not client.is_available:
|
||
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
|
||
return None
|
||
effective_ratio = ratio or "9:16"
|
||
try:
|
||
kwargs: dict = dict(
|
||
prompt=prompt,
|
||
image_url=image_url,
|
||
duration=int(duration),
|
||
resolution=resolution,
|
||
generate_audio=bool(generate_audio),
|
||
watermark=False,
|
||
output_dir=output_dir,
|
||
model=model,
|
||
reference_images=reference_images,
|
||
reference_audios=reference_audios,
|
||
reference_videos=reference_videos,
|
||
)
|
||
if effective_ratio:
|
||
kwargs["ratio"] = effective_ratio
|
||
return client.video_generation(**kwargs)
|
||
except Exception as e:
|
||
logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True)
|
||
return None
|