Skill: sunnykui/medeo-video (version 1.5.2)

--- name: medeo-video version: 1.5.2 description: AI-powered video generation skill. Use when the user wants to generate videos from text descriptions, browse video recipes, upload assets, or manage video creation workflows. metadata: {"openclaw":{"emoji":"🎬","requires":{"bins":["python3"],"env":{"MEDEO_API_KEY":{"required":true,"description":"Medeo API key (starts with mk_)"}}},"tags":["video","ai-video","medeo","video-generation","rendering","recipes","media","upload"]}} --- # Medeo Video Generator Skill Generate AI videos from text. Medeo is an **AI video agent** that handles full storylines, multi-scene narratives, and screenplays in a single call — shot composition, transitions, pacing, and music are all automatic. > ⚠️ **Do NOT split stories into multiple calls.** Pass the entire screenplay in one `--message`. ## 0. Pre-Flight Check (MANDATORY — run before anything else) **Before running any command**, check if API key is configured: ```bash python3 {baseDir}/scripts/medeo_video.py config 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('ok' if d.get('apiKey') else 'missing')" ``` - Output `ok` → proceed normally - Output `missing` (or command fails) → **stop immediately**, do NOT run any other commands. Send the setup message using the **channel-appropriate method**: **Feishu** — use Feishu API directly (NOT `message` tool — it won't render cards): ```python import json, urllib.request cfg = json.loads(open("/home/ec2-user/.openclaw/openclaw.json").read()) feishu = cfg["channels"]["feishu"]["accounts"]["default"] [REDACTED_SECRET] "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", data=json.dumps({"app_id": feishu["appId"], "app_secret": feishu["appSecret"]}).encode(), headers={"Content-Type": "application/json"} )).read())["tenant_access_token"] card = { "config": {"wide_screen_mode": True}, "header": {"title": {"tag": "plain_text", "content": "🎬 Video Generation — API Key Required"}, "template": "blue"}, "elements": [{"tag": "div", "text": {"tag": "lark_md", "content": "You need a **Medeo API Key** to generate videos.\n\n**Steps:**\n1. Go to https://medeo.app/dev/apikey\n - No account? You'll be guided to sign up. The key appears after login.\n2. Copy the key (starts with `mk_`) and send it back to me.\n\nOnce I have it, I'll configure everything for you."}}], } urllib.request.urlopen(urllib.request.Request( "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id", data=json.dumps({"receive_id": "<USER_OPEN_ID>", "msg_type": "interactive", "content": json.dumps(card)}).encode(), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"} )) ``` **Telegram / Discord / Other channels** — send plain text via `message` tool (these channels support markdown natively): ``` 🎬 Video Generation — API Key Required Steps: 1. Go to https://medeo.app/dev/apikey (sign up if needed — the key appears after login) 2. Copy the key (starts with mk_) and send it back to me Once I have it, I'll configure everything for you. ``` Once they provide the key: `python3 {baseDir}/scripts/medeo_video.py config-init --api-key "mk_..."` ## 1. First-Time Setup If no API Key is configured, the script outputs `"setup_required": true`. 1. Send the user this exact link: https://medeo.app/dev/apikey (this page auto-prompts registration if not logged in, then shows the API key) 2. Once they provide the key: `python3 {baseDir}/scripts/medeo_video.py config-init --api-key "mk_..."` ## 2. Generate a Video (5-30 min, always async) Users only need to know **3 ways** to generate a video: 1. **Send text** → generate video 2. **Send text + upload image** → generate video using their image 3. **Send text + image URL** → generate video using the URL image The agent handles everything else silently. > **IMPORTANT**: Before spawning the generation task, **immediately reply to the user** with an acknowledgment like: > "🎬 Starting video generation — I'll send you the result in about 5–10 minutes." > Do NOT wait in silence. The user should know their request was received. ### Usage 1: Text only ```bash python3 {baseDir}/scripts/medeo_video.py spawn-task \ --message "user's video description" \ --deliver-to "oc_xxx" \ --deliver-channel "feishu" ``` ### Usage 2: Text + uploaded image (user sends image in chat) ```bash # First: upload-file to get media_id (see Section 3) python3 {baseDir}/scripts/medeo_video.py spawn-task \ --message "user's video description" \ --media-ids "media_01..." \ --asset-sources my_uploaded_assets \ --deliver-to "oc_xxx" \ --deliver-channel "feishu" ``` ### Usage 3: Text + image URL ```bash python3 {baseDir}/scripts/medeo_video.py spawn-task \ --message "user's video description" \ --media-urls "https://example.com/photo.jpg" \ --asset-sources my_uploaded_assets \ --deliver-to "oc_xxx" \ --deliver-channel "feishu" ``` > **Agent auto-behavior:** When the user provides images (Usage 2 or 3), always pass `--asset-sources my_uploaded_assets` so Medeo uses their images instead of generating new ones. The user does not need to know this flag exists. ### Internal Parameters (agent use only — never expose to users) These are handled automatically by the agent. Do NOT mention them to users or ask users to provide them. | Flag | When to use | Default behavior | |------|------------|-----------------| | `--voice-id "voice_01..."` | When a specific voice is needed | Medeo picks automatically | | `--video-style-id "style_01..."` | When a specific visual style is needed | Medeo picks automatically | | `--asset-sources` | When user provides images: pass `my_uploaded_assets` | Medeo decides | | `--recipe-id "recipe_01..."` | When using a template | None | | `--aspect-ratio "9:16"` | When user specifies portrait/landscape | `16:9` | | `--duration-ms 30000` | When user specifies duration | Medeo decides | | `--no-render` | Debug only — skip rendering | Always render | ### Delivery Target (`--deliver-to`) **This is critical** — determines where the generated video gets sent. | Context | `--deliver-to` value | Example | |---------|---------------------|---------| | **Feishu group chat** | The group's `chat_id` (starts with `oc_`). Extract from inbound metadata `conversation_label` or `chat_id` — **strip the `chat:` prefix** if present (e.g. `chat:oc_xxx` → `oc_xxx`) | [REDACTED_SECRET] | | **Feishu private chat** | The user's `open_id` (starts with `ou_`). Extract from inbound metadata `sender_id` — **strip the `user:` prefix** if present | [REDACTED_SECRET] | | **Telegram** | The `chat_id` from the inbound message context | `-1001234567890` | | **Discord** | The `channel_id` from the inbound message context | `1234567890123456` | **How to determine group vs private on Feishu:** - Check `is_group_chat` in the inbound metadata - If `true` → use `conversation_label` / `chat_id` (the `oc_` value) - If `false` → use `sender_id` (the `ou_` value) Step 2: Use `sessions_spawn` with the returned args (`label: "medeo: <brief>"`, `runTimeoutSeconds: 2400`). Step 3: Tell user it's generating. Sub-agent auto-announces when done. ## 3. Upload Assets ### 3a. From URL (image already has a public URL) ```bash python3 {baseDir}/scripts/medeo_video.py upload \ --url "https://example.com/photo.jpg" \ --project-id "project_01..." # optional: associate media with existing project --no-wait # optional: return job_id immediately without polling ``` ### 3b. From IM attachment (user sends image directly) ← NEW Use `upload-file` when the user sends an image via Telegram, Discord, Feishu, or as a local file. This uses the direct upload API (prepare → S3 presigned PUT → register) instead of URL-based upload. **Trigger:** Only when the user **explicitly requests video generation** AND sends an image attachment in the same message (e.g. "make a video with this photo"). Do NOT auto-upload on every image message — other skills or conversations may involve images unrelated to video generation. ```bash # From local file (downloaded by OpenClaw from attachment) python3 {baseDir}/scripts/medeo_video.py upload-file \ --file /tmp/user_photo.jpg # From direct URL (Discord CDN, etc.) python3 {baseDir}/scripts/medeo_video.py upload-file \ --url "https://cdn.discordapp.com/attachments/..." # From Telegram (file_id from message.photo[-1].file_id) # TELEGRAM_BOT_TOKEN must be set as env var — never pass as CLI arg (ps aux leaks it) TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" python3 {baseDir}/scripts/medeo_video.py upload-file \ --telegram-file-id "AgACAgIAAxk..." # From Feishu (message_id + image_key from message content) python3 {baseDir}/scripts/medeo_video.py upload-file \ --feishu-message-id "om_xxx" \ --feishu-image-key "img_v3_xxx" \ --feishu-app-token "$FEISHU_APP_TOKEN" ``` Output: `{"media_id": "media_01...", "filename": "photo.jpg"}` Then pass `media_id` to generation: ```bash python3 {baseDir}/scripts/medeo_video.py spawn-task \ --message "Create a video featuring this person" \ --media-ids "media_01..." ``` ### Platform-Specific Image Extraction Guide | Platform | How to get image source | `upload-file` arg | |----------|------------------------|-------------------| | Telegram | `message.photo[-1].file_id` | `--telegram-file-id` | | Discord | `message.attachments[0].url` (public CDN URL) | `--url` | | Feishu | `message_id` + `image_key` from message content JSON | `--feishu-message-id` + `--feishu-image-key` | | WhatsApp | Download attachment binary → save to `/tmp` | `--file` | | Generic URL | Any direct image URL | `--url` | **Note:** Discord attachment URLs are public CDN links — `--url` works directly. All other platforms require authentication to download. ### 3c. Inline in generate pipeline ```bash # URL-based (existing behavior) python3 {baseDir}/scripts/medeo_video.py spawn-task \ --message "Product showcase for this sneaker" \ --media-urls "https://example.com/front.jpg" "https://example.com/side.jpg" ``` Supports `.jpg`, `.png`, `.webp`, `.mp4`, `.mov`, `.gif`. Higher resolution + multiple angles = better results. ### 3d. Check Upload Status After `upload` or `upload-file`, if you need to check the upload job: ```bash python3 {baseDir}/scripts/medeo_video.py upload-status --job-id "job_01..." ``` Returns media status (`processing`, `completed`, `failed`) and `media_id` once done. ## 4. Low-Level Pipeline Commands (agent internal — never expose to users) > These are for agent debugging or manual intervention only. Users should never see these commands. ``` Pipeline flow: spawn-task (recommended, async) └── generate (blocking, same pipeline) ├── upload (if --media-urls) ├── compose → compose-status (poll) └── render → render-status (poll) ``` | Command | What it does | Key args | |---------|-------------|----------| | `generate` | Blocking full pipeline (upload→compose→render) | Same as spawn-task minus deliver flags | | `compose` | Create project only (no render) | `--message`, `--media-ids`, `--recipe-id` | | `compose-status` | Poll compose task | `--task-id "task_01..."` | | `render` | Render existing project | `--project-id "project_01..."` | | `render-status` | Poll render job | `--job-id "render_01..."` | | `upload-status` | Poll upload job | `--job-id "job_01..."` | All commands support `--no-wait` to return immediately without polling. ## 5. Browse Recipes ```bash python3 {baseDir}/scripts/medeo_video.py recipes # list templates python3 {baseDir}/scripts/medeo_video.py recipes --cursor <c> # paginate ``` Use in generation: `--recipe-id "recipe_01..."`. See [docs/recipes.md](docs/recipes.md). ## 6. Quick Commands Reference (for agent, not user-facing) | Command | Description | User-visible? | |---------|-------------|--------------| | `recipes` | List video templates | Yes — "what templates are available?" | | `last-job` | Latest job status | Yes — "is my last video done?" | | `history` | Job history (last 50) | Yes — "show my video history" | | `config` | Show current configuration | No | | `config-init --api-key "mk_..."` | Initialize API key | Only during setup | | `upload --url "URL"` | Upload from public URL | No (agent internal) | | `upload-file --file PATH` | Upload from local file | No (agent internal) | | `upload-file --url "URL"` | Download URL → upload | No (agent internal) | | `upload-file --telegram-file-id "..."` | Upload Telegram attachment | No (agent internal) | | `upload-file --feishu-image-key "..."` | Upload Feishu attachment | No (agent internal) | | `upload-status --job-id "..."` | Check upload job status | No (agent internal) | | `compose-status --task-id "..."` | Check compose task progress | No (agent internal) | | `render-status --job-id "..."` | Check render job progress | No (agent internal) | ## 7. Key Rules 1. **Always async** — `spawn-task` + `sessions_spawn` for generation 2. **One call for stories** — full storylines in one `--message`, never split 3. **Insufficient credits** — share recharge link from error output 4. **IM image upload** — Only upload images when the user explicitly asks for video generation with that image. Do NOT auto-upload every image message (user may have other skills installed). When triggered: run `upload-file` first → get `media_id` → pass to generation via `--media-ids`. Never ask the user for a URL if they already sent the image. 5. **IM-native delivery** — After generation, deliver the video using the IM channel's native method (not just a URL). Each channel has a dedicated delivery script: - **Feishu**: `python3 {baseDir}/scripts/feishu_send_video.py --video /tmp/result.mp4 --to "oc_xxx_or_ou_xxx" --cover-url "<thumbnail_url>" --duration <ms>` (use `oc_` chat_id for group chats, `ou_` open_id for private chats; `chat:oc_xxx` and `user:ou_xxx` prefixed forms are also accepted) - **Telegram**: Download video, then send via `telegram_send_video.py` (token from env only): ```bash curl -sL -o /tmp/medeo_result.mp4 "<video_url>" TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" python3 {baseDir}/scripts/telegram_send_video.py \ --video /tmp/medeo_result.mp4 \ --to "<chat_id>" \ --cover-url "<thumbnail_url>" \ --duration <seconds> \ --caption "🎬 Video ready!" ``` - **Discord**: Use the `message` tool directly — download the video to `/tmp/result.mp4` via `curl -sL -o /tmp/result.mp4 "<video_url>"`, then call `message(action="send", channel="discord", target="<channel_id>", message="🎬 Video ready!", filePath="/tmp/result.mp4")`. For files >25 MB, send `video_url` as a plain link instead. - **WhatsApp / Signal / Other**: Use the `message` tool with `media` parameter, or share `video_url` as a link if native sending is unavailable. - **Cover image URL**: The generate output JSON includes `thumbnail_url` — the API always returns this field. Constructed as `{ossBaseUrl}/{thumbnail_relative_path}` (e.g. `https://oss.prd.medeo.app/assets/medias/media_xxx.png`). - **Video URL**: Same pattern — `{ossBaseUrl}/{video_relative_path}` (e.g. `https://oss.prd.medeo.app/exported_video/v_xxx`). - **Security**: Never pass bot tokens as CLI args (visible in `ps`). Always use env vars: `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`. 6. **Timeline completion** — Medeo's backend is an AI agent. Generated images/videos must be added to the Timeline to trigger task completion and rendering. Always append to your prompt: "Add the generated video/image to the Timeline." ## 8. Error Handling | Error | Action | |-------|--------| | `setup_required: true` | Guide user to register + configure key | | `upload_prep_rejected` | File format/size rejected; check supported formats | | `s3_put_failed` | S3 upload error; retry once | | Insufficient credits | Share recharge link from error output, retry after top-up | | Compose/render timeout | Inform user, suggest retry. Complex scripts may take 15+ min | | 401/403 | Key may be invalid or expired, ask user to regenerate | | Upload 404 | Some image hosts block server-side fetch; use `upload-file --url` to download first | ## 9. Reference Docs - [docs/recipes.md](docs/recipes.md) — Full recipe browsing and pagination - [docs/assets-upload.md](docs/assets-upload.md) — All upload paths (URL, local file, IM attachments), platform-specific guides, `upload` vs `upload-file` comparison - [docs/feishu-send.md](docs/feishu-send.md) — Sending generated video via Feishu (cover image, duration, compression) - [docs/multi-platform.md](docs/multi-platform.md) — Multi-platform video delivery (Feishu, Telegram, Discord, WhatsApp) ## 10. Data Storage All data in `~/.openclaw/workspace/medeo-video/`: `config.json` (API key), `last_job.json` (latest job), `history/` (last 50 jobs). ## 11. Security Notes - **API key resolution**: env var `MEDEO_API_KEY` → `config.json` → built-in defaults. No legacy system-level files are read. - **Feishu delivery**: `feishu_send_video.py` reads `appId` + `appSecret` from local `~/.openclaw/openclaw.json` to call Feishu Open API. Credentials stay local and are never transmitted beyond the Feishu API. - **Telegram delivery**: Bot token is read from `TELEGRAM_BOT_TOKEN` env var only (never CLI args). - **No secrets in skill directory**: `config.json` lives in the runtime data directory (`~/.openclaw/workspace/medeo-video/`), not in the skill source directory.

