From a92b10529c16876f70a6f25097c5d6369469c9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B4=BE=E6=99=93=E6=BA=90?= Date: Sat, 15 Aug 2026 02:33:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20agnes-media=20=E2=80=94=20image/v?= =?UTF-8?q?ideo=20generation=20via=20Agnes=20AI=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-plugin contribution for the community registry: plugins/Fectivnfy112357/agnes-media/ --- plugins/Fectivnfy112357/agnes-media/LICENSE | 21 ++ plugins/Fectivnfy112357/agnes-media/README.md | 54 +++ .../Fectivnfy112357/agnes-media/plugin.json | 12 + .../agnes-media/skills/agnes-media/SKILL.md | 102 ++++++ .../agnes-media/references/api-reference.md | 118 +++++++ .../agnes-media/references/prompt-styles.md | 98 ++++++ .../skills/agnes-media/scripts/agnes.py | 332 ++++++++++++++++++ 7 files changed, 737 insertions(+) create mode 100644 plugins/Fectivnfy112357/agnes-media/LICENSE create mode 100644 plugins/Fectivnfy112357/agnes-media/README.md create mode 100644 plugins/Fectivnfy112357/agnes-media/plugin.json create mode 100644 plugins/Fectivnfy112357/agnes-media/skills/agnes-media/SKILL.md create mode 100644 plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/api-reference.md create mode 100644 plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/prompt-styles.md create mode 100644 plugins/Fectivnfy112357/agnes-media/skills/agnes-media/scripts/agnes.py diff --git a/plugins/Fectivnfy112357/agnes-media/LICENSE b/plugins/Fectivnfy112357/agnes-media/LICENSE new file mode 100644 index 0000000..3837a91 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fectivnfy112357 + +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, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/plugins/Fectivnfy112357/agnes-media/README.md b/plugins/Fectivnfy112357/agnes-media/README.md new file mode 100644 index 0000000..b8a8597 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/README.md @@ -0,0 +1,54 @@ +# agnes-media + +Generate images and videos through the **Agnes AI API**: text-to-image, +image-to-image, multi-image composition, text-to-video, image-to-video and +keyframe animation — one stdlib-only Python script handles request building, +async task polling and result download; the agent only writes the prompt. + +## The problem + +Calling a production media-generation API from an agent means juggling +request payloads, async task queues, polling and file download — error-prone +and slow to repeat. agnes-media wraps both Agnes AI production models +(`agnes-image-2.1-flash` sync images, `agnes-video-v2.0` async videos) behind +a single script: `image` for seconds-fast images, `video` for minute-scale +videos with automatic frame-count math and built-in polling. + +## Try it + +Install from `/plugins` → **Local**, then ask: + +```text +generate an image of a glowing floating city above a canyon at sunrise, cinematic, wide angle +``` + +**Expected result**: the generated image saved to the output directory +(default `~/Downloads`), with the script printing `ok=true` and the file +paths. + +Direct usage: + +```text +python /scripts/agnes.py image --prompt "..." --size 2K --ratio 16:9 +python /scripts/agnes.py video --prompt "..." --duration 5 +python /scripts/agnes.py image --image a.png b.png --prompt "compose these" +``` + +## Requirements + +- **An Agnes AI API key** (paid service) — provided via `--api-key`, the + `AGNES_API_KEY` environment variable, or an `.env` file. +- Python 3.8+ (stdlib-only script). + +## Data and network + +- The script talks only to the Agnes AI API (`apihub.agnes-ai.com`). +- The API key is read from the user's own environment/config; it is never + stored in this plugin's directory. +- Local input images are sent to the API as data URIs for image-to-image / + video tasks. +- No telemetry, no other third-party services. + +## License + +MIT diff --git a/plugins/Fectivnfy112357/agnes-media/plugin.json b/plugins/Fectivnfy112357/agnes-media/plugin.json new file mode 100644 index 0000000..a83d2d7 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/plugin.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "agnes-media", + "version": "1.0.0", + "description": "Generate images and videos through the Agnes AI API: text-to-image, image-to-image, multi-image composition, text-to-video, image-to-video and keyframe animation — one Python script (stdlib-only) handles request building, async task polling and result download; the agent only writes the prompt.", + "author": { + "name": "Fectivnfy112357", + "url": "https://github.com/Fectivnfy112357" + }, + "license": "MIT", + "keywords": ["agnes-ai", "image-generation", "video-generation", "text-to-image", "text-to-video"] +} diff --git a/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/SKILL.md b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/SKILL.md new file mode 100644 index 0000000..fb5945f --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/SKILL.md @@ -0,0 +1,102 @@ +--- +name: agnes-media +description: Use when 用户用 Agnes AI 生成图片或视频。跑 scripts/agnes.py。 +version: 1.0.0 +author: Fectivnfy112357 +license: MIT +--- + +# Agnes AI 生图 / 生视频 + +## Overview + +封装 Agnes AI 两个生产模型的调用。Agent 只负责写 prompt 和传参数,脚本接管请求构建、任务轮询、结果下载、错误处理。 + +- **图像** `agnes-image-2.1-flash`:文生图 / 图生图 / 多图合成,URL 或 Base64 输出,同步(秒级)。 +- **视频** `agnes-video-v2.0`:文生视频 / 图生视频 / 关键帧动画,异步任务(先创建再轮询)。 + +脚本:`scripts/agnes.py`,标准库实现,零第三方依赖,Python 3.8+ 均可。 + +## When to Use + +- 用户要「生成一张图」→ 文生图;「按描述改这张图」→ 图生图;「把几张图合成」→ 多图合成。 +- 用户要「生成视频」→ 文生视频;「让这张图动起来」→ 图生视频;「两帧/多帧之间过渡」→ 关键帧动画。 +- 写 prompt 前先读 `references/prompt-styles.md`(推荐结构 + 示例)。 +- **不要用本 skill 做**:工具/模型选型与对比 → `ai-media-generation-tools` skill。 + +## 前置:API Key + +脚本按顺序读取:`--api-key` 参数 → `AGNES_API_KEY` 环境变量 → `~/.hermes/.env` 文件里的 `AGNES_API_KEY`。 + +key 已通过环境变量 `AGNES_API_KEY` 提供(`~/.bashrc` 里 export),脚本开箱即用。想迁移到 Hermes 统一管理,可用 `hermes config set AGNES_API_KEY=xxx` 写进 `~/.hermes/.env`——脚本会直接解析该文件。 +明文不进 skill 目录(避免被 skill 同步/分享时泄露)。 + +## 固定流程 + +统一脚本 + 两个子命令: + +``` +python /scripts/agnes.py image ... # 图像,同步 +python /scripts/agnes.py video ... # 视频,异步(脚本内部轮询) +``` + +`` = 本 skill 的安装目录(SKILL.md 所在目录;由宿主的技能加载器提供,如 `skill_view` 的 `skill_dir` / `resourceBase` 字段)。执行脚本统一用:`python "/scripts/agnes.py" ...`。 + +### 图像(同步,秒级返回) + +```bash +python .../scripts/agnes.py image \ + --prompt "日出薄雾峡谷上方的发光浮空城市,电影级写实,广角,高视觉密度" \ + --size 2K --ratio 16:9 --out ~/Downloads +``` + +| 参数 | 说明 | +|---|---| +| `--prompt` | 必填,风格见 prompt-styles.md | +| `--size` | 档位 `1K/2K/3K/4K`,默认 `1K` | +| `--ratio` | `1:1/3:4/4:3/16:9/9:16/2:3/3:2/21:9`,默认 `1:1` | +| `--image` | 图生图/多图合成:本地路径或 URL,可多次传入;本地文件自动转 data URI | +| `--format` | `url`(默认)或 `b64` | +| `--out` | 输出目录,默认 `~/Downloads` | + +### 视频(异步,分钟级,脚本自动轮询) + +```bash +python .../scripts/agnes.py video \ + --prompt "A young astronaut walking across a red desert planet, dust blowing in the wind, slow cinematic tracking shot, dramatic sunset lighting, realistic sci-fi style" \ + --duration 5 --out ~/Downloads +``` + +| 参数 | 说明 | +|---|---| +| `--prompt` | 必填 | +| `--image` | 图生视频:输入图(本地路径或 URL) | +| `--keyframes` | 关键帧动画:多个输入图(本地或 URL,至少 2 个) | +| `--duration` | 目标时长(秒),脚本自动算 `num_frames`(8n+1 且 ≤441) | +| `--num-frames` / `--frame-rate` | 手动指定(高级,绕过 --duration) | +| `--width` / `--height` | 默认 `1152x768` | +| `--seed` / `--negative-prompt` | 可复现 / 排除内容 | + +## 提示词风格 + +→ `references/prompt-styles.md`:文生图 / 图生图 / 多图合成 / 高信息密度 + 文生视频 / 图生视频 / 关键帧动画的推荐结构 + 中英文示例。**写 prompt 前先读,套结构而非空想。** + +## 参数详情 + +→ `references/api-reference.md`:尺寸档位表、ratio 输出尺寸表、视频时长表、任务状态、错误码、定价。 + +## Common Pitfalls + +1. **图像 `response_format` 别放顶层**:脚本已按正确位置处理(url→`extra_body.response_format`;文生 b64→顶层 `return_base64`;图生 b64→`extra_body.response_format=b64_json`)。不要手改 payload。 +2. **图生图别传 `tags:["img2img"]`**:文档明确不需要,脚本也不会传。 +3. **视频 `num_frames` 必须满足 `8n+1` 且 ≤441**:用 `--duration` 让脚本算,别手填 120 这类非法值。 +4. **视频是异步的**:脚本内部轮询(5s 间隔,最长 10 分钟)。不要以为无返回就是失败。 +5. **本地图片**:图像/视频脚本都会把本地文件自动转 data URI,实测图像与视频 API 均接受(视频网关会把 data URI 落盘成内部 URL 再处理)。无需手动上传图床。 +6. **选型问题别混进来**:本 skill 只执行生成;工具/模型选型走 `ai-media-generation-tools`。 + +## Verification Checklist + +- [ ] 输出文件存在于 `--out` 目录且非空(图像 >10KB,视频 >100KB) +- [ ] 视频任务 `status=completed`,`metadata.url` 已下载 +- [ ] 脚本末尾打印的 JSON 里 `ok=true` 且含 `files` 绝对路径 +- [ ] 用 `vision_analyze` / `video_analyze` 抽查成片质量 diff --git a/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/api-reference.md b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/api-reference.md new file mode 100644 index 0000000..a70e5f3 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/api-reference.md @@ -0,0 +1,118 @@ +# Agnes API 参数参考 + +整理自 Agnes 官方文档。脚本已封装这些参数,此处供 Agent 查表(尺寸映射、时长、状态、错误码)。 + +## 图像 API + +- Endpoint:`POST https://apihub.agnes-ai.com/v1/images/generations` +- 模型:`agnes-image-2.1-flash` +- 请求头:`Authorization: Bearer `、`Content-Type: application/json` + +### 参数 + +| 参数 | 必填 | 说明 | +|---|---|---| +| `model` | 是 | `agnes-image-2.1-flash` | +| `prompt` | 是 | 生成/编辑指令 | +| `size` | 是 | 档位 `1K/2K/3K/4K`,也兼容 `1024x768` 历史写法(可能被标准化) | +| `ratio` | 否 | `1:1/3:4/4:3/16:9/9:16/2:3/3:2/21:9`,默认 `1:1` | +| `return_base64` | 否 | 文生图 Base64 输出用 | +| `extra_body.image` | 否 | 图生图/多图合成输入图(URL 或 Data URI),数组 | +| `extra_body.response_format` | 否 | `url` 或 `b64_json` | + +### 输出尺寸表(size × ratio) + +| Ratio | 1K | 2K | 3K | 4K | +|---|---|---|---|---| +| 1:1 | 1024x1024 | 2048x2048 | 3072x3072 | 4096x4096 | +| 3:4 | 864x1152 | 1728x2304 | 2592x3456 | 3456x4608 | +| 4:3 | 1152x864 | 2304x1728 | 3456x2592 | 4608x3456 | +| 16:9 | 1312x736 | 2624x1472 | 3936x2208 | 5248x2944 | +| 9:16 | 736x1312 | 1472x2624 | 2208x3936 | 2944x5248 | +| 2:3 | 832x1248 | 1664x2496 | 2496x3744 | 3328x4992 | +| 3:2 | 1248x832 | 2496x1664 | 3744x2496 | 4992x3328 | +| 21:9 | 1568x672 | 3136x1344 | 4704x2016 | 6272x2688 | + +> `1920x1080` / `2560x1440` 非原生尺寸,会被标准化。要 16:9 显示素材请用 `size=2K, ratio=16:9`(得 2624x1472)再下游裁剪。 + +### 响应 + +- URL 输出:`data[0].url` +- Base64 输出:`data[0].b64_json` + +### 关键约束 + +- `response_format` 不能放顶层:url → `extra_body.response_format`;文生 b64 → 顶层 `return_base64`;图生 b64 → `extra_body.response_format=b64_json`。 +- 图生图不要传 `tags:["img2img"]`。 +- 输入图无法公开访问时用 Data URI。 +- 客户端超时建议 60–360s。 + +--- + +## 视频 API + +- 创建:`POST https://apihub.agnes-ai.com/v1/videos` +- 查结果(推荐):`GET https://apihub.agnes-ai.com/agnesapi?video_id=` +- 查结果(兼容旧版):`GET https://apihub.agnes-ai.com/v1/videos/` +- 模型:`agnes-video-v2.0` + +### 创建参数 + +| 参数 | 类型 | 说明 | +|---|---|---| +| `model` | string | `agnes-video-v2.0` | +| `prompt` | string | 视频内容描述 | +| `image` | string | 图生视频输入图 URL | +| `mode` | string | `ti2vid` 或 `keyframes` | +| `height` | int | 默认 768 | +| `width` | int | 默认 1152 | +| `num_frames` | int | ≤441 且 8n+1 | +| `frame_rate` | number | 1–60 | +| `num_inference_steps` | int | 推理步数 | +| `seed` | int | 可复现 | +| `negative_prompt` | string | 排除内容 | +| `extra_body.image` | array | 关键帧模式输入图 URL 数组 | +| `extra_body.mode` | string | `keyframes` | + +### 尺寸标准化 + +提交的宽高会被映射到最近的 480p/720p/1080p 标准档。以响应 `size` / `seconds` / `metadata.size_mapping` 为准。 + +### 时长控制 + +`seconds = num_frames / frame_rate` + +| 目标时长 | 推荐参数 | +|---|---| +| 约 3 秒 | `num_frames: 81`, `frame_rate: 24` | +| 约 5 秒 | `num_frames: 121`, `frame_rate: 24` | +| 约 10 秒 | `num_frames: 241`, `frame_rate: 24` | +| 约 18 秒 | `num_frames: 441`, `frame_rate: 24` | + +### 任务状态 + +| 状态 | 说明 | +|---|---| +| `queued` | 排队中 | +| `in_progress` | 生成中 | +| `completed` | 成功。视频 URL 在顶层 `url` 字段(文档示例写 `metadata.url`,实测无 `metadata` 包装,脚本两者都兼容) | +| `failed` | 失败(`error` 字段给原因) | + +### 错误码 + +| 码 | 说明 | +|---|---| +| 400 | 参数无效 | +| 401 | key 未授权 | +| 404 | 任务/视频未找到 | +| 500 | 服务器错误 | +| 503 | 繁忙,稍后重试 | + +--- + +## 定价 + +| 类型 | 标准价 | 当前价 | +|---|---|---| +| 图像 | $0.003/张 | $0/张 | +| 视频 | $0.005/秒 | $0/秒 | diff --git a/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/prompt-styles.md b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/prompt-styles.md new file mode 100644 index 0000000..4c76454 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/references/prompt-styles.md @@ -0,0 +1,98 @@ +# Agnes 提示词风格参考 + +落地自 Agnes 官方文档(图像 2.1 Flash + 视频 V2.0)。写 prompt 时套用对应结构、按需组合元素,不要空想。 + +## 图像(agnes-image-2.1-flash) + +### 文生图 + +结构:`[主体] + [场景/环境] + [风格] + [光照] + [构图] + [质量要求]` + +中文示例: + +> 日出时分薄雾峡谷上方的发光浮空城市,电影级写实风格,广角构图,丰富的建筑细节,柔和的金色光线,高视觉密度 + +英文示例: + +> A luminous floating city above a misty canyon at sunrise, cinematic realism + +要点:主体在前,随后堆叠场景→风格→光照→构图→质量。 + +### 图生图 + +结构:`[改变要求] + [新风格/场景] + [需添加或移除的元素] + [需保留的元素]` + +中文示例: + +> 将白天街道场景改为电影级赛博朋克夜景,添加霓虹招牌和湿滑路面倒影,同时保留原始街道布局、相机角度和主要建筑形状 + +英文示例: + +> Transform the scene into a rain-soaked cyberpunk night with neon reflections while preserving the original composition + +要点:说清「改什么」和「保留什么」——构图保留是 2.1 的核心能力。 + +### 多图合成 + +结构:`[参考图角色] + [目标场景] + [图像间关系] + [风格/光照/构图]` + +中文示例: + +> 将第一张图作为主要角色,第二张图作为产品参考,生成一张电影级活动海报,保留角色身份和产品外形,使用自然光照和干净的商业构图 + +英文示例: + +> Combine the two characters into an intense fantasy battle scene, dynamic lighting, detailed background, cinematic composition + +要点:明确每张参考图的角色,以及最终图如何组合它们。 + +### 高信息密度图像 + +结构:明确视觉层次——主要主体、背景环境、重要次要细节、风格、光照、构图约束。 + +示例: + +> 建在悬崖上的大型奇幻港口城市,数百艘小船,层叠的石桥,发光的窗户,远山,多云的日落天空,电影级奇幻写实风格,广角构图,丰富的建筑细节,高视觉密度 + +--- + +## 视频(agnes-video-v2.0) + +### 文生视频 + +结构:`[主体] + [动作] + [场景] + [镜头运动] + [光线] + [风格]` + +示例: + +> A young astronaut walking across a red desert planet, dust blowing in the wind, slow cinematic tracking shot, dramatic sunset lighting, realistic sci-fi style + +要点:动作和镜头运动是关键(区别于静态图),明确主体在做什么、镜头怎么动。 + +### 图生视频 + +结构:描述「哪些该动」+「哪些主体元素保持稳定」。 + +示例: + +> Animate the character with subtle breathing motion, hair moving gently in the wind, background lights flickering softly, while keeping the face and outfit consistent + +要点:同时约束运动元素和一致性元素。 + +### 关键帧动画 + +结构:清晰描述关键帧之间的过渡关系 + 一致性约束。 + +示例: + +> Create a smooth transition from the first keyframe to the second keyframe, maintaining character identity, consistent camera angle, and natural motion between scenes + +要点:点明过渡的目标(平滑、自然),并锁住身份/机位/运动的一致性。 + +--- + +## 通用要点 + +- **英文 prompt 通常更稳**:官方示例均为英文;中文示例由文档原文给出,亦可直接用。 +- **主体放最前**:两种模型的推荐结构都以主体开头。 +- **一致性约束要显式写**:图生/关键帧/多图都要写「保留 / 保持 X」。 +- **视频必须写动作和镜头**:纯静态描述生成不出运动。 diff --git a/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/scripts/agnes.py b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/scripts/agnes.py new file mode 100644 index 0000000..d423b42 --- /dev/null +++ b/plugins/Fectivnfy112357/agnes-media/skills/agnes-media/scripts/agnes.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Agnes AI 图像 / 视频生成统一入口。 + +图像(同步): + python agnes.py image --prompt "..." [--size 2K] [--ratio 16:9] [--image a.png b.png] [--format url] [--out DIR] +视频(异步,内部轮询): + python agnes.py video --prompt "..." [--image x.png] [--keyframes a b] [--duration 5] [--width W --height H] [--out DIR] + +API key 读取顺序:--api-key > AGNES_API_KEY 环境变量 > ~/.hermes/.env 文件里的 AGNES_API_KEY。 +标准库实现,零第三方依赖,Python 3.8+。 +""" + +import argparse +import base64 +import json +import os +import shutil +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +try: + sys.stdout.reconfigure(newline="\n") + sys.stderr.reconfigure(newline="\n") +except Exception: + pass + +BASE = "https://apihub.agnes-ai.com" +IMAGE_MODEL = "agnes-image-2.1-flash" +VIDEO_MODEL = "agnes-video-v2.0" + +MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +} + + +class ApiError(Exception): + def __init__(self, code, msg): + self.code = code + super().__init__(f"HTTP {code}: {msg}") + + +def normalize_path(p): + """MSYS 路径 /c/Users/... → Windows 盘符路径 C:/Users/...。""" + s = str(p) + if len(s) >= 3 and s[0] == "/" and s[2] == "/" and s[1].isalpha(): + s = f"{s[1].upper()}:/{s[3:]}" + return s + + +def _read_env_file(path): + """从 .env 文件读 AGNES_API_KEY=xxx(兼容 export 前缀与引号)。""" + try: + for line in Path(path).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + if line.startswith("AGNES_API_KEY="): + val = line.split("=", 1)[1].strip().strip('"').strip("'") + if val: + return val + except OSError: + pass + return None + + +def get_api_key(explicit): + if explicit: + return explicit.strip() + env = os.environ.get("AGNES_API_KEY", "").strip() + if env: + return env + # Hermes .env:HERMES_HOME 优先,其次 ~/.hermes,再 Windows 默认 AppData 位置 + candidates = [] + hh = os.environ.get("HERMES_HOME", "").strip() + if hh: + candidates.append(os.path.join(hh, ".env")) + candidates.append(str(Path.home() / ".hermes" / ".env")) + candidates.append(str(Path.home() / "AppData" / "Local" / "hermes" / ".env")) + for env_path in candidates: + v = _read_env_file(env_path) + if v: + return v + raise SystemExit( + "ERROR: 未找到 API key。用 --api-key、AGNES_API_KEY 环境变量或 ~/.hermes/.env 里的 AGNES_API_KEY 提供。" + ) + + +def _request(url, payload=None, key=None, timeout=60): + headers = {"User-Agent": "agnes-media-skill/1.0"} + data = None + if key: + headers["Authorization"] = f"Bearer {key}" + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + raise ApiError(e.code, body[:800]) + except urllib.error.URLError as e: + raise ApiError(0, f"network error: {e.reason}") + + +def _post_with_retry(url, payload, key, timeout, retries=3, backoff=10): + """创建任务,对 500/503(繁忙/队列满)带退避重试。""" + last = None + for i in range(retries): + try: + return _request(url, payload=payload, key=key, timeout=timeout) + except ApiError as e: + last = e + if e.code in (500, 503) and i < retries - 1: + time.sleep(backoff) + continue + raise + raise last + + +def to_data_uri(path): + p = Path(path) + mime = MIME.get(p.suffix.lower()) + if not mime: + raise SystemExit(f"ERROR: 不支持的图片格式 {p.suffix}: {path}") + data = p.read_bytes() + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + + +def resolve_image(v): + v = normalize_path(v) + if v.startswith(("http://", "https://", "data:")): + return v + if not Path(v).exists(): + raise SystemExit(f"ERROR: 图片文件不存在: {v}") + return to_data_uri(v) + + +def download(url, dest_dir, filename): + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / filename + req = urllib.request.Request(url, headers={"User-Agent": "agnes-media-skill/1.0"}) + with urllib.request.urlopen(req, timeout=600) as r, open(dest, "wb") as f: + shutil.copyfileobj(r, f) + return dest + + +def cmd_image(args): + key = get_api_key(args.api_key) + payload = {"model": IMAGE_MODEL, "prompt": args.prompt, "size": args.size} + if args.ratio: + payload["ratio"] = args.ratio + + images = [resolve_image(v) for v in (args.image or [])] + if images: + extra = {"image": images} + extra["response_format"] = "b64_json" if args.format == "b64" else "url" + payload["extra_body"] = extra + else: + if args.format == "b64": + payload["return_base64"] = True + else: + payload["extra_body"] = {"response_format": "url"} + + resp = _post_with_retry(f"{BASE}/v1/images/generations", payload, key, timeout=360) + item = (resp.get("data") or [{}])[0] + out_dir = normalize_path(args.out) if args.out else str(Path.home() / "Downloads") + stem = f"agnes_{time.strftime('%Y%m%d_%H%M%S')}" + files = [] + + if item.get("url"): + dest = download(item["url"], out_dir, f"{stem}.png") + files.append(str(dest)) + elif item.get("b64_json"): + dest = Path(out_dir) / f"{stem}.png" + Path(out_dir).mkdir(parents=True, exist_ok=True) + dest.write_bytes(base64.b64decode(item["b64_json"])) + files.append(str(dest)) + else: + raise SystemExit( + f"ERROR: 响应既无 url 也无 b64_json: {json.dumps(resp, ensure_ascii=False)[:500]}" + ) + + print(json.dumps( + {"ok": True, "kind": "image", "model": IMAGE_MODEL, "size": args.size, + "ratio": args.ratio or "1:1", "files": files}, + ensure_ascii=False)) + + +def poll_video(vid, key, interval=5, timeout=600): + url = f"{BASE}/agnesapi?video_id={vid}" + deadline = time.time() + timeout + last_status = "unknown" + while time.time() < deadline: + try: + resp = _request(url, key=key, timeout=60) + except ApiError as e: + if e.code in (401, 403): + raise SystemExit(f"ERROR: 查询任务失败 {e}") + last_status = "poll_error" + time.sleep(interval) + continue + status = resp.get("status") + last_status = status or last_status + if status == "completed": + meta = resp.get("metadata") or {} + # 文档写 metadata.url,但实测 url 与 size_mapping 都在顶层(无 metadata 包装),两者都兼容 + url = meta.get("url") or resp.get("url") + return "completed", url, resp + if status == "failed": + return "failed", None, resp + time.sleep(interval) + return last_status, None, None + + +def cmd_video(args): + key = get_api_key(args.api_key) + payload = { + "model": VIDEO_MODEL, + "prompt": args.prompt, + "width": args.width if args.width else 1152, + "height": args.height if args.height else 768, + } + + if args.keyframes: + imgs = [resolve_image(v) for v in args.keyframes] + payload["extra_body"] = {"image": imgs, "mode": "keyframes"} + elif args.image: + payload["image"] = resolve_image(args.image) + + fr = args.frame_rate if args.frame_rate else 24 + if args.num_frames: + nf = args.num_frames + if nf > 441 or (nf - 1) % 8 != 0: + raise SystemExit(f"ERROR: num_frames={nf} 不合法(须 8n+1 且 ≤441)") + elif args.duration: + nf = round(args.duration * fr / 8) * 8 + 1 + nf = max(9, min(nf, 441)) + else: + nf = 121 # 约 5 秒 + payload["num_frames"] = nf + payload["frame_rate"] = fr + + if args.seed is not None: + payload["seed"] = args.seed + if args.negative_prompt: + payload["negative_prompt"] = args.negative_prompt + if args.num_inference_steps: + payload["num_inference_steps"] = args.num_inference_steps + + resp = _post_with_retry(f"{BASE}/v1/videos", payload, key, timeout=120, retries=4, backoff=15) + vid = resp.get("video_id") or resp.get("task_id") or resp.get("id") + if not vid: + raise SystemExit( + f"ERROR: 创建任务响应缺少 video_id/task_id: {json.dumps(resp, ensure_ascii=False)[:500]}" + ) + + status, meta_url, final_resp = poll_video(vid, key) + if status == "completed" and meta_url: + out_dir = normalize_path(args.out) if args.out else str(Path.home() / "Downloads") + dest = download(meta_url, out_dir, f"{vid}.mp4") + print(json.dumps( + {"ok": True, "kind": "video", "model": VIDEO_MODEL, "video_id": vid, + "seconds": (final_resp or {}).get("seconds"), + "size": (final_resp or {}).get("size"), + "files": [str(dest)]}, + ensure_ascii=False)) + elif status == "failed": + err = (final_resp or {}).get("error") + raise SystemExit(f"ERROR: 视频任务失败: {json.dumps(err, ensure_ascii=False)[:500]}") + else: + raise SystemExit( + f"ERROR: 轮询超时(600s),任务仍为 {status}。可用 video_id={vid} 稍后手动查询。" + ) + + +def build_parser(): + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--api-key", help="Agnes API key(可选,默认读环境变量/.env)") + common.add_argument("--out", default=None, help="输出目录(默认 ~/Downloads)") + + p = argparse.ArgumentParser(description="Agnes AI 图像/视频生成") + sub = p.add_subparsers(dest="command", required=True) + + im = sub.add_parser("image", parents=[common], help="生成图像(同步)") + im.add_argument("--prompt", required=True) + im.add_argument("--size", default="1K", help="尺寸档位 1K/2K/3K/4K(默认 1K)") + im.add_argument("--ratio", default="1:1") + im.add_argument("--image", action="append", help="图生图/多图合成输入(可多次)") + im.add_argument("--format", choices=["url", "b64"], default="url") + im.set_defaults(func=cmd_image) + + vd = sub.add_parser("video", parents=[common], help="生成视频(异步)") + vd.add_argument("--prompt", required=True) + vd.add_argument("--image", help="图生视频输入图(本地路径或 URL)") + vd.add_argument("--keyframes", nargs="+", help="关键帧动画输入图(至少 2 个,空格分隔)") + vd.add_argument("--duration", type=float, help="目标时长(秒)") + vd.add_argument("--num-frames", type=int, help="手动指定帧数(8n+1 且 ≤441)") + vd.add_argument("--frame-rate", type=int, default=24) + vd.add_argument("--width", type=int, default=None) + vd.add_argument("--height", type=int, default=None) + vd.add_argument("--seed", type=int, default=None) + vd.add_argument("--negative-prompt", default=None) + vd.add_argument("--num-inference-steps", type=int, default=None) + vd.set_defaults(func=cmd_video) + return p + + +def main(): + try: + args = build_parser().parse_args() + if args.command == "video" and args.keyframes and len(args.keyframes) < 2: + raise SystemExit("ERROR: 关键帧动画至少需要 2 张输入图(--keyframes)") + args.func(args) + except ApiError as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()