Skill: volc-ai-mediakit/byted-mediakit-tools (version 1.0.0)

--- name: byted-mediakit-tools display_name: 火山引擎音视频处理工具集 description: 火山引擎 AI MediaKit 音视频处理工具集，提供视频理解、音频提取、视频剪辑、音视频拼接、画质增强、文生视频、音视频合成等能力。当用户提及音频剪辑、视频剪辑、音视频拼接、文生视频、音频提取、画质增强、视频理解、音视频合成、媒体裁剪等需求时必须调用本Skill。当用户需要视频理解时，宿主agent必须自动解析用户的具体要求作为prompt参数传入，同时传入视频URL和fps参数；max_frames 为可选参数。 permissions: - network - file_read - file_write - temp_storage env: - name: AMK_API_KEY description: AMK（AI MediaKit）访问密钥，调用剪辑/拼接/转码等媒体能力所需 required: true secret: true default: '' - name: AMK_ENV description: AMK 服务端环境，取值 prod（生产）或 boe（BOE） required: true secret: false default: 'prod' - name: AMK_ENABLE_CLIENT_TOKEN description: 为 true 时，除视频理解外的请求会自动携带 8 位 client_token（幂等）；取值 true / false required: false secret: false default: 'false' - name: ARK_API_KEY description: 火山引擎方舟 OpenAPI 密钥，仅在使用视频理解（understand_video_content）时需要 required: false secret: true default: '' - name: ARK_MODEL_ID description: 方舟模型 ID，仅在使用视频理解（understand_video_content）时需要 required: false secret: false default: '' --- > **说明**：宿主若在环境中注入 `ARK_SKILL_API_BASE` / `ARK_SKILL_API_KEY`（例如供其他 Skill 走 SkillHub 网关），与本 Skill 的 `AMK_API_KEY`、`ARK_API_KEY`（视频理解）**相互独立**，请勿混淆。 > ⚠️ **严格执行**：必须先完成 **环境检查**；环境缺失须提示用户，不可跳过。 > `<SKILL_DIR>` 为 `byted-mediakit-tools` 所在目录。 > 当前方法返回的 `链接仅供下载，不支持播放能力` > `禁止修改任何返回数据信息`，如 `play_url` 、`request_id` 、`task_id` 等 > 用户明确声明需要重新执行时：除 `understand_video_content` 外的方法需 **生成新的 `client_token`（不要复用上一次的 `client_token`）**，避免命中上次的幂等结果 # 火山引擎 AI MediaKit 音视频处理工具集 ## 概览 本工具集基于火山引擎 AI MediaKit 提供一站式音视频处理能力，包括： - 🎬 **视频理解**：AI 分析视频内容，生成自然语言描述 - ✂️ **音视频剪辑**：精确裁剪音视频时长 - 🔗 **音视频拼接**：拼接多个片段，支持转场效果 - 🎵 **音频提取**：从视频中提取音频轨道 - 🖼️ **画质增强**：提升视频画质、分辨率、帧率 - 🎬 **文生视频**：图片生成视频，支持动画效果 - 🎧 **音视频合成**：合成新的音轨与视频 --- ## 获取密钥 在开始使用前，请先获取必要的 API 密钥： - **AI MediaKit 控制台**：https://console.volcengine.com/imp/ai-mediakit/ - **方舟模型与密钥**：https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-8 --- ## 快速开始 ### 1. 环境配置 在 `<SKILL_DIR>/.env` 中配置环境变量（首次使用会自动创建模板）： ```bash # AMK API Key (必填) - https://console.volcengine.com/imp/ai-mediakit/ AMK_API_KEY=your_amk_api_key_here # AMK 环境取值 prod 或 boe AMK_ENV=prod # 是否启用 client_token 自动注入（用于幂等） AMK_ENABLE_CLIENT_TOKEN=false # 方舟 密钥（可选，仅使用视频理解功能时必须配置） ARK_API_KEY=your_ark_api_key_here # 方舟 模型ID（可选，仅使用视频理解功能时必须配置） ARK_MODEL_ID=doubao-seed-1-8 ``` ### 2. 依赖安装 ```bash cd <SKILL_DIR>/scripts python3 -m venv .venv . .venv/bin/activate python -m pip install -U pip python -m pip install -r requirements.txt ``` --- ## 核心功能 ### 同步能力（立即返回结果） | 能力 | 说明 | | ---------------------------- | ------------------------------------------------ | | **understand_video_content** | 视频内容理解，使用 AI 分析视频并生成自然语言描述 | ### 异步能力（默认自动等待结果） | 能力 | 说明 | | ------------------------- | --------------------------------------- | | **trim_media_duration** | 裁剪音视频时长，精确到毫秒 | | **concat_media_segments** | 拼接多个音视频片段，支持转场效果 | | **extract_audio** | 从视频中提取音频轨道，支持 mp3/m4a 格式 | | **enhance_video** | 视频画质增强，支持超分、插帧等 | | **image_to_video** | 图片生成视频，支持动画和转场 | | **mux_audio_video** | 音视频合成，支持时长对齐 | ### 辅助能力 | 能力 | 说明 | | -------------- | -------------------------- | | **query_task** | 查询异步任务执行状态和结果 | --- ## 使用示例 ### 视频理解 ```bash ./byted-mediakit-tools.sh understand_video_content \ --video_url "https://example.com/video.mp4" \ --prompt "总结视频内容" \ --fps 1 ``` ### 视频裁剪 ```bash # 裁剪前 10 秒 ./byted-mediakit-tools.sh trim_media_duration \ --type video \ --source "https://example.com/video.mp4" \ --start_time 0 \ --end_time 10 ``` ### 音视频拼接 ```bash ./byted-mediakit-tools.sh concat_media_segments \ --type video \ --sources "https://example.com/1.mp4" "https://example.com/2.mp4" ``` ### 音频提取 ```bash ./byted-mediakit-tools.sh extract_audio \ --video_url "https://example.com/video.mp4" \ --format mp3 ``` ### 画质增强 ```bash ./byted-mediakit-tools.sh enhance_video \ --video_url "https://example.com/video.mp4" \ --tool_version professional \ --resolution 1080p ``` ### 图片生成视频 ```bash ./byted-mediakit-tools.sh image_to_video \ --images "image_url=https://example.com/1.jpg,duration=3,animation_type=zoom_in" \ "image_url=https://example.com/2.jpg,duration=3,animation_type=pan_left" ``` ### 音视频合成 ```bash ./byted-mediakit-tools.sh mux_audio_video \ --video_url "https://example.com/video.mp4" \ --audio_url "https://example.com/audio.mp3" \ --is_audio_reserve false ``` ### 异步任务（不等待结果） ```bash # 使用 --no-wait 立即返回 task_id ./byted-mediakit-tools.sh --no-wait trim_media_duration \ --type video \ --source "https://example.com/video.mp4" \ --start_time 0 \ --end_time 10 # 查询任务结果 ./byted-mediakit-tools.sh query_task --task_id "amk-xxx-xxx" ``` --- ## 响应格式 ### 同步响应（视频理解） ```json { "status": "success", "result": { "choices": [ { "role": "assistant", "content": "视频内容分析结果..." } ] } } ``` ### 异步响应（默认自动等待） ```json { "task_id": [REDACTED_SECRET], "duration": 82.454056, "play_url": "https://example.vod.cn-north-1.volcvideo.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.mp3?preview=1&auth_key=***", "request_id": [REDACTED_SECRET], "status": "completed", "task_type": "extract-audio" } ``` ### 异步响应（--no-wait） ```json { "status": "pending", "task_id": "amk-xxx-xxx", "message": "任务已提交，已跳过等待，可调用 query_task 接口传入 task_id 查询结果", "query_example": "./byted-mediakit-tools.sh query_task --task_id amk-xxx-xxx" } ``` ### 错误响应 ```json { "status": "failed/canceled/timeout", "task_id": "amk-xxx-xxx", "message": "错误详情" } ``` --- ## 详细文档 各功能的详细参数说明请参考 `reference/` 目录下的对应文档： | 能力 | 文档链接 | | ------------------------ | ------------------------------------------------------------------------------ | | understand_video_content | [reference/understand_video_content.md](reference/understand_video_content.md) | | query_task | [reference/query_task.md](reference/query_task.md) | | concat_media_segments | [reference/concat_media_segments.md](reference/concat_media_segments.md) | | enhance_video | [reference/enhance_video.md](reference/enhance_video.md) | | extract_audio | [reference/extract_audio.md](reference/extract_audio.md) | | image_to_video | [reference/image_to_video.md](reference/image_to_video.md) | | mux_audio_video | [reference/mux_audio_video.md](reference/mux_audio_video.md) | | trim_media_duration | [reference/trim_media_duration.md](reference/trim_media_duration.md) | | 统一响应格式 | [reference/common_response.md](reference/common_response.md) | --- ## 注意事项 1. **返回链接**：所有返回的 `play_url` 等链接仅供下载，不支持直接播放 2. **幂等性**：重新执行任务时，请确保生成新的 `client_token`（`AMK_ENABLE_CLIENT_TOKEN=true` 时自动处理） 3. **视频理解**：使用视频理解功能必须配置 `ARK_API_KEY` 和 `ARK_MODEL_ID` 4. **超时处理**：大文件处理可能耗时较长，建议使用 `--no-wait` 配合 `query_task` 轮询 --- © 北京火山引擎科技有限公司 2026 版权所有

path: byted-mediakit-tools.sh
content: #!/bin/bash
# AMK音视频处理工具集启动脚本
# 兼容各种执行路径
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
cd "$SCRIPT_DIR/scripts" || exit 1
if [ -f ".venv/bin/activate" ]; then
  source .venv/bin/activate
fi
python main.py "$@"

path: LICENSE.txt
content: MIT No Attribution

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.



path: reference/common_response.md
content: # 统一返回格式

除 `understand_video_content`（视频理解）外，异步能力的 HTTP 结果与 CLI 包装的 `status` / `task_id` 等字段约定如下。

## 视频理解（同步，直接返回模型结果）

```json
{
  "status": "success",
  "result": {
    "choices": [
      {
        "role": "assistant",
        "content": "视频内容分析结果..."
      }
    ]
  }
}
```

## 异步能力 — 成功（默认自动等待结果）

```json
{
  "task_id": "amk-tool-extract-audio-xxxxxxxxxxxxxx",
  "duration": 82.454056,
  "play_url": "https://example.vod.cn-north-1.volcvideo.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.mp3?preview=1&auth_key=***",
  "request_id": "20260401xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "status": "completed",
  "task_type": "extract-audio"
}
```

## 异步能力 — `--no-wait`（仅返回 task_id）

```json
{
  "status": "pending",
  "task_id": "amk-xxx-xxx",
  "message": "任务已提交，已跳过等待，可调用 query_task 接口传入 task_id 查询结果",
  "query_example": "./byted-mediakit-tools.sh query_task --task_id amk-xxx-xxx"
}
```