path: README.md
content: # 🎬 Medeo Video Generator

Generate AI-powered videos from text. Tell your AI assistant "make me a video about XX" and it will automatically create the video using the [Medeo](https://medeo.app) platform, sending you the result in a few minutes.

---

## Installation

### Option A: Install via OpenClaw (Recommended)

Tell your AI assistant:

```
Install the medeo-video skill from https://github.com/one2x-ai/medeo-video-skill
```

### Option B: Manual Install

```bash
cd ~/.agents/skills/
git clone https://github.com/one2x-ai/medeo-video-skill.git medeo-video
openclaw gateway restart
```

---

## Setup (One-Time)

The first time you use the skill, your assistant will prompt you to set up an API key:

1. Go to 👉 **https://medeo.app/dev/apikey**
   - No account? The page will guide you through registration
2. Copy your API key (starts with `mk_`)
3. Send it to your assistant — it will handle the rest

That's it. One-time setup, works forever.

---

## Usage

### Text to Video

Just describe what you want:

```
Make me a video about the coffee brewing process with a cozy vibe
```

```
Create a 30-second product launch video for new running shoes
```

### Image + Text to Video

Send an image (or paste a URL) along with your description:

```
Use this image to create a product promo video
[send your image]
```

```
Make a video using this photo: https://example.com/photo.jpg
```

Your assistant will automatically upload your image and ensure the video uses your provided assets.

### Vertical / Short-Form Video

Want TikTok / Reels / Shorts format? Just say so:

```
Make a vertical short video of a sunset timelapse
```

---

## What You Can Do

| What You Say | What Happens |
|-------------|-------------|
| "Make me a XX video" | Full auto video generation |
| "Use this image to make a video" + image | Uses your image as assets |
| "Make it vertical" / "9:16" | Portrait short-form video |
| "What video templates are available?" | Browse preset recipes |
| "Is my last video done?" | Check recent job status |
| "Show my video history" | View past generations |

---

## FAQ

### How long does generation take?

Typically 5–15 minutes. Complex scripts may take longer. Generation runs in the background — your assistant will send you the video when it's ready.

### What image/video formats are supported?

`.jpg`, `.png`, `.webp`, `.mp4`, `.mov`, `.gif`

### What if I run out of credits?

Your assistant will let you know and provide a link to top up. After recharging, just say "credits topped up, please retry."

### Which chat platforms are supported?

All platforms supported by OpenClaw: Feishu, Telegram, Discord, WhatsApp, Signal, and more. The video will be delivered directly to your chat.

---

## Technical Details

- **Requirements**: Python 3.6+ with `requests` library (`pip install requests`)
- **Data Storage**: `~/.openclaw/workspace/medeo-video/` (config + job history)
- **API Docs**: https://docs.prd.medeo.app/

### Feishu Video Delivery

The optional `feishu_send_video.py` script reads Feishu app credentials (`appId` + `appSecret`) from your local `~/.openclaw/openclaw.json` to obtain a `tenant_access_token` for sending videos. **No credentials are uploaded or transmitted to third parties** — they are only used locally to call the Feishu Open API. If you don't use Feishu, this script is not invoked.

---

## Links

- [Medeo Platform](https://medeo.app)
- [OpenClaw](https://github.com/openclaw/openclaw)
- [ClawhHub Skill Marketplace](https://clawhub.com)

---

MIT License


path: docs/assets-upload.md
content: # Uploading Assets (Images & Videos)

Medeo accepts user-provided images and videos as input assets for video generation. These can be used as reference images, character photos, scene backgrounds, or video clips to incorporate.

There are **three upload paths** depending on where the image comes from:

| Path | When to use | Command |
|------|-------------|---------|
| **URL upload** | Image already has a public URL | `upload --url "https://..."` |
| **Direct file upload** | Local file, IM attachment, or URL requiring download-first | `upload-file --file PATH` / `--url URL` / platform-specific flags |
| **Inline in generation** | Pass URLs directly to `spawn-task` (auto-uploaded) | `spawn-task --media-urls "https://..."` |

## 1. Upload via Public URL

Best when the image is already hosted publicly (e.g., product page, CDN link).

```bash
python3 {baseDir}/scripts/medeo_video.py upload --url "https://example.com/photo.jpg"
```

Returns:
```json
{"media_id": "media_01...", "media_ids": ["media_01..."], "job_id": "..."}
```

**API flow:** `POST /api/v2/medias:create_from_url` → poll job → media_id

## 2. Direct File Upload (`upload-file`)

Use when you have a local file or need to download from a platform that requires authentication (Telegram, Feishu, etc.).

**API flow:** `prepare_for_upload` → S3 presigned PUT → `create_from_upload` → poll job → media_id

### From a local file

```bash
python3 {baseDir}/scripts/medeo_video.py upload-file --file /tmp/photo.jpg
```

### From a URL (download first, then upload)

Useful when the URL might block server-side fetch (some CDNs, Discord attachments, etc.):

```bash
python3 {baseDir}/scripts/medeo_video.py upload-file --url "https://cdn.example.com/image.jpg"
```

### From Telegram attachment

```bash
python3 {baseDir}/scripts/medeo_video.py upload-file \
  --telegram-file-id "AgACAgIAAxk..." \
  --telegram-bot-token "$TELEGRAM_BOT_TOKEN"
```

- `--telegram-file-id`: from `message.photo[-1].file_id` (highest resolution)
- Bot token via env var `TELEGRAM_BOT_TOKEN` or `--telegram-bot-token` flag

### From Feishu attachment

```bash
python3 {baseDir}/scripts/medeo_video.py upload-file \
  --feishu-image-key "img_v3_xxx" \
  --feishu-message-id "om_xxx" \
  --feishu-app-token "$FEISHU_APP_TOKEN"
```

- `--feishu-image-key`: from the message content JSON
- `--feishu-message-id`: the message containing the image
- App token via env var `FEISHU_APP_TOKEN` or `--feishu-app-token` flag

### Output

All `upload-file` variants return:
```json
{"media_id": "media_01...", "filename": "photo.jpg"}
```

### Platform-Specific Image Extraction Guide

| Platform | How to get image source | `upload-file` arg |
|----------|------------------------|-------------------|
| **Telegram** | `message.photo[-1].file_id` | `--telegram-file-id` |
| **Discord** | `message.attachments[0].url` (public CDN URL) | `--url` |
| **Feishu** | `message_id` + `image_key` from message content JSON | `--feishu-image-key` + `--feishu-message-id` |
| **WhatsApp** | Download attachment binary → save to `/tmp` | `--file` |
| **Generic URL** | Any direct image URL | `--url` |

**Note:** Discord attachment URLs are public CDN links — `--url` works directly. All other IM platforms require authentication to download.

## 3. Inline Upload in Generation Pipeline

Pass URLs directly to `spawn-task` — they're auto-uploaded before compose:

```bash
python3 {baseDir}/scripts/medeo_video.py spawn-task \
  --message "Create a product showcase video for this sneaker" \
  --media-urls "https://example.com/sneaker-front.jpg" "https://example.com/sneaker-side.jpg"
```

Or pass pre-uploaded media IDs:

```bash
# Step 1: Upload first
python3 {baseDir}/scripts/medeo_video.py upload-file --file /tmp/photo.jpg
# → {"media_id": "media_01ABC..."}

# Step 2: Generate with the media_id
python3 {baseDir}/scripts/medeo_video.py spawn-task \
  --message "Create a video featuring this person" \
  --media-ids "media_01ABC..."
```

## Supported Formats

`.jpg`, `.png`, `.webp`, `.mp4`, `.mov`, `.gif`

## Common Use Cases

| Use Case | How |
|----------|-----|
| Character reference photo | Upload image → `--media-ids "media_01..."` + describe character in `--message` |
| Product showcase | Upload product images → `--media-ids` or `--media-urls` + describe video style |
| Scene background | Upload background → describe what happens in the scene |
| IM user sends image | `upload-file` with platform flag → get `media_id` → pass to `spawn-task` |
| Multiple references | Upload each → pass all IDs: `--media-ids "id1" "id2" "id3"` |

## Tips

- **Image quality matters** — higher resolution images produce better results
- **Multiple angles help** — for character/product videos, provide 2-3 reference images from different angles
- **IM images**: When a user sends an image directly in chat and asks for video generation, use `upload-file` to upload it — don't ask them for a URL
- **`upload-file --url` vs `upload --url`**: Use `upload-file --url` when the URL might block server-side fetch (the file is downloaded locally first then uploaded via S3). Use `upload --url` for reliable public URLs (faster, single API call)
- Medeo's AI agent will intelligently incorporate the assets into the video based on your `--message` description


path: docs/feishu-send.md
content: # Sending Video via Feishu

After video generation completes, if running in a Feishu environment, send the actual video file to the user instead of just a link.

## Prerequisites

- `feishu_send_video.py` script (included in `scripts/`)
- Feishu app credentials configured in `~/.openclaw/openclaw.json`

## Quick Start

```bash
# Video only
python3 {baseDir}/scripts/feishu_send_video.py \
  --video /tmp/video.mp4 \
  --to "ou_user_open_id" \
  --cover-url "https://thumbnail_url" \
  --duration 20875

# Video + text (rich post)
python3 {baseDir}/scripts/feishu_send_video.py \
  --video /tmp/video.mp4 \
  --to "oc_group_chat_id" \
  --cover-url "https://thumbnail_url" \
  --duration 20875 \
  --title "My Video" \
  --text "Description text here"
```

## Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `--video` | Yes | Local video file path |
| `--to` | Yes | Recipient open_id (ou_xxx) or group chat_id (oc_xxx) |
| `--cover` / `--cover-url` | No | Cover image local path or URL |
| `--duration` | No | Video duration in milliseconds |
| `--title` | No | Post title (triggers rich text mode) |
| `--text` | No | Text content (triggers rich text mode, supports \n) |

## Two Modes

**Mode 1: Video only** (no `--title` or `--text`)
- Sends as `msg_type: "media"` — video card with play button
- Best for: quick video delivery

**Mode 2: Video + text** (with `--title` and/or `--text`)
- Sends as `msg_type: "post"` — rich text with embedded video
- Best for: sharing to groups with context/description

## Full Workflow (After Generation)

1. Get `video_url` and `thumbnail_url` from generation result
2. Download video: `curl -o /tmp/video.mp4 <video_url>`
3. Send with cover: `python3 feishu_send_video.py --video /tmp/video.mp4 --to ou_xxx --cover-url <thumbnail_url> --duration <ms>`

## Important Notes

- **ALWAYS include a cover image** — without it, the video shows a blank/black thumbnail
- If no thumbnail URL is available, extract the first frame with ffmpeg:
  ```bash
  ffmpeg -i video.mp4 -vframes 1 -q:v 2 cover.jpg -y
  ```
- **Duration is in milliseconds** — omitting it causes the player to show 00:00
- **File size limit ~25MB** — compress large videos with:
  ```bash
  ffmpeg -i input.mp4 -c:v libx264 -crf 28 -preset fast -c:a aac -b:a 128k output.mp4
  ```

## Technical Details

- Feishu video messages use `msg_type: "media"` (not "video" or "file")
- Upload video with `file_type: "mp4"` via `POST /im/v1/files`
- Upload cover with `image_type: "message"` via `POST /im/v1/images`
- Rich text uses `msg_type: "post"` with `{"tag": "media"}` and `{"tag": "text"}` content rows


path: docs/multi-platform.md
content: # Multi-Platform Video Delivery

After a video is generated, deliver it natively via the user's IM channel for the best experience.

## Delivery Scripts

| Platform | Script | Key Args |
|----------|--------|----------|
| Feishu | `scripts/feishu_send_video.py` | `--video`, `--to` (open_id), `--cover-url`, `--duration` (ms) |
| Telegram | `scripts/telegram_send_video.py` | `--video`, `--to` (chat_id), `--cover-url`, `--duration` (seconds), `--caption` |
| Discord | OpenClaw `message` tool | `action="send"`, `channel="discord"`, `target`, `filePath` |

## Platform Notes

### Feishu
- Reads credentials from `~/.openclaw/openclaw.json` (never hardcode).
- Supports cover image and duration metadata.
- See [feishu-send.md](feishu-send.md) for full details.

### Telegram
- Pass bot token via env var: `TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN python3 scripts/telegram_send_video.py ...`
- Duration is in **seconds** (not milliseconds).
- Max file size: 50 MB via Bot API.

### Discord
- Use the OpenClaw `message` tool directly: `message(action="send", channel="discord", target="<channel_id>", message="🎬 Video ready!", filePath="/tmp/medeo_result.mp4")`
- Download the video to a local path first via `curl -sL -o /tmp/medeo_result.mp4 "<video_url>"`.
- Max file size: 25 MB (8 MB for non-Nitro servers). For larger files, share `video_url` as a link.

### WhatsApp / Signal / Other
- Use the OpenClaw `message` tool with `media` parameter.
- Fallback: share `video_url` as a plain link.

## Security

- **Never pass bot tokens as CLI arguments** — they are visible in `ps` output.
- Always use environment variables: `TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`.
- Feishu credentials are read from config file automatically.


path: docs/recipes.md
content: # Recipes Guide

Browse and use Medeo's pre-built video recipes (templates).

## List Available Recipes

```bash
python3 {baseDir}/scripts/medeo_video.py recipes
```

Returns a list of recipe objects with:
- `recipe_id` — use with `--recipe-id` in generation
- `name` — human-readable name
- `description` — what the recipe produces
- `thumbnail_url` — preview image

## Paginate Through Recipes

```bash
python3 {baseDir}/scripts/medeo_video.py recipes --cursor <cursor_value>
```

The `cursor` value comes from the previous response. Keep calling with the new cursor until no more results.

## Use a Recipe in Video Generation

```bash
python3 {baseDir}/scripts/medeo_video.py spawn-task \
  --message "your video description" \
  --recipe-id "recipe_01..."
```

The recipe provides a pre-built style/structure. Your `--message` customizes the content within that structure.


path: requirements.txt
content: requests>=2.20.0


path: scripts/feishu_send_video.py
content: #!/usr/bin/env python3
"""
Send video messages via Feishu (Lark)

Usage:
    python3 feishu_send_video.py \
        --video /path/to/video.mp4 \
        --to ou_xxx \
        [--cover /path/to/cover.jpg | --cover-url https://...] \
        [--duration 20875]

Flow:
    1. Upload video file (file_type=mp4, with duration) -> get file_key
    2. Upload cover image (image_type=message) -> get image_key (optional)
    3. Send media message (msg_type=media)

Key points:
    - msg_type must be "media" (not "video" or "file")
    - file_type is "mp4" when uploading video
    - duration is in milliseconds; shows 00:00 if omitted
    - Cover image uploaded via im/v1/images for image_key, included in content
    - Sending without cover works but shows a black background with no preview

Feishu API reference:
    - Upload file: POST /open-apis/im/v1/files
    - Upload image: POST /open-apis/im/v1/images
    - Send message: POST /open-apis/im/v1/messages
"""

import argparse
import json
import os
import requests
import sys


def get_feishu_credentials():
    """Read Feishu credentials from openclaw config."""
    config_path = os.path.expanduser("~/.openclaw/openclaw.json")
    with open(config_path) as f:
        config = json.load(f)
    feishu = config.get("channels", {}).get("feishu", {})
    accounts = feishu.get("accounts", {})
    # Try accounts.main first, then top-level
    if accounts:
        main_acct = accounts.get("main", accounts.get("default", list(accounts.values())[0] if accounts else {}))
        app_id = main_acct.get("appId", "")
        app_secret = main_acct.get("appSecret", "")
    else:
        app_id = feishu.get("appId", "")
        app_secret = feishu.get("appSecret", "")
    if not app_id or not app_secret:
        print(json.dumps({
            "error": "Feishu credentials not found in ~/.openclaw/openclaw.json",
            "hint": "Configure channels.feishu.accounts.main.appId and appSecret"
        }), file=sys.stderr)
        sys.exit(1)
    return app_id, app_secret


def get_tenant_token():
    """Get tenant access token from Feishu."""
    app_id, app_secret = get_feishu_credentials()
    resp = requests.post(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        json={"app_id": app_id, "app_secret": app_secret},
    )
    resp.raise_for_status()
    return resp.json()["tenant_access_token"]


def upload_video(token, video_path, duration_ms=None):
    """Upload video file, return file_key."""
    data = {"file_type": "mp4", "file_name": os.path.basename(video_path)}
    if duration_ms:
        data["duration"] = str(int(duration_ms))

    with open(video_path, "rb") as f:
        resp = requests.post(
            "https://open.feishu.cn/open-apis/im/v1/files",
            headers={"Authorization": f"Bearer {token}"},
            data=data,
            files={"file": (os.path.basename(video_path), f, "video/mp4")},
            timeout=(15, 120),
        )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Upload failed: {result}")
    return result["data"]["file_key"]


def upload_cover(token, cover_source):
    """Upload cover image, return image_key. cover_source can be a local path or URL."""
    if cover_source.startswith("http"):
        # Download from URL
        img_resp = requests.get(cover_source, timeout=30)
        img_resp.raise_for_status()
        img_data = img_resp.content
        filename = "cover.jpg"
    else:
        with open(cover_source, "rb") as f:
            img_data = f.read()
        filename = os.path.basename(cover_source)

    resp = requests.post(
        "https://open.feishu.cn/open-apis/im/v1/images",
        headers={"Authorization": f"Bearer {token}"},
        data={"image_type": "message"},
        files={"image": (filename, img_data, "image/jpeg")},
        timeout=(15, 60),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Cover upload failed: {result}")
    return result["data"]["image_key"]


def send_media_message(token, to, file_key, image_key=None):
    """Send media message via Feishu."""
    content = {"file_key": file_key}
    if image_key:
        content["image_key"] = image_key

    # Strip common prefixes from OpenClaw inbound metadata (e.g. "chat:oc_xxx" → "oc_xxx")
    if to.startswith("chat:"):
        to = to[len("chat:"):]
    elif to.startswith("user:"):
        to = to[len("user:"):]

    # Determine receive_id_type based on prefix
    if to.startswith("oc_"):
        receive_id_type = "chat_id"
    else:
        receive_id_type = "open_id"

    resp = requests.post(
        f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={
            "receive_id": to,
            "msg_type": "media",
            "content": json.dumps(content),
        },
        timeout=(15, 30),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Send failed: {result}")
    return result["data"]["message_id"]


def main():
    parser = argparse.ArgumentParser(description="Send video messages via Feishu")
    parser.add_argument("--video", required=True, help="Video file path")
    parser.add_argument("--to", required=True, help="Recipient open_id (ou_xxx) or group chat_id (oc_xxx)")
    parser.add_argument("--cover", help="Cover image path or URL")
    parser.add_argument("--cover-url", help="Cover image URL (alias for --cover)")
    parser.add_argument("--duration", type=int, help="Video duration in milliseconds")
    args = parser.parse_args()

    cover = args.cover or args.cover_url

    [REDACTED_SECRET])
    print(f"[feishu] Uploading video: {args.video}", file=sys.stderr)
    file_key = upload_video(token, args.video, args.duration)
    print(f"[feishu] Video file_key: {file_key}", file=sys.stderr)

    image_key = None
    if cover:
        print(f"[feishu] Uploading cover: {cover}", file=sys.stderr)
        image_key = upload_cover(token, cover)
        print(f"[feishu] Cover image_key: {image_key}", file=sys.stderr)

    msg_id = send_media_message(token, args.to, file_key, image_key)
    print(json.dumps({
        "status": "ok",
        "message_id": msg_id,
        "file_key": file_key,
        "image_key": image_key,
    }))


if __name__ == "__main__":
    main()


path: scripts/medeo_video.py
content: #!/usr/bin/env python3
"""
Medeo Video Generator — AI-powered video creation via Medeo API.

Flow: Upload Media → Create Video (Compose) → Render Video
API:  https://api.prd.medeo.app

Requires:
  - pip install requests
  - Config with Medeo API key
"""

import argparse
import json
import os
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple


try:
    import requests
except ImportError:
    print(json.dumps({
        "error": "requests package not installed",
        "install_command": "pip install requests"
    }), file=sys.stderr)
    sys.exit(1)


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

STATE_DIR = Path(os.path.expanduser("~/.openclaw/workspace/medeo-video"))
CONFIG_FILE = STATE_DIR / "config.json"
LAST_JOB_FILE = STATE_DIR / "last_job.json"
HISTORY_DIR = STATE_DIR / "history"

# Defaults
DEFAULT_ENV = os.environ.get("MEDEO_ENV", "prd")

ENV_DEFAULTS = {
    "prd": {
        "baseUrl": "https://api.prd.medeo.app",
        "ossBaseUrl": "https://oss.prd.medeo.app",
        "apiKeyUrl": os.environ.get(
            "MEDEO_SIGNUP_URL",
            "https://medeo.app/dev/apikey",
        ),
    },
}

# Top-up / recharge URL (for insufficient credits)
MEDEO_TOPUP_URL = os.environ.get("MEDEO_TOPUP_URL", "https://medeo.app/")

# Network
CONNECT_TIMEOUT = 15   # seconds — TCP handshake
READ_TIMEOUT = 60      # seconds — wait for response body
MAX_RETRIES = 2

# Polling
POLL_INITIAL_INTERVAL = 3     # seconds
POLL_MAX_INTERVAL = 10        # seconds
POLL_BACKOFF_FACTOR = 1.5
UPLOAD_MAX_ATTEMPTS = 90      # ~5 min
COMPOSE_MAX_ATTEMPTS = 360    # ~30 min
RENDER_MAX_ATTEMPTS = 360     # ~30 min

# Valid asset sources
VALID_ASSET_SOURCES = ["ai_images", "ai_videos", "my_uploaded_assets", "stock_videos"]
VALID_ASPECT_RATIOS = ["16:9", "9:16"]

# Upload safety limits
MAX_UPLOAD_SIZE = 100 * 1024 * 1024   # 100 MB
ALLOWED_URL_SCHEMES = ("http://", "https://")

# Supported file extensions → MIME type mapping
CONTENT_TYPE_MAP = {
    "jpg": "image/jpeg", "jpeg": "image/jpeg",
    "png": "image/png", "webp": "image/webp", "gif": "image/gif",
    "mp4": "video/mp4", "mov": "video/quicktime", "avi": "video/x-msvideo",
}

# Sessions Spawn defaults
SPAWN_TIMEOUT_SECONDS = 2400  # 40 minutes


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class MedeoApiError(Exception):
    """Raised when a Medeo API call fails."""
    def __init__(self, message: str, code: str = "", case: str = "",
                 status_code: int = 0, details: Any = None):
        super().__init__(message)
        self.code = code
        self.case = case
        self.status_code = status_code
        self.details = details

    def is_retryable(self) -> bool:
        """Return True if this error is a transient server-side failure."""
        # status_code default is 0 (no HTTP context); 0 < 500 so this won't trigger
        if self.status_code >= 500:
            return True
        try:
            if int(self.code) == 13:  # gRPC INTERNAL
                return True
        except (ValueError, TypeError):
            pass
        # network_error: RequestException (timeout, DNS, connection reset)
        _RETRYABLE_CASES = frozenset({
            "internal_error", "service_unavailable", "upstream_error", "network_error",
        })
        if self.case in _RETRYABLE_CASES:
            return True
        return False

    def to_dict(self) -> dict:
        d = {"error": str(self), "code": self.code, "case": self.case}
        if self.status_code:
            d["http_status"] = self.status_code
        if self.details:
            d["details"] = self.details
        return d


class MedeoPollTimeout(Exception):
    """Raised when polling exceeds max attempts."""
    def __init__(self, stage: str, attempts: int, elapsed: float,
                 last_status: str = ""):
        msg = (f"{stage} polling timed out after {attempts} attempts "
               f"({elapsed:.0f}s), last status: {last_status}")
        super().__init__(msg)
        self.stage = stage
        self.attempts = attempts
        self.elapsed = elapsed
        self.last_status = last_status


class MedeoPollFailed(Exception):
    """Raised when a polled job enters a terminal failure state."""
    def __init__(self, stage: str, status: str, error_detail: Any = None):
        msg = f"{stage} failed with status: {status}"
        super().__init__(msg)
        self.stage = stage
        self.status = status
        self.error_detail = error_detail


# ---------------------------------------------------------------------------
# Config / Credential Loading
# ---------------------------------------------------------------------------

def _ensure_dir():
    STATE_DIR.mkdir(parents=True, exist_ok=True)


def _ensure_history_dir():
    HISTORY_DIR.mkdir(parents=True, exist_ok=True)


def _load_raw_config() -> dict:
    """Load raw config from config.json (fallback only)."""
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE, "r") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            pass
    return {}


def _load_config_from_env(env: Optional[str] = None) -> dict:
    """
    Load Medeo config from environment variables (primary source).

    Environment variable: MEDEO_API_KEY (set via raw-config.json → env section)

    Base URLs are derived automatically.
    """
    [REDACTED_SECRET]"MEDEO_API_KEY", "")

    return {
        "env": env or DEFAULT_ENV,
        "apiKey": api_key,
    }


def _load_config_from_file(env: Optional[str] = None) -> dict:
    """
    Fallback: load config from config.json.

    Supports:
    - Multi-env format: {"prd": {"apiKey": "..."}}
    - Flat format: {"apiKey": "..."}
    """
    raw = _load_raw_config()
    if not raw:
        return {}

    target = env or DEFAULT_ENV

    # Multi-env format
    if target in raw and isinstance(raw[target], dict):
        cfg = dict(raw[target])
        cfg.setdefault("env", target)
        return cfg

    # Flat format
    if "apiKey" in raw:
        cfg = dict(raw)
        cfg.setdefault("env", target)
        return cfg

    return {"env": target}


def _load_api_key_from_sys_env() -> str:
    """Legacy fallback removed. API key resolution uses env var + config.json only."""
    return ""


def _get_config(env: Optional[str] = None) -> dict:
    """
    Get full resolved config.

    Resolution priority:
      1. Environment variable MEDEO_API_KEY (set via openclaw env injection or shell)
      2. Skill-local config file (~/.openclaw/workspace/medeo-video/config.json)
      3. Built-in defaults (base URLs)
    """
    target = env or DEFAULT_ENV
    defaults = ENV_DEFAULTS.get(target, ENV_DEFAULTS["prd"])

    # 1) Environment variable (primary — from raw-config.json env section)
    env_cfg = _load_config_from_env(target)
    [REDACTED_SECRET]"apiKey", "")

    # 2) Fallback to config.json
    file_cfg = {}
    if not [REDACTED_SECRET] = _load_config_from_file(target)
        [REDACTED_SECRET]"apiKey", "")

    return {
        "env": target,
        "apiKey": api_key,
        "baseUrl": file_cfg.get("baseUrl", defaults["baseUrl"]),
        "ossBaseUrl": file_cfg.get("ossBaseUrl", defaults["ossBaseUrl"]),
    }


def _check_api_key(config: dict):
    """Exit with instructions if API key is missing."""
    if not config.get("apiKey"):
        get_key_url = ENV_DEFAULTS.get(DEFAULT_ENV, ENV_DEFAULTS["prd"])["apiKeyUrl"]
        print(json.dumps({
            "error": "Medeo API key not configured",
            "setup_required": True,
            "step1": f"Visit {get_key_url} to get your Medeo API key (starts with mk_)",
            "step2": 'Run: python3 scripts/medeo_video.py config-init --api-key "mk_your_key_here"',
        }, indent=2), file=sys.stderr)
        sys.exit(1)


# ---------------------------------------------------------------------------
# Low-Level HTTP Helpers
# ---------------------------------------------------------------------------

def _headers(api_key: str, idempotency_key: Optional[str] = None) -> dict:
    h = {
        "X-API-KEY": api_key,
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    if idempotency_key:
        h["Idempotency-Key"] = idempotency_key
    return h


def _parse_error_response(resp: requests.Response) -> MedeoApiError:
    """Parse a Medeo error response into a MedeoApiError."""
    try:
        body = resp.json()
        return MedeoApiError(
            message=body.get("message", f"HTTP {resp.status_code}"),
            code=str(body.get("code", "")),
            case=body.get("case", ""),
            status_code=resp.status_code,
            details=body.get("details"),
        )
    except (ValueError, KeyError):
        return MedeoApiError(
            message=f"HTTP {resp.status_code}: {resp.text[:500]}",
            status_code=resp.status_code,
        )


def _user_friendly_error(stage: str = "") -> dict:
    """Return a sanitized, user-facing error payload for retryable failures."""
    prefix = f"{stage} failed: " if stage else ""
    return {
        "error": "service_unavailable",
        "message": f"{prefix}The video generation service is temporarily unavailable. Please try again in a few minutes.",
        "retryable": True,
    }


def _is_poll_failure_retryable(e: "MedeoPollFailed") -> bool:
    """Determine if a MedeoPollFailed is caused by a transient server error."""
    detail_str = (json.dumps(e.error_detail) if e.error_detail else "").upper()
    # gRPC INTERNAL (code 13) — the known embedding service failure
    if '"CODE": 13' in detail_str or '"CODE":"13"' in detail_str:
        return True
    # Explicit embedding service error message
    if "FAILED TO GENERATE EMBEDDING" in detail_str:
        return True
    # Generic server-side markers in the detail payload
    if any(kw in detail_str for kw in ("INTERNAL_ERROR", "INTERNAL ERROR",
                                        "SERVICE_UNAVAILABLE", "UPSTREAM_ERROR")):
        return True
    return False


def _api_get(base_url: str, path: str, api_key: str,
             params: Optional[dict] = None) -> dict:
    """GET request to Medeo API with retry."""
    url = f"{base_url}{path}"
    last_err = None

    for attempt in range(MAX_RETRIES + 1):
        try:
            resp = requests.get(
                url,
                headers=_headers(api_key),
                params=params,
                timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
            )
            if resp.status_code == 200:
                return resp.json()
            last_err = _parse_error_response(resp)
            # Don't retry 4xx
            if 400 <= resp.status_code < 500:
                raise last_err
        except MedeoApiError:
            raise
        except requests.RequestException as e:
            last_err = MedeoApiError(
                message=f"Network error: {e}",
                case="network_error",
            )

        if attempt < MAX_RETRIES:
            time.sleep(2 ** attempt)

    raise last_err or MedeoApiError("Unknown error after retries")


def _api_get_public(base_url: str, path: str,
                    params: Optional[dict] = None) -> dict:
    """GET request to a public Medeo endpoint (no auth)."""
    url = f"{base_url}{path}"
    last_err = None

    for attempt in range(MAX_RETRIES + 1):
        try:
            resp = requests.get(
                url,
                headers={"Accept": "application/json"},
                params=params,
                timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
            )
            if resp.status_code == 200:
                return resp.json()
            last_err = _parse_error_response(resp)
            if 400 <= resp.status_code < 500:
                raise last_err
        except MedeoApiError:
            raise
        except requests.RequestException as e:
            last_err = MedeoApiError(
                message=f"Network error: {e}",
                case="network_error",
            )

        if attempt < MAX_RETRIES:
            time.sleep(2 ** attempt)

    raise last_err or MedeoApiError("Unknown error after retries")


def _api_post(base_url: str, path: str, api_key: str,
              body: dict, idempotency_key: Optional[str] = None) -> dict:
    """POST request to Medeo API with retry."""
    url = f"{base_url}{path}"
    idem_key = idempotency_key  # May be None; see _headers()
    last_err = None

    for attempt in range(MAX_RETRIES + 1):
        try:
            resp = requests.post(
                url,
                headers=_headers(api_key, idem_key),
                json=body,
                timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
            )
            if resp.status_code == 200:
                return resp.json()
            last_err = _parse_error_response(resp)
            if 400 <= resp.status_code < 500:
                raise last_err
        except MedeoApiError:
            raise
        except requests.RequestException as e:
            last_err = MedeoApiError(
                message=f"Network error: {e}",
                case="network_error",
            )

        if attempt < MAX_RETRIES:
            time.sleep(2 ** attempt)

    raise last_err or MedeoApiError("Unknown error after retries")


# ---------------------------------------------------------------------------
# Progress Reporting (stderr)
# ---------------------------------------------------------------------------


# Timeline instruction to ensure AI places clips on the track
TIMELINE_INSTRUCTION = """[WORKFLOW REQUIREMENT - DO NOT SKIP]
You MUST complete the following steps in order for every video creation request:

Step 1: Generate video content (using generate_video or other content generation tools)
Step 2: **MANDATORY** - Call editor_create_video_clips to place ALL generated clips on the timeline
   - Include ALL media_id values from generated content
   - Set appropriate duration_ms for each clip (use the actual media duration or user preference)
   - This step is REQUIRED - the video cannot be completed without it

Important: A video is NOT complete until clips are placed on the timeline using editor_create_video_clips. Do not stop after generating content - you must place the clips to create a valid video draft.

User request: """

def _log(msg: str):
    """Print progress info to stderr."""
    print(f"[medeo] {msg}", file=sys.stderr, flush=True)


# ---------------------------------------------------------------------------
# Medeo API Endpoint Functions
# ---------------------------------------------------------------------------

def api_list_recipes(config: dict, limit: int = 20, order: str = "desc",
                     starting_after: Optional[str] = None) -> dict:
    """List available video recipes (public endpoint)."""
    params = {"limit": limit, "order": order}
    if starting_after:
        params["starting_after"] = starting_after
    return _api_get_public(config["baseUrl"], "/api/v2/recipes", params)


def api_create_media_from_url(config: dict, url: str,
                              project_id: Optional[str] = None) -> dict:
    """Upload a media asset from URL (async). Returns job info."""
    body: Dict[str, Any] = {"url": url}
    if project_id:
        body["project_id"] = project_id
    return _api_post(
        config["baseUrl"],
        "/api/v2/medias:create_from_url",
        config["apiKey"],
        body,
    )


def api_get_media_job_status(config: dict, job_id: str) -> dict:
    """Poll media creation job status."""
    return _api_get(
        config["baseUrl"],
        "/api/v2/medias:create_medias_job",
        config["apiKey"],
        params={"job_id": job_id},
    )


def api_prepare_for_upload(config: dict, metadata_list: List[Dict[str, Any]]) -> dict:
    """
    Prepare for direct file upload: validate metadata and get presigned S3 URLs.

    Args:
        metadata_list: list of {"filename": str, "size_bytes": int, "extension": str}

    Returns:
        {"results": [{"is_valid": bool, "storage_key": str, "presigned_url": str, ...}]}
    """
    return _api_post(
        config["baseUrl"],
        "/api/v2/medias:prepare_for_upload",
        config["apiKey"],
        {"metadata_list": metadata_list},
    )


def api_create_from_upload(config: dict,
                           uploaded_fileinfo_list: List[Dict[str, Any]],
                           project_id: Optional[str] = None,
                           idempotency_key: Optional[str] = None) -> dict:
    """
    Create medias from pre-uploaded files (after presigned PUT).

    Args:
        uploaded_fileinfo_list: list of {"storage_key": str, "file_metadata": {...}}
        project_id: optional project ID
        idempotency_key: optional dedup key

    Returns:
        {"jobs": [{"id": str, "state": str, "media_ids": [...]}]}
    """
    body: Dict[str, Any] = {"uploaded_fileinfo_list": uploaded_fileinfo_list}
    if project_id:
        body["project_id"] = project_id

    return _api_post(
        config["baseUrl"],
        "/api/v2/medias:create_from_upload",
        config["apiKey"],
        body,
        idempotency_key=idempotency_key,
    )


def api_initiate_video_creation(config: dict, message_text: str,
                                media_ids: Optional[List[str]] = None,
                                settings: Optional[dict] = None) -> dict:
    """Initiate AI video creation workflow."""
    body: Dict[str, Any] = {
        "message": {
            "sender_id": f"sk_{config['apiKey'][-8:]}",
            "content": [
                {
                    "text": {
                        "type": "text",
                        "text": TIMELINE_INSTRUCTION + message_text,
                    }
                }
            ],
        },
        "settings": settings or {},
    }
    if media_ids:
        body["media_ids"] = media_ids
    return _api_post(
        config["baseUrl"],
        "/api/v2/create_video_jobs:initiate_video_creation",
        config["apiKey"],
        body,
    )


def api_get_last_task_status(config: dict, chat_session_id: str) -> dict:
    """Poll video creation task status."""
    return _api_get(
        config["baseUrl"],
        f"/api/v2/chat_sessions/{chat_session_id}/last_task_status",
        config["apiKey"],
    )


def api_create_render_job(config: dict,
                          video_draft_op_record_id: str) -> dict:
    """Create a video rendering job."""
    return _api_post(
        config["baseUrl"],
        "/api/v2/render_video_jobs",
        config["apiKey"],
        {"video_draft_op_record_id": video_draft_op_record_id},
    )


def api_query_render_job(config: dict,
                         video_draft_op_record_id: str) -> dict:
    """Query video rendering job status."""
    return _api_get(
        config["baseUrl"],
        "/api/v2/render_video_jobs",
        config["apiKey"],
        params={"video_draft_op_record_id": video_draft_op_record_id},
    )


# ---------------------------------------------------------------------------
# Polling Functions
# ---------------------------------------------------------------------------

def poll_media_upload(config: dict, job_id: str,
                      label: str = "Upload") -> dict:
    """
    Poll media upload job until completed.
    Returns: {"id": ..., "state": "completed", "media_ids": [...]}
    """
    interval = POLL_INITIAL_INTERVAL
    start = time.time()

    for attempt in range(1, UPLOAD_MAX_ATTEMPTS + 1):
        time.sleep(interval)
        try:
            result = api_get_media_job_status(config, job_id)
        except MedeoApiError as e:
            # 404 = job not yet visible in DB; retry instead of aborting
            if getattr(e, "status_code", None) == 404 and attempt <= 5:
                _log(f"{label}: attempt {attempt}, job not found yet (404), retrying...")
                interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)
                continue
            raise
        state = result.get("state", "")
        elapsed = time.time() - start

        if attempt % 3 == 0 or state in ("completed", "failed"):
            _log(f"{label}: attempt {attempt}, state={state}, "
                 f"elapsed={elapsed:.0f}s")

        if state == "completed":
            media_ids = result.get("media_ids", [])
            if media_ids:
                return result
            # state is completed but media_ids empty — keep polling briefly
            _log(f"{label}: completed but media_ids empty, continuing...")

        if state == "failed":
            raise MedeoPollFailed("upload", state, result)

        interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)

    elapsed = time.time() - start
    raise MedeoPollTimeout("upload", UPLOAD_MAX_ATTEMPTS, elapsed, state)


