diff --git a/server/mcp_server_vod/README.md b/server/mcp_server_vod/README.md index c211efe5..db645ed2 100644 --- a/server/mcp_server_vod/README.md +++ b/server/mcp_server_vod/README.md @@ -178,30 +178,140 @@ Query media processing task results, including task status, processing progress, Query the media processing result for the portrait cutout task with runId xxx in space space1. +### Tool 18 flip_video + +#### Description + +Video flip (vertical and horizontal). Supports vid, http url, and directurl input modes. + +#### Trigger Example + +Flip the video vertically and horizontally; video is vid1, space is space1. + +### Tool 19 speedup_video + +#### Description + +Video speed adjustment. Supports vid, http url, and directurl input modes. + +#### Trigger Example + +Adjust video vid1 to 2x speed; space is space1. + +### Tool 20 speedup_audio + +#### Description + +Audio speed adjustment. Supports vid, http url, and directurl input modes. + +#### Trigger Example + +Adjust audio vid1 to 2x speed; space is space1. + +### Tool 21 image_to_video + +#### Description + +Image-to-video conversion. Supports vid, http url, and directurl input modes. + +#### Trigger Example + +Apply zoom and random transitions to images; image resources are vid1, vid2, vid3; space is space1. + +### Tool 22 compile_video_audio + +#### Description + +Audio-video composition: strip original audio from video, align audio with video duration, and related capabilities. + +#### Trigger Example + +Merge audio and video, strip original audio from the video, use video duration as reference; video is vid1, audio is vid2; space is space1. + +### Tool 23 extract_audio + +#### Description + +Extract audio from video. Supports configuring audio output format. + +#### Trigger Example + +Extract audio from vid1; space is space1. + +### Tool 24 mix_audios + +#### Description + +Audio mixing (e.g. adding background music to audio). + +#### Trigger Example + +Mix audios vid1 and vid2; space is space1. + +### Tool 25 add_subtitle + +#### Description + +Add subtitles to video. Typical flow: generate narration with a model, generate voice + subtitles, then composite into the video. + +#### Trigger Example + +Add subtitle `https:****.srt` to video vid1; outline color red, font size 70, outline width 10; space is space1. + +### Tool 26 add_sub_video + +#### Description + +Image to video, and video watermarking. + +#### Trigger Example + +Add watermark overlay: main video vid1, overlay video vid2; overlay at top-right, size 100×100; space is space1. + +### Tool 27 get_video_audio_info + +#### Description + +Retrieve video/audio metadata. + +#### Trigger Example + +Get playback info for vid1; space is space1. + +### Tool 28 get_play_url + +#### Description + +Get audio/video playback URL. + +#### Trigger Example + +Get playback URL for vid1; space is space1. + ## Supported Platforms -Ark, Cursor, Trae etc. +Ark, Cursor, Trae, etc. -## Service Activation Link (Full Product) +## Product Console -[Volcano Engine-Video on Demand-Console](https://www.volcengine.com/product/vod) +[Volcano Engine - Video on Demand - Console](https://www.volcengine.com/product/vod) -## Authentication Method +## Authentication -Please apply for VOLCENGINE_ACCESS_KEY, VOLCENGINE_SECRET_KEY at [Volcano Engine-Video on Demand-Console](https://www.volcengine.com/product/vod) +Apply for VOLCENGINE_ACCESS_KEY and VOLCENGINE_SECRET_KEY at [Volcano Engine - Video on Demand - Console](https://www.volcengine.com/product/vod). ## Installation -### Environment Requirements +### Requirements - Python 3.13+ - Volcano Engine account and AccessKey/SecretKey ## Deployment -### Integration in MCP Client +### Integrate in MCP Client -Configure MCP service in mcp client, MCP JSON configuration: +Configure the MCP service in your MCP client. Example MCP JSON: ```json { @@ -216,13 +326,99 @@ Configure MCP service in mcp client, MCP JSON configuration: "env": { "VOLCENGINE_ACCESS_KEY": "Your Volcengine AK", "VOLCENGINE_SECRET_KEY": "Your Volcengine SK", - "MCP_TOOL_GROUPS": "YOUR_TOOL_GROUPS" + "MCP_TOOLS_TYPE": "Your Source", // groups | tools + // - In groups mode, MCP_TOOLS_SOURCE is the group name + // - In tools mode, MCP_TOOLS_SOURCE is the tool name + "MCP_TOOLS_SOURCE": "Your Source" } } } } ``` +### Cloud Deployment + +**Dynamic authentication and grouping are supported. Header configuration:** + +- To enable all tools: + - x-tt-tools-type: groups + - x-tt-tools-source: all +- You can switch MCP tools dynamically via x-tt-tools-source to reduce noise and token usage for scenario-specific agents. +- x-tt-tools-type: loading mode; values: groups | tools +- In groups mode, x-tt-tools-source is the group name; use all for all groups. +- In tools mode, x-tt-tools-source is the tool name. + +### Group Configuration + +**Default exposed tools:** + +- get_play_url: Get playback URL +- audio_video_stitching: Audio/video stitching +- audio_video_clipping: Audio/video clipping +- get_v_creative_task_result: Query edit task result + +**Full group mapping:** + +```json +{ + # edit group + "edit": [ # group name + "audio_video_stitching", + "audio_video_clipping", + "get_v_creative_task_result", + "flip_video", + "speedup_video", + "speedup_audio", + "image_to_video", + "compile_video_audio", + "extract_audio", + "mix_audios", + "add_sub_video", + ], + # video_play group + "video_play": [ + "get_play_url", + "get_video_audio_info" + ], + # upload group + "upload": [ + "video_batch_upload", + "query_batch_upload_task_info" + ], + # intelligent_slicing group + "intelligent_slicing": [ + "intelligent_slicing_task" + ], + # intelligent_matting group + "intelligent_matting": [ + "portrait_image_retouching_task", + "green_screen_task" + ], + # subtitle_processing group + "subtitle_processing": [ + "asr_speech_to_text_task", + "ocr_text_to_subtitles_task", + "video_subtitles_removal_task", + "add_subtitle", + ], + # audio_processing group + "audio_processing": [ + "voice_separation_task", + "audio_noise_reduction_task" + ], + # video_enhancement group + "video_enhancement": [ + "video_interlacing_task", + "video_super_resolution_task", + "video_quality_enhancement_task" + ], + # media_tasks group (common) + "media_tasks": [ + "get_media_execution_task_result" + ], +} +``` + ## License MIT diff --git a/server/mcp_server_vod/README_zh.md b/server/mcp_server_vod/README_zh.md index f7b24ece..a89dd7fb 100644 --- a/server/mcp_server_vod/README_zh.md +++ b/server/mcp_server_vod/README_zh.md @@ -182,6 +182,116 @@ OCR 文字转字幕,支持 Vid 和 DirectUrl 两种输入模式。 查询人像抠图任务的视频处理任务的处理结果,runId 为 xxx,空间为 space1 +### Tool 18 flip_video + +#### 功能描述 + +视频上下翻转、左右翻转能力,支持 vid、http url、directurl 三种模式输入 + +#### 最容易被唤起的 Prompt 示例 + +把视频上下翻转和左右翻转,视频为 vid1 空间 为 space1 + +### Tool 19 speedup_video + +#### 功能描述 + +视频倍速能力,支持 vid、http url、directurl 三种模式输入 + +#### 最容易被唤起的 Prompt 示例 + +把视频 vid1 进行调整至 2 倍速, 空间 为 space1 + +### Tool 20 speedup_audio + +#### 功能描述 + +音频倍速能力,支持 vid、http url、directurl 三种模式输入 + +#### 最容易被唤起的 Prompt 示例 + +把音频 vid1 进行调整至 2 倍速, 空间 为 space1 + +### Tool 21 image_to_video + +#### 功能描述 + +图片转视频能力,支持 vid、http url、directurl 三种模式输入 + +#### 最容易被唤起的 Prompt 示例 + +对图片进行渐变放大,并增加随机转场, 图片资源为 vid1, vid2、vid3, 空间 为 space1 + +### Tool 22 compile_video_audio + +#### 功能描述 + +音视频合成能力,支持擦除原视频流中的音频、对齐音频和视频时长等能力 + +#### 最容易被唤起的 Prompt 示例 + +进行音视频合并 ,擦除视频原始音效,以视频时长为准,视频为 vid1, 音频为 vid2, 空间 为 space1 + +### Tool 23 extract_audio + +#### 功能描述 + +视频提取音频能力,支持设置音频输出格式 + +#### 最容易被唤起的 Prompt 示例 + +提取音频,音频为 vid1, 空间 为 space1 + +### Tool 24 mix_audios + +#### 功能描述 + +音频叠加能力,常用场景为音频添加背景音乐 + +#### 最容易被唤起的 Prompt 示例 + +帮我进行音频合并操作 vid1, vid2 空间 为 space1 + +### Tool 25 add_subtitle + +#### 功能描述 + +视频添加字幕能力,通常流程:大模型生成旁白,使用配音能力生成语音+字幕;再合成到视频中。 + +#### 最容易被唤起的 Prompt 示例 + +为视频 vid1 增加字幕 `https:****.srt` ,描边的颜色 为 红色,字体大小为 70,描边宽度为 10 ,描边的颜色 为 红色,字体大小为 70,描边宽度为 10 空间 为 space1 + +### Tool 26 add_sub_video + +#### 功能描述 + +视频画中画、视频水印能力 + +#### 最容易被唤起的 Prompt 示例 + +进行水印贴片,主视频为 vid1 , 贴片视频为 vid2;贴片位于右上角,贴片宽高为 100\* 100。 空间 为 space1 + +### Tool 27 get_video_audio_info + +#### 功能描述 + +视频元信息获取能力 + +#### 最容易被唤起的 Prompt 示例 + +帮我获取 vid1 的播放信息, 空间 为 space1 + +### Tool 28 get_play_url + +#### 功能描述 + +音视频播放链接获取 + +#### 最容易被唤起的 Prompt 示例 + +帮我获取 vid1 的播放链接, 空间 为 space1 + ## 可适配平台 方舟,Cursor,Trae 等 @@ -220,13 +330,100 @@ OCR 文字转字幕,支持 Vid 和 DirectUrl 两种输入模式。 "env": { "VOLCENGINE_ACCESS_KEY": "Your Volcengine AK", "VOLCENGINE_SECRET_KEY": "Your Volcengine SK", - "MCP_TOOL_GROUPS": "YOUR_TOOL_GROUPS" + "MCP_TOOLS_TYPE": "Your Source" // groups | tools + // - groups 模式下 为 MCP_TOOLS_SOURCE 值需要输入分组值; + // - tools: 模式下 MCP_TOOLS_SOURCE 值为 tool.name + "MCP_TOOLS_SOURCE": "Your Source", + } } } } ``` +### 云部署 + +** 支持动态鉴权以及分组,header 信息如下 ** + +- 全选设置为 + - x-tt-tools-type: groups + - x-tt-tools-source: all +- 本期支持了可以通过配置 x-tt-tools-source 来动态切换 mcp tool,可以针对场景化智能体,减少不必要的干扰以及 token 消耗 +- x-tt-tools-type: 动态加载类型的模式,支持: groups | tools 两种 +- groups 模式下 为 x-tt-tools-source 值需要输入分组值 、 all 为 全部设置 +- tools: 模式下 x-tt-tools-source 值为 tool.name + +### 分组信息 + +** 默认输出内容为 ** + +- get_play_url: 获取播放链接 +- audio_video_stitching: 音视频拼接 +- audio_video_clipping: 音视频剪切 +- get_v_creative_task_result: 查询剪辑结果 + +** 详细分组信息如下: ** + +```json +{ + # edit 分组 + "edit": [ #分组名称 + "audio_video_stitching", + "audio_video_clipping", + "get_v_creative_task_result", + "flip_video", + "speedup_video", + "speedup_audio", + "image_to_video", + "compile_video_audio", + "extract_audio", + "mix_audios", + "add_sub_video", + ], + # video_play 分组 + "video_play": [ + "get_play_url", + "get_video_audio_info" + ], + # upload 分组 + "upload": [ + "video_batch_upload", + "query_batch_upload_task_info" + ], + # intelligent_slicing 分组 + "intelligent_slicing": [ + "intelligent_slicing_task" + ], + # intelligent_matting 分组 + "intelligent_matting": [ + "portrait_image_retouching_task", + "green_screen_task" + ], + # subtitle_processing 分组 + "subtitle_processing": [ + "asr_speech_to_text_task", + "ocr_text_to_subtitles_task", + "video_subtitles_removal_task", + "add_subtitle", + ], + # audio_processing 分组 + "audio_processing": [ + "voice_separation_task", + "audio_noise_reduction_task" + ], + # video_enhancement 分组 + "video_enhancement": [ + "video_interlacing_task", + "video_super_resolution_task", + "video_quality_enhancement_task" + ], + # media_tasks 分组(通用) + "media_tasks": [ + "get_media_execution_task_result" + ], +} +``` + ## License MIT diff --git a/server/mcp_server_vod/src/base/base_mcp.py b/server/mcp_server_vod/src/base/base_mcp.py new file mode 100644 index 00000000..fd956e1b --- /dev/null +++ b/server/mcp_server_vod/src/base/base_mcp.py @@ -0,0 +1,373 @@ +from mcp.server.fastmcp import FastMCP +from mcp.shared.context import LifespanContextT, RequestT +import logging +from typing import Dict, Any,Optional +from collections.abc import ( + Sequence, +) + +from mcp.server.fastmcp.server import Context +from mcp.server.session import ServerSessionT +from mcp.types import ContentBlock +from mcp.server.session import ServerSession +from starlette.requests import Request +from src.base.constant import ( + # VOLCENGINE_ACCESS_KEY_HEADER, + # VOLCENGINE_SECRET_KEY_HEADER, + # VOLCENGINE_SESSION_TOKEN_HEADER, + # VOLCENGINE_HOST_HEADER, + # VOLCENGINE_REGION_HEADER, + VOLCENGINE_TOOLS_SOURCE_HEADER, + VOLCENGINE_TOOLS_TYPE_HEADER, + VOLCENGINE_SPACE_NAME_HEADER, + VOLCENGINE_SPACE_NAME_ENV, + + # VOLCENGINE_ACCESS_KEY_ENV, + # VOLCENGINE_SECRET_KEY_ENV, + # VOLCENGINE_SESSION_TOKEN_ENV, + # VOLCENGINE_HOST_ENV, + # VOLCENGINE_REGION_ENV, + VOLCENGINE_TOOLS_SOURCE_ENV, + VOLCENGINE_TOOLS_TYPE_ENV +) + +from os import environ + +logger = logging.getLogger(__name__) + +AVAILABLE_GROUPS = [ +# 视频剪辑相关tools +"edit", +# 智能切片相关tools +"intelligent_slicing", +# 智能抠图相关tools +"intelligent_matting", +# 字幕处理相关tools +"subtitle_processing", +# 音频处理相关tools +"audio_processing", +# 视频增强相关tools +"video_enhancement", +# 上传相关 +'upload', +# 视频播放相关 +"video_play" +] + + +DEFAULT_GROUPS = [ + "edit", + "video_play" +] + +DEFAULT_TOOL_LIST = [ + "audio_video_stitching", + "audio_video_clipping", + "get_v_creative_task_result", + "get_play_url", +] + +TRANSCODE_GROUPS = { + # 智能切片相关tools + "intelligent_slicing", + # 智能抠图相关tools + "intelligent_matting", + # # 字幕处理相关tools + # "subtitle_processing", + # 音频处理相关tools + "audio_processing", + # 视频增强相关tools + "video_enhancement", +} + +ALL_GROUPS = 'all' + +# 工具分组到工具列表的映射 +# 所有分组统一为列表格式,用于在 list_tools 时动态过滤 +TOOL_GROUP_MAP = { + # edit 分组 + "edit": [ + "audio_video_stitching", + "audio_video_clipping", + "get_v_creative_task_result", + "flip_video", + "speedup_video", + "speedup_audio", + "image_to_video", + "compile_video_audio", + "extract_audio", + "mix_audios", + "add_sub_video", + "wait_for_v_creative_task_result", + ], + # video_play 分组 + "video_play": [ + "get_play_url", + "get_video_audio_info" + ], + # upload 分组 + "upload": [ + "video_batch_upload", + "query_batch_upload_task_info" + ], + # intelligent_slicing 分组 + "intelligent_slicing": [ + "intelligent_slicing_task" + ], + # intelligent_matting 分组 + "intelligent_matting": [ + "portrait_image_retouching_task", + "green_screen_task" + ], + # subtitle_processing 分组 + "subtitle_processing": [ + "asr_speech_to_text_task", + "ocr_text_to_subtitles_task", + "video_subtitles_removal_task", + "add_subtitle", + ], + # audio_processing 分组 + "audio_processing": [ + "voice_separation_task", + "audio_noise_reduction_task" + ], + # video_enhancement 分组 + "video_enhancement": [ + "video_interlacing_task", + "video_super_resolution_task", + "video_quality_enhancement_task" + ], + # media_tasks 分组(通用) + "media_tasks": [ + "get_media_execution_task_result" + ], +} + +# 工具名到工具分组的反向映射,用于快速查找工具所属分组 +TOOL_NAME_TO_GROUP_MAP = {} +for group_name, tool_list in TOOL_GROUP_MAP.items(): + for tool_name in tool_list: + TOOL_NAME_TO_GROUP_MAP[tool_name] = group_name + + + + +class BaseMCP(FastMCP): + def __init__(self, + name: str | None = None, # + instructions: str | None = None, + host: str = "127.0.0.1", # + port: int = 8000, # + streamable_http_path: str = "/mcp", # + stateless_http: bool = False, # + ): + super().__init__( + name=name, + host=host, + port=port, + stateless_http=stateless_http, + streamable_http_path=streamable_http_path, + instructions=instructions + ) + self._base_mcp_store = {} + + def set_base_mcp_store(self, store: Dict[Any, Any]) -> Dict: + """设置 base_mcp_store + + Args: + store: 要存储的字典 + + Returns: + Dict: 更新后的 _base_mcp_store + """ + self._base_mcp_store = { + **self._base_mcp_store, + **store + } + return self._base_mcp_store + + def get_base_mcp_store(self, key: Any) -> Any: + """获取 base_mcp_store 中的值 + + Args: + key: 要获取的键 + + Returns: + Any: 对应的值,如果 key 为 None 或不存在则返回 None + """ + if key is None: + return None + return self._base_mcp_store.get(key) + def get_request_ctx(self) -> Optional[Request]: + """获取当前请求的 Request 对象 + + Returns: + Optional[Request]: 当前请求的 Request 对象,如果无法获取则返回 None + """ + try: + ctx: Optional[Context[ServerSession, object]] = self.get_context() + if ctx and hasattr(ctx, 'request_context') and ctx.request_context: + raw_request: Optional[Request] = ctx.request_context.request + return raw_request + except Exception as e: + logger.debug(f"Failed to get request context: {e}") + return None + + def _handle_display_tools(self) -> tuple[Optional[str], Optional[list[str]]]: + """处理要显示的工具列表或分组列表 + + 优先级:header > 环境变量 > _base_mcp_store + + Returns: + tuple[Optional[str], Optional[list[str]]]: (tools_type, tools/sources列表),如果未配置则返回 (None, None) + """ + tools_type = None + tools_source = None + + # 优先级1: 从 header 获取 + try: + raw_request: Optional[Request] = self.get_request_ctx() + if raw_request: + headers = raw_request.headers + tools_type_h = headers.get(VOLCENGINE_TOOLS_TYPE_HEADER) + tools_source_h = headers.get(VOLCENGINE_TOOLS_SOURCE_HEADER) + + if tools_type_h and tools_source_h: + if tools_type_h in ['tools', 'groups']: + tools_type = tools_type_h + tools_source = [item.strip() for item in tools_source_h.split(',') if item.strip()] + logger.debug(f"Got tools_type and source from header: {tools_type}, {tools_source}") + return (tools_type, tools_source) + except Exception as e: + logger.debug(f"Failed to get tools from header: {e}") + + # 优先级2: 从环境变量获取 + tool_type_e = environ.get(VOLCENGINE_TOOLS_TYPE_ENV) + tools_source_e = environ.get(VOLCENGINE_TOOLS_SOURCE_ENV) + + if tool_type_e is None and tools_source_e is None: + source_groups = environ.get("MCP_TOOL_GROUPS") + if source_groups: + tool_type_e = 'groups' + tools_source_e = source_groups + + if tool_type_e and tools_source_e: + if tool_type_e in ['tools', 'groups']: + tools_type = tool_type_e + tools_source = [item.strip() for item in tools_source_e.split(',') if item.strip()] + logger.debug(f"Got tools_type and source from environment: {tools_type}, {tools_source}") + return (tools_type, tools_source) + + + # 优先级3: 从 _base_mcp_store 获取 + tools_type_store = self.get_base_mcp_store('tools_type') + source_store = self.get_base_mcp_store('source') + + if tools_type_store and source_store: + if tools_type_store in ['tools', 'groups']: + # 如果 source_store 是字符串,需要分割 + if isinstance(source_store, str): + tools_source = [item.strip() for item in source_store.split(',') if item.strip()] + elif isinstance(source_store, list): + tools_source = [str(item).strip() for item in source_store if item] + else: + tools_source = None + + if tools_source: + tools_type = tools_type_store + logger.debug(f"Got tools_type and source from _base_mcp_store: {tools_type}, {tools_source}") + return (tools_type, tools_source) + + # 如果都没有配置,返回默认工具列表 + logger.debug("No tools configuration found, using DEFAULT_TOOL_LIST") + return ('tools', DEFAULT_TOOL_LIST) + async def list_tools(self): + """Get the list of tools available for this MCP instance. + Returns: + list[Tool]: A list of Tool objects. + """ + try: + res = await super().list_tools() + + # 获取要显示的工具列表或分组列表 + # _handle_display_tools 返回 (tools_type, tools/sources列表) + tools_type, display_config = self._handle_display_tools() + + if tools_type is None or display_config is None: + # 如果没有配置,返回所有工具 + logger.info(f"BaseMCP.list_tools: no filter, returning all {len(res)} tools") + return res + + # 获取允许的工具列表 + allowed_tools = set() + + if tools_type == 'tools': + # 如果指定的是工具列表,直接使用 + allowed_tools = set(display_config) + elif tools_type == 'groups': + # 如果指定的是分组列表,从 TOOL_GROUP_MAP 中获取对应的工具 + for group_name in display_config: + if group_name == ALL_GROUPS: + # 如果指定了 "all",返回所有工具 + logger.info(f"BaseMCP.list_tools: returning all {len(res)} tools (all groups)") + return res + if group_name in TOOL_GROUP_MAP: + logger.info(f"TOOL_GROUP_MAP {TOOL_GROUP_MAP[group_name]} tools in group {group_name}") + allowed_tools.update(TOOL_GROUP_MAP[group_name]) + + # 过滤工具 + filtered_tools = [] + for tool in res: + if tool.name in allowed_tools: + filtered_tools.append(tool) + + logger.info(f"BaseMCP.list_tools: filtered to {len(filtered_tools)}/{len(res)} tools based on {tools_type}: {display_config}") + return filtered_tools + except Exception as e: + logger.error(f"BaseMCP.list_tools failed with error: {e}") + import traceback + logger.error(traceback.format_exc()) + return [] + async def call_tool(self, + name: str, + arguments: dict[str, Any], + ) -> Sequence[ContentBlock] | dict[str, Any]: + """Call a tool by name with arguments.""" + try: + context: Optional[Request] = self.get_request_ctx() + ctx: Optional[Context[ServerSession, object]] = self.get_context() + arguments['ctx'] = ctx + if self._base_mcp_store.get('apiRequestInstance'): + apiRequestInstance = self._base_mcp_store['apiRequestInstance'] + if hasattr(apiRequestInstance, 'set_headers') and name: + apiRequestInstance.set_headers('x-tt-tools-name', name) + + # 兼容处理 space_name, spaceName, space + space_name_value = None + for key in ['space_name', 'spaceName', 'space']: + if key in arguments and arguments[key] and isinstance(arguments[key], str) and arguments[key].strip(): + space_name_value = arguments[key].strip() + break + + if space_name_value: + arguments['space_name'] = space_name_value + else: + space_name_env = environ.get(VOLCENGINE_SPACE_NAME_ENV) + if context and hasattr(context, 'headers'): + headers = context.headers + space_name_h = headers.get(VOLCENGINE_SPACE_NAME_HEADER) + if space_name_h: + arguments['space_name'] = space_name_h.strip() + elif space_name_env: + arguments['space_name'] = space_name_env.strip() + elif space_name_env: + arguments['space_name'] = space_name_env.strip() + space_name = arguments.get('space_name') + if not space_name or not isinstance(space_name, str) or not space_name.strip(): + logger.error(f"space_name is required") + return await super().call_tool(name, arguments) + except Exception as e: + logger.error(f"BaseMCP.call_tool failed with error: {e}") + import traceback + logger.error(traceback.format_exc()) + raise e diff --git a/server/mcp_server_vod/src/base/base_service.py b/server/mcp_server_vod/src/base/base_service.py index 02e722ea..1d920644 100644 --- a/server/mcp_server_vod/src/base/base_service.py +++ b/server/mcp_server_vod/src/base/base_service.py @@ -1,8 +1,11 @@ from volcengine.vod.VodService import VodService import json import os -from typing import Dict, Any -from src.base.credential import get_volcengine_credentials_base +import logging +from typing import Dict, Any, Optional +from src.base.credential import get_volcengine_credentials_base, get_volcengine_credentials_from_context +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession class BaseService(VodService): @@ -18,7 +21,28 @@ def __init__(self): self.set_sk(credentials.secret_access_key) self.set_session_token(credentials.session_token) self.service_info.header["x-tt-mcp"] = 'volc' + if os.getenv("VOLCENGINE_HOST"): + self.set_host(os.getenv("VOLCENGINE_HOST")) self.mcp_state = {} + self._mcp_instance = None # Store MCP instance for context access + + def update_credentials_from_mcp(self): + """Update credentials from MCP context if mcp instance is available.""" + if self._mcp_instance is None: + logging.debug("update_credentials_from_mcp: _mcp_instance is None, skipping") + return + + try: + ctx = self._mcp_instance.get_context() + if ctx: + logging.debug("update_credentials_from_mcp: got context, updating credentials") + self.update_credentials_from_context(ctx) + else: + logging.debug("update_credentials_from_mcp: get_context() returned None") + except Exception as e: + logging.debug(f"update_credentials_from_mcp: Failed to get context from MCP instance: {e}") + import traceback + logging.debug(traceback.format_exc()) @staticmethod def get_api_info(): @@ -26,8 +50,11 @@ def get_api_info(): def set_api_info(self, api_info): self.api_info = {**self.api_info, **api_info} return - + def set_headers(self, key: str, value: str): + self.service_info.header[key] = value def mcp_get(self, action, params={}, doseq=0): + self.update_credentials_from_mcp() + res = self.get(action, params, doseq) if res == '': raise Exception("%s: empty response" % action) @@ -35,6 +62,7 @@ def mcp_get(self, action, params={}, doseq=0): return res_json def mcp_post(self, action, params={}, body={}): + self.update_credentials_from_mcp() res = self.json(action, params, body) if res == '': raise Exception("%s: empty response" % action) @@ -47,3 +75,40 @@ def set_state(self, state: Dict[str, Any] = {}): } def get_state(self): return self.mcp_state + + def update_credentials_from_context(self, ctx: Optional[Context[ServerSession, object, Any]] = None): + """Update credentials from MCP context headers. + + Args: + ctx: MCP context object. If None, will try to get from current context. + """ + if ctx is None: + return + + try: + # 从上下文获取凭证信息 + context_cred = get_volcengine_credentials_from_context(ctx) + if context_cred: + # 更新凭证 + if context_cred.get("access_key_id"): + self.set_ak(context_cred["access_key_id"]) + if context_cred.get("secret_access_key"): + self.set_sk(context_cred["secret_access_key"]) + if context_cred.get("session_token"): + self.set_session_token(context_cred["session_token"]) + + # 更新 host + if context_cred.get("host"): + self.set_host(context_cred["host"]) + + # 更新 region + if context_cred.get("region"): + # 注意:VodService 的 region 在初始化时设置,可能需要重新初始化 + # 但为了兼容性,我们只更新 service_info 中的 region + if hasattr(self, 'service_info') and hasattr(self.service_info, 'region'): + self.service_info.region = context_cred["region"] + + logging.debug("Credentials updated from MCP context") + except Exception as e: + logging.warning(f"Failed to update credentials from context: {e}") + diff --git a/server/mcp_server_vod/src/base/constant.py b/server/mcp_server_vod/src/base/constant.py new file mode 100644 index 00000000..d0cbe942 --- /dev/null +++ b/server/mcp_server_vod/src/base/constant.py @@ -0,0 +1,19 @@ +VOLCENGINE_ACCESS_KEY_HEADER = 'x-tt-access-key' +VOLCENGINE_SECRET_KEY_HEADER = 'x-tt-secret-key' +VOLCENGINE_SESSION_TOKEN_HEADER = 'x-tt-session-token' +VOLCENGINE_HOST_HEADER = 'x-tt-host' +VOLCENGINE_REGION_HEADER = 'x-tt-region' +VOLCENGINE_TOOLS_SOURCE_HEADER = 'x-tt-tools-source' +VOLCENGINE_TOOLS_TYPE_HEADER = 'x-tt-tools-type' +VOLCENGINE_SPACE_NAME_HEADER = 'x-tt-space-name' + + + +VOLCENGINE_ACCESS_KEY_ENV = 'VOLCENGINE_ACCESS_KEY' +VOLCENGINE_SECRET_KEY_ENV = 'VOLCENGINE_SECRET_KEY' +VOLCENGINE_SESSION_TOKEN_ENV = 'VOLCENGINE_SESSION_TOKEN' +VOLCENGINE_HOST_ENV = 'VOLCENGINE_HOST' +VOLCENGINE_REGION_ENV = 'VOLCENGINE_REGION' +VOLCENGINE_TOOLS_SOURCE_ENV = 'MCP_TOOLS_SOURCE' +VOLCENGINE_TOOLS_TYPE_ENV = 'MCP_TOOLS_TYPE' +VOLCENGINE_SPACE_NAME_ENV = 'MCP_SPACE_NAME' \ No newline at end of file diff --git a/server/mcp_server_vod/src/base/credential.py b/server/mcp_server_vod/src/base/credential.py index 7d29f15c..c9e42083 100644 --- a/server/mcp_server_vod/src/base/credential.py +++ b/server/mcp_server_vod/src/base/credential.py @@ -1,8 +1,26 @@ import json +import logging from pathlib import Path from os import environ from pydantic import BaseModel +from typing import Optional, Dict, Any +from mcp.server.fastmcp import Context +from mcp.server.session import ServerSession +from starlette.requests import Request +from src.base.constant import ( + VOLCENGINE_ACCESS_KEY_HEADER, + VOLCENGINE_SECRET_KEY_HEADER, + VOLCENGINE_SESSION_TOKEN_HEADER, + VOLCENGINE_HOST_HEADER, + VOLCENGINE_REGION_HEADER, + + VOLCENGINE_ACCESS_KEY_ENV, + VOLCENGINE_SECRET_KEY_ENV, + VOLCENGINE_SESSION_TOKEN_ENV, + # VOLCENGINE_HOST_ENV, + # VOLCENGINE_REGION_ENV, +) VEFAAS_IAM_CRIDENTIAL_PATH = "/var/run/secrets/iam/credential" @@ -11,13 +29,99 @@ class VeIAMCredential(BaseModel): secret_access_key: str session_token: str -VOLCENGINE_ACCESS_KEY_ENV = 'VOLCENGINE_ACCESS_KEY' -VOLCENGINE_SECRET_KEY_ENV = 'VOLCENGINE_SECRET_KEY' -VOLCENGINE_SESSION_TOKEN_ENV = 'VOLCENGINE_SESSION_TOKEN' +# VOLCENGINE_ACCESS_KEY_ENV = 'VOLCENGINE_ACCESS_KEY' +# VOLCENGINE_SECRET_KEY_ENV = 'VOLCENGINE_SECRET_KEY' +# VOLCENGINE_SESSION_TOKEN_ENV = 'VOLCENGINE_SESSION_TOKEN' +# VOLCENGINE_HOST_ENV = 'VOLCENGINE_HOST' +# VOLCENGINE_REGION_ENV = 'VOLCENGINE_REGION' -def get_volcengine_credentials_base() -> VeIAMCredential: - """Get Volcengine credentials from environment variables.""" - # 优先从环境变量读取 +def get_volcengine_credentials_from_context(ctx: Optional[Context[ServerSession, object, Any]] = None) -> Optional[Dict[str, Any]]: + """Get Volcengine credentials from MCP context headers. + + Args: + ctx: MCP context object + + Returns: + Dict containing credentials and config if found in headers, None otherwise. + Keys: access_key_id, secret_access_key, session_token, host, region + """ + print("get_volcengine_credentials_from_context: ctx", ctx.request_context.request) + if ctx is None: + return None + + try: + raw_request: Optional[Request] = ctx.request_context.request + if not raw_request: + return None + + headers = raw_request.headers + print("headers", headers) + if not headers: + return None + + # 从 header 中读取凭证信息(使用与环境变量一致的命名,同时支持连字符和下划线格式) + # 优先使用下划线格式(与环境变量一致),也支持连字符格式和 x- 前缀 + access_key = ( + headers.get(VOLCENGINE_ACCESS_KEY_HEADER) or + None + ) + secret_key = ( + headers.get(VOLCENGINE_SECRET_KEY_HEADER) or + None + ) + session_token = ( + headers.get(VOLCENGINE_SESSION_TOKEN_HEADER) or + None + ) + host = ( + headers.get(VOLCENGINE_HOST_HEADER) or + None + ) + region = ( + headers.get(VOLCENGINE_REGION_HEADER) or + None + ) + + # 如果 header 中有 ak 和 sk,返回凭证信息 + if access_key and secret_key: + result = { + "access_key_id": access_key, + "secret_access_key": secret_key, + "session_token": session_token, + } + if host: + result["host"] = host + if region: + result["region"] = region + return result + + return None + except Exception as e: + logging.warning(f"Failed to get credentials from context: {e}") + return None + + +def get_volcengine_credentials_base(ctx: Optional[Context[ServerSession, object, Any]] = None) -> VeIAMCredential: + """Get Volcengine credentials from context headers, environment variables, or VeFaaS IAM. + + Args: + ctx: Optional MCP context object + + Returns: + VeIAMCredential object + """ + # 优先从 MCP 上下文 header 读取 + if ctx: + context_cred = get_volcengine_credentials_from_context(ctx) + print("context_cred",context_cred) + if context_cred: + return VeIAMCredential( + access_key_id=context_cred.get("access_key_id", ""), + secret_access_key=context_cred.get("secret_access_key", ""), + session_token=context_cred.get("session_token", ""), + ) + + # 如果上下文没有,则从环境变量读取 access_key = environ.get(VOLCENGINE_ACCESS_KEY_ENV, '') secret_key = environ.get(VOLCENGINE_SECRET_KEY_ENV, '') session_token = environ.get(VOLCENGINE_SESSION_TOKEN_ENV, '') @@ -29,9 +133,9 @@ def get_volcengine_credentials_base() -> VeIAMCredential: secret_key = vefaas_cred.secret_access_key session_token = vefaas_cred.session_token - # 如果仍未获取到有效凭证,则抛出异常 + # 如果仍未获取到有效凭证,仅打印警告(支持通过 Header 动态传递凭证) if not (access_key and secret_key): - raise RuntimeError("无法获取有效的 Volcengine 凭证,请检查环境变量或 VeFaaS IAM 配置") + logging.warning("未检测到 Volcengine 凭证(环境变量/IAM),服务将以无凭证模式启动。请确保在请求 Header 中传递凭证。") return VeIAMCredential( access_key_id=access_key, secret_access_key=secret_key, diff --git a/server/mcp_server_vod/src/vod/mcp_server.py b/server/mcp_server_vod/src/vod/mcp_server.py index b4036ef1..ffb44c5f 100644 --- a/server/mcp_server_vod/src/vod/mcp_server.py +++ b/server/mcp_server_vod/src/vod/mcp_server.py @@ -7,9 +7,10 @@ from mcp.server.fastmcp import FastMCP from pathlib import Path -from urllib.parse import quote + import json import os +import logging AVAILABLE_GROUPS = [ # 视频剪辑相关tools @@ -41,36 +42,34 @@ "intelligent_slicing", # 智能抠图相关tools "intelligent_matting", - # 字幕处理相关tools - "subtitle_processing", + # # 字幕处理相关tools + # "subtitle_processing", # 音频处理相关tools "audio_processing", # 视频增强相关tools "video_enhancement", } -def create_mcp_server(groups: list[str] = None, mcp: FastMCP = None): + +ALL_GROUPS = 'all' + +# 工具名到工具分组的映射从 base_mcp 导入 +# TOOL_NAME_TO_GROUP_MAP: 工具名 -> 分组名 (用于根据工具名查找分组) +# TOOL_GROUP_MAP: 分组名 -> 工具列表 (用于根据分组名获取工具列表) + + +def create_mcp_server(mcp: FastMCP = None): ## init api client service = VodAPI() ## init public methods public_methods = {} - - ## init tool groups - current_tool_groups = [] - env_type = os.getenv("MCP_TOOL_GROUPS") - if groups is not None: - current_tool_groups = groups - elif env_type is not None: - try: - current_tool_groups = [group.strip() for group in env_type.split(",") if group.strip()] - print(f"[MCP] Loaded tool groups from environment: {current_tool_groups}") - except Exception as e: - print(f"[MCP] Error parsing MCP_TOOL_GROUPS environment variable: {e}") - current_tool_groups = DEFAULT_GROUPS - else: - current_tool_groups = DEFAULT_GROUPS + # 确保 mcp 不为 None + if mcp is None: + raise ValueError("mcp parameter cannot be None") - + mcp.set_base_mcp_store({ + 'apiRequestInstance': service + }) ## update media publish status def update_media_publish_status (vid: str, SpaceName: str, PublishStatus: str) -> str: @@ -103,7 +102,14 @@ def get_play_video_info (vid: str, SpaceName: str, OutputType: str = 'CDN') -> Returns: - PlayURL (str): 视频播放地址. - - Duration (int): 视频时长,单位秒. + - Duration (float): 视频时长,单位秒. + - FormatName (str): 容器名称. + - Size (float): 大小,单位为字节. + - BitRate (str): 码率,单位为 bps. + - CodecName (str): 编码器名称. + - AvgFrameRate (str): 视频平均帧率,单位为 fps. + - Width (int): 视频宽,单位为 px. + - Height (int): 视频高,单位为 px. """ reqs = service.mcp_get("McpGetVideoPlayInfo", { @@ -114,6 +120,13 @@ def get_play_video_info (vid: str, SpaceName: str, OutputType: str = 'CDN') -> },json.dumps({})) url = None duration = 0 + formatName = "" + size = 0 + bitRate = "" + codecName = "" + avgFrameRate = "" + width = 0 + height = 0 if isinstance(reqs, str): reqs = json.loads(reqs) @@ -121,7 +134,18 @@ def get_play_video_info (vid: str, SpaceName: str, OutputType: str = 'CDN') -> videoDetail = result.get("VideoDetail", {}) videoDetailInfo = videoDetail.get("VideoDetailInfo", {}) playInfo = videoDetailInfo.get("PlayInfo", {}) - duration = videoDetailInfo.get("Duration", 0) + durationValue = videoDetailInfo.get("Duration") + duration = float(durationValue) if durationValue is not None else 0 + + # 提取完整的视频信息 + formatName = videoDetailInfo.get("Format", "") + sizeValue = videoDetailInfo.get("Size") + size = float(sizeValue) if sizeValue is not None else 0 + bitRate = str(videoDetailInfo.get("Bitrate", "")) if videoDetailInfo.get("Bitrate") else "" + codecName = videoDetailInfo.get("Codec", "") + avgFrameRate = str(videoDetailInfo.get("Fps", "")) if videoDetailInfo.get("Fps") else "" + width = int(videoDetailInfo.get("Width", 0)) if videoDetailInfo.get("Width") else 0 + height = int(videoDetailInfo.get("Height", 0)) if videoDetailInfo.get("Height") else 0 if videoDetailInfo.get("PublishStatus") == 'Published': url = playInfo.get("MainPlayURL", None) or playInfo.get("BackupPlayUrl", None) @@ -139,7 +163,17 @@ def get_play_video_info (vid: str, SpaceName: str, OutputType: str = 'CDN') -> raise Exception("update publish status failed:", reqs, publishStatus) if url is None: raise Exception("%s: get publish url failed" % vid) - return json.dumps({"PlayURL": url, "Duration": duration}) + return json.dumps({ + "PlayURL": url, + "Duration": duration, + "FormatName": formatName, + "Size": size, + "BitRate": bitRate, + "CodecName": codecName, + "AvgFrameRate": avgFrameRate, + "Width": width, + "Height": height, + }) public_methods["update_media_publish_status"] = update_media_publish_status public_methods["get_play_video_info"] = get_play_video_info @@ -176,5 +210,14 @@ def _load_tools(groupList: list[str]): module.create_mcp_server(mcp, public_methods, service) print(f"[MCP] Loaded tool: {module_name}") - _load_tools(current_tool_groups) + # Store mcp instance in service for context access + service._mcp_instance = mcp + logging.info(f"Set _mcp_instance on service: {mcp is not None}") + + # 注册所有工具(启动时注册所有工具,在 streamable HTTP 建联时会根据 header 动态过滤) + # 注意:这里注册所有工具,而不是只注册 current_tool_groups + # 因为 streamable HTTP 模式下,每次请求的 header 可能不同 + _load_tools(AVAILABLE_GROUPS) + + return mcp diff --git a/server/mcp_server_vod/src/vod/mcp_tools/audio_processing.py b/server/mcp_server_vod/src/vod/mcp_tools/audio_processing.py index 75183550..357d8754 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/audio_processing.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/audio_processing.py @@ -3,11 +3,11 @@ def create_mcp_server(mcp, public_methods: dict): _build_media_input = public_methods["_build_media_input"] _start_execution = public_methods["_start_execution"] - # audio noise reduction @mcp.tool() - def audio_noise_reduction_task(type: str, audio: str, spaceName: str) -> Any: - """ Audio noise reduction, supporting two input modes: `Vid` and `DirectUrl`. + def audio_noise_reduction_task(type: str, audio: str, space_name: str = None) -> Any: + """ + Audio noise reduction, supporting two input modes: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 @@ -15,12 +15,12 @@ def audio_noise_reduction_task(type: str, audio: str, spaceName: str) -> Any: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - audio: ** 必选字段 **, 音频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `audioNoiseReduction` """ - media_input = _build_media_input(type, audio, spaceName) + media_input = _build_media_input(type, audio, space_name) params = { "Input": media_input, "Operation": { @@ -35,21 +35,22 @@ def audio_noise_reduction_task(type: str, audio: str, spaceName: str) -> Any: # voice separation @mcp.tool() - def voice_separation_task(type: str, video: str, spaceName: str) -> Any: - """Voice separation is supported, with two input modes available: `Vid` and `DirectUrl`. + def voice_separation_task(type: str, video: str, space_name: str = None) -> Any: + """ + Voice separation is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `voiceSeparation` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { diff --git a/server/mcp_server_vod/src/vod/mcp_tools/edit.py b/server/mcp_server_vod/src/vod/mcp_tools/edit.py index d1a6b4a8..11243fde 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/edit.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/edit.py @@ -1,18 +1,49 @@ import json +import asyncio +from venv import logger from src.vod.api.api import VodAPI +from mcp.server.fastmcp import Context +from typing import List, Optional, Callable, Any + + + +def _format_source(type: str, source: str) -> str: + """根据 type 自动添加前缀到 source + Args: + type: 文件类型,vid、directurl、http + source: 文件信息 + Returns: + 格式化后的 source + """ + if not source: + return source + + # 如果已经包含协议前缀,直接返回 + if source.startswith(('vid://', 'directurl://', 'http://', 'https://')): + return source + + # 根据 type 添加前缀 + if type == 'vid': + return f'vid://{source}' + elif type == 'directurl': + return f'directurl://{source}' + # http 类型不需要添加前缀,直接返回 + return source + def create_mcp_server(mcp,public_methods: dict, service: VodAPI, ): @mcp.tool() - def audio_video_stitching(type: str, SpaceName: str, videos: list = None, audios: list = None, transitions: list = None) -> dict: - """ Carry out video stitching, audio stitching, and support for transitions and other capabilities,需要参考 Note 中的要求。 - Note: - - **audio splicing does not support transitions. ** - - ** vid 模式下需要增加 vid:// 前缀, 示例:vid://123456 ** - - ** directurl://{fileName} 格式指定资源的 FileName。示例:directurl://test.mp3** - - ** http(s):// 格式指定资源的 URL。示例:http://example.com/test.mp4** + def audio_video_stitching(type: str, space_name: str = None, videos: List[str] = None, audios: List[str] = None, transitions: List[str] = None) -> dict: + """ + Carry out video stitching, audio stitching, and support for transitions and other capabilities,需要参考 Note 中的要求。 + Note: + - **audio splicing does not support transitions. ** + - ** vid 模式下需要增加 vid:// 前缀, 示例:vid://123456 ** + - ** directurl://{fileName} 格式指定资源的 FileName。示例:directurl://test.mp3** + - ** http(s):// 格式指定资源的 URL。示例:http://example.com/test.mp4** Args: - type(str): ** 必选字段 ** , 拼接类型。 `audio` | `video` - - SpaceName(str): ** 必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 - videos(List[str]): **视频下必选字段,音频下不传递 ** - 待拼接的视频列表:支持 ** vid:// ** 、 ** http:// ** 格式,** directUrl:// ** 格式 *** 视频要求: *** @@ -45,8 +76,10 @@ def audio_video_stitching(type: str, SpaceName: str, videos: list = None, audios - StatusCode(int): 接口请求的状态码。0表示成功,其他值表示不同的错误状态。 """ + print(f"audio_video_stitchingspace_name: {space_name}") + try: - params = {"type": type, "SpaceName": SpaceName, "videos": videos, "audios": audios, "transitions": transitions} + params = {"type": type, "SpaceName": space_name, "videos": videos, "audios": audios, "transitions": transitions} if "SpaceName" not in params: raise ValueError("audio_video_stitching: params must contain SpaceName") if not isinstance(params["SpaceName"], str): @@ -64,9 +97,21 @@ def audio_video_stitching(type: str, SpaceName: str, videos: list = None, audios raise TypeError("audio_video_stitching: params['audios'] must be a list") if not params["audios"]: raise ValueError("audio_video_stitching: params['audios'] must contain at least one audio") + # 格式化音频列表 + formattedAudios = [] + for audio in params.get("audios", []): + if isinstance(audio, str): + # 如果已经是完整格式,直接使用 + if audio.startswith(('vid://', 'directurl://', 'http://', 'https://')): + formattedAudios.append(audio) + else: + # 默认使用 vid 格式 + formattedAudios.append(_format_source("vid", audio)) + else: + formattedAudios.append(audio) ParamObj = { "space_name": params["SpaceName"], - "audios": params.get("audios", []), + "audios": formattedAudios, } WorkflowId = "loki://158487089" else: @@ -76,9 +121,21 @@ def audio_video_stitching(type: str, SpaceName: str, videos: list = None, audios raise Exception("audio_video_stitching: params['videos'] must be a list", params) if not params["videos"]: raise Exception("audio_video_stitching: params['videos'] must contain at least one video", params) + # 格式化视频列表 + formattedVideos = [] + for video in params.get("videos", []): + if isinstance(video, str): + # 如果已经是完整格式,直接使用 + if video.startswith(('vid://', 'directurl://', 'http://', 'https://')): + formattedVideos.append(video) + else: + # 默认使用 vid 格式 + formattedVideos.append(_format_source("vid", video)) + else: + formattedVideos.append(video) ParamObj = { "space_name": params["SpaceName"], - "videos": params.get("videos", []), + "videos": formattedVideos, "transitions": params.get("transitions", []), } WorkflowId = "loki://154775772" @@ -109,7 +166,7 @@ def audio_video_stitching(type: str, SpaceName: str, videos: list = None, audios raise Exception("audio_video_stitching: %s" % e, params) @mcp.tool() - def audio_video_clipping(type: str, SpaceName: str, source: str, start_time: float, end_time: float) -> dict: + def audio_video_clipping(type: str, source: str, start_time: float, end_time: float, space_name: str = None) -> dict: """ Invoke the current tools to complete the cropping of audio and video,需要参考 Note 中的要求。 Note: - ** vid 模式下需要增加 vid:// 前缀, 示例:vid://123456 ** @@ -118,7 +175,7 @@ def audio_video_clipping(type: str, SpaceName: str, source: str, start_time: flo - `start_time` 和 `end_time` 必须同时指定,且 `end_time` 必须大于 `start_time` Args: - type(str): ** 必选字段 ** , 拼接类型。 `audio` | `video` - - SpaceName(str): ** 必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 - source(str): ** 必选字段 ** - 输入视频:支持 ** vid:// ** 、 ** http:// ** 格式,** directUrl:// ** 格式 - end_time(float): ** 必选字段 ** @@ -133,7 +190,7 @@ def audio_video_clipping(type: str, SpaceName: str, source: str, start_time: flo """ try: - params = {"type": type, "SpaceName": SpaceName, "source": source, "start_time": start_time, "end_time": end_time} + params = {"type": type, "SpaceName": space_name, "source": source, "start_time": start_time, "end_time": end_time} if "SpaceName" not in params: raise Exception("audio_video_clipping: params must contain SpaceName", params) if not isinstance(params["SpaceName"], str): @@ -145,9 +202,13 @@ def audio_video_clipping(type: str, SpaceName: str, source: str, start_time: flo startTime = params.get("start_time", 0) endTime = params.get("end_time", startTime + 1) + sourceType = params.get("type", "video") + sourceValue = params.get("source", "") + # 根据 type 自动添加前缀 + formattedSource = _format_source(sourceType, sourceValue) if sourceType in ["vid", "directurl"] else sourceValue ParamObj = { "space_name": params["SpaceName"], - "source": params.get("source", ""), + "source": formattedSource, "end_time": endTime, "start_time": startTime, } @@ -183,27 +244,8 @@ def audio_video_clipping(type: str, SpaceName: str, source: str, start_time: flo except Exception as e: raise Exception("audio_video_clipping: %s" % e) - @mcp.tool() - def get_v_creative_task_result(VCreativeId: str, SpaceName: str) -> dict: - """ Query the execution status and results of video stitching, audio stitching, and audio-video cropping by using the `VCreativeId`. - Note: - - **audio splicing does not support transitions. ** - Args: - - VCreativeId(str): `String type`, ID for AI intelligent trimming task. - - SpaceName(str): `String type`, space name. - Returns: - - Status(str): 任务的当前处理状态。 - - running: 执行中 - - success: 执行成功 - - failed_run: 执行失败 - - OutputJson(dict[str,Any]): 任务的输出结果 - - vid: 输出的点播空间vid - - resolution: 分辨率 - - filename: 文件名 - - url: 产物链接,仅当任务成功时返回 - - duration: 时长, 单位:秒(s) - """ - params={"VCreativeId": VCreativeId, "SpaceName": SpaceName} + def _get_v_creative_task_result_impl(VCreativeId: str, space_name: str = None) -> dict: + params={"VCreativeId": VCreativeId, "SpaceName": space_name} if "VCreativeId" not in params: raise ValueError("get_v_creative_task_result: params must contain VCreativeId") if not isinstance(params["VCreativeId"], str): @@ -263,4 +305,758 @@ def get_v_creative_task_result(VCreativeId: str, SpaceName: str) -> dict: return json.dumps(reqsTmp) else: return reqs - + + # @mcp.tool( + # description=""" + # Query the execution status and results of video stitching, audio stitching, and audio-video cropping by using the `VCreativeId`. + # Note: + # - **audio splicing does not support transitions. ** + # """, + # ) + # def get_v_creative_task_result(VCreativeId: str, space_name: str = None) -> dict: + # """ + # Args: + # - VCreativeId(str): `String type`, ID for AI intelligent trimming task. + # - space_name(str): `String type`, space name. ** 非必选字段 ** + # Returns: + # - Status(str): 任务的当前处理状态。 + # - running: 执行中 + # - success: 执行成功 + # - failed_run: 执行失败 + # - OutputJson(dict[str,Any]): 任务的输出结果 + # - vid: 输出的点播空间vid + # - resolution: 分辨率 + # - filename: 文件名 + # - url: 产物链接,仅当任务成功时返回 + # - duration: 时长, 单位:秒(s) + # """ + # return _get_v_creative_task_result_impl(VCreativeId, space_name) + + @mcp.tool() + async def get_v_creative_task_result(VCreativeId: str,space_name: str = None, interval: float = 2.0, max_retries: int = 10, ctx: Context = None) -> dict: + """ + Poll the execution status and results of video stitching, audio stitching, and audio-video cropping by using the `VCreativeId` until success or timeout. + Args: + - VCreativeId(str): `String type`, ID for AI intelligent trimming task. + - space_name(str): `String type`, space name. ** 非必选字段 ** + - interval(float): Polling interval in seconds. Default is 2.0. + - max_retries(int): Maximum number of retries. Default is 30. + Returns: + - Status(str): 任务的当前处理状态。 + - running: 执行中 + - success: 执行成功 + - failed_run: 执行失败 + - OutputJson(dict[str,Any]): 任务的输出结果 + - vid: 输出的点播空间vid + - resolution: 分辨率 + - filename: 文件名 + - url: 产物链接,仅当任务成功时返回 + - duration: 时长, 单位:秒(s) + """ + result_json = None + for i in range(max_retries): + try: + logger.info(f"wait_for_v_creative_task_result: VCreativeId={VCreativeId}, space_name={space_name}, i={i}, ctx=",ctx.report_progress) + result_json = _get_v_creative_task_result_impl(VCreativeId, space_name) + result = json.loads(result_json) if isinstance(result_json, str) else result_json + status = result.get("Status") + if status == "success" or status == "failed_run": + if ctx: + await ctx.report_progress(progress=100, total=100) + return result_json + if status == "running": + if ctx: + await ctx.report_progress(progress=50, total=100) + return result_json + except Exception as e: + raise e + + await asyncio.sleep(interval) + + return result_json + + + + @mcp.tool() + def flip_video(type: str, source: str, space_name: str = None, flip_x: bool = False, flip_y: bool = False) -> dict: + """ + Video rotation capability is supported, allowing for `vertical and horizontal flipping of the video`. ** Default: No flipping ** + Args: + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - flip_x(bool): ** 非必选字段 **, 是否对视频进行上下翻转。Boolean 类型,默认值为 false,表示不翻转。 + - flip_y(bool): ** 非必选字段 **, 是否对视频进行左右翻转。Boolean 类型,默认值为 false,表示不翻转。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"type": type, "source": source, "space_name": space_name, "flip_x": flip_x, "flip_y": flip_y} + if "space_name" not in params: + raise ValueError("flip_video: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("flip_video: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("flip_video: params['space_name'] cannot be empty") + if "source" not in params: + raise ValueError("flip_video: params must contain source") + + formattedSource = _format_source(params.get("type", "vid"), params.get("source", "")) + ParamObj = { + "space_name": params["space_name"], + "source": formattedSource, + "flip_x": params.get("flip_x", False), + "flip_y": params.get("flip_y", False), + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://165221855", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("flip_video: %s" % e, params) + except Exception as e: + raise Exception("flip_video: %s" % e, params) + + @mcp.tool() + def speedup_video(type: str, source: str, space_name: str = None, speed: float = 1.0) -> dict: + """ + Adjust the speed multiplier of the video, of type Float, with a range from 0.1 to 4. + Args: + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - speed(float): ** 非必选字段 **, 调整速度的倍数,Float类型,取值范围为** 0.1~4 **。参考如下: + - 0.1:放慢至原速的 0.1 倍。 + - 1(默认值):原速。 + - 4:加速至原速的 4 倍。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"type": type, "source": source, "space_name": space_name, "speed": speed} + if "space_name" not in params: + raise ValueError("speedup_video: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("speedup_video: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("speedup_video: params['space_name'] cannot be empty") + if "source" not in params: + raise ValueError("speedup_video: params must contain source") + + speedValue = params.get("speed", 1.0) + if speedValue < 0.1 or speedValue > 4: + raise ValueError("speedup_video: speed must be between 0.1 and 4") + + formattedSource = _format_source(params.get("type", "vid"), params.get("source", "")) + ParamObj = { + "space_name": params["space_name"], + "source": formattedSource, + "speed": speedValue, + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://165223469", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("speedup_video: %s" % e, params) + except Exception as e: + raise Exception("speedup_video: %s" % e, params) + @mcp.tool( ) + def speedup_audio(type: str, source: str, space_name: str = None, speed: float = 1.0) -> dict: + """ + Adjust the speed multiplier of the audio, of type Float, with a range from 0.1 to 4. + Args: + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - speed(float): ** 非必选字段 **, 调整速度的倍数,Float类型,取值范围为** 0.1~4 **。参考如下: + - 0.1:放慢至原速的 0.1 倍。 + - 1(默认值):原速。 + - 4:加速至原速的 4 倍。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"type": type, "source": source, "space_name": space_name, "speed": speed} + if "space_name" not in params: + raise ValueError("speedup_video: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("speedup_video: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("speedup_video: params['space_name'] cannot be empty") + if "source" not in params: + raise ValueError("speedup_video: params must contain source") + + speedValue = params.get("speed", 1.0) + if speedValue < 0.1 or speedValue > 4: + raise ValueError("speedup_video: speed must be between 0.1 and 4") + + formattedSource = _format_source(params.get("type", "vid"), params.get("source", "")) + ParamObj = { + "space_name": params["space_name"], + "source": formattedSource, + "speed": speedValue, + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://174663067", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("speedup_video: %s" % e, params) + except Exception as e: + raise Exception("speedup_video: %s" % e, params) + + @mcp.tool() + def image_to_video(images: List[dict], space_name: str = None, transitions: List[str] = None) -> dict: + """The image-to-video conversion function supports non-overlapping transition effects. When the number of videos exceeds the number of transitions by 2 or more, the system will automatically cycle through the transitions. ** Default: No transition ** + Args: + - images(list[dict]): ** 必选字段 **,待合成的图片列表,子类型取值如下 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 图片文件信息 + - duration(float): ** 非必选字段 **, 图片播放时长,`默认值:3,单位:秒,支持 2 位小数`。 + - animation_type(str): ** 非必选字段 **, 图片的动画类型,选填,不填时无动画效果,取值如下: + - move_up:向上移动 + - move_down:向下移动 + - move_left:向左移动 + - move_right:向右移动 + - zoom_in:缩小 + - zoom_out:放大 + - animation_in(float): ** 非必选字段 **, 动画结束时间,支持2位小数。默认为图片展示时长,表示动画随图片播放同时结束,单位:秒。 + - animation_out(float): ** 非必选字段 **, 动画结束时间,支持2位小数。默认为图片展示时长,表示动画随图片播放同时结束,单位:秒。 + - transitions(list[str]): 非必选字段,转场效果 ID:例如 ["1182359"]。concat_videos 工具支持非交叠转场的效果, + - 可用于非交叠转场的动画 + - 分类:交替出场,ID:1182359 + - 分类:旋转放大,ID:1182360 + - 分类:泛开,ID:1182358 + - 分类:六角形,ID:1182365 + - 分类:故障转换,ID:1182367 + - 分类:飞眼,ID:1182368 + - 分类:梦幻放大,ID:1182369 + - 分类:开门展现,ID:1182370 + - 分类:立方转换,ID:1182373 + - 分类:透镜变换,ID:1182374 + - 分类:晚霞转场,ID:1182375 + - 分类:圆形交替,ID:1182378 + - 注意:如果不提供,则没有转场 + - 当视频数量超过转场数量 2 个及以上时,系统将自动循环使用转场。例如有 10 个视频,2 种转场效果,那么在 9 处拼接点上,这 2 种转场效果将被依次循环使用。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"images": images, "space_name": space_name, "transitions": transitions} + if "space_name" not in params: + raise ValueError("image_to_video: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("image_to_video: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("image_to_video: params['space_name'] cannot be empty") + if "images" not in params: + raise ValueError("image_to_video: params must contain images") + if not isinstance(params["images"], list): + raise TypeError("image_to_video: params['images'] must be a list") + if not params["images"]: + raise ValueError("image_to_video: params['images'] must contain at least one image") + + # 格式化图片列表 + formattedImages = [] + for image in params.get("images", []): + if isinstance(image, dict): + imgType = image.get("type", "vid") + imgSource = image.get("source", "") + formattedSource = _format_source(imgType, imgSource) if imgType in ["vid", "directurl"] else imgSource + formattedImage = { + "type": imgType, + "source": formattedSource, + } + if "duration" in image: + formattedImage["duration"] = image["duration"] + if "animation_type" in image: + formattedImage["animation_type"] = image["animation_type"] + if "animation_in" in image: + formattedImage["animation_in"] = image["animation_in"] + if "animation_out" in image: + formattedImage["animation_out"] = image["animation_out"] + formattedImages.append(formattedImage) + elif hasattr(image, 'type') and hasattr(image, 'source'): + # InputSource 对象 + imgType = image.type or "vid" + imgSource = image.source or "" + formattedSource = _format_source(imgType, imgSource) if imgType in ["vid", "directurl"] else imgSource + formattedImage = { + "type": imgType, + "source": formattedSource, + } + formattedImages.append(formattedImage) + else: + formattedImages.append(image) + + ParamObj = { + "space_name": params["space_name"], + "images": formattedImages, + "transitions": params.get("transitions", []), + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://167979998", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("image_to_video: %s" % e, params) + except Exception as e: + raise Exception("image_to_video: %s" % e, params) + + @mcp.tool() + def compile_video_audio(video: dict, audio: dict, space_name: str = None, is_audio_reserve: bool = True, is_video_audio_sync: bool = False, sync_mode: str = "video", sync_method: str = "trim") -> dict: + """ + The compilation of video and audio capabilities require the transmission of both ** audio and video resources ** for processing. + Args: + - video(dict): ** 必选字段 **,视频信息 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - audio(dict): ** 必选字段 **,音频信息 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 音频文件信息 + - is_audio_reserve(bool): ** 非必选字段 **, 是否保留原视频流中的音频,取值参考如下: + - true:保留。默认值; + - false:不保留。 + - is_video_audio_sync(bool): ** 非必选字段 **, 是否对齐音频和视频时长,取值参考如下: + - true:通过 output_sync 配置,对齐音频和视频时长。 + - false(默认值):保持原样输出,不做音视频对齐。最终合成的视频时长,以较长的流为准。 + - sync_mode(str): ** 非必选字段 **, ** 设置 is_video_audio_sync 为 true 时生效 **;当音频和视频时长不相等时,可指定对齐基准,可选项:video、audio。 + - video:【默认值】以视频的时长为准。 + - audio:以音频的时长为准。 + - sync_method(str): ** 非必选字段 **, **设置 is_video_audio_sync 为 true 时生效**;指定对齐方式,支持通过裁剪或加速的方式,对齐音频和视频的时长。可选项:speed、trim。 + - speed:通过加快音频或视频的速度,对齐音频和视频的时长。 + - trim:【默认值】通过裁剪音频或视频,对齐音频和视频的时长。从头开始计算并裁剪。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"video": video, "audio": audio, "space_name": space_name, "is_audio_reserve": is_audio_reserve, "is_video_audio_sync": is_video_audio_sync, "sync_mode": sync_mode, "sync_method": sync_method} + if "space_name" not in params: + raise ValueError("compile_video_audio: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("compile_video_audio: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("compile_video_audio: params['space_name'] cannot be empty") + if "video" not in params: + raise ValueError("compile_video_audio: params must contain video") + if not isinstance(params["video"], dict) and not hasattr(params["video"], 'type'): + raise TypeError("compile_video_audio: params['video'] must be a dict or InputSource object") + if "audio" not in params: + raise ValueError("compile_video_audio: params must contain audio") + if not isinstance(params["audio"], dict) and not hasattr(params["audio"], 'type'): + raise TypeError("compile_video_audio: params['audio'] must be a dict or InputSource object") + + # 格式化视频和音频 + if isinstance(params["video"], dict): + videoType = params["video"].get("type", "vid") + videoSource = params["video"].get("source", "") + else: + videoType = params["video"].type or "vid" + videoSource = params["video"].source or "" + formattedVideoSource = _format_source(videoType, videoSource) if videoType in ["vid", "directurl"] else videoSource + + if isinstance(params["audio"], dict): + audioType = params["audio"].get("type", "vid") + audioSource = params["audio"].get("source", "") + else: + audioType = params["audio"].type or "vid" + audioSource = params["audio"].source or "" + formattedAudioSource = _format_source(audioType, audioSource) if audioType in ["vid", "directurl"] else audioSource + + ParamObj = { + "space_name": params["space_name"], + "video": formattedVideoSource, + "audio": formattedAudioSource, + "is_audio_reserve": params.get("is_audio_reserve", True), + "is_video_audio_sync": params.get("is_video_audio_sync", False), + } + + if params.get("is_video_audio_sync", False): + ParamObj["sync_mode"] = params.get("sync_mode", "video") + ParamObj["sync_method"] = params.get("sync_method", "trim") + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://167984726", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("compile_video_audio: %s" % e, params) + except Exception as e: + raise Exception("compile_video_audio: %s" % e, params) + + @mcp.tool() + def extract_audio(type: str, source: str, space_name: str = None, format: str = "m4a") -> dict: + """"Audio extraction, outputting the audio format. Supports mp3 and m4a formats. Default is m4a. + Args: + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - format(str): ** 非必选字段 **, 输出音频的格式,支持 mp3、m4a 格式。默认 m4a + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"type": type, "source": source, "space_name": space_name, "format": format} + if "space_name" not in params: + raise ValueError("extract_audio: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("extract_audio: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("extract_audio: params['space_name'] cannot be empty") + if "source" not in params: + raise ValueError("extract_audio: params must contain source") + + formatValue = params.get("format", "m4a") + if formatValue not in ["mp3", "m4a"]: + raise ValueError("extract_audio: format must be mp3 or m4a") + + formattedSource = _format_source(params.get("type", "vid"), params.get("source", "")) + ParamObj = { + "space_name": params["space_name"], + "source": formattedSource, + "format": formatValue, + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://167986559", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("extract_audio: %s" % e, params) + except Exception as e: + raise Exception("extract_audio: %s" % e, params) + + @mcp.tool() + def mix_audios(audios: List[dict], space_name: str = None) -> dict: + """Mix audios, 混音 + Args: + - audios(list[dict]): ** 必选字段 **,叠加的音频列表 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 音频文件信息 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"audios": audios, "space_name": space_name} + if "space_name" not in params: + raise ValueError("mix_audios: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("mix_audios: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("mix_audios: params['space_name'] cannot be empty") + if "audios" not in params: + raise ValueError("mix_audios: params must contain audios") + if not isinstance(params["audios"], list): + raise TypeError("mix_audios: params['audios'] must be a list") + if not params["audios"]: + raise ValueError("mix_audios: params['audios'] must contain at least one audio") + + # 格式化音频列表 + formattedAudios = [] + for audio in params.get("audios", []): + if isinstance(audio, dict): + audioType = audio.get("type", "vid") + audioSource = audio.get("source", "") + formattedSource = _format_source(audioType, audioSource) if audioType in ["vid", "directurl"] else audioSource + formattedAudios.append(formattedSource) + elif hasattr(audio, 'type') and hasattr(audio, 'source'): + # InputSource 对象 + audioType = audio.type or "vid" + audioSource = audio.source or "" + formattedSource = _format_source(audioType, audioSource) if audioType in ["vid", "directurl"] else audioSource + formattedAudios.append(formattedSource) + else: + formattedAudios.append(audio) + + ParamObj = { + "space_name": params["space_name"], + "audios": formattedAudios, + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://167987532", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("mix_audios: %s" % e, params) + except Exception as e: + raise Exception("mix_audios: %s" % e, params) + + @mcp.tool() + def add_sub_video(video: dict, sub_video: dict, space_name: str, sub_options: Optional[dict] = None) -> dict: + """ + `水印贴片`, `画中画`,Add the capability of video watermarking, support adjusting the width and height of the watermark, as well as the position in the horizontal or vertical direction, and determine the timing of the watermark's appearance in the original video by setting start_time and end_time,. + Note: + - 如果设置的水印开始时间、结束时间超出原始视频时长,那么输出视频的长度将以水印的结束时间为准,超出原始视频部分将以黑屏形式延续。例如原始视频为 20 秒,设置 end_time 为 30,那么输出时长为 30 秒 + Args: + - video(dict): ** 必选字段 **,视频信息 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - sub_video(dict): ** 必选字段 **, 水印贴片信息 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - sub_options(dict): ** 非必选字段 **,水印贴片叠加选项信息,包含如下字段: + - height(str): 水印的高度,支持设置为百分比(相对于视频高度)或具体像素值,例如 100% 或 100。 + - width(str): 水印的宽度,支持设置为百分比(相对于视频高度)或具体像素值,String 类型,例如 100% 或 100。 + - pos_x(str): 水印在水平方向(X 轴)的位置,以视频左上角为原点,单位:像素。例如值为 0 时,表示水印处于水平方向的最左侧;值为 100 时,表示水印相对原点向右移动 100 像素。 + - pos_y(str): 水印在垂直方向(Y 轴)的位置,以视频左上角为原点,单位:像素,例如值为 0 时,表示水印在垂直方向的最上侧;值为 100 时,表示水印相对原点向下移动 100 像素。 + - start_time(float): 水印的开始时间,单位:秒。 + - end_time(float): 水印的结束时间,单位:秒。 + - space_name(str): ** 必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"video": video, "sub_video": sub_video, "space_name": space_name, "sub_options": sub_options} + if "space_name" not in params: + raise ValueError("add_sub_video: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("add_sub_video: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("add_sub_video: params['space_name'] cannot be empty") + if "video" not in params: + raise ValueError("add_sub_video: params must contain video") + if not isinstance(params["video"], dict) and not hasattr(params["video"], 'type'): + raise TypeError("add_sub_video: params['video'] must be a dict or InputSource object") + if "sub_video" not in params: + raise ValueError("add_sub_video: params must contain sub_video") + if not isinstance(params["sub_video"], dict) and not hasattr(params["sub_video"], 'type'): + raise TypeError("add_sub_video: params['sub_video'] must be a dict or InputSource object") + + # 格式化视频和水印视频 + if isinstance(params["video"], dict): + videoType = params["video"].get("type", "vid") + videoSource = params["video"].get("source", "") + else: + videoType = params["video"].type or "vid" + videoSource = params["video"].source or "" + formattedVideoSource = _format_source(videoType, videoSource) if videoType in ["vid", "directurl"] else videoSource + + if isinstance(params["sub_video"], dict): + subVideoType = params["sub_video"].get("type", "vid") + subVideoSource = params["sub_video"].get("source", "") + else: + subVideoType = params["sub_video"].type or "vid" + subVideoSource = params["sub_video"].source or "" + formattedSubVideoSource = _format_source(subVideoType, subVideoSource) if subVideoType in ["vid", "directurl"] else subVideoSource + + ParamObj = { + "space_name": params["space_name"], + "video": formattedVideoSource, + "sub_video": formattedSubVideoSource, + } + + if params.get("sub_options") and isinstance(params["sub_options"], dict): + + ParamObj["sub_options"] = { + **params["sub_options"], + } + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://168021310", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("add_sub_video: %s" % e, params) + except Exception as e: + raise Exception("add_sub_video: %s" % e, params) + + diff --git a/server/mcp_server_vod/src/vod/mcp_tools/intelligent_matting.py b/server/mcp_server_vod/src/vod/mcp_tools/intelligent_matting.py index b331a7ba..846a762c 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/intelligent_matting.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/intelligent_matting.py @@ -5,29 +5,32 @@ def create_mcp_server(mcp, public_methods: dict): _start_execution = public_methods["_start_execution"] # green screen @mcp.tool() - def green_screen_task(type: str, video: str, spaceName: str, outputFormat: str = "WEBM") -> Any: - """Green Screen (绿幕抠图) is supported, with two input modes available: `Vid` and `DirectUrl`. + def green_screen_task(type: str, video: str, space_name: str = None, output_format: str = "WEBM") -> Any: + """ + Green Screen (绿幕抠图) is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName - - outputFormat: ** 必选字段 **, 输出视频的封装格式,默认 `WEBM`,支持的取值如下: + - output_format ** 必选字段 **, 输出视频的封装格式,默认 `WEBM`,支持的取值如下: - MOV - WEBM Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `greenScreen` """ valid_formats = {"MOV", "WEBM"} - fmt = outputFormat.upper() + fmt = output_format.upper() if fmt not in valid_formats: - raise ValueError(f"outputFormat must be one of {sorted(valid_formats)}, but got {outputFormat}") + raise ValueError(f"output_format must be one of {sorted(valid_formats)}, but got {output_format}") - media_input = _build_media_input(type, video, spaceName) + + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { @@ -47,30 +50,32 @@ def green_screen_task(type: str, video: str, spaceName: str, outputFormat: str = # portrait image retouching @mcp.tool() def portrait_image_retouching_task( - type: str, video: str, spaceName: str, outputFormat: str = "WEBM" + type: str, video: str, space_name: str = None, output_format: str = "WEBM" ) -> Any: - """Portrait image retouching is supported, with two input modes available: `Vid` and `DirectUrl`. + """ + Portrait image retouching is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName - - outputFormat: ** 必选字段 **, 输出视频的封装格式,默认 `WEBM`,支持的取值如下: + - output_format ** 必选字段 **, 输出视频的封装格式,默认 `WEBM`,支持的取值如下: - MOV - WEBM Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `portraitImageRetouching` """ valid_formats = {"MOV", "WEBM"} - fmt = outputFormat.upper() + fmt = output_format.upper() if fmt not in valid_formats: - raise ValueError(f"outputFormat must be one of {sorted(valid_formats)}, but got {outputFormat}") + raise ValueError(f"output_format must be one of {sorted(valid_formats)}, but got {output_format}") - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { diff --git a/server/mcp_server_vod/src/vod/mcp_tools/intelligent_slicing.py b/server/mcp_server_vod/src/vod/mcp_tools/intelligent_slicing.py index 73c30946..446e513d 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/intelligent_slicing.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/intelligent_slicing.py @@ -9,21 +9,22 @@ def create_mcp_server(mcp, public_methods: dict): # intelligent slicing @mcp.tool() - def intelligent_slicing_task(type: str, video: str, spaceName: str) -> Any: - """ Intelligent slicing is supported, with two input modes available: `Vid` and `DirectUrl`. + def intelligent_slicing_task(type: str, video: str, space_name: str = None) -> Any: + """ + Intelligent slicing is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `intelligentSlicing` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { diff --git a/server/mcp_server_vod/src/vod/mcp_tools/media_tasks.py b/server/mcp_server_vod/src/vod/mcp_tools/media_tasks.py index 7287caf3..8401de56 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/media_tasks.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/media_tasks.py @@ -5,10 +5,11 @@ def create_transcode_result_server(mcp, public_methods: dict,): _get_media_execution_task_result = public_methods["_get_media_execution_task_result"] @mcp.tool() - def get_media_execution_task_result(type: str, runId: str) -> Any: - """Obtain the query results of the media processing task, 场景区分, 仅仅支持单任务模式 + def get_media_execution_task_result(type: str, run_id: str) -> Any: + """ + Obtain the query results of the media processing task, 场景区分, 仅仅支持单任务模式 Args: - - runId(str): ** 必选字段 **, 执行 ID。用于唯一指示当前这次媒体处理任务。 + - run_id: ** 必选字段 **, 执行 ID。用于唯一指示当前这次媒体处理任务。 - type(str): ** 必选字段 **, 场景类型 ,取值如下: - portraitImageRetouching:人像抠图 - greenScreen: 绿幕抠图 @@ -64,11 +65,11 @@ def get_media_execution_task_result(type: str, runId: str) -> Any: ] if type not in valid_types: raise ValueError(f"type must be one of {sorted(valid_types)}, but got {type}") - if not runId or not runId.strip(): + if not run_id or not run_id.strip(): raise ValueError("runId must be provided") # query result - response = _get_media_execution_task_result(runId, type) + response = _get_media_execution_task_result(run_id, type) return response diff --git a/server/mcp_server_vod/src/vod/mcp_tools/subtitle_processing.py b/server/mcp_server_vod/src/vod/mcp_tools/subtitle_processing.py index 512aad9e..0133a159 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/subtitle_processing.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/subtitle_processing.py @@ -1,21 +1,26 @@ from typing import Any +from src.vod.mcp_tools.edit import _format_source +import json -def create_mcp_server(mcp, public_methods: dict): + +def create_mcp_server(mcp, public_methods: dict,service): _build_media_input = public_methods["_build_media_input"] _start_execution = public_methods["_start_execution"] @mcp.tool() - def asr_speech_to_text_task(type: str, video: str, spaceName: str, language: str = None) -> Any: - """ASR speech-to-text captioning is supported, with two input modes available: `Vid` and `DirectUrl`. - Note: - - `language`: ** 可选字段 **, 不传会探测, 仅是在 语言较相似的情况下传递 来提高识别效果 - - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 - Args: + def asr_speech_to_text_task(type: str, video: str, space_name: str = None, language: str = None) -> Any: + """ + ASR speech-to-text captioning is supported, with two input modes available: `Vid` and `DirectUrl`. + Note: + - `language`: ** 可选字段 **, 不传会探测, 仅是在 语言较相似的情况下传递 来提高识别效果 + - `Vid`: vid 模式下不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + + Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName - language(str): ** 可选字段 **, 识别提示语言,取值如下: - cmn-Hans-CN: 简体中文 @@ -44,7 +49,7 @@ def asr_speech_to_text_task(type: str, video: str, spaceName: str, language: str Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `asr` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) ask = { "WithSpeakerInfo": True, } @@ -58,21 +63,23 @@ def asr_speech_to_text_task(type: str, video: str, spaceName: str, language: str # OCR @mcp.tool() - def ocr_text_to_subtitles_task(type: str, video: str, spaceName: str) -> Any: - """OCR text to subtitles is supported, with two input modes available: `Vid` and `DirectUrl`. + def ocr_text_to_subtitles_task(type: str, video: str, space_name: str = None) -> Any: + """ + OCR text to subtitles is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `ocr` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": {"Type": "Task", "Task": {"Type": "Ocr", "Ocr": {}}}, @@ -81,21 +88,23 @@ def ocr_text_to_subtitles_task(type: str, video: str, spaceName: str) -> Any: # subtitle removal @mcp.tool() - def video_subtitles_removal_task(type: str, video: str, spaceName: str) -> Any: - """Video subtitles removal is supported, with two input modes available: `Vid` and `DirectUrl`. + def video_subtitles_removal_task(type: str, video: str, space_name: str) -> Any: + """ + Video subtitles removal is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,type 为 `subtitlesRemoval` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { @@ -113,6 +122,117 @@ def video_subtitles_removal_task(type: str, video: str, spaceName: str) -> Any: } return _start_execution(params) + @mcp.tool() + def add_subtitle(video: dict, space_name: str = None, subtitle_url: str = None, text_list: list = None, subtitle_config: dict = None) -> dict: + """ + Add subtitle functionality, supporting both subtitle file (subtitle_url) and subtitle list (text_list) methods. However, subtitle_url and text_list must specify that subtitle_url has a higher priority. + Note: + - subtitle_url 和 text_list 必须指定一个,subtitle_url 优先级更高 + Args: + - video(dict): ** 必选字段 **,视频信息 + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl + - http + - vid + - source(str): ** 必选字段 **, 视频文件信息 + - subtitle_url(str): ** 非必选字段 **, 字幕文件 URL、filename,常见的字幕文件为 SRT、VTT、ASS 等格式。` subtitle_url和text_list必须指定一个subtitle_url优先级更高 ` + - text_list(list[dict]): ** 非必选字段 **, 字幕列表 + - text(str): 输入文本 + - start_time(float): 文本开始时间,单位s + - end_time(float): 文本结束时间,单位s + - subtitle_config(dict): ** 非必选字段 **,字幕配置信息,包含如下字段: + - font_size(int): 字幕的字体大小,Integer 类型,单位:像素。默认200 + - font_type(str): 字幕的字体 ID,String 类型,详情请参考字体 ID。默认 SY_BLACK(思源黑体) + - 字体名称:站酷意大利体,字体id:1187225 + - 字体名称:站酷仓耳渔阳体,字体id:1187223 + - 字体名称:站酷高端黑,字体id:1187221 + - 字体名称:站酷酷黑体,字体id:1187219 + - 字体名称:站酷快乐体,字体id:1187217 + - 字体名称:站酷文艺体,字体id:1187213 + - 字体名称:站酷小薇 LOGO 体,字体id:1187211 + - 字体名称:思源黑体,字体id:SY_Black + - 字体名称:阿里巴巴普惠体,字体id:ALi_PuHui + - 字体名称:庞门正道标题体,字体id:PM_ZhengDao + (注:站酷意大利体标注“不支持中文”,但按要求仅提取分类与ID) + - font_color(str): 字幕的字体颜色,String 类型,RGBA 格式,默认白色 #FFFFFFFF。 + - background_border_width(number): 字幕背景的边框宽度,Number 类型,单位:像素。 + - background_color(str): 字幕的背景颜色,String 类型,RGBA 格式,默认 #00000000。 + - border_color(str): 字幕内容描边的颜色,String 类型,RGBA 格式,默认 #00000000。 + - border_width(int): 字幕内容描边的的宽度,Integer 类型,单位:像素。默认0。 + - font_pos_config(dict): 字幕位置配置 + - width(str): 字幕的宽度,支持设置为百分比(相对于视频宽度)或具体像素值,String 类型,例如 100% 或 1000。 + - height(str): 字幕的高度,支持设置为百分比(相对于视频高度)或具体像素值,String 类型,例如 10% 或 100。 + - pos_x(str): 字幕在水平方向(X 轴)的位置,以视频正上方居中位置为原点,单位:像素,String 类型。例如值为 0 时,表示字幕在水平位置居中;值为 - 100 时,表示字幕向左移动 100 像素;值为 100 时,表示字幕向右移动 100 像素。 + - pos_y(str): 水印在垂直方向(Y 轴)的位置,以视频正上方居中位置为原点,单位:像素,String 类型。例如值为 0 时,表示字幕在视频顶部;值为 100 时,表示字幕向下移动 100 像素。 + - space_name(str): ** 非必选字段 ** , 任务产物的上传空间。AI 处理生成的视频将被上传至此点播空间。 + Returns: + - VCreativeId(str): AI 智剪任务 ID,用于查询任务状态。可以通过调用 `get_v_creative_task_result` 接口查询任务状态。 + - Code(int): 任务状态码。为 0 表示任务执行成功。 + - StatusMessage(str): 接口请求的状态信息。当 StatusCode 为 0 时,此时该字段返回 success,表示成功;其他状态码时,该字段会返回具体的错误信息。 + - StatusCode(int): 接口请求的状态码。0 表示成功,其他值表示不同的错误状态。 + """ + try: + params = {"video": video, "space_name": space_name, "subtitle_url": subtitle_url, "text_list": text_list, "subtitle_config": subtitle_config} + if "space_name" not in params: + raise ValueError("add_subtitle: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("add_subtitle: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("add_subtitle: params['space_name'] cannot be empty") + if "video" not in params: + raise ValueError("add_subtitle: params must contain video") + if not isinstance(params["video"], dict): + raise TypeError("add_subtitle: params['video'] must be a dict") + if not params.get("subtitle_url") and not params.get("text_list"): + raise ValueError("add_subtitle: params must contain either subtitle_url or text_list") + + # 格式化视频 + videoType = params["video"].get("type", "vid") + videoSource = params["video"].get("source", "") + formattedVideoSource = _format_source(videoType, videoSource) if videoType in ["vid", "directurl"] else videoSource + + ParamObj = { + "space_name": params["space_name"], + "video": formattedVideoSource, + } + + if params.get("subtitle_url"): + ParamObj["subtitle_url"] = params["subtitle_url"] + elif params.get("text_list"): + ParamObj["text_list"] = params["text_list"] + + if params.get("subtitle_config"): + # 获取用户提供的 font_pos_config,如果不存在则使用空字典 + # 合并默认配置和用户配置,用户配置优先级更高 + ParamObj["subtitle_config"] = params["subtitle_config"] + + + audioVideoStitchingParams = { + "ParamObj": ParamObj, + "Uploader": params["space_name"], + "WorkflowId": "loki://168214785", + } + + reqs = None + try: + reqs = service.mcp_post("McpAsyncVCreativeTask", {}, json.dumps(audioVideoStitchingParams)) + if isinstance(reqs, str): + reqs = json.loads(reqs) + reqsTmp = reqs.get('Result', {}) + BaseResp = reqsTmp.get("BaseResp", {}) + return json.dumps({ + "VCreativeId": reqsTmp.get("VCreativeId", ""), + "Code": reqsTmp.get("Code"), + "StatusMessage": BaseResp.get("StatusMessage", ""), + "StatusCode": BaseResp.get("StatusCode", 0), + }) + else: + return reqs + except Exception as e: + raise Exception("add_subtitle: %s" % e, params) + except Exception as e: + raise Exception("add_subtitle: %s" % e, params) + diff --git a/server/mcp_server_vod/src/vod/mcp_tools/upload.py b/server/mcp_server_vod/src/vod/mcp_tools/upload.py index 921f99d9..d092c0c9 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/upload.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/upload.py @@ -1,20 +1,23 @@ import json from src.vod.api.api import VodAPI from volcengine.vod.models.request.request_vod_pb2 import VodUrlUploadRequest +# from src.vod.models.request.request_models import BatchUploadUrlItem +from typing import List def create_mcp_server(mcp, public_methods: dict, service: VodAPI,): get_play_url = public_methods['get_play_url'] @mcp.tool() - def video_batch_upload(space_name: str, urls: list[dict[str, any]] = None, ) -> dict: - """ Batch retrieval and upload of URLs upload video to specified space via synchronous upload + def video_batch_upload(space_name: str = None, urls: List[dict] = None, ) -> dict: + """ + Batch retrieval and upload of URLs upload video、 audio to specified space via synchronous upload Note: - 本接口主要适用于文件没有存储在本地服务器或终端,需要通过公网访问的 URL 地址上传的场景。源文件 URL 支持 HTTP 和 HTTPS。 - 本接口为异步上传接口。上传任务成功提交后,系统会生成异步执行的任务,排队执行,不保证时效性。 - SourceUrl 必须是可公网直接访问的文件 URL,而非包含视频的网页 URL。 Args: - - space_name:** 必选字段 ** 空间名称 - - urls(list[dict[str, any]]): ** 必选字段 ** 资源URL列表,每个元素是一个包含URL信息的字典 + - space_name:** 非必选字段 ** 空间名称 + - urls(list[dict[str, str]]): ** 必选字段 ** 资源URL列表,每个元素是一个包含URL信息的字典 - SourceUrl (str):** 必选字段 ** 源文件 URL。 - - FileExtension(str):** 必选字段 ** 文件件后缀,即点播存储中文件的类型 + - FileExtension(str):** 必选字段 ** 文件后缀,即点播存储中文件的类型 - 文件后缀必须以 . 开头,不超过 8 位。 - 当您传入 FileExtension 时,视频点播将生成 32 位随机字符串,和您传入的 FileExtension 共同拼接成文件路径。 Returns: @@ -25,8 +28,8 @@ def video_batch_upload(space_name: str, urls: list[dict[str, any]] = None, ) -> req.SpaceName = space_name for video_info in urls: url_set = req.URLSets.add() - url_set.SourceUrl = video_info['SourceUrl'] - url_set.FileExtension = video_info['FileExtension'] + url_set.SourceUrl = video_info.get('SourceUrl', '') + url_set.FileExtension = video_info.get('FileExtension', '') resp = service.upload_media_by_url(req) except Exception as e: raise Exception(f'video_batch_upload failed, space_name: {space_name}, urls: {urls}, error: {e}') @@ -40,7 +43,8 @@ def video_batch_upload(space_name: str, urls: list[dict[str, any]] = None, ) -> @mcp.tool() def query_batch_upload_task_info(job_ids: str) -> dict: - """ Obtain the query results of media processing tasks Obtain the query results of batch upload tasks + """ + Obtain the query results of media processing tasks, Obtain the query results of batch upload tasks Args: - job_ids(str): ** 必选字段 ** ,每个 URL 对应的任务 ID。查询多个以 , 逗号分隔,最多 ** 20 条 **。 Returns: diff --git a/server/mcp_server_vod/src/vod/mcp_tools/video_enhancement.py b/server/mcp_server_vod/src/vod/mcp_tools/video_enhancement.py index ddcc7159..7eef3ba3 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/video_enhancement.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/video_enhancement.py @@ -6,8 +6,9 @@ def create_mcp_server(mcp, public_methods: dict): """Register all VOD media MCP tools.""" # video quality enhancement @mcp.tool() - def video_quality_enhancement_task(type: str, video: str, spaceName: str) -> Any: - """ Video quality enhancement is supported, with two input modes available: `Vid` and `DirectUrl`.。 + def video_quality_enhancement_task(type: str, video: str, space_name: str = None) -> Any: + """ + Video quality enhancement is supported, with two input modes available: `Vid` and `DirectUrl`. Note: - `Vid`: vid 模式下不需要进行任何处理 - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 @@ -15,13 +16,13 @@ def video_quality_enhancement_task(type: str, video: str, spaceName: str) -> Any - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询, 输入 type 为 `enhanceVideo` """ - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { @@ -46,19 +47,20 @@ def video_quality_enhancement_task(type: str, video: str, spaceName: str) -> Any # video super-resolution @mcp.tool() def video_super_resolution_task( - type: str, video: str, spaceName: str, Res: str = None, ResLimit: int = None + type: str, video: str, space_name: str, Res: str = None, ResLimit: int = None ) -> Any: - """ Video Super-Resolution is supported, with two input modes available: `Vid` and `DirectUrl`. - Note: - - `Res` 和 `ResLimit` ** 不能同时指定,否则会返回错误 **。 - - `Vid`: vid 模式下不需要进行任何处理 - - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 + """ + Video Super-Resolution is supported, with two input modes available: `Vid` and `DirectUrl`. + Note: + - `Res` 和 `ResLimit` ** 不能同时指定,否则会返回错误 **。 + - `Vid`: vid 模式下不需要进行任何处理 + - `DirectUrl`: directurl 模式下需要传递 FileName,不需要进行任何处理 Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - video: ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 必选字段 **, 视频空间名称 - Res(str): ** 必选字段 ** 目标分辨率。支持的取值如下所示。 - 240p - 360p @@ -78,7 +80,7 @@ def video_super_resolution_task( if ResLimit is not None and (not isinstance(ResLimit, int) or ResLimit < 64 or ResLimit > 2160): raise ValueError("ResLimit must be an int in [64, 2160]") - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) target = {} if ResLimit is not None: @@ -114,22 +116,23 @@ def video_super_resolution_task( # video interlacing @mcp.tool() - def video_interlacing_task(type: str, video: str, spaceName: str, Fps: float) -> Any: - """ Video Super-Resolution is supported, with two input modes available: `Vid` and `DirectUrl`. + def video_interlacing_task(type: str, video: str, space_name: str = None, Fps: float = None) -> Any: + """ + Video Super-Resolution is supported, with two input modes available: `Vid` and `DirectUrl`. Args: - type(str):** 必选字段 **,文件类型,默认值为 `Vid` 。字段取值如下 - Vid - DirectUrl - video(str): ** 必选字段 **, 视频文件信息, 当 type 为 `Vid` 时, video 为视频文件 ID;当 type 为 `DirectUrl` 时, video 为 FileName - - spaceName(str): ** 必选字段 **, 视频空间名称 + - space_name(str): ** 非必选字段 **, 视频空间名称 - Fps(Float): 目标帧率,单位为 fps。取值范围为 (0, 120]。 Returns - RunId(str): 媒体处理任务执行 ID, 可通过 `get_media_execution_task_result` 方法进行结果查询,输入 type 为 `videSuperResolution` """ - if not isinstance(Fps, (int, float)) or Fps <= 0 or Fps >= 120: + if not isinstance(Fps, (int, float)) or Fps <= 0 or Fps > 120: raise ValueError("Fps must be > 0 and <= 120") - media_input = _build_media_input(type, video, spaceName) + media_input = _build_media_input(type, video, space_name) params = { "Input": media_input, "Operation": { diff --git a/server/mcp_server_vod/src/vod/mcp_tools/video_play.py b/server/mcp_server_vod/src/vod/mcp_tools/video_play.py index 779f32da..b8c90a74 100644 --- a/server/mcp_server_vod/src/vod/mcp_tools/video_play.py +++ b/server/mcp_server_vod/src/vod/mcp_tools/video_play.py @@ -5,10 +5,14 @@ from typing import Any, Dict from datetime import datetime, timezone, timedelta import hashlib -from urllib.parse import quote +from urllib.parse import quote, urlparse, urlencode, parse_qs, urlunparse import secrets import json import re +import urllib.request +import urllib.error + + def register_video_play_methods(service: VodAPI, public_methods: dict,): def str_to_number(s, default=None): @@ -219,19 +223,198 @@ def get_play_directurl(spaceName: str, fileName: str, expired_minutes: int = 60) return urlPath public_methods["get_play_url"] = get_play_directurl + def get_video_audio_info_directurl(spaceName: str, source: str) -> dict: + """通过 directurl 模式获取音视频元数据 + Args: + spaceName: 空间名称 + source: 文件名 + Returns: + 音视频元数据字典 + """ + # 获取播放地址 + playUrl = get_play_directurl(spaceName, source, 60) + if not playUrl: + raise Exception("get_video_audio_info: failed to get play url") + + # 在链接上拼接 x-vod-process=video/info + parsed = urlparse(playUrl) + query_params = parse_qs(parsed.query) + query_params['x-vod-process'] = ['video/info'] + new_query = urlencode(query_params, doseq=True) + info_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, new_query, parsed.fragment)) + + # 发起 GET 请求 + try: + req = urllib.request.Request(info_url) + with urllib.request.urlopen(req, timeout=30) as response: + result_data = json.loads(response.read().decode('utf-8')) + except urllib.error.URLError as e: + raise Exception(f"get_video_audio_info: failed to fetch video info: {e}") + except json.JSONDecodeError as e: + raise Exception(f"get_video_audio_info: failed to parse JSON response: {e}") + + # 解析结果 + format_info = result_data.get("format", {}) + streams = result_data.get("streams", []) + + # 提取视频流信息 + video_stream = None + audio_stream = None + for stream in streams: + codec_type = stream.get("codec_type", "") + if codec_type == "video" and video_stream is None: + video_stream = stream + elif codec_type == "audio" and audio_stream is None: + audio_stream = stream + + # 构建返回结果 + durationValue = format_info.get("duration") + duration = float(durationValue) if durationValue is not None else 0 + sizeValue = format_info.get("size") + size = float(sizeValue) if sizeValue is not None else 0 + + result = { + "FormatName": format_info.get("format_name", ""), + "Duration": duration, + "Size": size, + "BitRate": format_info.get("bit_rate", ""), + "CodecName": "", + "AvgFrameRate": "", + "Width": 0, + "Height": 0, + "Channels": 0, + "SampleRate": "", + "BitsPerSample": "", + "PlayURL": playUrl, + } + + # 填充视频信息 + if video_stream: + result["CodecName"] = video_stream.get("codec_name", "") + result["AvgFrameRate"] = video_stream.get("avg_frame_rate", "") + result["Width"] = int(video_stream.get("width", 0)) if video_stream.get("width") else 0 + result["Height"] = int(video_stream.get("height", 0)) if video_stream.get("height") else 0 + if not result["BitRate"]: + result["BitRate"] = str(video_stream.get("bit_rate", "")) + + # 填充音频信息 + if audio_stream: + result["Channels"] = int(audio_stream.get("channels", 0)) if audio_stream.get("channels") else 0 + result["SampleRate"] = str(audio_stream.get("sample_rate", "")) + bits_per_sample = audio_stream.get("bits_per_sample") + if bits_per_sample is not None and bits_per_sample != 0: + result["BitsPerSample"] = str(bits_per_sample) + + return result + + public_methods["get_video_audio_info_directurl"] = get_video_audio_info_directurl + def create_mcp_server(mcp: FastMCP, public_methods: dict, service: VodAPI): @mcp.tool() - def get_play_url(spaceName: str, fileName: str, expired_minutes: int = 60) -> str: + def get_play_url( type: str, source: str, space_name: str = None, expired_minutes: int = 60) -> Any: """ - Obtain the video playback link through `fileName` + Obtain the video playback link through `directurl` or `vid`, 通过 directurl or vid 获取视频播放地址, + Note: + expired_minutes 仅在 directurl 模式下生效 Args: - - spaceName: **必选字段** 空间名称 - - fileName: **必选字段** 文件名 - - expired_minutes: **可选字段** 过期时间,默认60分钟 + - space_name: **非必选字段** 空间名称 + - source: **必选字段** 文件名 or vid + - 文件名:直接传入文件名,例如 `test.mp4` + - vid:直接传入 vid + - type: **必选字段** 文件类型,默认值为 `directurl` 。字段取值如下 + - directurl:仅仅支持点播存储 + - vid + - expired_minutes: **可选字段** 过期时间,默认60分钟, vid 模式下不生效 Returns: - 播放地址 """ - return public_methods["get_play_url"](spaceName, fileName, expired_minutes) + print("get_play_urlget_play_urlget_play_urlget_play_url",type, source, space_name, expired_minutes) + if type == "directurl": + return public_methods["get_play_url"](space_name, source, expired_minutes) + elif type == "vid": + videoInfo = public_methods["get_play_video_info"](source, space_name) + if isinstance(videoInfo, str): + videoInfo = json.loads(videoInfo) + return videoInfo.get("PlayURL", "") + + @mcp.tool() + def get_video_audio_info(type: str, source: str, space_name: str = None) -> dict: + """ + Obtaining audio and video metadata, 获取音视频播放信息 + Note: + - ** directurl 模式:仅支持点播存储 ** + - ** vid 模式:通过 get_play_video_info 获取数据 ** + - ** 不支持 http 模式** + Args: + - type(str): ** 必选字段 **,文件类型,默认值为 `vid` 。字段取值如下 + - directurl:仅仅支持点播存储 + - vid + - source(str): 文件信息 + - space_name(str): ** 非必选字段 ** , 点播空间 + Returns: + - FormatName(str): 容器名称。 + - Duration(float): 时长,单位为秒。 + - Size(float): 大小,单位为字节。 + - BitRate(str): 码率,单位为 bps + - CodecName(str): 编码器名称。 + - AvgFrameRate(str): 视频平均帧率,单位为 fps。 + - Width(int): 视频宽,单位为 px。 + - Height(int): 视频高,单位为 px。 + - Channels(int): 音频通道数 + - SampleRate(str): 音频采样率,单位 Hz。 + - BitsPerSample(str): 音频采样码率,单位 bit。 + - PlayURL(str): 播放地址 + """ + + try: + params = {"type": type, "source": source, "space_name": space_name} + if "space_name" not in params: + raise ValueError("get_video_audio_info: params must contain space_name") + if not isinstance(params["space_name"], str): + raise TypeError("get_video_audio_info: params['space_name'] must be a string") + if not params["space_name"].strip(): + raise ValueError("get_video_audio_info: params['space_name'] cannot be empty") + if "source" not in params: + raise ValueError("get_video_audio_info: params must contain source") + + sourceType = params.get("type", "vid") + sourceValue = params.get("source", "") + + if sourceType == "directurl": + # directurl 模式 + result = public_methods["get_video_audio_info_directurl"](params["space_name"], sourceValue) + return json.dumps(result) + elif sourceType == "vid": + # vid 模式 - 直接使用 get_play_video_info 的返回结果 + videoInfo = public_methods["get_play_video_info"](sourceValue, params["space_name"]) + if isinstance(videoInfo, str): + videoInfo = json.loads(videoInfo) + + durationValue = videoInfo.get("Duration") + duration = float(durationValue) if durationValue is not None else 0 + sizeValue = videoInfo.get("Size") + size = float(sizeValue) if sizeValue is not None else 0 + + result = { + "FormatName": videoInfo.get("FormatName", ""), + "Duration": duration, + "Size": size, + "BitRate": videoInfo.get("BitRate", ""), + "CodecName": videoInfo.get("CodecName", ""), + "AvgFrameRate": videoInfo.get("AvgFrameRate", ""), + "Width": int(videoInfo.get("Width", 0)) if videoInfo.get("Width") else 0, + "Height": int(videoInfo.get("Height", 0)) if videoInfo.get("Height") else 0, + "Channels": 0, + "SampleRate": "", + "BitsPerSample": "", + "PlayURL": videoInfo.get("PlayURL", ""), + } + + return json.dumps(result) + else: + raise ValueError(f"get_video_audio_info: unsupported type: {sourceType}") + except Exception as e: + raise Exception("get_video_audio_info-1: %s" % e, params) diff --git a/server/mcp_server_vod/src/vod/server.py b/server/mcp_server_vod/src/vod/server.py index 0443adea..68a4108d 100644 --- a/server/mcp_server_vod/src/vod/server.py +++ b/server/mcp_server_vod/src/vod/server.py @@ -2,6 +2,7 @@ from src.vod.mcp_server import create_mcp_server from mcp.server.fastmcp import FastMCP +from src.base.base_mcp import BaseMCP from dotenv import load_dotenv import os import asyncio @@ -10,6 +11,24 @@ load_dotenv() +mcp = BaseMCP( + name="VOD MCP", + host=os.getenv("MCP_SERVER_HOST", "0.0.0.0"), + port=int(os.getenv("MCP_SERVER_PORT", "8000")), + stateless_http=os.getenv("STATLESS_HTTP", "true").lower() == "true", + streamable_http_path=os.getenv("STREAMABLE_HTTP_PATH", "/mcp"), + instructions=""" + ## VOD MCP is the Volcengine VOD MCP Server. + ### Before using the VOD service, please note: + - `SpaceName` is the name of the VOD space. + - `Vid` is the video ID, 示例:v02399g100***2qpj9aljht4nmunv9ng. + - `DirectUrl` 指定资源的 FileName。示例:test.mp3 + """, + ) + + +def init_mcp () -> FastMCP: + return mcp def main(): try: @@ -21,29 +40,10 @@ def main(): default="stdio", help="Transport protocol to use (streamable-http or stdio)", ) - parser.add_argument("--groups", type=str, help="Comma-separated tool groups") args = parser.parse_args() - print(args.transport) - print(args.groups) - if args.groups: - groups = args.groups.split(',') - else: - groups = None - mcp = FastMCP( - name="VOD MCP", - host=os.getenv("MCP_SERVER_HOST", "0.0.0.0"), - port=int(os.getenv("MCP_SERVER_PORT", "8000")), - stateless_http=os.getenv("STATLESS_HTTP", "true").lower() == "true", - streamable_http_path=os.getenv("STREAMABLE_HTTP_PATH", "/mcp"), - instructions=""" - ## VOD MCP is the Volcengine VOD MCP Server. - ### Before using the VOD service, please note: - - `SpaceName` is the name of the VOD space. - - `Vid` is the video ID, 示例:v02399g100***2qpj9aljht4nmunv9ng. - - `DirectUrl` 指定资源的 FileName。示例:test.mp3 - """, - ) - mcp_instance = create_mcp_server(groups, mcp) + + mcp_instance = create_mcp_server(mcp) + asyncio.run(mcp_instance.run(transport=args.transport)) except Exception as e: print(f"Error occurred while starting the server: {e}", file=sys.stderr)