## 错误 / 超时

```json
{
  "status": "failed/canceled/timeout",
  "task_id": "amk-xxx-xxx",
  "message": "错误详情"
}
```


path: reference/concat_media_segments.md
content: # 音、视频拼接能力

## 功能命名

- `concat_media_segments`

## 作用

- 对音频或视频进行拼接，视频支持转场效果。

## 参数

| 参数名      | 类型   | 必填 | 说明                                                                           |
| ----------- | ------ | ---- | ------------------------------------------------------------------------------ |
| type        | string | ✅   | 拼接类型：`audio` \| `video`                                                   |
| sources     | array  | ✅   | 待拼接资源 URL 列表，支持 `http://` 或 `https://` ， **文件数量不超过 100个 ** |
| transitions | array  | ❌   | 视频转场 ID 列表；**音频拼接不支持**                                           |

### 可选转场 ID（仅视频）

| 分类     | ID      |
| -------- | ------- |
| 交替出场 | 1182359 |
| 旋转放大 | 1182360 |
| 泛开     | 1182358 |
| 六角形   | 1182365 |
| 故障转换 | 1182367 |
| 飞眼     | 1182368 |
| 梦幻放大 | 1182369 |
| 开门展现 | 1182370 |
| 立方转换 | 1182373 |
| 透镜变换 | 1182374 |
| 晚霞转场 | 1182375 |
| 圆形交替 | 1182378 |

- 若不提供转场，默认无转场；当拼接点多于转场数量时，系统会循环使用转场。

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: reference/trim_media_duration.md
content: # 音、视频剪切能力

## 功能命名

- `trim_media_duration`

## 作用

- 按时间范围裁剪音频或视频,`end_time` 必须大于 `start_time`。

## 参数

| 参数名     | 类型   | 必填 | 说明                                              |
| ---------- | ------ | ---- | ------------------------------------------------- |
| type       | string | ✅   | 媒体类型：`audio` \| `video`                      |
| source     | string | ✅   | 待剪切资源 URL，支持 `http://` 或 `https://`      |
| start_time | float  | ❌   | 裁剪开始时间，默认 0，最多 2 位小数，单位秒       |
| end_time   | float  | ❌   | 裁剪结束时间，默认片源结尾，最多 2 位小数，单位秒 |

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: reference/mux_audio_video.md
content: # 视频加音频能力

## 功能命名

- `mux_audio_video`

## 作用

- 将视频和音频进行合成，支持保留原音轨及音视频时长对齐策略。

## 参数

| 参数名              | 类型   | 必填 | 说明                                                         |
| ------------------- | ------ | ---- | ------------------------------------------------------------ |
| video_url           | string | ✅   | 待处理视频 URL，支持 `http://` 或 `https://`                 |
| audio_url           | string | ✅   | 待处理音频 URL，支持 `http://` 或 `https://`                 |
| is_audio_reserve    | bool   | ❌   | 是否保留原视频音频，默认 `true`                              |
| is_video_audio_sync | bool   | ❌   | 是否对齐音视频时长，默认 `false`                            |
| sync_mode           | string | ❌   | 对齐模式：`video` \| `audio`，`is_video_audio_sync=true` 时生效 |
| sync_method         | string | ❌   | 对齐方式：`speed` \| `trim`，`is_video_audio_sync=true` 时生效 |

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: reference/understand_video_content.md
content: # 视频理解能力

## 功能命名

- `understand_video_content`

## 作用

- 使用大模型进行视频理解，生成视频内容解析与自然语言描述。

## 参数

| 参数名     | 类型   | 必填 | 说明                                            |
| ---------- | ------ | ---- | ----------------------------------------------- |
| video_url  | string | ✅   | 视频文件 URL，支持 `http://` 或 `https://`      |
| prompt     | string | ✅   | 用户提示词，描述对视频理解的具体要求            |
| fps        | float  | ✅   | 抽帧帧率，须大于 0，支持整数或浮点数            |
| max_frames | int    | ❌   | 最大抽帧数，须大于 0；不传则由服务端默认策略处理 |

## 环境变量

- ARK_MODEL_ID(str): **必选**，方舟模型 ID，用于请求体 `model` 字段
- 未设置、为空或 `null` 时会抛出配置异常

## 返回数据

- choices(List[message]): 仅返回模型 `choices` 中的 `message` 列表
  - message.role(str): 角色（通常为 `assistant`）
  - message.content(str): 主回答内容
  - message.reasoning_content(str): 推理内容（模型支持时返回）

## 说明

- 方法会在内部自动构造 `chat.completions` 请求体，不需要外部传递 `messages` / `model` / `stream`
- 内部另有默认上限等行为，与网关/模型版本有关；`max_frames` 见上表
- 返回不会包含 `usage` / `id` / `model` 等元信息，仅保留 `choices[].message`


path: reference/extract_audio.md
content: # 音频提取能力

## 功能命名

- `extract_audio`

## 作用

- 从视频中提取音频，支持输出 `mp3` 或 `m4a`， 默认输出格式为**m4a** 。

## 参数

| 参数名    | 类型   | 必填 | 说明                                       |
| --------- | ------ | ---- | ------------------------------------------ |
| video_url | string | ✅   | 输入视频 URL，支持 `http://` 或 `https://` |
| format    | string | ❌   | 输出格式：`mp3` \| `m4a`，默认 `m4a`       |

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: reference/image_to_video.md
content: # 图片转视频能力

## 功能命名

- `image_to_video`

## 作用

- 将图片序列合成为视频，支持单图动画及转场效果。

## 参数

| 参数名      | 类型  | 必填 | 说明                                                                                       |
| ----------- | ----- | ---- | ------------------------------------------------------------------------------------------ |
| images      | array | ✅   | 待合成图片列表；CLI 可用 `image_url=xxx,duration=3,animation_type=zoom_in`，多图空格分隔   |
| transitions | array | ❌   | 视频转场 ID 列表，同 [`concat_media_segments.md`](concat_media_segments.md) 中的转场 ID 表 |

### `images` 单项字段

| 字段           | 类型   | 必填 | 说明                               |
| -------------- | ------ | ---- | ---------------------------------- |
| image_url      | string | ✅   | 图片 URL                           |
| duration       | float  | ❌   | 播放时长，默认 3 秒，最多 2 位小数 |
| animation_type | string | ❌   |

              图片的动画类型，选填，不填时无动画效果。
              move_up：向上移动
              move_down：向下移动
              move_left：向左移动
              move_right：向右移动
              zoom_in：缩小
              zoom_out：放大|

| animation_in | float | ❌ | 动画开始时间，支持2位小数。默认为图片展示时长，表示动画随图片播放同时结束，单位：秒" |
| animation_out | float | ❌ | "动画结束时间，，支持2位小数。默认为图片展示时长，表示动画随图片播放同时结束，单位：秒" |

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: reference/query_task.md
content: # 任务查询能力

## 功能命名

- `query_task`

## 作用

- 查询异步任务状态，支持轮询直到任务完成。

## 参数

| 参数名      | 类型   | 必填 | 说明                    |
| ----------- | ------ | ---- | ----------------------- |
| task_id     | string | ✅   | 任务查询 ID             |
| interval    | int    | ❌   | 轮询间隔，默认 5 秒     |
| max_retries | int    | ❌   | 最大轮询次数，默认 6 次 |

## 返回数据

- duration(float): **非必选**，时长（秒）
- play_url(str): **非必选**，播放地址（根据任务类型解析为音频或视频地址）
- requst_id(str): 日志 id
- status(str): 任务状态（`running` \| `completed` \| `queued` \| `failed` \| `canceled`）
- task_id(str): 任务查询 ID


path: reference/enhance_video.md
content: # 画质增强能力

## 功能命名

- `enhance_video`

## 作用

- 视频画质增强，支持最高到 `4k` 目标分辨率。

## 参数

| 参数名           | 类型   | 必填 | 说明                                                                                                                                 |
| ---------------- | ------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------ |
| video_url        | string | ✅   | 输入视频 URL，支持 `http://` 或 `https://`                                                                                           |
| tool_version     | string | ❌   | 工具版本：`standard`（标准版，默认）\| `professional`（专业版）                                                                      |
| resolution       | string | ❌   | 目标分辨率：`240p` \| `360p` \| `480p` \| `540p` \| `720p` \| `1080p` \| `2k` \| `4k`；未提供则使用原始分辨率（即不传 `resolution`） |
| resolution_limit | int    | ❌   | 目标长宽限制，范围 [64, 2160]                                                                                                        |
| fps              | float  | ❌   | 目标帧率，范围 (0, 120]                                                                                                              |

## 分辨率选择策略（宿主agent）

- 若用户超过 `20s`, 默认选择原始分辨率
- 若用户明确表示“使用原始分辨率/保持原画”，则调用时不传 `resolution`。
- 若用户明确指定目标分辨率（如 `720p`、`2k`、`4k`），则传 `resolution`。
- 若用户未提及分辨率，宿主agent在调用前需要追问：`需要使用原始分辨率还是选择目标分辨率？`；根据回答决定是否传 `resolution`。

## 返回数据

- task_id(str): 任务查询 id
- requst_id(str): 日志 id


path: scripts/requirements.txt
content: annotated-types==0.7.0
anyio==4.13.0
certifi==2026.2.25
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.11
pydantic==2.12.5
pydantic-core==2.41.5
python-dotenv==1.2.2
typing-extensions==4.15.0
typing-inspection==0.4.2


path: scripts/main.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os
import sys
import asyncio
import argparse
from typing import Any
from importlib.util import find_spec