def poll_video_creation(config: dict, chat_session_id: str) -> dict:
    """
    Poll video creation (compose) until completed.
    Returns: {"status": "completed", "video_draft_op_record_id": "..."}
    """
    interval = POLL_INITIAL_INTERVAL
    start = time.time()
    last_status = ""

    completed_without_op_id = 0

    for attempt in range(1, COMPOSE_MAX_ATTEMPTS + 1):
        time.sleep(interval)
        try:
            result = api_get_last_task_status(config, chat_session_id)
        except MedeoApiError as e:
            if e.is_retryable():
                _log(f"Compose: transient error on attempt {attempt}: {e}, retrying...")
                interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)
                continue
            raise
        status = result.get("status", "")
        last_status = status
        elapsed = time.time() - start

        if attempt % 5 == 0 or status in ("completed", "failed", "aborted"):
            _log(f"Compose: attempt {attempt}, status={status}, "
                 f"elapsed={elapsed:.0f}s")

        if status == "completed":
            op_id = result.get("video_draft_op_record_id", "")
            if op_id:
                return result
            completed_without_op_id += 1
            if completed_without_op_id > 5:
                raise MedeoApiError(
                    "Compose completed but video_draft_op_record_id missing after 5 checks",
                    case="invalid_response",
                )
            _log("Compose: completed but no video_draft_op_record_id, "
                 "continuing...")

        if status in ("failed", "aborted"):
            raise MedeoPollFailed("compose", status, result)

        interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)

    elapsed = time.time() - start
    raise MedeoPollTimeout("compose", COMPOSE_MAX_ATTEMPTS, elapsed,
                           last_status)


