From 605b9cb1d489ea995831a15d9db03dce59b8203b Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 23 Jul 2026 21:48:39 +0800 Subject: [PATCH] =?UTF-8?q?refactor(#775):=20=E7=A6=81=E6=AD=A2API?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B0=83=E7=94=A8Worker=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8C=E5=BF=85=E9=A1=BB=E8=B5=B0Celery?= =?UTF-8?q?=E9=98=9F=E5=88=97=E6=88=96shared=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI推荐/封面生成核心逻辑迁移到packages/shared/ai_service.py API端从shared层调用,不再import worker代码 Worker端ai_tasks.py改为re-export,保持向后兼容 - TTS合成/音色克隆任务改用celery_app.send_task()方式 与ingest/classification等现有范式一致 消除API对worker_app.tasks的直接import依赖 - 测试patch路径同步更新到packages.shared.ai_service - 全量单测4383 passed, 8 skipped --- apps/api/app/api/routes/templates_editor.py | 4 +- apps/api/app/api/routes/tts.py | 9 +- apps/api/app/api/routes/voice_clones.py | 9 +- apps/worker/worker_app/tasks/ai_tasks.py | 398 +------------------- packages/shared/ai_service.py | 383 +++++++++++++++++++ tests/unit/test_ai_tasks.py | 8 +- 6 files changed, 414 insertions(+), 397 deletions(-) mode change 100644 => 100755 apps/api/app/api/routes/voice_clones.py create mode 100755 packages/shared/ai_service.py diff --git a/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py index 58b5310c9..abf8aaa62 100644 --- a/apps/api/app/api/routes/templates_editor.py +++ b/apps/api/app/api/routes/templates_editor.py @@ -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, diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index b9bd54e86..954c52c2e 100755 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -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: diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py old mode 100644 new mode 100755 index d52915595..08a201718 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -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}") diff --git a/apps/worker/worker_app/tasks/ai_tasks.py b/apps/worker/worker_app/tasks/ai_tasks.py index 6ef25d7b0..ee8e74191 100755 --- a/apps/worker/worker_app/tasks/ai_tasks.py +++ b/apps/worker/worker_app/tasks/ai_tasks.py @@ -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", +] diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py new file mode 100755 index 000000000..a44517431 --- /dev/null +++ b/packages/shared/ai_service.py @@ -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 diff --git a/tests/unit/test_ai_tasks.py b/tests/unit/test_ai_tasks.py index 369f68d69..b2396575f 100755 --- a/tests/unit/test_ai_tasks.py +++ b/tests/unit/test_ai_tasks.py @@ -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", -- 2.54.0