# 环境检查与初始化（最先执行）
def env_check_and_init():
    # 仅查看帮助信息时，不做环境/依赖检查（保证 -h/--help 可用）
    if any(a in ("-h", "--help") for a in sys.argv[1:]):
        return

    # 获取当前脚本所在目录（scripts目录）
    script_dir = os.path.dirname(os.path.abspath(__file__))
    # 项目根目录是scripts的上级目录
    root_dir = os.path.dirname(script_dir)
    env_path = os.path.join(root_dir, ".env")
    
    # 1. 自动创建.env文件（不存在时）
    if not os.path.exists(env_path):
        default_env_content = """# AMK API Key (必填)
AMK_API_KEY=
# AMK 环境取值 prod 或 boe
AMK_ENV=prod
# 是否启用 client_token 自动注入（用于幂等，取值 true/false）
AMK_ENABLE_CLIENT_TOKEN=false
# 方舟 密钥（可选，仅使用视频理解功能时必须配置）
ARK_API_KEY=
# 方舟 模型ID（可选，仅使用视频理解功能时必须配置）
ARK_MODEL_ID=
"""
        with open(env_path, "w", encoding="utf-8") as f:
            f.write(default_env_content)
        print(f"已自动创建.env模板文件到 {env_path}，如需使用视频理解功能请配置ARK_API_KEY和ARK_MODEL_ID", file=sys.stderr)
    
    # 2. 检查依赖安装（避免在依赖缺失时顶层 import 直接失败）
    required_pkgs = ["httpx", "pydantic", "dotenv"]
    pkg_missing = [p for p in required_pkgs if not find_spec(p)]
    if pkg_missing:
        raise RuntimeError(
            "依赖缺失，请在 <SKILL_DIR>/scripts 目录创建虚拟环境并安装："
            "python3 -m venv .venv && . .venv/bin/activate && python -m pip install -U pip && python -m pip install -r requirements.txt"
        )

    # 3. 加载环境变量（优先系统环境变量，覆盖.env配置）
    from dotenv import load_dotenv

    load_dotenv(env_path, override=False)

    # 4. 检查必填环境变量
    required_vars = ["AMK_API_KEY", "AMK_ENV"]
    missing = [v for v in required_vars if not os.getenv(v) or os.getenv(v).strip() == ""]
    if missing:
        raise RuntimeError(f"缺失必填环境变量：{', '.join(missing)}，请在.env文件或ClawAI Skill配置中设置")

    # 5. 校验可选开关变量取值
    client_token_flag = (os.getenv("AMK_ENABLE_CLIENT_TOKEN") or "false").strip().lower()
    if client_token_flag not in ("true", "false"):
        raise RuntimeError("环境变量 AMK_ENABLE_CLIENT_TOKEN 仅支持 true 或 false")
# 立即执行环境检查
try:
    env_check_and_init()
except Exception as e:
    print(f"环境检查失败：{str(e)}", file=sys.stderr)
    sys.exit(1)
# 通用任务轮询函数
async def poll_task_until_complete(client, task_id, timeout=1800):
    """
    轮询任务直到完成或超时
    :param client: AMK客户端实例
    :param task_id: 任务ID
    :param timeout: 超时时间（秒），默认30分钟
    :return: 任务结果
    """
    from amk_client import query_task, normalize_query_task_response
    
    interval = 2
    total_wait = 0
    
    while total_wait < timeout:
        resp = await query_task(task_id=task_id)
        resp.raise_for_status()
        task_info = normalize_query_task_response(resp.json())
        status = task_info.status
        
        if status == "completed":
            return task_info.model_dump()
        if status == "failed":
            return {
                "status": "failed",
                "task_id": task_id,
                "message": "任务执行失败"
            }
        if status == "canceled":
            return {
                "status": "canceled",
                "task_id": task_id,
                "message": "任务已取消"
            }
        
        # 等待下次查询
        await asyncio.sleep(interval)
        total_wait += interval
        interval = min(interval * 1.5, 30)  # 指数退避，最大间隔30秒
    
    # 超时返回
    return {
        "status": "timeout",
        "task_id": task_id,
        "message": f"任务执行超时，可使用任务ID {task_id} 调用 query_task 手动查询结果"
    }
# 功能处理函数
async def handle_understand_video(client, args):
    """处理视频理解（同步返回）"""
    try:
        from httpx import TimeoutException
        from amk_client import AmkAsyncClient
        from amk_client import understand_video_content
        long_timeout_client = AmkAsyncClient(timeout=600.0)  # 10分钟超时
        result = await understand_video_content(
            prompt=args.prompt,
            video_url=args.video_url,
            fps=args.fps,
            max_frames=args.max_frames,
            client=long_timeout_client
        )
        return {
            "status": "success",
            "result": result
        }
    except TimeoutException:
        return {
            "status": "timeout",
            "message": "视频理解任务执行超时，请尝试上传更小的视频文件或稍后重试"
        }
    except Exception as e:
        return {
            "status": "failed",
            "message": f"视频理解失败：{str(e)}（请检查是否已配置ARK_API_KEY和ARK_MODEL_ID）"
        }
async def handle_async_task(client, args, task_name):
    """处理异步任务（默认自动轮询返回结果，可通过--no-wait直接返回task_id）"""
    try:
        import httpx
        from amk_client import (
            extract_audio,
            trim_media_duration,
            concat_media_segments,
            mux_audio_video,
            enhance_video,
            image_to_video,
        )
        
        params = vars(args)
        params.pop('func', None)
        no_wait = params.pop('no_wait', False)
        
        func_map = {
            "extract_audio": extract_audio,
            "trim_media_duration": trim_media_duration,
            "concat_media_segments": concat_media_segments,
            "mux_audio_video": mux_audio_video,
            "enhance_video": enhance_video,
            "image_to_video": image_to_video,
        }
        
        func = func_map[task_name]
        task = await func(params)

        task_id = None
        submit_response: dict[str, Any] | str | None = None

        if isinstance(task, httpx.Response):
            task.raise_for_status()
            try:
                payload = task.json()
                submit_response = payload
            except ValueError:
                payload = {}
                submit_response = task.text
            print("payload:", payload)
            if isinstance(payload, dict):
                task_id = payload.get("task_id") or payload.get("TaskId")
                if task_id is None and isinstance(payload.get("Result"), dict):
                    task_id = payload["Result"].get("task_id") or payload["Result"].get("TaskId")
        elif hasattr(task, "task_id"):
            task_id = getattr(task, "task_id")
            if hasattr(task, "model_dump"):
                submit_response = task.model_dump()
        else:
            submit_response = str(task)

        if not task_id:
            raise ValueError(f"提交成功但未返回有效 task_id，submit_response={submit_response}")
        
        # 如果指定了no_wait，直接返回task_id
        if no_wait:
            return {
                "status": "pending",
                "task_id": task_id,
                "message": "任务已提交，已跳过等待，可调用 query_task 接口传入 task_id 查询结果",
                "query_example": f"./byted-mediakit-tools.sh query_task --task_id {task_id}",
                "submit_response": submit_response,
            }
        
        # 否则自动轮询等待结果
        return await poll_task_until_complete(client, task_id)
    except Exception as e:
        return {
            "status": "failed",
            "message": f"{task_name}任务提交失败：{str(e)}"
        }
async def handle_query_task(client, args):
    """处理任务查询"""
    try:
        from amk_client import query_task, normalize_query_task_response
        params = vars(args)
        params.pop('func', None)
        resp = await query_task(task_id=params.get('task_id'))
        resp.raise_for_status()
        result = normalize_query_task_response(resp.json())
        return result.model_dump()
    except Exception as e:
        return {
            "status": "failed",
            "message": f"任务查询失败：{str(e)}"
        }
# 主函数
def main():
    parser = argparse.ArgumentParser(description="AMK 音视频处理工具集")
    parser.add_argument("--no-wait", action="store_true", help="异步任务不等待结果，直接返回task_id（仅对异步功能生效）")
    
    subparsers = parser.add_subparsers(dest="command", required=True, help="支持的功能，直接使用功能名称调用即可")
    
    # 1. 视频理解（同步）
    parser_uv = subparsers.add_parser("understand_video_content", help="视频内容理解（同步返回结果，需要配置ARK_API_KEY和ARK_MODEL_ID）")
    parser_uv.add_argument("--video_url", required=True, help="【必填】视频文件URL，支持http://或https://")
    parser_uv.add_argument("--prompt", required=True, help="【必填】用户提示词，描述对视频理解的具体要求，由宿主agent自动解析用户诉求生成")
    parser_uv.add_argument("--fps", type=float, required=True, help="【必填】抽帧帧率，须大于0，支持整数或浮点数")
    parser_uv.add_argument("--max_frames", type=int, help="【可选】最大抽帧数，须大于0")
    parser_uv.set_defaults(func=handle_understand_video)
    
    # 2. 任务查询
    parser_qt = subparsers.add_parser("query_task", help="查询异步任务结果")
    parser_qt.add_argument("--task_id", required=True, help="【必填】任务查询ID")
    parser_qt.add_argument("--interval", type=int, help="【可选】轮询间隔，默认5秒")
    parser_qt.add_argument("--max_retries", type=int, help="【可选】最大轮询次数，默认6次")
    parser_qt.set_defaults(func=handle_query_task)
    
    # 3. 异步任务 - 音视频拼接
    parser_cms = subparsers.add_parser("concat_media_segments", help="拼接多个音视频片段，默认自动等待结果")
    parser_cms.add_argument("--type", required=True, choices=["audio", "video"], help="【必填】拼接类型：audio | video")
    parser_cms.add_argument("--sources", nargs="+", required=True, help="【必填】待拼接资源URL列表，支持http://或https://")
    parser_cms.add_argument("--transitions", nargs="+", help="【可选】视频转场ID列表，音频拼接不支持")
    parser_cms.set_defaults(func=lambda c, a: handle_async_task(c, a, "concat_media_segments"))
    
    # 4. 异步任务 - 画质增强
    parser_ev = subparsers.add_parser("enhance_video", help="视频画质增强，默认自动等待结果")
    parser_ev.add_argument("--video_url", required=True, help="【必填】输入视频URL，支持http://或https://")
    parser_ev.add_argument(
        "--tool_version",
        choices=["standard", "professional"],
        default="standard",
        help="【可选】工具版本：standard 标准版 | professional 专业版，默认 standard",
    )
    parser_ev.add_argument(
        "--resolution",
        choices=["240p", "360p", "480p", "540p", "720p", "1080p", "2k", "4k"],
        help="【可选】目标分辨率；不传则使用原始分辨率",
    )
    parser_ev.add_argument("--resolution_limit", type=int, choices=range(64, 2161), help="【可选】目标长宽限制，范围[64, 2160]")
    parser_ev.add_argument("--fps", type=float, help="【可选】目标帧率，范围(0, 120]")
    parser_ev.set_defaults(func=lambda c, a: handle_async_task(c, a, "enhance_video"))
    
    # 5. 异步任务 - 音频提取
    parser_ea = subparsers.add_parser("extract_audio", help="从视频提取音频，默认自动等待结果")
    parser_ea.add_argument("--video_url", required=True, help="【必填】输入视频URL，支持http://或https://")
    parser_ea.add_argument("--format", choices=["mp3", "m4a"], default="m4a", help="【可选】输出格式：mp3 | m4a，默认m4a")
    parser_ea.set_defaults(func=lambda c, a: handle_async_task(c, a, "extract_audio"))
    
    # 6. 异步任务 - 文生视频（图片转视频）
    parser_itv = subparsers.add_parser("image_to_video", help="图片生成视频，默认自动等待结果")
    parser_itv.add_argument("--images", required=True, nargs="+", help="【必填】待合成图片列表，格式：'image_url=xxx,duration=3,animation_type=zoom_in'，多个图片用空格分隔")
    parser_itv.add_argument("--transitions", nargs="+", help="【可选】视频转场ID列表")
    parser_itv.set_defaults(func=lambda c, a: handle_async_task(c, a, "image_to_video"))
    
    # 7. 异步任务 - 音视频合成
    parser_mav = subparsers.add_parser("mux_audio_video", help="音视频合成，默认自动等待结果")
    parser_mav.add_argument("--video_url", required=True, help="【必填】待处理视频URL，支持http://或https://")
    parser_mav.add_argument("--audio_url", required=True, help="【必填】待处理音频URL，支持http://或https://")
    parser_mav.add_argument("--is_audio_reserve", type=lambda x: x.lower() in ('true', '1', 'yes'), default=True, help="【可选】是否保留原视频音频，默认true")
    parser_mav.add_argument("--is_video_audio_sync", type=lambda x: x.lower() in ('true', '1', 'yes'), default=False, help="【可选】是否对齐音视频时长，默认false")
    parser_mav.add_argument("--sync_mode", choices=["video", "audio"], help="【可选】对齐模式：video | audio，is_video_audio_sync=true时生效")
    parser_mav.add_argument("--sync_method", choices=["speed", "trim"], help="【可选】对齐方式：speed | trim，is_video_audio_sync=true时生效")
    parser_mav.set_defaults(func=lambda c, a: handle_async_task(c, a, "mux_audio_video"))
    
    # 8. 异步任务 - 媒体裁剪
    parser_tmd = subparsers.add_parser("trim_media_duration", help="裁剪音视频时长，默认自动等待结果")
    parser_tmd.add_argument("--type", required=True, choices=["audio", "video"], help="【必填】媒体类型：audio | video")
    parser_tmd.add_argument("--source", required=True, help="【必填】待剪切的资源URL，支持http://或https://")
    parser_tmd.add_argument("--start_time", type=float, default=0, help="【可选】裁剪开始时间，默认0，支持两位小数，单位秒")
    parser_tmd.add_argument("--end_time", type=float, help="【可选】裁剪结束时间，默认片源结尾，支持两位小数，单位秒")
    parser_tmd.set_defaults(func=lambda c, a: handle_async_task(c, a, "trim_media_duration"))
    
    args = parser.parse_args()
    
    # 特殊处理image_to_video的images参数，转换为字典列表
    if args.command == "image_to_video":
        images_list = []
        for img_str in args.images:
            img_dict = {}
            for pair in img_str.split(','):
                k, v = pair.split('=', 1)
                k = k.strip()
                v = v.strip()
                if k == 'duration':
                    v = float(v)
                elif k in ('animation_in', 'animation_out'):
                    v = float(v)
                img_dict[k] = v
            images_list.append(img_dict)
        args.images = images_list
    
    # 创建客户端
    from amk_client import AmkClient

    client = AmkClient()
    
    # 执行任务
    result = asyncio.run(args.func(client, args))
    
    # 输出结果
    import json
    print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
    main()

