refactor(#775): 禁止API直接调用Worker任务函数,必须走Celery队列或shared层 #786
@@ -1907,7 +1907,7 @@ def editor_ai_recommend(
|
||||
detail="当前草稿状态不支持AI推荐,请先编辑后再试",
|
||||
)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||
from packages.shared.ai_service import run_ai_recommend
|
||||
|
||||
result = run_ai_recommend(
|
||||
plan_id=plan_id,
|
||||
@@ -1992,7 +1992,7 @@ def editor_generate_cover(
|
||||
_, plan_svc = services
|
||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||
|
||||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||
from packages.shared.ai_service import run_generate_cover
|
||||
|
||||
cover_data = run_generate_cover(
|
||||
plan_id=plan_id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
@@ -181,13 +182,9 @@ def synthesize(
|
||||
|
||||
try:
|
||||
if is_segment:
|
||||
from worker_app.tasks import process_tts_segment_synthesis
|
||||
|
||||
process_tts_segment_synthesis.delay(job.id)
|
||||
celery_app.send_task("worker.process_tts_segment_synthesis", args=[job.id])
|
||||
else:
|
||||
from worker_app.tasks import process_tts_synthesis
|
||||
|
||||
process_tts_synthesis.delay(job.id)
|
||||
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
|
||||
except Exception as e:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
|
||||
Regular → Executable
+3
-6
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
@@ -97,9 +98,7 @@ def create_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_voice_clone
|
||||
|
||||
process_voice_clone.delay(profile.id)
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
@@ -213,9 +212,7 @@ def retry_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
try:
|
||||
from worker_app.tasks import process_voice_clone
|
||||
|
||||
process_voice_clone.delay(profile.id)
|
||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
||||
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
|
||||
@@ -1,387 +1,27 @@
|
||||
"""AI 相关异步任务 — 智能推荐 & 封面生成.
|
||||
|
||||
提供两个 Celery 任务:
|
||||
- ai_recommend_clips: 分析素材并推荐片段编排方案
|
||||
- generate_cover: 从视频中选帧或生成封面图
|
||||
|
||||
当前为 stub 实现(返回模拟数据),后续接入真实 AI 服务时
|
||||
只需替换 _call_ai_recommend_service / _call_ai_cover_service 内部逻辑。
|
||||
核心业务逻辑已迁移到 packages.shared.ai_service,
|
||||
本模块仅保留 Worker 侧的 Celery 任务包装和向后兼容的直接导入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
from packages.shared.ai_service import (
|
||||
_call_ai_cover_service,
|
||||
_call_ai_recommend_service,
|
||||
_fallback_recommend_clips,
|
||||
_parse_recommend_response,
|
||||
run_ai_recommend,
|
||||
run_generate_cover,
|
||||
)
|
||||
|
||||
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 = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
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 = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务生成片段编排方案.
|
||||
|
||||
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
|
||||
"""
|
||||
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)
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = (
|
||||
"你是一个专业的视频剪辑导演助手。"
|
||||
"根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
"要求:\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}'
|
||||
)
|
||||
|
||||
assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])])
|
||||
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",
|
||||
plan_id,
|
||||
len(parsed["clips"]),
|
||||
parsed["total_duration"],
|
||||
parsed["confidence"],
|
||||
)
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务(stub)
|
||||
|
||||
TODO: 接入真实 AI 服务,从视频中选帧或生成封面。
|
||||
当前返回模拟封面数据。
|
||||
"""
|
||||
# 模拟 AI 处理耗时
|
||||
time.sleep(0.3)
|
||||
|
||||
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
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
# ── 任务入口(供 Celery 调度或路由直接调用) ─────────────────────────────────
|
||||
|
||||
|
||||
def run_ai_recommend(
|
||||
plan_id: str,
|
||||
template_id: str,
|
||||
asset_ids: List[str],
|
||||
editing_mode: str = "one_take",
|
||||
target_duration: float = 30.0,
|
||||
) -> 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: 目标时长(秒)
|
||||
|
||||
Returns:
|
||||
推荐方案 dict,包含 clips / config / total_duration / confidence
|
||||
"""
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s template_id=%s assets=%d mode=%s duration=%.1f",
|
||||
plan_id,
|
||||
template_id,
|
||||
len(asset_ids),
|
||||
editing_mode,
|
||||
target_duration,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 AI 封面生成
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_ids: 素材 ID 列表(用于确定视频来源)
|
||||
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
|
||||
frame_time: 手动选帧时间点(仅 manual 模式使用)
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
"""
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s assets=%d",
|
||||
plan_id,
|
||||
cover_type,
|
||||
len(asset_ids),
|
||||
)
|
||||
result = _call_ai_cover_service(
|
||||
plan_id=plan_id,
|
||||
asset_ids=asset_ids,
|
||||
cover_type=cover_type,
|
||||
frame_time=frame_time,
|
||||
)
|
||||
logger.info(
|
||||
"AI 封面生成完成: plan_id=%s type=%s url=%s",
|
||||
plan_id,
|
||||
result.get("type"),
|
||||
result.get("image_url", "")[:60],
|
||||
)
|
||||
return result
|
||||
__all__ = [
|
||||
"run_ai_recommend",
|
||||
"run_generate_cover",
|
||||
"_call_ai_recommend_service",
|
||||
"_call_ai_cover_service",
|
||||
"_fallback_recommend_clips",
|
||||
"_parse_recommend_response",
|
||||
]
|
||||
|
||||
Executable
+383
@@ -0,0 +1,383 @@
|
||||
"""AI 服务层 — 智能推荐 & 封面生成.
|
||||
|
||||
提供 AI 推荐片段编排方案和封面生成的核心业务逻辑。
|
||||
API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
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 = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 推荐服务生成片段编排方案.
|
||||
|
||||
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
|
||||
"""
|
||||
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)
|
||||
|
||||
# 构建 prompt
|
||||
system_prompt = (
|
||||
"你是一个专业的视频剪辑导演助手。"
|
||||
"根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
|
||||
"要求:\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}'
|
||||
)
|
||||
|
||||
assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])])
|
||||
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",
|
||||
plan_id,
|
||||
len(parsed["clips"]),
|
||||
parsed["total_duration"],
|
||||
parsed["confidence"],
|
||||
)
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 AI 封面生成服务(stub)
|
||||
|
||||
TODO: 接入真实 AI 服务,从视频中选帧或生成封面。
|
||||
当前返回模拟封面数据。
|
||||
"""
|
||||
# 模拟 AI 处理耗时
|
||||
time.sleep(0.3)
|
||||
|
||||
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
|
||||
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,
|
||||
) -> 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: 目标时长(秒)
|
||||
|
||||
Returns:
|
||||
推荐方案 dict,包含 clips / config / total_duration / confidence
|
||||
"""
|
||||
logger.info(
|
||||
"AI 推荐片段方案: plan_id=%s template_id=%s assets=%d mode=%s duration=%.1f",
|
||||
plan_id,
|
||||
template_id,
|
||||
len(asset_ids),
|
||||
editing_mode,
|
||||
target_duration,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
) -> Dict[str, Any]:
|
||||
"""执行 AI 封面生成
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_ids: 素材 ID 列表(用于确定视频来源)
|
||||
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
|
||||
frame_time: 手动选帧时间点(仅 manual 模式使用)
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
"""
|
||||
logger.info(
|
||||
"AI 封面生成: plan_id=%s type=%s assets=%d",
|
||||
plan_id,
|
||||
cover_type,
|
||||
len(asset_ids),
|
||||
)
|
||||
result = _call_ai_cover_service(
|
||||
plan_id=plan_id,
|
||||
asset_ids=asset_ids,
|
||||
cover_type=cover_type,
|
||||
frame_time=frame_time,
|
||||
)
|
||||
logger.info(
|
||||
"AI 封面生成完成: plan_id=%s type=%s url=%s",
|
||||
plan_id,
|
||||
result.get("type"),
|
||||
result.get("image_url", "")[:60],
|
||||
)
|
||||
return result
|
||||
@@ -306,7 +306,7 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
mock_client.is_available = False
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
@@ -350,7 +350,7 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
}
|
||||
mock_client.chat_completion = MagicMock(return_value=json.dumps(mock_response))
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
@@ -368,7 +368,7 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value=None)
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
@@ -386,7 +386,7 @@ class TestRunAIRecommend(unittest.TestCase):
|
||||
mock_client.is_available = True
|
||||
mock_client.chat_completion = MagicMock(return_value="一堆废话不是json")
|
||||
|
||||
with patch("worker_app.tasks.ai_tasks.get_doubao_client", return_value=mock_client):
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
result = run_ai_recommend(
|
||||
plan_id="plan-1",
|
||||
template_id="tpl-1",
|
||||
|
||||
Reference in New Issue
Block a user