"""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," "" "" "手动选帧" "" ) 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, timeout: int | 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, timeout=timeout) 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 preheat_trust_chain(portrait_descriptions: list[str], *, timeout: int = 120) -> list[str] | None: """#2174 信任链预热(t2i版):用 VLM 分析出的人物外貌描述,跑 Seedream 文生图, 生成的信任产物 URL 可传给 call_video_generation(pre_trusted_images=...)。 - portrait_descriptions: VLM输出的portrait_prompt列表(中文人物外貌描述) - 成功返回与输入同序的信任图URL列表;任意一张失败返回None(调用方回退到纯t2v) - 必须传VLM人物描述,不传reference_images,走纯t2i路径才是方舟信任产物 """ client = get_doubao_client() if not client.is_available: return None try: return client.preheat_trust_chain(portrait_descriptions, timeout=timeout) except Exception as e: logger.error("[ai_service] preheat_trust_chain 异常: %s", e, exc_info=True) return None 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, pre_trusted_images: list[str] | None = None, ) -> dict | None: """调用 Seedance / Wan 视频生成(v1.6.2 多模型版 + #2172 信任链预热)。 成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。 失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取: {"error_code": str, "user_message": str, "status_code": int, "detail": str, ...} """ client = get_doubao_client() if not client.is_available: msg = "豆包客户端未配置(DOUBAO_API_KEY 缺失),跳过视频生成" logger.warning("[ai_service] %s", msg) # 写入 last_video_error 供上层读取 client.last_video_error = { "error_code": "auth_error", "user_message": "视频生成服务未配置,请联系管理员。", "status_code": 0, "detail": msg, } 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, pre_trusted_images=pre_trusted_images, ) 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) client.last_video_error = { "error_code": "unknown", "user_message": f"视频生成异常:{e!s}"[:200], "status_code": 0, "detail": str(e), } return None def get_last_video_error() -> dict: """读取最近一次视频生成失败的详细错误(含 error_code/user_message/status_code/detail)。 成功或未调用过返回空 dict。 """ try: client = get_doubao_client() return client.get_last_video_error() if hasattr(client, "get_last_video_error") else {} except Exception: return {}