def poll_render_job(config: dict,
                    video_draft_op_record_id: str) -> dict:
    """
    Poll render job until completed.
    Returns full render result with url and metadata.
    """
    interval = POLL_INITIAL_INTERVAL
    start = time.time()
    last_status = ""

    completed_without_url = 0

    for attempt in range(1, RENDER_MAX_ATTEMPTS + 1):
        time.sleep(interval)
        try:
            result = api_query_render_job(config, video_draft_op_record_id)
        except MedeoApiError as e:
            if e.is_retryable():
                _log(f"Render: transient error on attempt {attempt}: {e}, retrying...")
                interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)
                continue
            raise
        status = result.get("status", "")
        last_status = status
        elapsed = time.time() - start

        if attempt % 3 == 0 or status in ("completed", "failed", "stopped"):
            _log(f"Render: attempt {attempt}, status={status}, "
                 f"elapsed={elapsed:.0f}s")

        if status == "completed":
            res = result.get("result", {})
            if res and res.get("url"):
                return result
            completed_without_url += 1
            if completed_without_url > 5:
                raise MedeoApiError(
                    "Render completed but video URL missing after 5 checks",
                    case="invalid_response",
                )
            _log("Render: completed but no URL yet, continuing...")

        if status in ("failed", "stopped"):
            raise MedeoPollFailed("render", status,
                                  result.get("result", {}).get("error"))

        interval = min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL)

    elapsed = time.time() - start
    raise MedeoPollTimeout("render", RENDER_MAX_ATTEMPTS, elapsed,
                           last_status)