path: scripts/amk_client/config.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import os

from amk_client.auth_mode import AmkAuthMode

HOSTS_BY_AUTH_MODE: dict[AmkAuthMode, dict[str, str]] = {
    AmkAuthMode.MEDIAKIT_ONLY: {
        "prod": "https://amk.cn-beijing.volces.com",
        "boe": "https://amk-stable.cn-beijing.byted.org",
    },
    AmkAuthMode.ARK_AND_MEDIAKIT: {
        "prod": "https://amk-ark.cn-beijing.volces.com",
        "boe": "https://amk-ark.cn-beijing.byted.org",
    },
}

DEFAULT_AMK_ENV = "prod"
DEFAULT_AMK_AUTH_MODE = AmkAuthMode.MEDIAKIT_ONLY

# 控制台入口，供缺失/错误配置时的 reference_url
AMK_CONSOLE_REFERENCE = "https://console.volcengine.com/imp/ai-mediakit/"
ARK_CONSOLE_REFERENCE = (
    "https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-8"
)


class MissingConfigError(ValueError):
    """缺少或非法配置。``var`` 供程序判断；``args[0]`` 为结构化说明，便于人与 Agent 阅读。"""

    var: str

    @classmethod
    def missing_env(
        cls,
        name: str,
        *,
        purpose: str,
        reference_url: str | None = None,
        value_placeholder: str = "<值>",
    ) -> MissingConfigError:
        steps = (
            f"在仓库根目录的 `.env` 增加一行: {name}={value_placeholder}",
            f"或在本终端执行: export {name}={value_placeholder}（再重新运行命令）",
            "若由 Agent 处理：向用户索取真实密钥后，直接编辑仓库根目录 `.env` 写入该键值，"
            f"或在执行命令的 shell 中 `export {name}=<密钥>`，然后重试；不要仅复述本错误而不改配置。",
        )
        lines = [
            f"missing_env_var: {name}",
            f"purpose: {purpose}",
            "fix:",
            *[f"  - {s}" for s in steps],
            "agent_instruction: Obtain the secret from the user, then add `"
            f"{name}=<actual_value>` to repo-root `.env` or export it in the shell, and retry; do not stop after parroting this error.",
        ]
        if reference_url:
            lines.append(f"reference_url: {reference_url}")
        msg = "\n".join(lines)
        err = cls(msg)
        err.var = name
        return err

    @classmethod
    def invalid_env_value(
        cls,
        name: str,
        value: str,
        *,
        allowed: tuple[str, ...],
        purpose: str,
    ) -> MissingConfigError:
        lines = [
            f"invalid_env_var: {name}",
            f"given_value: {value!r}",
            f"allowed: {', '.join(allowed)}",
            f"purpose: {purpose}",
            "fix:",
            "  - 将环境变量设为 allowed 中之一；若未设置该变量，将使用默认 `prod`。",
            f"agent_instruction: Edit repo-root `.env` (or export) so `{name}` is one of: "
            f"{', '.join(allowed)}; or remove `{name}` to use default `prod`, then retry.",
        ]
        msg = "\n".join(lines)
        err = cls(msg)
        err.var = name
        return err


def _read_amk_env(*, auth_mode: AmkAuthMode) -> str:
    raw = os.environ.get("AMK_ENV")
    if raw is None or not raw.strip():
        return DEFAULT_AMK_ENV
    v = raw.strip().lower()
    allowed_envs = HOSTS_BY_AUTH_MODE[auth_mode]
    if v not in allowed_envs:
        raise MissingConfigError.invalid_env_value(
            "AMK_ENV",
            v,
            allowed=tuple(sorted(allowed_envs)),
            purpose="选择 AMK 网关环境：`prod` 为线上，`boe` 为 BOE。",
        )
    return v


def resolve_default_auth_mode() -> AmkAuthMode:
    """从 ``AMK_AUTH_MODE`` 解析 **AmkClient 的默认** 鉴权模式（仅当单次请求未传 ``auth_mode``、且构造时也未指定 ``auth_mode`` 时生效）；未设置环境变量时为 ``mediakit_only``。

    合法取值：``mediakit_only``（或 ``mediakit`` / ``single``）、
    ``ark_and_mediakit``（或 ``ark_mediakit`` / ``dual`` / ``combined``）。
    """
    raw = (os.environ.get("AMK_AUTH_MODE") or "").strip().lower()
    if raw in ("", "mediakit_only", "mediakit", "single"):
        return AmkAuthMode.MEDIAKIT_ONLY
    if raw in ("ark_and_mediakit", "ark_mediakit", "dual", "combined", "ark+mediakit"):
        return AmkAuthMode.ARK_AND_MEDIAKIT
    raise MissingConfigError.invalid_env_value(
        "AMK_AUTH_MODE",
        raw or "(empty)",
        allowed=("mediakit_only", "ark_and_mediakit"),
        purpose="注册 API 时选择的鉴权方式：仅 MediaKit Key，或 方舟 Key + MediaKit Key。",
    )


def build_authorization_header(
    *,
    mediakit_api_key: str,
    ark_api_key: str | None = None,
    mode: AmkAuthMode = DEFAULT_AMK_AUTH_MODE,
) -> str:
    """组装 ``Authorization`` 请求头的完整值（含 ``Bearer `` 前缀）。"""
    if mode == AmkAuthMode.MEDIAKIT_ONLY:
        return f"Bearer {mediakit_api_key}"
    if ark_api_key is None:
        raise ValueError("ark_and_mediakit 模式下必须提供 ark_api_key")
    return f"Bearer {ark_api_key}/{mediakit_api_key}"


def resolve_base_url(
    *,
    auth_mode: AmkAuthMode = DEFAULT_AMK_AUTH_MODE,
    fallback_env: str = DEFAULT_AMK_ENV,
) -> str:
    """Return API base URL for current ``AMK_ENV`` and auth mode.

    若显式设置了非法的 ``AMK_ENV``，抛出 ``MissingConfigError``。
    """
    env = _read_amk_env(auth_mode=auth_mode)
    hosts = HOSTS_BY_AUTH_MODE[auth_mode]
    return hosts.get(env, hosts[fallback_env])


def normalize_secret(value: str | None) -> str | None:
    """非空、非 ``null`` 的字符串视为有效密钥；否则 ``None``。"""
    if value is None:
        return None
    stripped = value.strip()
    if not stripped or stripped.lower() == "null":
        return None
    return stripped


def default_bearer_token() -> str | None:
    """Token from ``AMK_API_KEY`` (empty or ``null`` ignored)."""
    return normalize_secret(os.environ.get("AMK_API_KEY"))


def require_amk_api_key() -> str:
    """返回可用的 MediaKit / AMK ``AMK_API_KEY``；缺失时抛出 ``MissingConfigError``。"""
    key = default_bearer_token()
    if key is None:
        raise MissingConfigError.missing_env(
            "AMK_API_KEY",
            purpose="调用 AMK（MediaKit）HTTP API 时的 Bearer 鉴权。",
            reference_url=AMK_CONSOLE_REFERENCE,
            value_placeholder="<MediaKit API Key>",
        )
    return key


def default_ark_api_key() -> str | None:
    """方舟 ``ARK_API_KEY``；未设置、空或 ``null`` 时返回 ``None``。"""
    return normalize_secret(os.environ.get("ARK_API_KEY"))


def require_ark_api_key() -> str:
    """返回可用的方舟密钥；缺失时抛出 ``MissingConfigError``。"""
    key = default_ark_api_key()
    if key is None:
        raise MissingConfigError.missing_env(
            "ARK_API_KEY",
            purpose="方舟 + MediaKit 双 Key 鉴权时，作为 ``Authorization: Bearer <方舟>/<MediaKit>`` 的前段。",
            reference_url=ARK_CONSOLE_REFERENCE,
            value_placeholder="<Ark API Key>",
        )
    return key


