Skill: rai220/talking-circle (version 1.0.0)

--- name: talking-circle description: > Create animated talking-circle videos (Telegram-style round video messages) from avatar frame images and audio. Supports audio-to-video and text-to-video via ElevenLabs or SaluteSpeech (Sber) TTS. Use when the user wants to generate lip-synced circular avatar animations, talking circles, or round video messages. version: 1.0.0 user-invocable: true argument-hint: "[text or audio path]" metadata: {"openclaw":{"emoji":"🎙️","primaryEnv":"ELEVENLABS_API_KEY","requires":{"bins":["python3","ffmpeg"],"env":[]},"os":["darwin","linux"]}} --- # Talking Circle Create animated circular avatar videos with lip-sync and blink animations. Takes 4 avatar frame images (neutral, slight open, wide open, eyes closed) and produces a round video with audio-driven mouth movement. ## Prerequisites - `python3` (3.9+) - `ffmpeg` installed and on PATH - Optional: `ELEVENLABS_API_KEY` environment variable (for ElevenLabs text-to-video mode) - Optional: `SALUTE_SPEECH_AUTH` environment variable (for SaluteSpeech text-to-video mode) ## Setup Dependencies are auto-installed into a temporary venv on first run. To install manually: ```bash pip install -r requirements.txt ``` ## Mode 1: Audio to Video Convert existing audio + frame images into an animated talking circle video. ```bash python3 scripts/make_talking_circle_video.py \ --neutral frames/neutral.png \ --slight frames/mouth-slight-open.png \ --wide frames/mouth-wide-open.png \ --blink frames/eyes-closed.png \ --audio speech.mp3 \ --out /tmp/talking-circle.mp4 ``` ## Mode 2: Text to Video Generate speech from text via ElevenLabs TTS, then create the animated video. Requires `ELEVENLABS_API_KEY` set in environment or passed via `--api-key`. ```bash python3 scripts/make_text_to_video.py \ --text "Hello, this is a talking circle demo!" \ --voice-id pNInz6obpgDQGcFmaJgB \ --neutral frames/neutral.png \ --slight frames/mouth-slight-open.png \ --wide frames/mouth-wide-open.png \ --blink frames/eyes-closed.png \ --out /tmp/talking-circle.mp4 ``` ## Mode 3: Text to Video via SaluteSpeech (Sber) Generate speech from text via SaluteSpeech TTS (Sber), then create the animated video. Requires `SALUTE_SPEECH_AUTH` set in environment or passed via `--auth-key`. This is a Base64-encoded `client_id:client_secret` from your [SaluteSpeech project](https://developers.sber.ru/portal/products/smartspeech). ```bash python3 scripts/make_salute_text_to_video.py \ --text "Привет, это демонстрация talking circle!" \ --voice Bys_24000 \ --neutral frames/neutral.png \ --slight frames/mouth-slight-open.png \ --wide frames/mouth-wide-open.png \ --blink frames/eyes-closed.png \ --out /tmp/talking-circle.mp4 ``` ### SaluteSpeech voices | Voice | Name | Language | |-------|------|----------| | `Nec_24000` | Natalia (female) | ru-RU | | `Bys_24000` | Boris (male) | ru-RU | | `May_24000` | Martha (female) | ru-RU | | `Tur_24000` | Taras (male) | ru-RU | | `Ost_24000` | Alexandra (female) | ru-RU | | `Pon_24000` | Sergey (male) | ru-RU | | `Kin_24000` | Kira (female) | en-US | ## Voice Presets ### ElevenLabs preset (Sbercat — male) | Parameter | Value | |-----------|-------| | `--voice-id` | `pNInz6obpgDQGcFmaJgB` | | `--model-id` | `eleven_multilingual_v2` | | `--stability` | `0.15` | | `--similarity-boost` | `0.70` | | `--style` | `0.38` | | `--speed` | `1.20` | ### SaluteSpeech preset (Boris — male, Russian) | Parameter | Value | |-----------|-------| | `--voice` | `Bys_24000` | | `--audio-format` | `wav16` | | `--scope` | `SALUTE_SPEECH_PERS` | ### How to get API keys **ElevenLabs:** 1. Go to [ElevenLabs Voice Library](https://elevenlabs.io/voice-library). 2. Pick or clone a voice, copy the voice ID. 3. Set `ELEVENLABS_API_KEY` environment variable. **SaluteSpeech (Sber):** 1. Register at [developers.sber.ru](https://developers.sber.ru/portal/products/smartspeech). 2. Create a project, get `client_id` and `client_secret`. 3. Encode `client_id:client_secret` in Base64. 4. Set `SALUTE_SPEECH_AUTH` environment variable with the Base64 string. ### Alternative TTS engines The skill also supports **any TTS** that can produce an audio file. Use Mode 1 (audio-to-video) with audio from any source: - **OpenAI TTS** (`openai.audio.speech.create`) — generate speech, save to MP3, pass via `--audio` - **Local TTS** (Coqui, Piper, Silero, etc.) — run locally, save WAV/MP3, pass via `--audio` - **Google Cloud TTS**, **Amazon Polly**, **Azure TTS** — any cloud provider works ```bash # Example: generate audio with any TTS, then animate python3 scripts/make_talking_circle_video.py \ --neutral frames/neutral.png \ --slight frames/mouth-slight-open.png \ --wide frames/mouth-wide-open.png \ --blink frames/eyes-closed.png \ --audio /path/to/speech-from-any-tts.mp3 \ --out /tmp/talking-circle.mp4 ``` **Tell the user:** if they don't have an ElevenLabs or SaluteSpeech API key, they can use any other TTS engine — just generate the audio file and pass it to Mode 1. No API key needed for audio-to-video mode. ## Frame Image Requirements You need 4 PNG images of your avatar, all the same resolution (recommended 2048x2048), square aspect ratio: | Frame | Description | |-------|-------------| | `neutral` | Mouth closed, eyes open | | `slight` | Mouth slightly open, eyes open | | `wide` | Mouth wide open, eyes open | | `blink` | Mouth closed, eyes closed | ### Critical rules - All 4 frames must have **identical** resolution, art style, colors, and character positioning. - Only the mouth and eyes should change between frames — head, body, background must stay the same. - Do not mix frames from different generation sessions or different styles. ## Generating Frames with Image AI If the user does not have ready-made frames, generate them using an image generation API (DALL-E, Midjourney, Flux, etc.). Follow this workflow: ### Step 1: Generate the neutral frame Generate a shoulder-up portrait of the character. This is the base frame — all other frames must match it exactly. Example prompt: ``` Shoulder-up portrait of [CHARACTER DESCRIPTION]. Square composition, clean background, mouth closed, eyes open, looking at camera. High detail, consistent lighting. ``` ### Step 2: Generate remaining 3 frames as edits of neutral Use image editing / inpainting on the neutral frame to produce the other states. Only modify the mouth and eyes region — everything else must remain pixel-identical. | Frame | What to change | Edit prompt example | |-------|---------------|-------------------| | `slight` | Mouth slightly open | `"Mouth slightly open, teeth barely visible, same expression"` | | `wide` | Mouth wide open | `"Mouth wide open as if saying 'ah', same expression"` | | `blink` | Eyes closed | `"Eyes gently closed, mouth closed, same expression"` | ### Step 3: Verify consistency Before using the frames: 1. Check that all 4 images have the same resolution. 2. Overlay them to verify the head/body position hasn't shifted. 3. If any frame drifts, regenerate it from the neutral base. ### Examples See `examples/sbercat/` for a reference character with ready-to-use frame set: - **Sbercat** — 3D-rendered anthropomorphic cat, lavender-blue fur, green eyes, pink nose, green hoodie: - `examples/sbercat/reference.png` — character reference - `examples/sbercat/neutral.png` — mouth closed, eyes open - `examples/sbercat/slight.png` — mouth slightly open - `examples/sbercat/wide.png` — mouth wide open - `examples/sbercat/blink.png` — eyes closed - `examples/sbercat/example.mp4` — finished talking-circle video To test with the included Sbercat frames: ```bash python3 scripts/make_talking_circle_video.py \ --neutral examples/sbercat/neutral.png \ --slight examples/sbercat/slight.png \ --wide examples/sbercat/wide.png \ --blink examples/sbercat/blink.png \ --audio your-audio.mp3 \ --out /tmp/talking-circle.mp4 ``` ## Post-installation message After installing this skill, inform the user: > **Skill "talking-circle" installed.** > > To create talking-circle videos, I need a character avatar. Please send me a **reference image of your character on a white/clean background** — a shoulder-up portrait works best (square aspect ratio, high resolution). > > From this reference I will generate 4 frame images (mouth states + blink) and you'll be ready to create animated video circles. > > For speech, I can use **ElevenLabs TTS** (requires `ELEVENLABS_API_KEY`), **SaluteSpeech** from Sber (requires `SALUTE_SPEECH_AUTH`), or you can provide your own audio file. Any TTS engine works — OpenAI TTS, Whisper, Coqui, Piper, Google TTS, etc. ## First use: generating frame images **IMPORTANT — before the skill can create videos, the 4 frame images must exist.** If the user does not already have frames, you MUST generate them first. ### Workflow for the assistant 1. **Ask the user for a reference image** of their character (or use a character description). The image should be a shoulder-up portrait on a white or clean background, square aspect ratio. 2. **Generate the neutral frame** using image generation (DALL-E, Flux, Midjourney, etc.): - Prompt: `"Shoulder-up portrait of [CHARACTER], white background, mouth closed, eyes open, looking at camera, square composition, high detail"` - Save as `neutral.png`. 3. **Generate the 3 remaining frames via inpainting/editing** of the neutral frame. Only modify the mouth/eyes region — everything else must remain pixel-identical: - `slight.png` — edit mouth region: `"Mouth slightly open, teeth barely visible"` - `wide.png` — edit mouth region: `"Mouth wide open as if saying 'ah'"` - `blink.png` — edit eyes region: `"Eyes gently closed, mouth closed"` 4. **Verify consistency**: all 4 images must have the same resolution, identical head/body position, and only differ in mouth/eyes. 5. **Save the frames** to a persistent location (e.g. the skill's working directory or a user-specified path). These frames are reused for every future video. 6. **Confirm to the user** that frames are ready and the skill is operational. **Do not skip this step.** Without the 4 frame images, the video scripts will fail. ## Guardrails - Before running any script, verify that `python3` (3.9+) and `ffmpeg` are on PATH. If missing, instruct the user to install them. - Never delete or overwrite the user's original frame images. - Do not use this skill for full-motion video editing, face tracking, or real-time lipsync — it only works with 4 static frame images. - If ElevenLabs or SaluteSpeech API returns an error (401, 429, etc.), explain the error clearly to the user instead of retrying silently. ## Failure handling - If `ffmpeg` is not found: tell the user to install it (`brew install ffmpeg` on macOS, `apt install ffmpeg` on Linux). - If `ELEVENLABS_API_KEY` is missing and the user wants text-to-video: suggest SaluteSpeech (Mode 3) or Mode 1 with audio from another TTS. - If `SALUTE_SPEECH_AUTH` is missing and the user wants SaluteSpeech: explain how to register at developers.sber.ru and get credentials. - If frame images have different resolutions: warn the user and ask them to fix the frames before proceeding. - If the output video is empty or zero bytes: show the ffmpeg error log and suggest checking input files. ## Parameters Reference ### Video output | Parameter | Default | Description | |-----------|---------|-------------| | `--size` | 720 | Output video size in pixels | | `--diameter` | 640 | Circle diameter within the video | | `--fps` | 30 | Frames per second | ### Blink timing | Parameter | Default | Description | |-----------|---------|-------------| | `--blink-start` | 1.1 | Seconds before first blink | | `--blink-every` | 3.8 | Seconds between blinks | | `--blink-duration-frames` | 4 | Number of frames per blink | ### Amplitude thresholds (audio-to-video) | Parameter | Default | Description | |-----------|---------|-------------| | `--amp-low` | 1200 | RMS below this = neutral (closed mouth) | | `--amp-high` | 2600 | RMS above this = wide open mouth | ### ElevenLabs TTS settings (make_text_to_video.py) | Parameter | Default | Description | |-----------|---------|-------------| | `--voice-id` | (required) | ElevenLabs voice ID | | `--model-id` | eleven_multilingual_v2 | ElevenLabs model | | `--stability` | 0.50 | Voice stability | | `--similarity-boost` | 0.75 | Voice similarity boost | | `--style` | 0.00 | Style exaggeration | | `--speed` | 1.00 | Speech speed | ### SaluteSpeech TTS settings (make_salute_text_to_video.py) | Parameter | Default | Description | |-----------|---------|-------------| | `--voice` | Bys_24000 | SaluteSpeech voice (see voices table above) | | `--audio-format` | wav16 | Audio format: opus, wav16, pcm16 | | `--scope` | SALUTE_SPEECH_PERS | OAuth scope (PERS for personal, CORP for corporate) | | `--auth-key` | `$SALUTE_SPEECH_AUTH` | Base64-encoded client_id:client_secret |