# ---------------------------------------------------------------------------
# High-Level Operations
# ---------------------------------------------------------------------------

def resolve_video_url(config: dict, relative_url: str) -> str:
    """Convert a relative video URL to a full OSS URL."""
    if relative_url.startswith("http://") or relative_url.startswith("https://"):
        return relative_url
    oss_base = config["ossBaseUrl"].rstrip("/")
    return f"{oss_base}/{relative_url.lstrip('/')}"


def upload_media_urls(config: dict, urls: List[str]) -> List[str]:
    """
    Upload multiple media URLs and return their media_ids.
    Each URL is uploaded and polled until complete.
    """
    all_media_ids = []

    for i, url in enumerate(urls, 1):
        _log(f"Uploading media {i}/{len(urls)}: {url}")
        job = api_create_media_from_url(config, url)
        job_id = job.get("id", "")
        _log(f"Upload job created: {job_id}")

        result = poll_media_upload(config, job_id,
                                   label=f"Upload {i}/{len(urls)}")
        media_ids = result.get("media_ids", [])
        _log(f"Upload {i}/{len(urls)} complete: media_ids={media_ids}")
        all_media_ids.extend(media_ids)

    return all_media_ids


def build_settings(args) -> dict:
    """Build VideoCreationSettings from CLI args."""
    settings: Dict[str, Any] = {}

    if hasattr(args, "recipe_id") and args.recipe_id:
        settings["recipe_id"] = args.recipe_id

    if hasattr(args, "duration_ms") and args.duration_ms:
        settings["duration_ms"] = args.duration_ms

    if hasattr(args, "aspect_ratio") and args.aspect_ratio:
        settings["aspect_ratio"] = args.aspect_ratio

    if hasattr(args, "voice_id") and args.voice_id:
        settings["voice_id"] = args.voice_id

    if hasattr(args, "video_style_id") and args.video_style_id:
        settings["video_style_id"] = args.video_style_id

    if hasattr(args, "asset_sources") and args.asset_sources:
        settings["asset_sources"] = args.asset_sources

    return settings


def save_job_record(record: dict):
    """Save job record to last_job.json and history."""
    _ensure_dir()
    with open(LAST_JOB_FILE, "w") as f:
        json.dump(record, f, indent=2, ensure_ascii=False)

    # Save to history
    _ensure_history_dir()
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    hist_file = HISTORY_DIR / f"job_{ts}.json"
    with open(hist_file, "w") as f:
        json.dump(record, f, indent=2, ensure_ascii=False)

    # Prune old history (keep last 50)
    history_files = sorted(HISTORY_DIR.glob("job_*.json"))
    if len(history_files) > 50:
        for old_file in history_files[:-50]:
            old_file.unlink(missing_ok=True)


# ---------------------------------------------------------------------------
# Sessions Spawn Task Builder
# ---------------------------------------------------------------------------

def build_spawn_task(message, media_urls=None, media_ids=None,
                     recipe_id=None, duration_ms=None,
                     aspect_ratio=None, voice_id=None,
                     video_style_id=None, asset_sources=None,
                     no_render=False, script_path=None,
                     deliver_to=None, deliver_channel=None):
    """Build a sessions_spawn payload for async video generation.

    Returns a dict with 'sessions_spawn_args' ready to pass to the
    OpenClaw sessions_spawn tool, plus the raw command for reference.
    """
    if script_path is None:
        script_path = str(Path(__file__).resolve())

    cmd_parts = ["python3", script_path]
    cmd_parts.append("generate")
    cmd_parts.extend(["--message", json.dumps(message, ensure_ascii=False)])

    if media_urls:
        cmd_parts.append("--media-urls")
        for u in media_urls:
            cmd_parts.append(json.dumps(u))
    if media_ids:
        cmd_parts.append("--media-ids")
        cmd_parts.extend(json.dumps(mid) for mid in media_ids)
    if recipe_id:
        cmd_parts.extend(["--recipe-id", recipe_id])
    if duration_ms is not None:
        cmd_parts.extend(["--duration-ms", str(duration_ms)])
    if aspect_ratio:
        cmd_parts.extend(["--aspect-ratio", aspect_ratio])
    if voice_id:
        cmd_parts.extend(["--voice-id", voice_id])
    if video_style_id:
        cmd_parts.extend(["--video-style-id", video_style_id])
    if asset_sources:
        cmd_parts.append("--asset-sources")
        cmd_parts.extend(json.dumps(s) for s in asset_sources)
    if no_render:
        cmd_parts.append("--no-render")

    cmd_str = " ".join(cmd_parts)

    # Determine delivery target and channel for the task template
    deliver_to_str = deliver_to or "<user_id>"
    deliver_channel_str = deliver_channel or "feishu"
    base_dir = str(Path(__file__).resolve().parent.parent)

    # Build channel-specific delivery instructions
    if deliver_channel_str == "feishu":
        delivery_instructions = (
            "  2. **Deliver the video via Feishu:**\n"
            "     Download the video locally, then send via feishu_send_video.py:\n"
            "     ```\n"
            "     curl -sL -o /tmp/medeo_result.mp4 \"<video_url>\"\n"
            "     python3 {base_dir}/scripts/feishu_send_video.py "
            "--video /tmp/medeo_result.mp4 --to \"{deliver_to}\" "
            "--cover-url \"<thumbnail_url>\" "
            "--duration <duration_ms>\n"
            "     ```\n"
            "     Note: --to accepts both ou_xxx (private chat) and "
            "oc_xxx (group chat). Use exactly the value shown above."
        ).format(base_dir=base_dir, deliver_to=deliver_to_str)
    elif deliver_channel_str == "telegram":
        delivery_instructions = (
            "  2. **Deliver the video via Telegram:**\n"
            "     ```\n"
            "     curl -sL -o /tmp/medeo_result.mp4 \"<video_url>\"\n"
            "     TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN "
            "python3 {base_dir}/scripts/telegram_send_video.py "
            "--video /tmp/medeo_result.mp4 --to \"{deliver_to}\" "
            "--cover-url \"<thumbnail_url>\" "
            "--duration <duration_seconds> "
            "--caption \"🎬 Video ready!\"\n"
            "     ```"
        ).format(base_dir=base_dir, deliver_to=deliver_to_str)
    elif deliver_channel_str == "discord":
        delivery_instructions = (
            "  2. **Deliver the video via Discord:**\n"
            "     Download the video first:\n"
            "     ```\n"
            "     curl -sL -o /tmp/medeo_result.mp4 \"<video_url>\"\n"
            "     ```\n"
            "     Then use the `message` tool:\n"
            "     `message(action=\"send\", channel=\"discord\", "
            "target=\"{deliver_to}\", message=\"🎬 Video ready!\", "
            "filePath=\"/tmp/medeo_result.mp4\")`\n"
            "     (25 MB limit; for larger files share video_url as link)"
        ).format(deliver_to=deliver_to_str)
    else:
        delivery_instructions = (
            "  2. **Deliver the video:** Use the `message` tool with "
            "`media` parameter to send the video to \"{deliver_to}\", "
            "or share the video_url as a link if native sending is "
            "unavailable."
        ).format(deliver_to=deliver_to_str)

    task_text = (
        "You are a video generation worker. Execute the following command "
        "and report the result back to the user.\n\n"
        "Command:\n"
        "```\n"
        "{cmd}\n"
        "```\n\n"
        "This command runs the full Medeo video pipeline "
        "(upload -> compose -> render) and typically takes 5-30 minutes. "
        "Wait for the command to complete — do NOT background it.\n\n"
        "When the command finishes:\n"
        "- If the JSON output contains \"status\": \"completed\":\n"
        "  1. Extract \"video_url\", \"thumbnail_url\", and "
        "\"total_duration_seconds\" from the JSON output.\n"
        "{delivery_instructions}\n"
        "  3. Always include a short text summary: prompt used, "
        "duration, resolution.\n"
        "- If the error indicates insufficient credits ("
        "insufficient balance / quota exceeded), inform the user that their "
        "Medeo credits have run out and guide them to top up at "
        "{topup_url} — once recharged, they can ask to retry.\n"
        "- For other failures, announce the error message and suggest the "
        "user try again or adjust their prompt."
    ).format(cmd=cmd_str, topup_url=MEDEO_TOPUP_URL,
             delivery_instructions=delivery_instructions)

    label = message[:60] + "..." if len(message) > 60 else message

    return {
        "sessions_spawn_args": {
            "task": task_text,
            "label": "medeo: " + label,
            "runTimeoutSeconds": SPAWN_TIMEOUT_SECONDS,
        },
        "command": cmd_str,
    }