def require_credentials_for_mode(
    mode: AmkAuthMode,
    *,
    mediakit_from_constructor: str | None,
    ark_from_constructor: str | None,
) -> None:
    """按鉴权模式校验必填密钥（构造参数或环境变量）；缺一则抛 ``MissingConfigError``。"""
    mk = (
        normalize_secret(mediakit_from_constructor)
        if mediakit_from_constructor is not None
        else default_bearer_token()
    )
    if mode == AmkAuthMode.MEDIAKIT_ONLY:
        if mediakit_from_constructor is not None and normalize_secret(mediakit_from_constructor) is None:
            raise MissingConfigError.missing_env(
                "AMK_API_KEY",
                purpose="单 MediaKit Key 鉴权，或双 Key 模式下的 MediaKit 段。",
                reference_url=AMK_CONSOLE_REFERENCE,
                value_placeholder="<MediaKit API Key>",
            )
        if mk is None:
            require_amk_api_key()
        return

    # ARK_AND_MEDIAKIT
    if mediakit_from_constructor is not None and normalize_secret(mediakit_from_constructor) is None:
        raise MissingConfigError.missing_env(
            "AMK_API_KEY",
            purpose="双 Key 鉴权：``Authorization: Bearer <方舟 API Key>/<MediaKit API Key>`` 的后段。",
            reference_url=AMK_CONSOLE_REFERENCE,
            value_placeholder="<MediaKit API Key>",
        )
    if mk is None:
        require_amk_api_key()

    ark = (
        normalize_secret(ark_from_constructor)
        if ark_from_constructor is not None
        else default_ark_api_key()
    )
    if ark_from_constructor is not None and normalize_secret(ark_from_constructor) is None:
        raise MissingConfigError.missing_env(
            "ARK_API_KEY",
            purpose="双 Key 鉴权：``Authorization: Bearer <方舟 API Key>/<MediaKit API Key>`` 的前段。",
            reference_url=ARK_CONSOLE_REFERENCE,
            value_placeholder="<Ark API Key>",
        )
    if ark is None:
        require_ark_api_key()


path: scripts/amk_client/models.py
content: """AMK 相关 Pydantic 模型（请求体、响应体、路径参数等，随 API 扩展）。"""

#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from typing import Any, Literal

from pydantic import (
    AliasChoices,
    BaseModel,
    ConfigDict,
    Field,
    ValidationInfo,
    field_validator,
)

TrimMediaKind = Literal["audio", "video"]


class TrimMediaDurationRequest(BaseModel):
    """``POST /api/v1/tools/trim_media_duration`` 请求体。

    Args:
        type (str): **必选字段**，裁剪类型。``audio`` | ``video``。
        source (str): **必选字段**，待剪切的资源 URL，支持 ``http://`` 或 ``https://`` 格式。
        start_time (int | float): **非必选字段**，裁剪开始时间，默认为 ``0``，表示从头开始裁剪；
            最多保留 2 位小数，单位：秒。
        end_time (int | float | None): **非必选字段**，裁剪结束时间，默认为片源结尾；
            最多保留 2 位小数，单位：秒；省略或 ``null`` 表示至片尾。
    """

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    type: TrimMediaKind = Field(
        ...,
        description="裁剪类型（与调用侧聚合节点一致）：audio | video",
    )
    source: str = Field(..., min_length=1, description="待裁剪资源 URL")
    start_time: float = Field(default=0.0, ge=0, description="裁剪开始时间（秒），默认从头")
    end_time: float | None = Field(
        default=None,
        ge=0,
        description="裁剪结束时间（秒），省略表示至片源结尾",
    )

    @field_validator("type", mode="before")
    @classmethod
    def type_must_be_audio_or_video_str(cls, v: Any) -> Any:
        if v is None:
            raise ValueError('type 不能为空；须为字符串 "audio" 或 "video"')
        if not isinstance(v, str):
            raise ValueError(
                f'type 须为字符串类型（取值 "audio" | "video"），当前为 {type(v).__name__}'
            )
        return v

    @field_validator("source", mode="before")
    @classmethod
    def source_must_be_str(cls, v: Any) -> Any:
        if v is None:
            raise ValueError(
                "source 不能为空；须为字符串类型，且为 http:// 或 https:// 开头的资源 URL"
            )
        if not isinstance(v, str):
            raise ValueError(
                f"source 须为字符串类型（资源 URL），当前为 {type(v).__name__}；"
                "若为多段资源请使用对应接口的数组字段，勿把列表误传到 source"
            )
        return v

    @field_validator("source")
    @classmethod
    def source_must_be_http(cls, v: str) -> str:
        s = v.strip()
        if not (s.startswith("http://") or s.startswith("https://")):
            raise ValueError("source 须为 http:// 或 https:// 开头的 URL 字符串")
        return s

    @field_validator("start_time", "end_time", mode="before")
    @classmethod
    def time_fields_must_be_number_or_omit(cls, v: Any, info: ValidationInfo) -> Any:
        field = info.field_name
        if v is None:
            if field == "end_time":
                return None
            raise ValueError(
                "start_time 不能为空（勿传 null）；须为数字类型（秒，≥0），"
                "若从片头开始请省略该字段或传 0"
            )
        if not isinstance(v, (int, float)):
            raise ValueError(
                f"{field} 须为数字类型（秒），当前为 {type(v).__name__}"
            )
        return v

    @field_validator("start_time", "end_time")
    @classmethod
    def at_most_two_decimal_places(cls, v: float | None) -> float | None:
        if v is None:
            return v
        text = f"{v:.4f}".rstrip("0").rstrip(".")
        if "." in text:
            _, frac = text.split(".", 1)
            if len(frac) > 2:
                raise ValueError("时间最多保留 2 位小数（单位：秒）")
        return v

    def to_api_json(self) -> dict[str, Any]:
        """发给网关的 JSON：省略 ``end_time`` 表示默认到片尾。"""
        return self.model_dump(mode="json", by_alias=True, exclude_none=True)


class TrimMediaDurationResponse(BaseModel):
    """``trim_media_duration`` 返回中的任务信息。

    Returns:
        task_id: 任务查询 id。
        request_id: 日志 id（兼容网关字段名 ``requst_id`` 拼写）。
    """

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    task_id: str = Field(..., description="任务查询 id")
    request_id: str = Field(
        ...,
        validation_alias=AliasChoices("request_id", "requst_id"),
        description="日志 id",
    )

    @field_validator("task_id", "request_id", mode="before")
    @classmethod
    def response_string_ids(cls, v: Any, info: ValidationInfo) -> Any:
        name = info.field_name
        if v is None:
            raise ValueError(f"{name} 不能为空；解析响应时期望非空字符串")
        if not isinstance(v, str):
            raise ValueError(f"{name} 须为字符串类型，当前为 {type(v).__name__}")
        return v


class TrimMediaDurationAsyncResult(BaseModel):
    """调用节点聚合接口的异步返回结构。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    task_id: str | None = Field(default=None, description="任务查询 id")
    request_id: str | None = Field(default=None, description="日志 id")
    error: str | None = Field(default=None, description="报错信息")


ConcatMediaKind = Literal["audio", "video"]
TransitionId = Literal[
    "1182359",
    "1182360",
    "1182358",
    "1182365",
    "1182367",
    "1182368",
    "1182369",
    "1182370",
    "1182373",
    "1182374",
    "1182375",
    "1182378",
]


class ConcatMediaSegmentsRequest(BaseModel):
    """调用层聚合接口 ``concat_media_segments`` 请求体。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    type: ConcatMediaKind = Field(
        ...,
        description="必选，拼接类型：audio | video",
    )
    sources: list[str] = Field(
        ...,
        min_length=1,
        max_length=100,
        description="必选，待拼接资源 URL 列表（http:// 或 https://）",
    )
    transitions: list[TransitionId] | None = Field(
        default=None,
        description="可选，转场效果 ID 列表；音频拼接不支持",
    )

    @field_validator("sources", mode="before")
    @classmethod
    def sources_must_be_string_list(cls, v: Any) -> Any:
        if not isinstance(v, list):
            raise ValueError("sources 须为字符串 URL 列表")
        if not v:
            raise ValueError("sources 不能为空；至少提供 1 个资源 URL")
        return v

    @field_validator("sources")
    @classmethod
    def sources_each_must_be_http_url(cls, values: list[str]) -> list[str]:
        for idx, item in enumerate(values):
            if not isinstance(item, str):
                raise ValueError(f"sources[{idx}] 须为字符串 URL")
            s = item.strip()
            if not (s.startswith("http://") or s.startswith("https://")):
                raise ValueError(f"sources[{idx}] 须为 http:// 或 https:// 开头的 URL")
        return values

    @field_validator("transitions", mode="before")
    @classmethod
    def transitions_must_be_string_list_or_none(cls, v: Any) -> Any:
        if v is None:
            return None
        if not isinstance(v, list):
            raise ValueError("transitions 须为字符串列表或省略")
        for idx, item in enumerate(v):
            if not isinstance(item, str):
                raise ValueError(f"transitions[{idx}] 须为字符串转场 ID")
        return v

    @field_validator("transitions")
    @classmethod
    def audio_should_not_pass_transitions(
        cls, v: list[str] | None, info: ValidationInfo
    ) -> list[str] | None:
        media_type = info.data.get("type")
        if media_type == "audio" and v:
            raise ValueError("音频拼接不支持 transitions，请不要传该字段")
        return v

    def to_api_json(self) -> dict[str, Any]:
        """发给底层 API 的 JSON；不必要字段自动省略。"""
        return self.model_dump(mode="json", by_alias=True, exclude_none=True)


class ConcatMediaSegmentsResponse(BaseModel):
    """``concat_media_segments`` 聚合后的任务返回。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    task_id: str = Field(..., description="任务查询 id")
    request_id: str = Field(
        ...,
        validation_alias=AliasChoices("request_id", "requst_id"),
        description="日志 id",
    )


class ConcatMediaSegmentsAsyncResult(BaseModel):
    """调用层聚合方法的异步返回结构。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    task_id: str | None = Field(default=None, description="任务查询 id")
    request_id: str | None = Field(default=None, description="日志 id")
    error: str | None = Field(default=None, description="报错信息")


class MuxAudioVideoRequest(BaseModel):
    """``mux_audio_video`` 请求体。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    video_url: str = Field(..., description="输入视频 URL")
    audio_url: str = Field(..., description="输入音频 URL")
    is_audio_reserve: bool = Field(default=True, description="是否保留原视频音频")
    is_video_audio_sync: bool = Field(default=False, description="是否对齐音视频时长")
    sync_mode: Literal["video", "audio"] = Field(default="video", description="对齐基准")
    sync_method: Literal["speed", "trim"] = Field(default="trim", description="对齐方式")


class ImageToVideoImageItem(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    image_url: str = Field(..., description="图片 URL")
    duration: float | None = Field(default=3.0, ge=0, description="播放时长（秒）")
    animation_type: (
        Literal["move_up", "move_down", "move_left", "move_right", "zoom_in", "zoom_out"] | None
    ) = Field(default=None, description="动画类型")
    animation_in: float | None = Field(default=None, ge=0, description="动画开始时间（秒）")
    animation_out: float | None = Field(default=None, ge=0, description="动画结束时间（秒）")


class ImageToVideoRequest(BaseModel):
    """``image_to_video`` 请求体。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    images: list[ImageToVideoImageItem] = Field(..., min_length=1, max_length=100)
    transitions: list[TransitionId] | None = Field(default=None, description="可选转场效果 ID")