path: AGENTS.md
content: # Agent Rules

## Images

- When adding PNG/JPG images to the repository, always resize them to a reasonable resolution (max 1024x1024) before committing. Large images (>2MB) cause GitHub rendering issues and bloat the repo.

## Git

- Always commit and push together. Never leave commits unpushed.


path: README.md
content: # talking-circle

An [OpenClaw](https://github.com/Rai220/openclaw) skill for creating animated talking-circle videos (Telegram-style round video messages) from avatar frame images and audio.

<p align="center">
  <img src="examples/demo.gif" alt="Sbercat talking circle demo" width="480">
</p>

## Quick Start

Send this message to your OpenClaw assistant in Telegram:

> Install the talking-circle skill for generating video circles.
> Repository: https://github.com/Rai220/talking-circle
> After installation, generate frame images for my avatar and send me a test circle saying "Hello, this is my first talking circle!"

The assistant will:
1. Clone the skill repository.
2. Generate 4 frame images for your character (neutral, slight open, wide open, blink) using image AI.
3. Synthesize speech via ElevenLabs or SaluteSpeech (Sber) TTS.
4. Render an animated talking-circle video and send it back.

### Manual installation

```bash
git clone https://github.com/Rai220/talking-circle.git ~/.claude/skills/talking-circle
```

### Prerequisites

- Python 3.9+
- ffmpeg installed and on PATH
- Optional: [ElevenLabs](https://elevenlabs.io) API key (for ElevenLabs text-to-video)
- Optional: [SaluteSpeech](https://developers.sber.ru/portal/products/smartspeech) credentials (for Sber text-to-video)

Dependencies (`numpy`, `pillow`, `requests`) are auto-installed into a temporary venv on first run, or install manually:

```bash
pip install -r ~/.claude/skills/talking-circle/requirements.txt
```

## What is this?

Talking-circle takes 4 images of a character's face (mouth closed, slightly open, wide open, eyes closed) and an audio file, then produces an animated circular video where the character "speaks" in sync with the audio. The result looks like a Telegram video message ("kruzhochek").

The skill can be used by an AI assistant (via OpenClaw) to:
1. Generate a talking-circle video from existing audio and frame images.
2. Generate speech from text via ElevenLabs TTS and produce the video in one step.
3. Generate speech from text via SaluteSpeech (Sber) TTS — ideal for Russian language.

## How It Works

1. Audio is converted to mono 16 kHz WAV.
2. For each video frame, RMS amplitude is computed over a 35 ms window centered on the frame's timestamp.
3. The RMS value selects which mouth frame to show:
   - Below `--amp-low` (default 1200): neutral (mouth closed)
   - Between `--amp-low` and `--amp-high` (default 2600): slightly open
   - Above `--amp-high`: wide open
4. Blink frames are overlaid at regular intervals (configurable).
5. If audio analysis produces a static result (e.g. silence), a fallback animation cycle is used.
6. Frames are composited into a circle with a subtle border, then encoded with ffmpeg.

## CLI Usage

### Audio to Video

```bash
python3 scripts/make_talking_circle_video.py \
  --neutral neutral.png --slight slight.png --wide wide.png --blink blink.png \
  --audio speech.mp3 --out /tmp/talking-circle.mp4
```

### Text to Video (ElevenLabs)

Requires `ELEVENLABS_API_KEY` environment variable.

```bash
python3 scripts/make_text_to_video.py \
  --text "Hello world!" --voice-id YOUR_VOICE_ID \
  --neutral neutral.png --slight slight.png --wide wide.png --blink blink.png \
  --out /tmp/talking-circle.mp4
```

### Text to Video (SaluteSpeech / Sber)

Requires `SALUTE_SPEECH_AUTH` environment variable (Base64-encoded `client_id:client_secret`).

```bash
python3 scripts/make_salute_text_to_video.py \
  --text "Привет мир!" --voice Bys_24000 \
  --neutral neutral.png --slight slight.png --wide wide.png --blink blink.png \
  --out /tmp/talking-circle.mp4
```

## Frame Preparation

You need 4 square PNG images of your character at the same resolution (recommended 2048x2048):

| Frame | Description |
|-------|-------------|
| Neutral | Mouth closed, eyes open — the default resting state |
| Slight open | Mouth slightly open, eyes open — moderate speech |
| Wide open | Mouth wide open, eyes open — loud speech |
| Blink | Mouth closed, eyes closed — periodic blink animation |

### Critical rules

- All 4 frames must have **identical** resolution, art style, colors, and character positioning.
- Only the mouth and eyes should differ between frames — head, body, background stay the same.
- Do not mix frames from different generation sessions.

### Generating frames with Image AI

If you don't have ready-made frames, generate them using an image generation API (DALL-E, Midjourney, Flux, etc.):

**Step 1.** Generate the neutral frame — a shoulder-up portrait with mouth closed, eyes open.

**Step 2.** Use image editing / inpainting on the neutral frame to produce the other 3 states:

| Frame | Edit region | Prompt hint |
|-------|-----------|-------------|
| Slight open | Mouth only | `"Mouth slightly open, teeth barely visible"` |
| Wide open | Mouth only | `"Mouth wide open as if saying 'ah'"` |
| Blink | Eyes only | `"Eyes gently closed, mouth closed"` |

**Step 3.** Verify all 4 images have the same resolution and that head/body position hasn't shifted.

See [`examples/frames/README.md`](examples/frames/README.md) for detailed instructions.

## Example: Sbercat

3D-rendered anthropomorphic cat character — lavender-blue fur, green eyes, pink nose, green hoodie.

**Reference and output:**

| Reference | Output |
|-----------|--------|
| <img src="examples/sbercat/reference.png" width="200"> | [example.mp4](examples/sbercat/example.mp4) |

**Frame set:**

| Neutral | Slight open | Wide open | Blink |
|---------|-------------|-----------|-------|
| <img src="examples/sbercat/neutral.png" width="150"> | <img src="examples/sbercat/slight.png" width="150"> | <img src="examples/sbercat/wide.png" width="150"> | <img src="examples/sbercat/blink.png" width="150"> |

## Voice & TTS

The text-to-video mode uses [ElevenLabs](https://elevenlabs.io) by default. Ready-to-use voice preset for Sbercat:

| Parameter | Value |
|-----------|-------|
| `--voice-id` | `pNInz6obpgDQGcFmaJgB` |
| `--model-id` | `eleven_multilingual_v2` |
| `--stability` | `0.15` |
| `--similarity-boost` | `0.70` |
| `--style` | `0.38` |
| `--speed` | `1.20` |

**Also supported: [SaluteSpeech](https://developers.sber.ru/portal/products/smartspeech) (Sber)** — great for Russian. Voices: Nec (Natalia), Bys (Boris), May (Martha), Tur (Taras), Ost (Alexandra), Pon (Sergey), Kin (Kira, en-US). Use `make_salute_text_to_video.py`.

**Don't have any API key?** Use any other TTS engine — OpenAI TTS, Whisper, Coqui, Piper, Silero — generate an audio file and pass it to the audio-to-video mode (`--audio`). No API key needed.

## Parameters

### Video output
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--size` | 720 | Output video size in pixels |
| `--diameter` | 640 | Circle diameter |
| `--fps` | 30 | Frames per second |

### Blink timing
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--blink-start` | 1.1s | Delay before first blink |
| `--blink-every` | 3.8s | Interval between blinks |
| `--blink-duration-frames` | 4 | Frames per blink |

### Amplitude thresholds
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--amp-low` | 1200 | RMS threshold for neutral vs slight |
| `--amp-high` | 2600 | RMS threshold for slight vs wide |

### TTS settings (text-to-video only)
| Parameter | Default | Description |
|-----------|---------|-------------|
| `--voice-id` | (required) | ElevenLabs voice ID |
| `--model-id` | eleven_multilingual_v2 | ElevenLabs model |
| `--stability` | 0.50 | Voice stability |
| `--similarity-boost` | 0.75 | Similarity boost |
| `--style` | 0.00 | Style exaggeration |
| `--speed` | 1.00 | Speech speed |

## License

MIT


path: examples/frames/README.md
content: # Frame Preparation Guide

The talking-circle skill requires 4 PNG images of your avatar character. Each image represents a different mouth/eye state used for lip-sync and blink animation.

## Required Frames

### 1. `neutral.png` — Mouth closed, eyes open
The default resting state. Shown when audio amplitude is low (silence or quiet).

### 2. `mouth-slight-open.png` — Mouth slightly open, eyes open
Shown during moderate speech volume. The mouth should be noticeably open but not at maximum.

### 3. `mouth-wide-open.png` — Mouth wide open, eyes open
Shown during loud speech. The mouth should be at its widest natural opening.

### 4. `eyes-closed.png` — Mouth closed, eyes closed
Used for periodic blink animation. Same as neutral but with eyes closed.

## Image Requirements

- **Format:** PNG
- **Resolution:** All 4 images must be the same resolution
- **Recommended size:** 2048x2048 pixels (will be downscaled for the video)
- **Aspect ratio:** Square (1:1)
- **Style consistency:** All frames should have the same art style, colors, and character positioning
- **Background:** Can be any color — the skill crops to a circle automatically

## Generating Frames with Image AI

If you don't have hand-drawn frames, use an image generation API (DALL-E, Midjourney, Flux, etc.):

1. **Generate the neutral frame first.** This is the base — all other frames must match it exactly.
   - Use a shoulder-up portrait prompt with mouth closed, eyes open.
   - Example: `"Shoulder-up portrait of [character], square, clean background, mouth closed, eyes open, looking at camera"`

2. **Generate remaining frames as edits of the neutral image.**
   - Use image editing / inpainting to modify only the mouth or eyes.
   - Do NOT regenerate the full image — this causes style and position drift.

   | Frame | Region to edit | Prompt hint |
   |-------|---------------|-------------|
   | Slight open | Mouth | `"Mouth slightly open, teeth barely visible"` |
   | Wide open | Mouth | `"Mouth wide open as if saying 'ah'"` |
   | Blink | Eyes | `"Eyes gently closed, mouth closed"` |

3. **Verify consistency.** Overlay all 4 images to check that head/body position hasn't shifted. If any frame drifts, regenerate it from the neutral base.

## Tips

- Keep the character's head position identical across all 4 frames — only the mouth and eyes should change
- Use clean, high-contrast artwork for best results at small circle sizes
- If using AI-generated art, generate all 4 frames via inpainting from the same base image
- Test with a short audio clip first to verify the frames look natural in motion
- See `examples/sbercat/` for a reference character with all 4 frame images


path: requirements.txt
content: numpy
pillow
requests


path: scripts/make_salute_text_to_video.py
content: #!/usr/bin/env python3
"""One-shot: SaluteSpeech TTS (Sber) + animated circle avatar video."""
import argparse
import os
import subprocess
import tempfile
import uuid
from pathlib import Path

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def run(cmd):
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"Command failed: {' '.join(str(c) for c in cmd)}\n{result.stderr}")
    return result


def ensure_runtime_python() -> str:
    """Return python path with required video deps for make_talking_circle_video.py."""
    venv_dir = Path("/tmp/talking-circle-venv")
    py = venv_dir / "bin" / "python"
    if py.exists():
        return str(py)

    subprocess.run(["python3", "-m", "venv", str(venv_dir)], check=True)
    pip = venv_dir / "bin" / "pip"
    req = Path(__file__).resolve().parents[1] / "requirements.txt"
    subprocess.run([str(pip), "install", "--quiet", "-r", str(req)], check=True)
    return str(py)


def get_salute_token(auth_key: str, scope: str) -> str:
    """Get OAuth token from SaluteSpeech API. Token is valid for 30 minutes."""
    url = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "Accept": "application/json",
        "RqUID": str(uuid.uuid4()),
        "Authorization": f"Basic {auth_key}",
    }
    r = requests.post(url, headers=headers, data={"scope": scope}, timeout=30, verify=False)
    r.raise_for_status()
    return r.json()["access_token"]


def synthesize_salute(token: str, text: str, voice: str, audio_format: str) -> bytes:
    """Synthesize speech via SaluteSpeech REST API."""
    url = "https://smartspeech.sber.ru/rest/v1/text:synthesize"
    params = {"format": audio_format, "voice": voice}
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/text",
    }
    r = requests.post(url, headers=headers, params=params, data=text.encode("utf-8"), timeout=90, verify=False)
    if r.status_code == 401:
        raise SystemExit("SaluteSpeech auth failed (401). Check your SALUTE_SPEECH_AUTH key.")
    if r.status_code == 429:
        raise SystemExit("SaluteSpeech rate limit (429). Try again later.")
    r.raise_for_status()
    return r.content


def main():
    p = argparse.ArgumentParser(description="One-shot: SaluteSpeech TTS + animated circle avatar video")
    p.add_argument("--text", required=True)
    p.add_argument("--out", required=True)

    p.add_argument("--neutral", required=True)
    p.add_argument("--slight", required=True)
    p.add_argument("--wide", required=True)
    p.add_argument("--blink", required=True)

    # SaluteSpeech auth
    p.add_argument("--auth-key", default=os.getenv("SALUTE_SPEECH_AUTH"),
                    help="Base64-encoded client_id:client_secret (or set SALUTE_SPEECH_AUTH)")
    p.add_argument("--scope", default="SALUTE_SPEECH_PERS",
                    choices=["SALUTE_SPEECH_PERS", "SALUTE_SPEECH_CORP"],
                    help="OAuth scope (default: SALUTE_SPEECH_PERS)")

    # Voice settings
    p.add_argument("--voice", default="Bys_24000",
                    help="SaluteSpeech voice (default: Bys_24000). "
                         "Options: Nec_24000 (Natalia), Bys_24000 (Boris), "
                         "May_24000 (Martha), Tur_24000 (Taras), "
                         "Ost_24000 (Alexandra), Pon_24000 (Sergey), "
                         "Kin_24000 (Kira, en-US)")
    p.add_argument("--audio-format", default="wav16", choices=["opus", "wav16", "pcm16"],
                    help="Audio output format (default: wav16)")

    # Video settings
    p.add_argument("--size", type=int, default=720)
    p.add_argument("--diameter", type=int, default=640)
    p.add_argument("--fps", type=int, default=30)

    p.add_argument("--blink-start", type=float, default=1.1)
    p.add_argument("--blink-every", type=float, default=3.8)
    p.add_argument("--blink-duration-frames", type=int, default=4)

    p.add_argument("--amp-low", type=int, default=1200)
    p.add_argument("--amp-high", type=int, default=2600)

    args = p.parse_args()

    if not args.auth_key:
        raise SystemExit(
            "Missing SaluteSpeech auth key. Set SALUTE_SPEECH_AUTH or pass --auth-key.\n"
            "To get the key: encode your client_id:client_secret in Base64.\n"
            "Register at https://developers.sber.ru/portal/products/smartspeech"
        )

    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)

    neutral = Path(args.neutral)
    slight = Path(args.slight)
    wide = Path(args.wide)
    blink = Path(args.blink)
    for f in [neutral, slight, wide, blink]:
        if not f.exists():
            raise SystemExit(f"Missing frame file: {f}")

    log_path = out.with_suffix(".build.log")

    # Get OAuth token
    [REDACTED_SECRET], args.scope)

    # Map format to file extension
    ext_map = {"opus": ".opus", "wav16": ".wav", "pcm16": ".pcm"}
    audio_ext = ext_map.get(args.audio_format, ".wav")

    with tempfile.TemporaryDirectory() as td:
        td = Path(td)
        audio = td / f"speech{audio_ext}"

        # Synthesize speech
        audio_bytes = synthesize_salute(token, args.text, args.voice, args.audio_format)
        audio.write_bytes(audio_bytes)

        script = Path(__file__).with_name("make_talking_circle_video.py")
        runtime_python = ensure_runtime_python()
        cmd = [
            runtime_python,
            str(script),
            "--neutral", str(neutral),
            "--slight", str(slight),
            "--wide", str(wide),
            "--blink", str(blink),
            "--audio", str(audio),
            "--out", str(out),
            "--blink-start", str(args.blink_start),
            "--blink-every", str(args.blink_every),
            "--blink-duration-frames", str(args.blink_duration_frames),
            "--size", str(args.size),
            "--diameter", str(args.diameter),
            "--fps", str(args.fps),
            "--amp-low", str(args.amp_low),
            "--amp-high", str(args.amp_high),
        ]

        result = subprocess.run(cmd, capture_output=True, text=True)
        log_path.write_text(
            f"RC={result.returncode}\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}\n"
        )

        if result.returncode != 0:
            raise SystemExit(
                f"Animated video build failed.\n{result.stderr}\nSee build log: {log_path}"
            )

    if not out.exists() or out.stat().st_size == 0:
        raise RuntimeError("Output video is missing or empty")

    print(str(out))


if __name__ == "__main__":
    main()


path: scripts/make_talking_circle_video.py
content: #!/usr/bin/env python3
import argparse
import subprocess
import numpy as np
import tempfile
import wave
from pathlib import Path

from PIL import Image, ImageDraw


def run(cmd):
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"Command failed: {' '.join(str(c) for c in cmd)}\n{result.stderr}")


def prep_circle_frame(path: Path, size: int, diameter: int, bg=(242, 242, 242)) -> Image.Image:
    r = diameter // 2
    center = (size // 2, size // 2)

    im = Image.open(path).convert("RGB").resize((diameter, diameter), Image.Resampling.LANCZOS)
    canvas = Image.new("RGB", (size, size), bg)
    canvas.paste(im, (center[0] - r, center[1] - r))

    mask = Image.new("L", (size, size), 0)
    d = ImageDraw.Draw(mask)
    d.ellipse((center[0] - r, center[1] - r, center[0] + r, center[1] + r), fill=255)

    out = Image.new("RGB", (size, size), bg)
    out.paste(canvas, mask=mask)
    d2 = ImageDraw.Draw(out)
    d2.ellipse((center[0] - r, center[1] - r, center[0] + r, center[1] + r), outline=(210, 210, 210), width=2)
    return out


def build_blink_frames(total: int, fps: int, dur: float, start: float, every: float, duration_frames: int):
    """Always schedule visible blink frames (at least one for short clips)."""
    frames = set()
    if duration_frames <= 0 or total <= 0:
        return frames

    half = duration_frames // 2
    centers = []

    # Regular schedule for normal-duration clips
    if every > 0:
        t = start
        while t < dur - 0.2:
            centers.append(int(t * fps))
            t += every

    # Mandatory fallback: at least one blink even for short videos
    if not centers:
        if dur >= 0.8:
            centers.append(int((dur * 0.5) * fps))
        else:
            centers.append(total // 2)

    for c in centers:
        for j in range(duration_frames):
            idx = c - half + j
            if 0 <= idx < total:
                frames.add(idx)

    return frames


def audio_rms_for_frame(raw: bytes, sr: int, frame_idx: int, fps: int, sample_width: int):
    total_samples = len(raw) // sample_width
    center_sample = int((frame_idx / fps) * sr)
    win_samples = int(0.035 * sr)
    s = max(0, center_sample - win_samples // 2)
    e = min(total_samples, center_sample + win_samples // 2)
    if e <= s:
        return 0

    seg = raw[s * sample_width : e * sample_width]
    if sample_width != 2:
        return 0

    arr = np.frombuffer(seg, dtype=np.int16).astype(np.float32)
    if arr.size == 0:
        return 0
    return int(np.sqrt(np.mean(np.square(arr))))


def render_sequence_frames(frames_dir: Path, prepared: dict, total: int, blink_frames: set, key_selector):
    used = set()
    for i in range(total):
        if i in blink_frames:
            key = "blink"
        else:
            key = key_selector(i)
        used.add(key)
        prepared[key].save(frames_dir / f"frame_{i:06d}.png")
    return used


def build_silent_video_from_frames(frames_dir: Path, fps: int, out_silent: Path):
    run(
        [
            "ffmpeg",
            "-y",
            "-framerate",
            str(fps),
            "-i",
            str(frames_dir / "frame_%06d.png"),
            "-c:v",
            "libx264",
            "-pix_fmt",
            "yuv420p",
            str(out_silent),
        ]
    )


def main():
    p = argparse.ArgumentParser(description="Create talking circle avatar video from mouth/eye frames + audio")
    p.add_argument("--neutral", required=True)
    p.add_argument("--slight", required=True)
    p.add_argument("--wide", required=True)
    p.add_argument("--blink", required=True)
    p.add_argument("--audio", required=True)
    p.add_argument("--out", required=True)

    p.add_argument("--size", type=int, default=720)
    p.add_argument("--diameter", type=int, default=640)
    p.add_argument("--fps", type=int, default=30)

    # mandatory blink: visible but still natural
    p.add_argument("--blink-start", type=float, default=1.1)
    p.add_argument("--blink-every", type=float, default=3.8)
    p.add_argument("--blink-duration-frames", type=int, default=4)

    # thresholds in 16-bit PCM RMS units
    p.add_argument("--amp-low", type=int, default=1200)
    p.add_argument("--amp-high", type=int, default=2600)

    args = p.parse_args()

    neutral = Path(args.neutral)
    slight = Path(args.slight)
    wide = Path(args.wide)
    blink = Path(args.blink)
    audio = Path(args.audio)
    out = Path(args.out)

    for f in [neutral, slight, wide, blink, audio]:
        if not f.exists():
            raise FileNotFoundError(f"Missing required input: {f}")

    out.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory() as td:
        td = Path(td)
        wav = td / "speech.wav"
        frames_dir = td / "frames"
        frames_dir.mkdir(parents=True, exist_ok=True)
        out_silent = td / "silent.mp4"

        run(["ffmpeg", "-y", "-i", str(audio), "-ac", "1", "-ar", "16000", str(wav)])

        with wave.open(str(wav), "rb") as w:
            sr = w.getframerate()
            n = w.getnframes()
            sample_width = w.getsampwidth()
            channels = w.getnchannels()
            if channels != 1:
                raise RuntimeError("Expected mono wav after ffmpeg conversion")
            raw = w.readframes(n)

        dur = n / sr
        total = max(1, int(dur * args.fps))

        prepared = {
            "neutral": prep_circle_frame(neutral, args.size, args.diameter),
            "slight": prep_circle_frame(slight, args.size, args.diameter),
            "wide": prep_circle_frame(wide, args.size, args.diameter),
            "blink": prep_circle_frame(blink, args.size, args.diameter),
        }

        blink_frames = build_blink_frames(
            total=total,
            fps=args.fps,
            dur=dur,
            start=args.blink_start,
            every=args.blink_every,
            duration_frames=args.blink_duration_frames,
        )

        # Audio-driven selection
        def select_key(i):
            rms = audio_rms_for_frame(raw, sr, i, args.fps, sample_width)
            if rms < args.amp_low:
                return "neutral"
            if rms < args.amp_high:
                return "slight"
            return "wide"

        used = render_sequence_frames(frames_dir, prepared, total, blink_frames, select_key)

        # If visually static by key usage (ignoring blink), fallback cycle
        non_blink_used = {k for k in used if k != "blink"}
        if len(non_blink_used) <= 1:
            for f in frames_dir.glob("frame_*.png"):
                f.unlink(missing_ok=True)
            cycle = ["neutral", "slight", "wide", "slight", "neutral", "slight", "wide", "slight"]

            def cycle_key(i):
                return cycle[i % len(cycle)]

            render_sequence_frames(frames_dir, prepared, total, blink_frames, cycle_key)

        build_silent_video_from_frames(frames_dir, args.fps, out_silent)

        run(
            [
                "ffmpeg",
                "-y",
                "-i",
                str(out_silent),
                "-i",
                str(audio),
                "-c:v",
                "copy",
                "-c:a",
                "aac",
                "-shortest",
                str(out),
            ]
        )

    if not out.exists() or out.stat().st_size == 0:
        raise RuntimeError("Output video is missing or empty")

    print(str(out))


if __name__ == "__main__":
    main()


path: scripts/make_text_to_video.py
content: #!/usr/bin/env python3
import argparse
import os
import subprocess
import tempfile
from pathlib import Path

import requests


def run(cmd):
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"Command failed: {' '.join(str(c) for c in cmd)}\n{result.stderr}")
    return result


def ensure_runtime_python() -> str:
    """Return python path with required video deps for make_talking_circle_video.py."""
    venv_dir = Path("/tmp/talking-circle-venv")
    py = venv_dir / "bin" / "python"
    if py.exists():
        return str(py)

    subprocess.run(["python3", "-m", "venv", str(venv_dir)], check=True)
    pip = venv_dir / "bin" / "pip"
    req = Path(__file__).resolve().parents[1] / "requirements.txt"
    subprocess.run([str(pip), "install", "--quiet", "-r", str(req)], check=True)
    return str(py)


def build_static_circle_video(neutral_path: Path, audio_path: Path, out: Path, size: int = 720, diameter: int = 640):
    # Optional fallback only (disabled by default)
    from PIL import Image, ImageDraw

    bg = (242, 242, 242)
    r = diameter // 2
    center = (size // 2, size // 2)

    im = Image.open(neutral_path).convert("RGB").resize((diameter, diameter), Image.Resampling.LANCZOS)
    canvas = Image.new("RGB", (size, size), bg)
    canvas.paste(im, (center[0] - r, center[1] - r))

    mask = Image.new("L", (size, size), 0)
    d = ImageDraw.Draw(mask)
    d.ellipse((center[0] - r, center[1] - r, center[0] + r, center[1] + r), fill=255)

    out_img = out.with_suffix(".png")
    final = Image.new("RGB", (size, size), bg)
    final.paste(canvas, mask=mask)
    d2 = ImageDraw.Draw(final)
    d2.ellipse((center[0] - r, center[1] - r, center[0] + r, center[1] + r), outline=(210, 210, 210), width=2)
    final.save(out_img)

    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-loop",
            "1",
            "-i",
            str(out_img),
            "-i",
            str(audio_path),
            "-c:v",
            "libx264",
            "-pix_fmt",
            "yuv420p",
            "-tune",
            "stillimage",
            "-shortest",
            str(out),
        ],
        check=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def main():
    p = argparse.ArgumentParser(description="One-shot: ElevenLabs TTS + animated circle avatar video")
    p.add_argument("--text", required=True)
    p.add_argument("--out", required=True)

    p.add_argument("--neutral", required=True)
    p.add_argument("--slight", required=True)
    p.add_argument("--wide", required=True)
    p.add_argument("--blink", required=True)

    p.add_argument("--api-key", default=os.getenv("ELEVENLABS_API_KEY"))
    p.add_argument("--voice-id", required=True)
    p.add_argument("--model-id", default="eleven_multilingual_v2")

    p.add_argument("--stability", type=float, default=0.50)
    p.add_argument("--similarity-boost", type=float, default=0.75)
    p.add_argument("--style", type=float, default=0.00)
    p.add_argument("--use-speaker-boost", action="store_true")
    p.add_argument("--speed", type=float, default=1.00)

    p.add_argument("--size", type=int, default=720)
    p.add_argument("--diameter", type=int, default=640)
    p.add_argument("--fps", type=int, default=30)

    p.add_argument("--blink-start", type=float, default=1.1)
    p.add_argument("--blink-every", type=float, default=3.8)
    p.add_argument("--blink-duration-frames", type=int, default=4)

    p.add_argument("--amp-low", type=int, default=1200)
    p.add_argument("--amp-high", type=int, default=2600)

    p.add_argument("--allow-static-fallback", action="store_true", help="Use static video fallback if animation build fails")
    args = p.parse_args()

    if not args.api_key:
        raise SystemExit("Missing ElevenLabs API key. Set ELEVENLABS_API_KEY or pass --api-key.")

    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)

    neutral = Path(args.neutral)
    slight = Path(args.slight)
    wide = Path(args.wide)
    blink = Path(args.blink)
    for f in [neutral, slight, wide, blink]:
        if not f.exists():
            raise SystemExit(f"Missing frame file: {f}")

    log_path = out.with_suffix(".build.log")

    with tempfile.TemporaryDirectory() as td:
        td = Path(td)
        audio = td / "speech.mp3"

        url = f"https://api.elevenlabs.io/v1/text-to-speech/{args.voice_id}?output_format=mp3_44100_128"
        payload = {
            "text": args.text,
            "model_id": args.model_id,
            "voice_settings": {
                "stability": args.stability,
                "similarity_boost": args.similarity_boost,
                "style": args.style,
                "use_speaker_boost": bool(args.use_speaker_boost),
                "speed": args.speed,
            },
        }
        r = requests.post(
            url,
            headers={"xi-api-key": args.api_key, "Content-Type": "application/json"},
            json=payload,
            timeout=90,
        )
        r.raise_for_status()
        audio.write_bytes(r.content)

        script = Path(__file__).with_name("make_talking_circle_video.py")
        runtime_python = ensure_runtime_python()
        cmd = [
            runtime_python,
            str(script),
            "--neutral",
            str(neutral),
            "--slight",
            str(slight),
            "--wide",
            str(wide),
            "--blink",
            str(blink),
            "--audio",
            str(audio),
            "--out",
            str(out),
            "--blink-start",
            str(args.blink_start),
            "--blink-every",
            str(args.blink_every),
            "--blink-duration-frames",
            str(args.blink_duration_frames),
            "--size",
            str(args.size),
            "--diameter",
            str(args.diameter),
            "--fps",
            str(args.fps),
            "--amp-low",
            str(args.amp_low),
            "--amp-high",
            str(args.amp_high),
        ]

        result = subprocess.run(cmd, capture_output=True, text=True)
        log_path.write_text(
            f"RC={result.returncode}\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}\n"
        )

        if result.returncode != 0:
            if args.allow_static_fallback:
                build_static_circle_video(neutral, audio, out)
            else:
                raise SystemExit(
                    f"Animated video build failed.\n{result.stderr}\nSee build log: {log_path}"
                )

    if not out.exists() or out.stat().st_size == 0:
        raise RuntimeError("Output video is missing or empty")

    print(str(out))


if __name__ == "__main__":
    main()


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