# ---------------------------------------------------------------------------
# CLI Command Handlers
# ---------------------------------------------------------------------------

def cmd_spawn_task(args, config):
    """Generate sessions_spawn payload for async video generation."""
    _check_api_key(config)

    result = build_spawn_task(
        message=args.message,
        media_urls=args.media_urls,
        media_ids=args.media_ids if hasattr(args, "media_ids") else None,
        recipe_id=getattr(args, "recipe_id", None),
        duration_ms=getattr(args, "duration_ms", None),
        aspect_ratio=getattr(args, "aspect_ratio", None),
        voice_id=getattr(args, "voice_id", None),
        video_style_id=getattr(args, "video_style_id", None),
        asset_sources=getattr(args, "asset_sources", None),
        no_render=getattr(args, "no_render", False),
        deliver_to=getattr(args, "deliver_to", None),
        deliver_channel=getattr(args, "deliver_channel", None),
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_recipes(args, config: dict):
    """List available video recipes."""
    params = {"limit": args.limit, "order": args.order}
    if args.starting_after:
        params["starting_after"] = args.starting_after

    result = api_list_recipes(config, **params)
    recipes = result.get("recipes", result.get("list", []))

    output = {
        "count": len(recipes),
        "has_more": result.get("has_more", False),
        "next_cursor": result.get("next_cursor"),
        "recipes": [
            {
                "id": r.get("id"),
                "name": r.get("name"),
                "description": r.get("description"),
                "aspect_ratio": (r.get("video_settings") or {}).get("aspect_ratio"),
                "duration_ms": (r.get("video_settings") or {}).get("duration_ms"),
                "asset_sources": (r.get("video_settings") or {}).get("asset_sources"),
                "user_prompt": r.get("user_prompt"),
                "label": r.get("label"),
                "is_new": r.get("is_new"),
                "status": r.get("status"),
            }
            for r in recipes
        ],
    }
    print(json.dumps(output, indent=2, ensure_ascii=False))


def cmd_upload(args, config: dict):
    """Upload media from URL."""
    _check_api_key(config)

    if not any(args.url.startswith(s) for s in ALLOWED_URL_SCHEMES):
        print(json.dumps({
            "error": "Invalid URL scheme. Only http:// and https:// are allowed.",
            "hint": "Provide a public HTTP(S) URL to the image or video.",
        }), file=sys.stderr)
        sys.exit(1)

    _log(f"Uploading media from: {args.url}")
    job = api_create_media_from_url(config, args.url, args.project_id)
    job_id = job.get("id", "")
    _log(f"Upload job created: {job_id}")

    if args.no_wait:
        print(json.dumps({
            "job_id": job_id,
            "state": job.get("state", "initial"),
            "message": "Upload initiated. Use upload-status to poll.",
        }, indent=2))
        return

    result = poll_media_upload(config, job_id)
    media_ids = result.get("media_ids", [])
    _log(f"Upload complete! media_ids: {media_ids}")

    print(json.dumps({
        "job_id": job_id,
        "state": "completed",
        "media_ids": media_ids,
    }, indent=2))


def _upload_file_bytes(config: dict, file_bytes: bytes, filename: str,
                       extension: str, project_id: Optional[str] = None) -> str:
    """
    Upload raw file bytes via presigned S3 URL.

    Flow: prepare_for_upload → PUT presigned_url → create_from_upload → poll → media_id

    Returns: media_id (str)
    Raises: MedeoApiError | MedeoPollTimeout | MedeoPollFailed
    """
    size_bytes = len(file_bytes)
    ext = extension.lower().lstrip(".")
    _log(f"Preparing upload: {filename} ({size_bytes} bytes, .{ext})")

    # Step 1: get presigned URL + storage_key
    prep_resp = api_prepare_for_upload(config, [{
        "filename": filename,
        "size_bytes": size_bytes,
        "extension": ext,
    }])
    result = prep_resp.get("results", [{}])[0]

    if not result.get("is_valid"):
        err_msg = result.get("error_message", "File rejected by Medeo API")
        raise MedeoApiError(
            message=f"Upload preparation failed: {err_msg}",
            case="upload_prep_rejected",
        )

    presigned_url = result["presigned_url"]
    storage_key = result["storage_key"]
    _log(f"Got presigned URL, storage_key={storage_key}")

    # Step 2: PUT file bytes directly to S3
    content_type = CONTENT_TYPE_MAP.get(ext, f"application/octet-stream")
    put_resp = requests.put(
        presigned_url,
        data=file_bytes,
        headers={"Content-Type": content_type},
        timeout=(CONNECT_TIMEOUT, 120),
    )
    if put_resp.status_code not in (200, 204):
        raise MedeoApiError(
            message=f"S3 presigned PUT failed: HTTP {put_resp.status_code}",
            case="s3_put_failed",
            status_code=put_resp.status_code,
        )
    _log("File uploaded to S3 successfully")

    # Step 3: register with Medeo (create_from_upload)
    # NOTE: Do NOT pass Idempotency-Key here — the prd API has a bug where
    # jobs created with an idempotency key are never visible to the poll
    # endpoint (create_medias_job returns 404 forever).  Discovered 2026-03-11.
    create_resp = api_create_from_upload(
        config,
        uploaded_fileinfo_list=[{
            "storage_key": storage_key,
            "file_metadata": {
                "filename": filename,
                "size_bytes": size_bytes,
                "extension": ext,
            },
        }],
        project_id=project_id,
    )

    jobs = create_resp.get("jobs", [])
    if not jobs:
        raise MedeoApiError(
            message="create_from_upload returned no jobs",
            case="no_jobs_returned",
        )
    job_id = jobs[0]["id"]
    _log(f"Media registration job created: {job_id}")

    # Step 4: poll for completion
    poll_result = poll_media_upload(config, job_id, label="FileUpload")
    media_ids = poll_result.get("media_ids", [])
    if not media_ids:
        raise MedeoApiError(
            message="Upload job completed but returned no media_ids",
            case="no_media_ids",
        )
    return media_ids[0]


def cmd_upload_file(args, config: dict):
    """
    Upload a local file or download+upload from an IM attachment URL.

    Supports:
    - Local file:      --file /tmp/photo.jpg
    - Direct URL:      --url https://cdn.example.com/photo.jpg  (downloaded first)
    - Telegram:        --telegram-file-id <file_id>  (requires TELEGRAM_BOT_TOKEN env var)
    - Feishu:          --feishu-message-id <msg_id> --feishu-image-key <key>
    """
    _check_api_key(config)

    file_bytes: Optional[bytes] = None
    filename: str = "upload"
    extension: str = "jpg"

    # --- Source: local file ---
    if args.file:
        path = Path(args.file)
        if not path.exists():
            print(json.dumps({"error": f"File not found: {args.file}"}), file=sys.stderr)
            sys.exit(1)
        extension = path.suffix.lstrip(".").lower() or ""
        if extension not in CONTENT_TYPE_MAP:
            print(json.dumps({
                "error": f"Unsupported file type: .{extension}",
                "hint": f"Supported formats: {', '.join('.' + k for k in CONTENT_TYPE_MAP)}",
            }), file=sys.stderr)
            sys.exit(1)
        file_size = path.stat().st_size
        if file_size > MAX_UPLOAD_SIZE:
            print(json.dumps({
                "error": f"File too large: {file_size / 1024 / 1024:.1f} MB (max {MAX_UPLOAD_SIZE // 1024 // 1024} MB)",
                "hint": "Try compressing the file or using a smaller image.",
            }), file=sys.stderr)
            sys.exit(1)
        file_bytes = path.read_bytes()
        filename = path.name
        _log(f"Reading local file: {path} ({len(file_bytes)} bytes)")

    # --- Source: direct URL (download first) ---
    elif args.url:
        if not any(args.url.startswith(s) for s in ALLOWED_URL_SCHEMES):
            print(json.dumps({
                "error": f"Invalid URL scheme. Only http:// and https:// are allowed.",
                "hint": "Provide a public HTTP(S) URL to the image or video.",
            }), file=sys.stderr)
            sys.exit(1)
        _log(f"Downloading from URL: {args.url}")
        r = requests.get(args.url, stream=True, timeout=(CONNECT_TIMEOUT, 60))
        if r.status_code != 200:
            print(json.dumps({"error": f"Failed to download URL: HTTP {r.status_code}"}),
                  file=sys.stderr)
            sys.exit(1)
        chunks = []
        total = 0
        for chunk in r.iter_content(chunk_size=65536):
            total += len(chunk)
            if total > MAX_UPLOAD_SIZE:
                print(json.dumps({
                    "error": f"Downloaded file too large (max {MAX_UPLOAD_SIZE // 1024 // 1024} MB)",
                    "hint": "Try a smaller image or video.",
                }), file=sys.stderr)
                sys.exit(1)
            chunks.append(chunk)
        file_bytes = b"".join(chunks)
        if len(file_bytes) > MAX_UPLOAD_SIZE:
            print(json.dumps({
                "error": f"Downloaded file too large: {len(file_bytes) / 1024 / 1024:.1f} MB (max {MAX_UPLOAD_SIZE // 1024 // 1024} MB)",
                "hint": "Try a smaller image or video.",
            }), file=sys.stderr)
            sys.exit(1)
        # Try to infer extension from URL or Content-Type
        url_path = args.url.split("?")[0]
        ext_from_url = url_path.rsplit(".", 1)[-1].lower() if "." in url_path else ""
        ct = r.headers.get("Content-Type", "")
        ct_ext_map = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp",
                      "image/gif": "gif", "video/mp4": "mp4", "video/quicktime": "mov"}
        extension = ext_from_url if ext_from_url in ("jpg", "jpeg", "png", "webp", "gif", "mp4", "mov") \
            else ct_ext_map.get(ct.split(";")[0].strip(), "jpg")
        filename = f"upload_{uuid.uuid4().hex[:8]}.{extension}"
        _log(f"Downloaded {len(file_bytes)} bytes, ext={extension}")

    # --- Source: Telegram file ---
    elif args.telegram_file_id:
        [REDACTED_SECRET]"TELEGRAM_BOT_TOKEN", "").strip()
        if not [REDACTED_SECRET]
                "error": "TELEGRAM_BOT_TOKEN environment variable is not set",
                "hint": "Export TELEGRAM_BOT_TOKEN before running this script. "
                        "Never pass bot tokens as CLI arguments (visible in ps aux).",
            }), file=sys.stderr)
            sys.exit(1)
        _log(f"Fetching Telegram file info: {args.telegram_file_id}")
        r = requests.get(
            f"https://api.telegram.org/bot{token}/getFile",
            params={"file_id": args.telegram_file_id},
            timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
        )
        r.raise_for_status()
        data = r.json()
        if not data.get("ok"):
            print(json.dumps({"error": f"Telegram getFile failed: {data}"}), file=sys.stderr)
            sys.exit(1)
        file_path = data["result"]["file_path"]
        ext_from_path = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else "jpg"
        extension = ext_from_path if ext_from_path in ("jpg", "jpeg", "png", "webp", "gif", "mp4", "mov") else "jpg"
        filename = f"tg_{args.telegram_file_id[:12]}.{extension}"
        dl_url = f"https://api.telegram.org/file/bot{token}/{file_path}"
        _log(f"Downloading Telegram file: {dl_url}")
        r2 = requests.get(dl_url, timeout=(CONNECT_TIMEOUT, 60))
        r2.raise_for_status()
        file_bytes = r2.content
        _log(f"Downloaded {len(file_bytes)} bytes from Telegram")

    # --- Source: Feishu image ---
    elif args.feishu_image_key:
        if not args.feishu_message_id:
            print(json.dumps({"error": "--feishu-message-id required with --feishu-image-key"}),
                  file=sys.stderr)
            sys.exit(1)
        feishu_token = args.feishu_app_token or os.environ.get("FEISHU_APP_TOKEN", "")
        if not feishu_token:
            print(json.dumps({"error": "--feishu-app-token or FEISHU_APP_TOKEN required"}),
                  file=sys.stderr)
            sys.exit(1)
        _log(f"Downloading Feishu image: msg={args.feishu_message_id} key={args.feishu_image_key}")
        r = requests.get(
            f"https://open.feishu.cn/open-apis/im/v1/messages/{args.feishu_message_id}/resources/{args.feishu_image_key}",
            params={"type": "image"},
            headers={"Authorization": f"Bearer {feishu_token}"},
            timeout=(CONNECT_TIMEOUT, 60),
        )
        if r.status_code != 200:
            print(json.dumps({"error": f"Feishu download failed: HTTP {r.status_code}"}),
                  file=sys.stderr)
            sys.exit(1)
        file_bytes = r.content
        extension = "jpg"
        ct = r.headers.get("Content-Type", "")
        if "png" in ct:
            extension = "png"
        elif "webp" in ct:
            extension = "webp"
        filename = f"feishu_{args.feishu_image_key[:12]}.{extension}"
        _log(f"Downloaded {len(file_bytes)} bytes from Feishu")

    else:
        print(json.dumps({"error": "Must provide one of: --file, --url, --telegram-file-id, --feishu-image-key"}),
              file=sys.stderr)
        sys.exit(1)

    # Upload
    media_id = _upload_file_bytes(
        config, file_bytes, filename, extension,
        project_id=getattr(args, "project_id", None),
    )
    _log(f"Upload complete! media_id={media_id}")
    print(json.dumps({"media_id": media_id, "filename": filename}, indent=2))