class ExtractAudioRequest(BaseModel):
    """``extract_audio`` 请求体。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    video_url: str = Field(..., description="输入视频 URL")
    format: Literal["mp3", "m4a"] = Field(default="m4a", description="输出音频格式")


class EnhanceVideoRequest(BaseModel):
    """``enhance_video`` 请求体。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    video_url: str = Field(..., description="输入视频 URL")
    tool_version: Literal["standard", "professional"] = Field(
        default="standard",
        description="工具版本：standard 标准版，professional 专业版",
    )
    resolution: Literal["240p", "360p", "480p", "540p", "720p", "1080p", "2k", "4k"] | None = (
        Field(default=None, description="目标分辨率；不传/不填写则使用原始分辨率（后端按原分辨率处理）")
    )
    resolution_limit: int | None = Field(default=None, ge=64, le=2160, description="目标长宽限制")
    fps: float | None = Field(default=None, gt=0, le=120, description="目标帧率")


TaskStatusStr = Literal["running", "completed", "queued", "failed", "canceled"]


class QueryTaskNormalizedResult(BaseModel):
    """任务查询的标准化返回结构。"""

    model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)

    task_id: str | None = Field(default=None, description="任务查询 ID")
    duration: float | None = Field(default=None, description="时长（秒）")
    play_url: str | None = Field(default=None, description="播放地址")
    request_id: str | None = Field(default=None, description="日志 id")
    status: TaskStatusStr = Field(..., description="任务状态")
    task_type: str | None = Field(default=None, description="任务类型")


path: scripts/amk_client/client.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from typing import Any

import httpx

from amk_client.auth_mode import AmkAuthMode
from amk_client.config import (
    build_authorization_header,
    default_ark_api_key,
    default_bearer_token,
    normalize_secret,
    require_credentials_for_mode,
    resolve_base_url,
    resolve_default_auth_mode,
)


def _load_dotenv() -> None:
    try:
        from dotenv import find_dotenv, load_dotenv

        path = find_dotenv(usecwd=True)
        if path:
            load_dotenv(path)
    except ImportError:
        pass


class AmkClient:
    """
    Sync HTTP client for AMK APIs.

    **鉴权模式按「单次请求」指定**（与网关上各 API 注册方式一致），在
    ``request`` / ``get`` / ``post`` / ``put`` / ``delete`` / ``stream`` /
    ``iter_*`` 上传入 ``auth_mode=...``。

    - ``AmkAuthMode.MEDIAKIT_ONLY``：``Authorization: Bearer <MediaKit API Key>``
    - ``AmkAuthMode.ARK_AND_MEDIAKIT``：
      ``Authorization: Bearer <火山方舟 API Key>/<MediaKit API Key>``

    若某次调用**未传** ``auth_mode``，则使用构造参数 ``auth_mode``（默认模式）；
    构造时也未指定时，再读环境变量 ``AMK_AUTH_MODE``。

    密钥来自 ``AMK_API_KEY`` / ``ARK_API_KEY`` 或构造参数 ``api_key`` / ``ark_api_key``。

    - Base URL 默认由 ``AMK_ENV`` 与当次 ``auth_mode`` 共同决定（同一环境下不同鉴权模式可命中不同 host 组）；也可用构造参数 ``base_url`` 覆盖。
    - ``require_api_key=True``（默认）时，**每次请求**按当次 ``auth_mode`` 校验所需密钥。
    - ``require_api_key=False`` 时不校验；有密钥则仍按当次模式组 ``Authorization``。
    - 单次请求可传 ``headers`` 覆盖 ``Authorization``。
    """

    def __init__(
        self,
        *,
        auth_mode: AmkAuthMode | None = None,
        base_url: str | None = None,
        api_key: str | None = None,
        ark_api_key: str | None = None,
        require_api_key: bool = True,
        timeout: float | httpx.Timeout | None = 30.0,
        extra_headers: Mapping[str, str] | None = None,
    ) -> None:
        _load_dotenv()
        # 仅当具体请求未传 auth_mode 时使用（非「全局业务配置」，而是默认值）
        self._default_auth_mode = auth_mode if auth_mode is not None else resolve_default_auth_mode()
        self._base_override = base_url.rstrip("/") if base_url else None
        self._mediakit_explicit = api_key
        self._ark_explicit = ark_api_key
        self._require_api_key = require_api_key
        self._timeout = timeout
        self._extra_headers = dict(extra_headers) if extra_headers else {}

    def _mediakit_resolved(self) -> str | None:
        if self._mediakit_explicit is not None:
            return normalize_secret(self._mediakit_explicit)
        return default_bearer_token()

    def _ark_resolved(self) -> str | None:
        if self._ark_explicit is not None:
            return normalize_secret(self._ark_explicit)
        return default_ark_api_key()

    def _authorization_for_mode(self, mode: AmkAuthMode) -> str | None:
        mk = self._mediakit_resolved()
        if mode == AmkAuthMode.MEDIAKIT_ONLY:
            if not mk:
                return None
            return build_authorization_header(mediakit_api_key=mk, mode=AmkAuthMode.MEDIAKIT_ONLY)
        ark = self._ark_resolved()
        if not mk or not ark:
            return None
        return build_authorization_header(
            mediakit_api_key=mk,
            ark_api_key=ark,
            mode=AmkAuthMode.ARK_AND_MEDIAKIT,
        )

    def _effective_auth_mode(self, auth_mode: AmkAuthMode | None) -> AmkAuthMode:
        return auth_mode if auth_mode is not None else self._default_auth_mode

    def _merge_headers(
        self,
        call_headers: Mapping[str, str] | None,
        *,
        auth_mode: AmkAuthMode | None = None,
    ) -> dict[str, str]:
        mode = self._effective_auth_mode(auth_mode)
        if self._require_api_key:
            require_credentials_for_mode(
                mode,
                mediakit_from_constructor=self._mediakit_explicit,
                ark_from_constructor=self._ark_explicit,
            )
        merged: dict[str, str] = {**self._extra_headers}
        auth = self._authorization_for_mode(mode)
        if auth:
            merged.setdefault("Authorization", auth)
        # 默认使用 JSON 请求体；调用方可在单次请求 headers 中覆盖
        merged.setdefault("Content-Type", "application/json")
        if call_headers:
            merged.update(call_headers)
        return merged

    def _url(self, path: str, *, auth_mode: AmkAuthMode | None = None) -> str:
        p = path if path.startswith("/") else f"/{path}"
        mode = self._effective_auth_mode(auth_mode)
        base = self._base_override or resolve_base_url(auth_mode=mode, fallback_env="prod")
        return f"{base}{p}"

    def _client(self) -> httpx.Client:
        return httpx.Client(timeout=self._timeout)

    def request(
        self,
        method: str,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        with self._client() as client:
            return client.request(
                method.upper(),
                self._url(path, auth_mode=auth_mode),
                headers=self._merge_headers(headers, auth_mode=auth_mode),
                **kwargs,
            )

    def get(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return self.request("GET", path, auth_mode=auth_mode, headers=headers, **kwargs)

    def post(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return self.request("POST", path, auth_mode=auth_mode, headers=headers, **kwargs)

    def put(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return self.request("PUT", path, auth_mode=auth_mode, headers=headers, **kwargs)

    def delete(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return self.request("DELETE", path, auth_mode=auth_mode, headers=headers, **kwargs)

    @contextmanager
    def stream(
        self,
        method: str,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> Iterator[httpx.Response]:
        """Stream response body (SSE/chunked). Caller should read ``iter_bytes`` / ``iter_lines``."""
        with self._client() as client:
            with client.stream(
                method.upper(),
                self._url(path, auth_mode=auth_mode),
                headers=self._merge_headers(headers, auth_mode=auth_mode),
                **kwargs,
            ) as response:
                yield response

    def iter_bytes(
        self,
        method: str,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        chunk_size: int | None = None,
        **kwargs: Any,
    ) -> Iterator[bytes]:
        """Convenience: yield byte chunks from a streaming request."""
        with self.stream(method, path, auth_mode=auth_mode, headers=headers, **kwargs) as resp:
            yield from resp.iter_bytes(chunk_size=chunk_size)

    def iter_lines(
        self,
        method: str,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> Iterator[str]:
        """Convenience: yield text lines (e.g. SSE) from a streaming request."""
        with self.stream(method, path, auth_mode=auth_mode, headers=headers, **kwargs) as resp:
            yield from resp.iter_lines()


class AmkAsyncClient(AmkClient):
    """Async HTTP client for AMK APIs."""

    def _async_client(self) -> httpx.AsyncClient:
        return httpx.AsyncClient(timeout=self._timeout)

    async def request(
        self,
        method: str,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        async with self._async_client() as client:
            return await client.request(
                method.upper(),
                self._url(path, auth_mode=auth_mode),
                headers=self._merge_headers(headers, auth_mode=auth_mode),
                **kwargs,
            )

    async def get(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return await self.request("GET", path, auth_mode=auth_mode, headers=headers, **kwargs)

    async def post(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return await self.request("POST", path, auth_mode=auth_mode, headers=headers, **kwargs)

    async def put(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return await self.request("PUT", path, auth_mode=auth_mode, headers=headers, **kwargs)

    async def delete(
        self,
        path: str,
        *,
        auth_mode: AmkAuthMode | None = None,
        headers: Mapping[str, str] | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        return await self.request("DELETE", path, auth_mode=auth_mode, headers=headers, **kwargs)


path: scripts/amk_client/api_path_params.py
content: """各 API 路径占位符对应的 Pydantic 校验模型（与 ``api_config`` 中 ``action`` 的 ``{name}`` 一致）。"""

#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field


class QueryTaskPathParams(BaseModel):
    """``GET /api/v1/tasks/{task_id}``"""

    model_config = ConfigDict(str_strip_whitespace=True)

    task_id: str = Field(..., min_length=1, description="任务 ID")


path: scripts/amk_client/__init__.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""AMK HTTP client: GET/POST/PUT/DELETE, streaming, AMK_ENV host selection."""

from amk_client.api_config import (
    PATH_PARAM_MODELS,
    ApiEntry,
    api_config,
    get_api_entry,
    path_placeholder_names,
    validate_and_format_path,
)
from amk_client.api_path_params import QueryTaskPathParams
from amk_client.api_request import (
    ApiRequest,
    AsyncApiRequest,
    concat_audio,
    concat_media_segments,
    concat_video,
    enhance_video,
    extract_audio,
    image_to_video,
    mux_audio_video,
    normalize_query_task_response,
    query_task,
    query_task_with_polling,
    trim_audio,
    trim_media_duration,
    trim_video,
    understand_video_content,
)
from amk_client.auth_mode import AmkAuthMode
from amk_client.models import (
    ConcatMediaKind,
    ConcatMediaSegmentsAsyncResult,
    ConcatMediaSegmentsRequest,
    ConcatMediaSegmentsResponse,
    EnhanceVideoRequest,
    ExtractAudioRequest,
    ImageToVideoImageItem,
    ImageToVideoRequest,
    MuxAudioVideoRequest,
    QueryTaskNormalizedResult,
    TaskStatusStr,
    TransitionId,
    TrimMediaDurationAsyncResult,
    TrimMediaDurationRequest,
    TrimMediaDurationResponse,
    TrimMediaKind,
)
from amk_client.client import AmkAsyncClient, AmkClient
from amk_client.config import (
    MissingConfigError,
    build_authorization_header,
    default_ark_api_key,
    default_bearer_token,
    normalize_secret,
    require_amk_api_key,
    require_ark_api_key,
    require_credentials_for_mode,
    resolve_base_url,
    resolve_default_auth_mode,
)

__all__ = [
    "AmkAuthMode",
    "AmkAsyncClient",
    "AmkClient",
    "ApiEntry",
    "AsyncApiRequest",
    "ApiRequest",
    "ConcatMediaKind",
    "ConcatMediaSegmentsAsyncResult",
    "concat_audio",
    "concat_media_segments",
    "concat_video",
    "ConcatMediaSegmentsRequest",
    "ConcatMediaSegmentsResponse",
    "EnhanceVideoRequest",
    "enhance_video",
    "ExtractAudioRequest",
    "ImageToVideoImageItem",
    "ImageToVideoRequest",
    "MuxAudioVideoRequest",
    "mux_audio_video",
    "normalize_query_task_response",
    "query_task",
    "query_task_with_polling",
    "QueryTaskNormalizedResult",
    "TaskStatusStr",
    "MissingConfigError",
    "PATH_PARAM_MODELS",
    "QueryTaskPathParams",
    "TrimMediaDurationAsyncResult",
    "TrimMediaDurationRequest",
    "TrimMediaDurationResponse",
    "trim_audio",
    "trim_media_duration",
    "trim_video",
    "TransitionId",
    "understand_video_content",
    "image_to_video",
    "extract_audio",
    "TrimMediaKind",
    "api_config",
    "build_authorization_header",
    "default_ark_api_key",
    "default_bearer_token",
    "get_api_entry",
    "normalize_secret",
    "path_placeholder_names",
    "require_amk_api_key",
    "require_ark_api_key",
    "require_credentials_for_mode",
    "resolve_base_url",
    "resolve_default_auth_mode",
    "validate_and_format_path",
]


path: scripts/amk_client/api_request.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""按 ``api_config`` 发起请求；路径参数经 Pydantic 校验。"""

from __future__ import annotations

import asyncio
import json
import os
import uuid
from typing import Any, Mapping

import httpx
from pydantic import BaseModel

from amk_client.api_config import get_api_entry, validate_and_format_path
from amk_client.client import AmkAsyncClient, AmkClient
from amk_client.config import MissingConfigError, normalize_secret
from amk_client.models import (
    ConcatMediaSegmentsAsyncResult,
    ConcatMediaSegmentsRequest,
    ConcatMediaSegmentsResponse,
    EnhanceVideoRequest,
    ExtractAudioRequest,
    ImageToVideoRequest,
    MuxAudioVideoRequest,
    QueryTaskNormalizedResult,
    TaskStatusStr,
    TrimMediaDurationAsyncResult,
    TrimMediaDurationRequest,
    TrimMediaDurationResponse,
)


class ApiRequest:
    """封装单个注册 API：路径参数校验 + 按配置的 method/auth_mode 调用 ``AmkClient``。"""

    def __init__(self, api_name: str, *, client: AmkClient | None = None) -> None:
        self.api_name = api_name
        self.entry = get_api_entry(api_name)
        self.client = client if client is not None else AmkClient()

    def build_path(
        self,
        path_params: Mapping[str, Any] | BaseModel | None = None,
    ) -> str:
        return validate_and_format_path(self.api_name, self.entry, path_params)

    def request(
        self,
        *,
        path_params: Mapping[str, Any] | BaseModel | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        path = self.build_path(path_params)
        method = self.entry.method.upper()
        auth_mode = self.entry.auth_mode
        force_new_client_token = bool(kwargs.pop("force_new_client_token", False))
        kwargs = _inject_client_token_if_needed(
            self.api_name, kwargs, force_new_client_token=force_new_client_token
        )
        if method in ("GET", "POST", "PUT", "DELETE"):
            return getattr(self.client, method.lower())(path, auth_mode=auth_mode, **kwargs)
        return self.client.request(method, path, auth_mode=auth_mode, **kwargs)


class AsyncApiRequest:
    """异步版 API 调用封装。"""

    def __init__(self, api_name: str, *, client: AmkAsyncClient | None = None) -> None:
        self.api_name = api_name
        self.entry = get_api_entry(api_name)
        self.client = client if client is not None else AmkAsyncClient()

    def build_path(
        self,
        path_params: Mapping[str, Any] | BaseModel | None = None,
    ) -> str:
        return validate_and_format_path(self.api_name, self.entry, path_params)

    async def request(
        self,
        *,
        path_params: Mapping[str, Any] | BaseModel | None = None,
        **kwargs: Any,
    ) -> httpx.Response:
        path = self.build_path(path_params)
        method = self.entry.method.upper()
        auth_mode = self.entry.auth_mode
        force_new_client_token = bool(kwargs.pop("force_new_client_token", False))
        kwargs = _inject_client_token_if_needed(
            self.api_name, kwargs, force_new_client_token=force_new_client_token
        )
        if method in ("GET", "POST", "PUT", "DELETE"):
            return await getattr(self.client, method.lower())(path, auth_mode=auth_mode, **kwargs)
        return await self.client.request(method, path, auth_mode=auth_mode, **kwargs)


def _inject_client_token_if_needed(
    api_name: str,
    kwargs: Mapping[str, Any],
    *,
    force_new_client_token: bool = False,
) -> dict[str, Any]:
    """为非视频理解请求按开关注入幂等 token。"""
    patched = dict(kwargs)
    if api_name == "understand_video_content":
        return patched
    enabled = _is_client_token_enabled()
    if not enabled and not force_new_client_token:
        return patched
    payload = patched.get("json")
    if not isinstance(payload, Mapping):
        return patched
    body = dict(payload)
    if force_new_client_token or "client_token" not in body:
        body["client_token"] = uuid.uuid4().hex[:8]
    patched["json"] = body
    return patched


def _is_client_token_enabled() -> bool:
    return (os.getenv("AMK_ENABLE_CLIENT_TOKEN") or "false").strip().lower() == "true"


async def trim_audio(
    payload: TrimMediaDurationRequest,
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = {"audio_url": payload.source, "start_time": payload.start_time}
    if payload.end_time is not None:
        body["end_time"] = payload.end_time
    return await AsyncApiRequest("trim_audio", client=use_client).request(json=body)


async def trim_video(
    payload: TrimMediaDurationRequest,
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = {"video_url": payload.source, "start_time": payload.start_time}
    if payload.end_time is not None:
        body["end_time"] = payload.end_time
    return await AsyncApiRequest("trim_video", client=use_client).request(json=body)


async def trim_media_duration(
    payload: TrimMediaDurationRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> TrimMediaDurationAsyncResult:
    """聚合方法：按 type 分发 trim_audio/trim_video，异常时返回 error。"""
    try:
        body = (
            payload
            if isinstance(payload, TrimMediaDurationRequest)
            else TrimMediaDurationRequest.model_validate(payload)
        )
        if body.type == "audio":
            res = await trim_audio(body, client=client)
        else:
            res = await trim_video(body, client=client)
        res.raise_for_status()
        data = TrimMediaDurationResponse.model_validate(res.json())
        return TrimMediaDurationAsyncResult(task_id=data.task_id, request_id=data.request_id)
    except Exception as exc:  # noqa: BLE001 - 统一返回 error 给调用方
        return TrimMediaDurationAsyncResult(error=str(exc))


async def concat_audio(
    payload: ConcatMediaSegmentsRequest,
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = {"audio_urls": payload.sources}
    return await AsyncApiRequest("concat_audio", client=use_client).request(json=body)


async def concat_video(
    payload: ConcatMediaSegmentsRequest,
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = {"video_urls": payload.sources}
    if payload.transitions is not None:
        body["transitions"] = payload.transitions
    return await AsyncApiRequest("concat_video", client=use_client).request(json=body)


async def concat_media_segments(
    payload: ConcatMediaSegmentsRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> ConcatMediaSegmentsAsyncResult:
    """聚合方法：按 type 分发 concat_audio/concat_video，异常时返回 error。"""
    try:
        body = (
            payload
            if isinstance(payload, ConcatMediaSegmentsRequest)
            else ConcatMediaSegmentsRequest.model_validate(payload)
        )
        if body.type == "audio":
            res = await concat_audio(body, client=client)
        else:
            res = await concat_video(body, client=client)
        res.raise_for_status()
        data = ConcatMediaSegmentsResponse.model_validate(res.json())
        return ConcatMediaSegmentsAsyncResult(task_id=data.task_id, request_id=data.request_id)
    except Exception as exc:  # noqa: BLE001 - 统一返回 error 给调用方
        return ConcatMediaSegmentsAsyncResult(error=str(exc))


async def understand_video_content(
    prompt: str,
    video_url: str,
    fps: int | float,
    max_frames: int | None = None,
    *,
    client: AmkAsyncClient | None = None,
) -> list[dict[str, Any]]:
    """
    视频理解（固定入参版本）：
    - 接收 prompt、video_url、fps，支持可选 max_frames
    - 内部组装 chat.completions payload
    - 仅返回 choices[].message
    """
    if not prompt.strip():
        raise ValueError("prompt 不能为空")
    if not (video_url.startswith("http://") or video_url.startswith("https://")):
        raise ValueError("video_url 须为 http:// 或 https:// 开头")
    if fps <= 0:
        raise ValueError("fps 必须大于 0")
    if max_frames is not None and max_frames <= 0:
        raise ValueError("max_frames 必须大于 0")
    model_id = normalize_secret(os.environ.get("ARK_MODEL_ID"))
    if model_id is None:
        raise MissingConfigError.missing_env(
            "ARK_MODEL_ID",
            purpose="视频理解 chat.completions 的 model 字段。",
            value_placeholder="<Ark Model ID>",
        )

    use_client = client if client is not None else AmkAsyncClient()
    video_payload: dict[str, Any] = {
        "url": video_url,
        "fps": fps,
    }
    if max_frames is not None:
        video_payload["max_frames"] = max_frames

    request_payload = {
        "model": model_id,
        "messages": [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": prompt},
                    {
                        "type": "video_url",
                        "video_url": video_payload,
                    },
                ],
            }
        ],
        "stream": False,
    }
    resp = await AsyncApiRequest("understand_video_content", client=use_client).request(
        json=request_payload
    )
    resp.raise_for_status()
    body = resp.json()
    data = body.get("data") if isinstance(body, Mapping) else None
    container = data if isinstance(data, Mapping) else body
    choices = container.get("choices") if isinstance(container, Mapping) else None
    if not isinstance(choices, list):
        raise ValueError("understand_video_content 响应缺少 choices")

    messages: list[dict[str, Any]] = []
    for choice in choices:
        if isinstance(choice, Mapping) and isinstance(choice.get("message"), Mapping):
            messages.append(dict(choice["message"]))
    return messages


async def mux_audio_video(
    payload: MuxAudioVideoRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = (
        payload.model_dump(mode="json", exclude_none=True)
        if isinstance(payload, MuxAudioVideoRequest)
        else MuxAudioVideoRequest.model_validate(payload).model_dump(mode="json", exclude_none=True)
    )
    return await AsyncApiRequest("mux_audio_video", client=use_client).request(json=body)


async def image_to_video(
    payload: ImageToVideoRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = (
        payload.model_dump(mode="json", exclude_none=True)
        if isinstance(payload, ImageToVideoRequest)
        else ImageToVideoRequest.model_validate(payload).model_dump(mode="json", exclude_none=True)
    )
    return await AsyncApiRequest("image_to_video", client=use_client).request(json=body)


async def extract_audio(
    payload: ExtractAudioRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = (
        payload.model_dump(mode="json", exclude_none=True)
        if isinstance(payload, ExtractAudioRequest)
        else ExtractAudioRequest.model_validate(payload).model_dump(mode="json", exclude_none=True)
    )
    return await AsyncApiRequest("extract_audio", client=use_client).request(json=body)


async def query_task(
    task_id: str,
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    return await AsyncApiRequest("query_task", client=use_client).request(path_params={"task_id": task_id})


def _extract_play_url(task_type: str | None, parsed_result: Mapping[str, Any]) -> str | None:
    # 优先查找 video_url 和 audio_url，不管任务类型
    video_url = parsed_result.get("video_url") or parsed_result.get("videoa_url")
    audio_url = parsed_result.get("audio_url")
    if video_url:
        return video_url
    if audio_url:
        return audio_url
    return parsed_result.get("play_url")


def normalize_query_task_response(payload: Mapping[str, Any]) -> QueryTaskNormalizedResult:
    # 新接口：status / task_id / result 等在根上；旧接口：包在 Result 里
    if isinstance(payload.get("status"), str):
        task_block: Mapping[str, Any] = payload
    else:
        wrapped = payload.get("Result")
        if not isinstance(wrapped, Mapping):
            raise ValueError("query_task 响应缺少 status 或 Result 对象")
        task_block = wrapped

    status = task_block.get("status")
    if not isinstance(status, str):
        raise ValueError("query_task 响应缺少 status 字段")

    # 新接口通常是根字段；旧接口可能在 Result 内
    task_id = None
    if isinstance(task_block.get("task_id"), str):
        task_id = task_block["task_id"]
    elif isinstance(task_block.get("TaskId"), str):
        task_id = task_block["TaskId"]
    elif isinstance(payload.get("task_id"), str):
        task_id = payload["task_id"]
    elif isinstance(payload.get("TaskId"), str):
        task_id = payload["TaskId"]

    task_type = task_block.get("task_type") if isinstance(task_block.get("task_type"), str) else None
    request_id = None
    if isinstance(task_block.get("request_id"), str):
        request_id = task_block["request_id"]
    else:
        meta = payload.get("ResponseMetadata")
        if isinstance(meta, Mapping) and isinstance(meta.get("RequestId"), str):
            request_id = meta["RequestId"]

    error = task_block.get("error")
    if status in ("failed", "canceled"):
        raise RuntimeError(str(error or f"task {status}"))

    result_raw = task_block.get("result")
    parsed_result: dict[str, Any] = {}
    if isinstance(result_raw, str) and result_raw.strip():
        parsed = json.loads(result_raw)
        if isinstance(parsed, Mapping):
            parsed_result = dict(parsed)
    elif isinstance(result_raw, Mapping):
        parsed_result = dict(result_raw)

    return QueryTaskNormalizedResult(
        task_id=task_id,
        duration=parsed_result.get("duration"),
        play_url=_extract_play_url(task_type, parsed_result),
        request_id=request_id,
        status=status,  # type: ignore[arg-type]
        task_type=task_type,
    )


async def query_task_with_polling(
    task_id: str,
    interval: int = 5,
    max_retries: int = 6,
    *,
    client: AmkAsyncClient | None = None,
) -> QueryTaskNormalizedResult:
    """
    轮询任务直到完成或失败。

    - status=running/queued: 继续轮询
    - status=completed: 返回标准化结果
    - status=failed/canceled: 抛异常
    """
    if interval <= 0:
        raise ValueError("interval 必须大于 0")
    if max_retries <= 0:
        raise ValueError("max_retries 必须大于 0")

    for attempt in range(max_retries):
        resp = await query_task(task_id, client=client)
        resp.raise_for_status()
        normalized = normalize_query_task_response(resp.json())
        status: TaskStatusStr = normalized.status
        if status == "completed":
            return normalized
        if status in ("running", "queued"):
            if attempt < max_retries - 1:
                await asyncio.sleep(interval)
                continue
            raise TimeoutError("task polling timeout")
        raise RuntimeError(f"unsupported task status: {status}")

    raise TimeoutError("task polling timeout")


async def enhance_video(
    payload: EnhanceVideoRequest | Mapping[str, Any],
    *,
    client: AmkAsyncClient | None = None,
) -> httpx.Response:
    use_client = client if client is not None else AmkAsyncClient()
    body = (
        payload.model_dump(mode="json", exclude_none=True)
        if isinstance(payload, EnhanceVideoRequest)
        else EnhanceVideoRequest.model_validate(payload).model_dump(mode="json", exclude_none=True)
    )
    return await AsyncApiRequest("enhance_video", client=use_client).request(json=body)


path: scripts/amk_client/api_config.py
content: """AMK HTTP API 注册表：路径、方法、鉴权模式。"""

#!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import re
from typing import Any, Literal, Mapping

from pydantic import BaseModel, ConfigDict

from amk_client.api_path_params import QueryTaskPathParams
from amk_client.auth_mode import AmkAuthMode

HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]

_PATH_PLACEHOLDERS = re.compile(r"\{(\w+)\}")


class ApiEntry(BaseModel):
    """单条 API 配置（不可变）。"""

    model_config = ConfigDict(frozen=True)

    action: str
    auth_mode: AmkAuthMode
    method: HttpMethod = "POST"


# api_name -> 路径参数模型（无路径占位符的 API 不必注册）
PATH_PARAM_MODELS: dict[str, type[BaseModel]] = {
    "query_task": QueryTaskPathParams,
}


api_config: dict[str, ApiEntry] = {
    "understand_video_content": ApiEntry(
        action="/api/v1/chat/completions",
        auth_mode=AmkAuthMode.ARK_AND_MEDIAKIT,
        method="POST",
    ),
    "trim_audio": ApiEntry(
        action="/api/v1/tools/trim-audio",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "trim_video": ApiEntry(
        action="/api/v1/tools/trim-video",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    # 调用节点聚合：统一由 concat_media_segments(type=audio|video) 分发到底层 concat_* API
    "concat_audio": ApiEntry(
        action="/api/v1/tools/concat-audio",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "concat_video": ApiEntry(
        action="/api/v1/tools/concat-video",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "mux_audio_video": ApiEntry(
        action="/api/v1/tools/mux-audio-video",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "image_to_video": ApiEntry(
        action="/api/v1/tools/image-to-video",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "extract_audio": ApiEntry(
        action="/api/v1/tools/extract-audio",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "enhance_video": ApiEntry(
        action="/api/v1/tools/enhance-video",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="POST",
    ),
    "query_task": ApiEntry(
        action="/api/v1/tasks/{task_id}",
        auth_mode=AmkAuthMode.MEDIAKIT_ONLY,
        method="GET",
    ),
}


def get_api_entry(name: str) -> ApiEntry:
    """按注册名取配置；未知名称抛 ``KeyError``。"""
    return api_config[name]


def path_placeholder_names(action: str) -> tuple[str, ...]:
    return tuple(_PATH_PLACEHOLDERS.findall(action))


def validate_and_format_path(
    api_name: str,
    entry: ApiEntry,
    path_params: Mapping[str, Any] | BaseModel | None,
) -> str:
    """用 Pydantic 校验路径参数（若已注册模型），再 ``str.format`` 生成最终 path。"""
    names = path_placeholder_names(entry.action)
    if not names:
        if path_params:
            raise ValueError(
                f"API {api_name!r} 的 action 无路径占位符，不应传入 path_params: {path_params!r}"
            )
        return entry.action

    raw: Mapping[str, Any]
    if path_params is None:
        raw = {}
    elif isinstance(path_params, BaseModel):
        raw = path_params.model_dump()
    else:
        raw = path_params

    model_cls = PATH_PARAM_MODELS.get(api_name)
    if model_cls is not None:
        data = model_cls.model_validate(raw).model_dump()
    else:
        data = dict(raw)

    missing = [n for n in names if n not in data]
    if missing:
        raise ValueError(f"API {api_name!r} 缺少路径参数: {missing}；已注册模型时可自动校验类型与约束")

    return entry.action.format(**{n: str(data[n]) for n in names})


path: scripts/amk_client/auth_mode.py
content: #!/usr/bin/env python3
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from enum import Enum


class AmkAuthMode(str, Enum):
    """AMK HTTP API 鉴权注册方式（与网关约定一致）。"""

    #: ``Authorization: Bearer <MediaKit API Key>``
    MEDIAKIT_ONLY = "mediakit_only"
    #: ``Authorization: Bearer <火山方舟 API Key>/<MediaKit API Key>``
    ARK_AND_MEDIAKIT = "ark_and_mediakit"


path: template/env.md
content: # 火山引擎 AI MediaKit（AMK）与方舟 ARK

# AMK 访问密钥（必填，媒体剪辑/拼接/转码等能力）

AMK_API_KEY=

# AMK 服务端环境：prod（生产）或 boe（BOE）

AMK_ENV=prod

# 是否为除「视频理解」外的异步请求自动注入 8 位 client_token（幂等）；仅支持 true 或 false

AMK_ENABLE_CLIENT_TOKEN=false

# 方舟 OpenAPI 密钥（可选；使用 understand_video_content 视频理解时必填）

ARK_API_KEY=

# 方舟模型 ID（可选；使用 understand_video_content 视频理解时必填，对应请求体 model 字段）

ARK_MODEL_ID=

# 控制台参考（勿将真实密钥提交到仓库）

# AI MediaKit：https://console.volcengine.com/imp/ai-mediakit/

# 方舟模型与密钥：https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-8


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)