def cmd_generate(args, config: dict):
    """Full pipeline: upload → compose → render."""
    _check_api_key(config)

    started_at = datetime.now(timezone.utc)
    stage_durations = {}
    media_ids = list(args.media_ids) if args.media_ids else []
    settings = build_settings(args)

    # --- Stage 1: Upload ---
    if args.media_urls:
        _log("=== Stage 1/3: Upload Media ===")
        t0 = time.time()
        uploaded_ids = upload_media_urls(config, args.media_urls)
        media_ids.extend(uploaded_ids)
        stage_durations["upload"] = {
            "duration_seconds": round(time.time() - t0, 1),
            "media_ids": uploaded_ids,
        }
        _log(f"Upload stage complete: {len(uploaded_ids)} media(s)")
    else:
        _log("=== Stage 1/3: Upload Media (skipped — no URLs) ===")

    # Auto-add my_uploaded_assets if user uploaded media
    if media_ids and settings.get("asset_sources"):
        if "my_uploaded_assets" not in settings["asset_sources"]:
            settings["asset_sources"].append("my_uploaded_assets")
    elif media_ids and "asset_sources" not in settings:
        settings["asset_sources"] = ["my_uploaded_assets"]

    # --- Stage 2: Compose ---
    _log("=== Stage 2/3: Create Video (Compose) ===")
    t0 = time.time()

    compose_result = api_initiate_video_creation(
        config, args.message, media_ids or None, settings
    )
    project_id = compose_result.get("project_id", "")
    video_draft_id = compose_result.get("video_draft_id", "")
    chat_session_id = compose_result.get("chat_session_id", "")

    _log(f"Video creation initiated: project={project_id}, "
         f"chat_session={chat_session_id}")

    task_result = poll_video_creation(config, chat_session_id)
    video_draft_op_record_id = task_result.get(
        "video_draft_op_record_id", "")

    stage_durations["compose"] = {
        "duration_seconds": round(time.time() - t0, 1),
        "project_id": project_id,
        "video_draft_id": video_draft_id,
        "chat_session_id": chat_session_id,
        "video_draft_op_record_id": video_draft_op_record_id,
    }
    _log(f"Compose complete: op_record_id={video_draft_op_record_id}")

    # --- Stage 3: Render ---
    if args.no_render:
        _log("=== Stage 3/3: Render (skipped — --no-render) ===")
        output = {
            "status": "compose_completed",
            "created_at": started_at.isoformat(),
            "project_id": project_id,
            "video_draft_id": video_draft_id,
            "video_draft_op_record_id": video_draft_op_record_id,
            "chat_session_id": chat_session_id,
            "media_ids": media_ids,
            "settings": settings,
            "stages": stage_durations,
            "message": "Compose completed. Use 'render' command to render.",
        }
        save_job_record(output)
        print(json.dumps(output, indent=2, ensure_ascii=False))
        return

    _log("=== Stage 3/3: Render Video ===")
    t0 = time.time()

    api_create_render_job(config, video_draft_op_record_id)
    _log("Render job created, polling...")

    render_result = poll_render_job(config, video_draft_op_record_id)
    result_data = render_result.get("result", {})
    raw_url = result_data.get("url", "")
    video_url = resolve_video_url(config, raw_url)
    metadata = result_data.get("metadata", {})
    # Extract thumbnail URL (relative path like assets/medias/media_xxx.png)
    raw_thumbnail = render_result.get("thumbnail_url", "")
    thumbnail_url = resolve_video_url(config, raw_thumbnail) if raw_thumbnail else ""

    stage_durations["render"] = {
        "duration_seconds": round(time.time() - t0, 1),
    }
    _log(f"Render complete! Video URL: {video_url}")

    # --- Final Output ---
    total_duration = sum(
        s.get("duration_seconds", 0) for s in stage_durations.values()
    )
    output = {
        "status": "completed",
        "created_at": started_at.isoformat(),
        "total_duration_seconds": round(total_duration, 1),
        "project_id": project_id,
        "video_draft_id": video_draft_id,
        "video_draft_op_record_id": video_draft_op_record_id,
        "video_url": video_url,
        "thumbnail_url": thumbnail_url,
        "metadata": metadata,
        "media_ids": media_ids,
        "settings": settings,
        "stages": stage_durations,
    }
    save_job_record(output)
    print(json.dumps(output, indent=2, ensure_ascii=False))


def cmd_compose(args, config: dict):
    """Initiate video creation (compose only, no upload or render)."""
    _check_api_key(config)

    media_ids = list(args.media_ids) if args.media_ids else None
    settings = build_settings(args)

    _log("Initiating video creation...")
    result = api_initiate_video_creation(
        config, args.message, media_ids, settings
    )

    project_id = result.get("project_id", "")
    video_draft_id = result.get("video_draft_id", "")
    chat_session_id = result.get("chat_session_id", "")

    _log(f"Video creation initiated: project={project_id}, "
         f"chat_session={chat_session_id}")

    if args.no_wait:
        print(json.dumps({
            "status": "initiated",
            "project_id": project_id,
            "video_draft_id": video_draft_id,
            "chat_session_id": chat_session_id,
            "message": "Use compose-status to poll progress.",
        }, indent=2))
        return

    task_result = poll_video_creation(config, chat_session_id)
    video_draft_op_record_id = task_result.get(
        "video_draft_op_record_id", "")
    _log(f"Compose complete: op_record_id={video_draft_op_record_id}")

    print(json.dumps({
        "status": "completed",
        "project_id": project_id,
        "video_draft_id": video_draft_id,
        "chat_session_id": chat_session_id,
        "video_draft_op_record_id": video_draft_op_record_id,
    }, indent=2))


def cmd_render(args, config: dict):
    """Render a video from a draft operation record."""
    _check_api_key(config)

    op_id = args.video_draft_op_record_id
    _log(f"Creating render job for: {op_id}")

    api_create_render_job(config, op_id)
    _log("Render job created, polling...")

    if args.no_wait:
        print(json.dumps({
            "status": "started",
            "video_draft_op_record_id": op_id,
            "message": "Use render-status to poll progress.",
        }, indent=2))
        return

    result = poll_render_job(config, op_id)
    result_data = result.get("result", {})
    raw_url = result_data.get("url", "")
    video_url = resolve_video_url(config, raw_url)
    metadata = result_data.get("metadata", {})

    _log(f"Render complete! Video URL: {video_url}")

    print(json.dumps({
        "status": "completed",
        "video_draft_op_record_id": op_id,
        "video_url": video_url,
        "metadata": metadata,
    }, indent=2))


def cmd_upload_status(args, config: dict):
    """Check media upload job status."""
    _check_api_key(config)
    result = api_get_media_job_status(config, args.job_id)
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_compose_status(args, config: dict):
    """Check video creation (compose) task status."""
    _check_api_key(config)
    result = api_get_last_task_status(config, args.chat_session_id)
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_render_status(args, config: dict):
    """Check render job status."""
    _check_api_key(config)
    result = api_query_render_job(config, args.video_draft_op_record_id)

    # Resolve URL if completed
    if result.get("status") == "completed":
        res = result.get("result", {})
        raw_url = res.get("url", "")
        if raw_url:
            result["resolved_video_url"] = resolve_video_url(config, raw_url)

    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_config(args, config: dict):
    """Show current configuration."""
    safe_config = dict(config)
    key = safe_config.get("apiKey", "")
    if key and len(key) > 10:
        safe_config["apiKey"] = key[:8] + "..." + key[-4:]
    safe_config["source_env_var"] = "MEDEO_API_KEY"
    safe_config["config_file"] = str(CONFIG_FILE)
    safe_config["state_dir"] = str(STATE_DIR)
    print(json.dumps(safe_config, indent=2, ensure_ascii=False))


def cmd_config_init(args, config: dict):
    """Initialize or update config.json."""
    _ensure_dir()

    target = getattr(args, 'env', None) or DEFAULT_ENV

    raw = _load_raw_config()
    if not raw:
        raw = {"env": target}

    if target not in raw:
        raw[target] = {}

    if args.api_key:
        if not args.api_key.startswith("mk_"):
            print(json.dumps({
                "warning": "API key should start with 'mk_'. Please verify you copied it correctly.",
                "received_prefix": args.api_key[:8] + "...",
            }, indent=2), file=sys.stderr)
        raw[target]["apiKey"] = args.api_key

    # Set defaults
    defaults = ENV_DEFAULTS.get(target, ENV_DEFAULTS["prd"])
    raw[target].setdefault("baseUrl", defaults["baseUrl"])
    raw[target].setdefault("ossBaseUrl", defaults["ossBaseUrl"])
    raw["env"] = target

    with open(CONFIG_FILE, "w") as f:
        json.dump(raw, f, indent=2, ensure_ascii=False)

    _log(f"Config saved to {CONFIG_FILE}")
    print(json.dumps({
        "status": "ok",
        "config_file": str(CONFIG_FILE),
    }, indent=2))


def cmd_last_job(args, config: dict):
    """Show the last job record."""
    if not LAST_JOB_FILE.exists():
        print(json.dumps({"message": "No job records found."}))
        return
    with open(LAST_JOB_FILE, "r") as f:
        record = json.load(f)
    print(json.dumps(record, indent=2, ensure_ascii=False))


def cmd_history(args, config: dict):
    """Show job history."""
    if not HISTORY_DIR.exists():
        print(json.dumps({"message": "No history found.", "jobs": []}))
        return

    files = sorted(HISTORY_DIR.glob("job_*.json"), reverse=True)
    limit = args.limit
    jobs = []

    for f in files[:limit]:
        try:
            with open(f, "r") as fh:
                record = json.load(fh)
            jobs.append({
                "file": f.name,
                "status": record.get("status", "unknown"),
                "created_at": record.get("created_at", ""),
                "video_url": record.get("video_url", ""),
                "total_duration_seconds": record.get(
                    "total_duration_seconds", ""),
            })
        except (json.JSONDecodeError, IOError):
            pass

    print(json.dumps({
        "count": len(jobs),
        "total_records": len(files),
        "jobs": jobs,
    }, indent=2, ensure_ascii=False))


# ---------------------------------------------------------------------------
# CLI Parser
# ---------------------------------------------------------------------------

def _add_settings_args(parser):
    """Add common video creation settings args to a parser."""
    parser.add_argument("--recipe-id", default=None,
                        help="Recipe/template ID to use")
    parser.add_argument("--duration-ms", type=int, default=None,
                        help="Target video duration in ms (e.g. 30000)")
    parser.add_argument("--aspect-ratio", default=None,
                        choices=VALID_ASPECT_RATIOS,
                        help="Video aspect ratio (default: 16:9)")
    parser.add_argument("--voice-id", default=None,
                        help="Voice ID for narration")
    parser.add_argument("--video-style-id", default=None,
                        help="Video style template ID")
    parser.add_argument("--asset-sources", nargs="+", default=None,
                        choices=VALID_ASSET_SOURCES,
                        help="Allowed asset sources")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="medeo_video",
        description="Medeo Video Generator — AI-powered video creation",
    )
    parser.add_argument("--env", choices=list(ENV_DEFAULTS.keys()),
                        default=None,
                        help=f"Environment (default: {DEFAULT_ENV})")

    sub = parser.add_subparsers(dest="command", help="Available commands")

    # --- recipes ---
    p_recipes = sub.add_parser("recipes",
                               help="List available video recipes")
    p_recipes.add_argument("--limit", type=int, default=20,
                           help="Number of recipes (1-100, default 20)")
    p_recipes.add_argument("--order", default="desc",
                           choices=["asc", "desc"],
                           help="Sort order (default: desc)")
    p_recipes.add_argument("--starting-after", default=None,
                           help="Cursor for pagination")

    # --- upload ---
    p_upload = sub.add_parser("upload",
                              help="Upload media from URL")
    p_upload.add_argument("--url", required=True,
                          help="Media URL to upload")
    p_upload.add_argument("--project-id", default=None,
                          help="Optional project ID")
    p_upload.add_argument("--no-wait", action="store_true",
                          help="Don't wait for completion")

    # --- upload-file ---
    p_uf = sub.add_parser("upload-file",
                          help="Upload image/video from local file, URL, Telegram, or Feishu")
    src_group = p_uf.add_mutually_exclusive_group(required=True)
    src_group.add_argument("--file", metavar="PATH",
                           help="Local file path (e.g. /tmp/photo.jpg)")
    src_group.add_argument("--url", metavar="URL",
                           help="Direct image/video URL (downloaded first)")
    src_group.add_argument("--telegram-file-id", metavar="FILE_ID",
                           help="Telegram file_id (requires TELEGRAM_BOT_TOKEN env var)")
    src_group.add_argument("--feishu-image-key", metavar="IMAGE_KEY",
                           help="Feishu image_key (requires --feishu-message-id)")
    p_uf.add_argument("--feishu-message-id", metavar="MSG_ID",
                      help="Feishu message_id (required with --feishu-image-key)")
    p_uf.add_argument("--feishu-app-token", metavar="TOKEN",
                      default=os.environ.get("FEISHU_APP_TOKEN", ""),
                      help="Feishu tenant_access_token (or set FEISHU_APP_TOKEN env)")
    p_uf.add_argument("--project-id", default=None,
                      help="Optional Medeo project ID")

    # --- generate (full pipeline) ---
    p_gen = sub.add_parser("generate",
                           help="Full pipeline: upload → compose → render")
    p_gen.add_argument("--message", required=True,
                       help="Video creation prompt / description")
    p_gen.add_argument("--media-urls", nargs="+", default=None,
                       help="Media URLs to upload first")
    p_gen.add_argument("--media-ids", nargs="+", default=None,
                       help="Pre-uploaded media IDs (skip upload)")
    p_gen.add_argument("--no-render", action="store_true",
                       help="Stop after compose (don't render)")
    _add_settings_args(p_gen)

    # --- compose ---
    p_compose = sub.add_parser("compose",
                               help="Create video (compose only)")
    p_compose.add_argument("--message", required=True,
                           help="Video creation prompt / description")
    p_compose.add_argument("--media-ids", nargs="+", default=None,
                           help="Media IDs to include")
    p_compose.add_argument("--no-wait", action="store_true",
                           help="Don't wait for completion")
    _add_settings_args(p_compose)

    # --- render ---
    p_render = sub.add_parser("render",
                              help="Render a video draft")
    p_render.add_argument("--video-draft-op-record-id", required=True,
                          help="Video draft operation record ID")
    p_render.add_argument("--no-wait", action="store_true",
                          help="Don't wait for completion")

    # --- upload-status ---
    p_us = sub.add_parser("upload-status",
                          help="Check media upload job status")
    p_us.add_argument("--job-id", required=True,
                      help="Upload job ID")

    # --- compose-status ---
    p_cs = sub.add_parser("compose-status",
                          help="Check compose task status")
    p_cs.add_argument("--chat-session-id", required=True,
                      help="Chat session ID")

    # --- render-status ---
    p_rs = sub.add_parser("render-status",
                          help="Check render job status")
    p_rs.add_argument("--video-draft-op-record-id", required=True,
                      help="Video draft operation record ID")

    # --- config ---
    sub.add_parser("config", help="Show current configuration")

    # --- config-init ---
    p_ci = sub.add_parser("config-init",
                          help="Initialize or update config")
    p_ci.add_argument("--api-key", required=True,
                      help="Medeo API key")

    # --- spawn-task ---
    p_spawn = sub.add_parser("spawn-task",
                             help="Build sessions_spawn payload for async generation")
    p_spawn.add_argument("--message", required=True,
                         help="Video creation prompt / description")
    p_spawn.add_argument("--media-urls", nargs="+", default=None,
                         help="Media URLs to upload first")
    p_spawn.add_argument("--media-ids", nargs="+", default=None,
                         help="Pre-uploaded media IDs")
    p_spawn.add_argument("--no-render", action="store_true",
                         help="Stop after compose (don't render)")
    p_spawn.add_argument("--deliver-to",
                         help="Delivery target: ou_xxx (private chat) or oc_xxx (group chat) for Feishu, "
                              "chat_id for Telegram, channel_id for Discord")
    p_spawn.add_argument("--deliver-channel", default=None,
                         help="Delivery channel: feishu, telegram, discord, etc.")
    _add_settings_args(p_spawn)

    # --- last-job ---
    sub.add_parser("last-job", help="Show last job record")

    # --- history ---
    p_hist = sub.add_parser("history", help="Show job history")
    p_hist.add_argument("--limit", type=int, default=20,
                        help="Number of records to show")

    return parser


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

COMMAND_MAP = {
    "recipes": cmd_recipes,
    "upload": cmd_upload,
    "upload-file": cmd_upload_file,
    "generate": cmd_generate,
    "spawn-task": cmd_spawn_task,
    "compose": cmd_compose,
    "render": cmd_render,
    "upload-status": cmd_upload_status,
    "compose-status": cmd_compose_status,
    "render-status": cmd_render_status,
    "config": cmd_config,
    "config-init": cmd_config_init,
    "last-job": cmd_last_job,
    "history": cmd_history,
}


def main():
    parser = build_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    # Resolve config
    config = _get_config(env=getattr(args, 'env', None))

    handler = COMMAND_MAP.get(args.command)
    if not handler:
        parser.print_help()
        sys.exit(1)

    try:
        handler(args, config)
    except MedeoApiError as e:
        if e.is_retryable():
            _log(f"[retryable] {e.to_dict()}")  # full detail to stderr debug log
            print(json.dumps(_user_friendly_error(), indent=2, ensure_ascii=False),
                  file=sys.stderr)
        else:
            print(json.dumps(e.to_dict(), indent=2, ensure_ascii=False),
                  file=sys.stderr)
        sys.exit(1)
    except MedeoPollTimeout as e:
        print(json.dumps({
            "error": "poll_timeout",
            "message": f"{e.stage} timed out after {round(e.elapsed)}s. The service may be under heavy load — please retry.",
            "retryable": True,
        }, indent=2), file=sys.stderr)
        sys.exit(1)
    except MedeoPollFailed as e:
        if _is_poll_failure_retryable(e):
            _log(f"[retryable poll failure] stage={e.stage} status={e.status} detail={e.error_detail}")
            print(json.dumps(_user_friendly_error(e.stage), indent=2, ensure_ascii=False),
                  file=sys.stderr)
        else:
            print(json.dumps({
                "error": str(e),
                "stage": e.stage,
                "status": e.status,
                "detail": e.error_detail,
            }, indent=2), file=sys.stderr)
        sys.exit(1)
    except KeyboardInterrupt:
        _log("Interrupted by user")
        sys.exit(130)


if __name__ == "__main__":
    main()


path: scripts/telegram_send_video.py
content: #!/usr/bin/env python3
"""
Send video messages via Telegram Bot API.

Usage:
    TELEGRAM_BOT_TOKEN=<token> python3 telegram_send_video.py \
        --video /path/to/video.mp4 \
        --to <chat_id> \
        [--cover-url https://...] \
        [--duration <seconds>] \
        [--caption "Video ready!"]

Security note:
    Bot token MUST be provided via TELEGRAM_BOT_TOKEN env var.
    Never pass it as a CLI argument (visible in `ps aux`).

Flow:
    1. Send video via sendVideo (supports thumbnail + caption)
    2. Output JSON with message_id on success

Telegram Bot API reference:
    - sendVideo: POST /bot<token>/sendVideo
"""

import argparse
import json
import os
import sys

try:
    import requests
except ImportError:
    print(json.dumps({
        "error": "requests package not installed",
        "install_command": "pip install requests",
    }), file=sys.stderr)
    sys.exit(1)

TELEGRAM_API_BASE = "https://api.telegram.org"
CONNECT_TIMEOUT = 15   # seconds
UPLOAD_TIMEOUT = 180   # seconds — video upload can be slow


def get_bot_token() -> str:
    """Read bot token from environment only (never CLI args)."""
    [REDACTED_SECRET]"TELEGRAM_BOT_TOKEN", "").strip()
    if not [REDACTED_SECRET]
            "error": "TELEGRAM_BOT_TOKEN environment variable is not set",
            "hint": "Export TELEGRAM_BOT_TOKEN before running this script",
        }), file=sys.stderr)
        sys.exit(1)
    return token


def send_video(token: str, chat_id: str, video_path: str,
               cover_url: str = "", duration_seconds: int = 0,
               caption: str = "") -> dict:
    """
    Send a video file to a Telegram chat.

    Args:
        token: Bot token (from env, never logged)
        chat_id: Telegram chat_id (int or string, e.g. -1001234567890)
        video_path: Local path to the .mp4 file
        cover_url: Optional thumbnail URL (downloaded and sent as thumbnail)
        duration_seconds: Video duration in seconds (shown in player)
        caption: Optional caption text

    Returns:
        Telegram sendVideo result dict
    """
    print(f"[telegram] Sending video: {video_path}", file=sys.stderr)

    data: dict = {"chat_id": chat_id}
    if duration_seconds:
        data["duration"] = duration_seconds
    if caption:
        data["caption"] = caption
    data["supports_streaming"] = True

    files: dict = {}

    with open(video_path, "rb") as vf:
        files["video"] = (os.path.basename(video_path), vf, "video/mp4")

        # Download and attach thumbnail if provided
        thumb_bytes = None
        if cover_url:
            try:
                print(f"[telegram] Downloading thumbnail: {cover_url}", file=sys.stderr)
                r = requests.get(cover_url, timeout=(CONNECT_TIMEOUT, 30))
                if r.status_code == 200:
                    thumb_bytes = r.content
                    files["thumbnail"] = ("cover.jpg", thumb_bytes, "image/jpeg")
                else:
                    print(f"[telegram] Thumbnail download failed (HTTP {r.status_code}), skipping",
                          file=sys.stderr)
            except Exception as exc:
                print(f"[telegram] Thumbnail download error: {exc}, skipping", file=sys.stderr)

        resp = requests.post(
            f"{TELEGRAM_API_BASE}/bot{token}/sendVideo",
            data=data,
            files=files,
            timeout=(CONNECT_TIMEOUT, UPLOAD_TIMEOUT),
        )

    if resp.status_code != 200:
        body = {}
        try:
            body = resp.json()
        except Exception:
            pass
        print(json.dumps({
            "error": f"Telegram API error: HTTP {resp.status_code}",
            "description": body.get("description", resp.text[:200]),
        }), file=sys.stderr)
        sys.exit(1)

    result = resp.json()
    if not result.get("ok"):
        print(json.dumps({
            "error": "Telegram sendVideo failed",
            "description": result.get("description", "unknown error"),
        }), file=sys.stderr)
        sys.exit(1)

    msg = result["result"]
    message_id = msg.get("message_id")
    print(f"[telegram] Sent successfully, message_id={message_id}", file=sys.stderr)
    return msg


def main():
    parser = argparse.ArgumentParser(
        description="Send video via Telegram Bot API. "
                    "Bot token must be set via TELEGRAM_BOT_TOKEN env var."
    )
    parser.add_argument("--video", required=True,
                        help="Local video file path (.mp4)")
    parser.add_argument("--to", required=True,
                        help="Telegram chat_id (e.g. -1001234567890 or @channel)")
    parser.add_argument("--cover-url", default="",
                        help="Thumbnail image URL (optional)")
    parser.add_argument("--duration", type=int, default=0,
                        help="Video duration in seconds (optional)")
    parser.add_argument("--caption", default="",
                        help="Caption text (optional)")
    args = parser.parse_args()

    if not os.path.isfile(args.video):
        print(json.dumps({"error": f"Video file not found: {args.video}"}),
              file=sys.stderr)
        sys.exit(1)

    [REDACTED_SECRET])
    msg = send_video(
        token=token,
        chat_id=args.to,
        video_path=args.video,
        cover_url=args.cover_url,
        duration_seconds=args.duration,
        caption=args.caption,
    )

    print(json.dumps({
        "status": "ok",
        "message_id": msg.get("message_id"),
        "chat_id": msg.get("chat", {}).get("id"),
    }))


if __name__ == "__main__":
    main()


path: skill.json
content: {
  "name": "medeo-video",
  "version": "1.5.0",
  "description": "AI-powered video generation from text. Tell your assistant to make a video and it handles everything — screenwriting, shot composition, transitions, music, and rendering via the Medeo platform.",
  "author": "one2x-ai",
  "license": "MIT",
  "homepage": "https://github.com/one2x-ai/medeo-video-skill",
  "repository": "https://github.com/one2x-ai/medeo-video-skill",
  "keywords": [
    "video",
    "ai-video",
    "video-generation",
    "medeo",
    "text-to-video",
    "rendering",
    "recipes",
    "media",
    "upload",
    "content-creation"
  ],
  "sbom": {
    "files": [
      {
        "path": "SKILL.md",
        "required": true,
        "description": "Agent instruction manual — how to use the Medeo video skill"
      },
      {
        "path": "README.md",
        "required": false,
        "description": "User-facing install and usage guide"
      },
      {
        "path": "scripts/medeo_video.py",
        "required": true,
        "description": "Main CLI script — all subcommands (generate, upload, compose, render, etc.)"
      },
      {
        "path": "scripts/feishu_send_video.py",
        "required": false,
        "description": "Feishu video delivery helper"
      },
      {
        "path": "docs/assets-upload.md",
        "required": false,
        "description": "All upload paths (URL, local file, IM attachments)"
      },
      {
        "path": "docs/feishu-send.md",
        "required": false,
        "description": "Feishu video delivery integration guide"
      },
      {
        "path": "docs/multi-platform.md",
        "required": false,
        "description": "Multi-platform delivery setup (Telegram, Discord, Feishu, WhatsApp)"
      },
      {
        "path": "docs/recipes.md",
        "required": false,
        "description": "Video recipe/template browsing and selection guide"
      }
    ]
  },
  "openclaw": {
    "emoji": "🎬",
    "category": "content-creation",
    "requires": {
      "bins": ["python3"],
      "env": {
        "MEDEO_API_KEY": {
          "required": true,
          "description": "Medeo API key (get from https://medeo.app/dev/apikey, starts with mk_)",
          "setup_url": "https://medeo.app/dev/apikey"
        }
      }
    },
    "triggers": [
      "generate video",
      "make a video",
      "create a video",
      "text to video",
      "video from text",
      "AI video",
      "medeo",
      "video generation",
      "make me a video",
      "produce a video",
      "video about",
      "video of",
      "short video",
      "make video",
      "video clip",
      "video creator"
    ],
    "platforms": [
      "feishu",
      "telegram",
      "discord",
      "whatsapp",
      "signal"
    ]
  }
}


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