Skill: gprecious/ai-music-video (version 1.2.4)

--- name: ai-music-video description: Generate AI music videos end-to-end. Creates music with Suno (sunoapi.org), generates visuals with OpenAI/Seedream/Google/Seedance, and assembles into music video with ffmpeg. Supports timestamped lyrics (auto SRT), Suno native music video generation, slideshow/video/hybrid modes. Token-based cost tracking per generation. metadata: openclaw: requires: bins: [curl, python3, ffmpeg] env: - SUNO_API_KEY - OPENAI_API_KEY optionalEnv: - BYTEPLUS_API_KEY - TOGETHER_API_KEY --- # AI Music Video Generator Create complete music videos: AI music + AI visuals + ffmpeg assembly. ## Quick Start ``` "90년대 보이밴드 풍 한국어 노래 만들어줘" → music only "발라드 뮤비 만들어줘" → music + slideshow MV "EDM 뮤비 풀영상으로" → music + video clips MV "Suno 뮤비로 만들어줘" → Suno native music video ``` ## Workflow ### 1. Plan scenes from lyrics/mood Before generating, create `prompts.json` — array of scene descriptions derived from the song's lyrics, mood, and narrative. 8-12 scenes for a 3-min song. ```json [ {"prompt": "Neon-lit city street at night, rain reflections", "type": "image"}, {"prompt": "Camera slowly panning across a rooftop at sunset", "type": "video"}, "A lone figure walking through cherry blossoms" ] ``` ### 2. Generate music ```bash bash scripts/suno_music.sh \ --prompt "가사 또는 설명" \ --style "90s boy band pop, korean" \ --title "너만을 원해" \ --model V4_5ALL --custom \ --outdir /tmp/mv_project ``` **Options:** - `--model V4_5ALL` (default), `V5`, `V4_5PLUS`, `V4_5`, `V4` - `--instrumental` — no vocals - `--vocal-gender m|f` — vocal gender hint - `--negative-tags "Heavy Metal, Drums"` — styles to avoid - `--music-video` — generate Suno native music video (MP4) - `--dry-run` — cost check only **Persona (일관된 스타일 유지):** - `--persona-id ID` — 기존 페르소나 사용 (같은 보컬/스타일로 여러 곡 생성) - `--create-persona` — 생성된 곡에서 페르소나 생성 → `persona.json` 저장 - `--persona-name "이름"` / `--persona-desc "설명"` / `--persona-style "스타일"` **Auto features:** - 🎤 **Timestamped Lyrics**: Non-instrumental tracks automatically fetch lyrics timestamps and save as `{outdir}/lyrics.srt` - 🎬 **Suno Native MV**: With `--music-video`, Suno generates a visualized MP4 video directly - 🎭 **Persona**: With `--create-persona`, extracts voice/style identity for reuse ### 3. Generate visuals (custom MV flow) ```bash bash scripts/gen_visuals.sh \ --mode slideshow \ --prompts-file /tmp/mv_project/prompts.json \ --image-provider seedream \ --outdir /tmp/mv_project ``` Or with OpenAI (cheaper, lower res): ```bash bash scripts/gen_visuals.sh \ --mode slideshow \ --prompts-file /tmp/mv_project/prompts.json \ --image-provider openai --image-model gpt-image-1-mini --image-quality medium \ --outdir /tmp/mv_project ``` Add `--dry-run` first to show cost estimate before spending. ### 4. Assemble ```bash bash scripts/assemble_mv.sh \ --audio /tmp/mv_project/track_0_xxx.mp3 \ --outdir /tmp/mv_project \ --output /tmp/mv_project/final_mv.mp4 \ --transition fade ``` **Subtitle behavior:** - Auto-detects `{outdir}/lyrics.srt` and overlays lyrics automatically - `--subtitle /path/to/custom.srt` — use custom SRT file - `--no-subtitle` — disable lyrics overlay entirely ## Modes | Mode | Visual | Best For | Cost (10 scenes) | |------|--------|----------|---------------------| | `slideshow` | AI images | Fast, cheap | ~$0.02 (mini low) / ~$0.09 (mini med) / ~$0.45 (Seedream) | | `video` | AI video clips | Premium | ~$1.40 (Seedance Lite) / ~$8.00 (Sora 2) | | `hybrid` | Mix of both | Balanced | ~$0.50-$4.00 | | `suno-native` | Suno MV | Easiest | Suno credits only | **Image cost is token-based** — actual billing may be lower than listed estimates. Use `--dry-run` for precise cost. ## Provider Options **Images:** `--image-provider seedream` (recommended), `openai`, or `google-together` **Image Model (OpenAI):** `--image-model gpt-image-1-mini` (default, cheap) or `gpt-image-1` (premium) **Videos:** `--video-provider sora` (default), `sora-pro`, `seedance-lite`, `seedance-pro`, `veo-fast`, `veo-audio` **Quality:** `--image-quality low|medium|high` ## Cost Tracking Every script outputs cost before and after. Always `--dry-run` first. Cost data saved to `{outdir}/cost_estimate.json` and `{outdir}/visuals_meta.json`. ## Environment Variables ```bash export SUNO_API_KEY="your-sunoapi-key" # Required — sunoapi.org export OPENAI_API_KEY="your-openai-key" # Required — images + Sora video export BYTEPLUS_API_KEY="your-byteplus-key" # Optional — Seedream 4.5 (recommended for images) export TOGETHER_API_KEY="your-together-key" # Optional — Seedance, Veo, Imagen export SUNO_CALLBACK_URL="" # Optional — see Callback URL below ``` **⚠️ Required keys:** `SUNO_API_KEY` and `OPENAI_API_KEY` must be set before running any script. `BYTEPLUS_API_KEY` is needed for Seedream image provider (sign up at [console.byteplus.com](https://console.byteplus.com), 200 free images). `TOGETHER_API_KEY` is only needed for Seedance/Veo/Imagen providers. ### Callback URL The Suno API requires a `callBackUrl` field for music generation requests. By default, if `SUNO_CALLBACK_URL` is not set, the script uses `https://localhost/noop` as a harmless no-op endpoint (an unreachable localhost URL that effectively disables callbacks). **To customize:** set `SUNO_CALLBACK_URL` to your own endpoint, or set it to any dummy URL you control. The callback payload contains task metadata and audio URLs — no API keys are sent. **To disable:** set `SUNO_CALLBACK_URL=https://localhost/noop` or any unreachable URL. Generation still works via polling; the callback is not required for the script to function. ## Persona Workflow (채널 컨셉 유지) YouTube 채널처럼 일관된 스타일로 여러 곡을 만들 때: ```bash # 1. 첫 곡 생성 + 페르소나 만들기 bash scripts/suno_music.sh \ --prompt "코드 리뷰하며 듣는 노래" \ --style "indie rock, energetic, coding vibe" \ --title "Pull Request" \ --custom --create-persona \ --persona-name "개발자 노동요 싱어" \ --persona-desc "개발자가 코딩하며 듣기 좋은 에너지 넘치는 보컬. 인디록, 일렉, 팝 장르를 넘나든다." \ --persona-style "indie rock, electronic, developer work music" \ --outdir /tmp/dev-bgm-01 # 2. persona.json에서 personaId 확인 cat /tmp/dev-bgm-01/persona.json # 3. 같은 페르소나로 다음 곡 생성 — 보컬/스타일 일관성 유지 bash scripts/suno_music.sh \ --prompt "야근하면서 듣는 노래" \ --style "electronic pop, night coding" \ --title "Midnight Deploy" \ --custom --persona-id <PERSONA_ID> \ --outdir /tmp/dev-bgm-02 ``` 페르소나는 보컬 특성 + 음악 스타일을 기억해서, 채널 전체의 통일감을 유지해줌. ## Prerequisites - `curl`, `python3`, `ffmpeg` (for assembly) ## References - **SunoAPI details:** Read `references/sunoapi.md` - **Visual provider details:** Read `references/visual-providers.md`

path: _meta.json
content: ownerId: kn73f73vfj91qzrvm10j4gjn89817ev6
slug: ai-music-video
version: 1.2.3
publishedAt: 1771287373338

path: references/sunoapi.md
content: # SunoAPI Reference (sunoapi.org)

## Base URL
`https://api.sunoapi.org/api/v1`

## Auth
`Authorization: Bearer $SUNO_API_KEY`

## Models
| Model | Max Duration | Notes |
|-------|-------------|-------|
| V4_5ALL | 8 min | **Default** — best overall song structure |
| V5 | 8 min | Latest (Sep 2025), best quality |
| V4_5PLUS | 8 min | Richer tones |
| V4_5 | 8 min | Smart prompts |
| V4 | 4 min | Improved vocals |

## Endpoints

### Generate Music
```
POST /generate
{
  "prompt": "lyrics or description (max 5000 chars for V5)",
  "model": "V4_5ALL",
  "customMode": true,           // enables style+title
  "instrumental": false,
  "style": "90s boy band pop",  // max 1000 chars (V5)
  "title": "Song Title",        // max 100 chars (V5)
  "negativeTags": "Heavy Metal",
  "vocalGender": "m",           // m/f
  "styleWeight": 0.65,          // optional
  "weirdnessConstraint": 0.65,  // optional
  "audioWeight": 0.65,          // optional
  "personaId": "persona_123",   // optional
  "personaModel": "style_persona", // optional
  "callBackUrl": "https://..."  // optional callback
}
→ {"code":200,"data":{"taskId":"xxx"}}
```

### Poll Status
```
GET /generate/record-info?taskId=xxx
→ {
  "code": 200,
  "data": {
    "taskId": "xxx",
    "status": "SUCCESS|PENDING|PROCESSING|FAILED",
    "response": {
      "sunoData": [{
        "id": "audio_id",
        "audioUrl": "https://...",
        "imageUrl": "https://...",
        "videoUrl": "https://...",
        "title": "...",
        "tags": "...",
        "duration": 180.5,
        "prompt": "[Verse] ..."
      }]
    }
  }
}
```

### Get Timestamped Lyrics
```
POST /generate/get-timestamped-lyrics
{
  "taskId": "5c79****be8e",
  "audioId": "e231****-****-****-****-****8cadc7dc"
}
→ {
  "code": 200,
  "msg": "success",
  "data": {
    "alignedWords": [
      {"word": "첫 번째 ", "startS": 0.5, "endS": 3.2, "success": true, "palign": 0},
      {"word": "가사\n", "startS": 3.5, "endS": 6.1, "success": true, "palign": 0}
    ]
  }
}
```
- Returns word-level timestamps (not line-level) via `alignedWords` array
- `startS`/`endS` in seconds; `\n` in `word` indicates line break
- Group words into lines by splitting on `\n`, then convert to SRT
- Instrumental tracks return empty array
- Use for karaoke-style display or SRT subtitle generation

### Create Music Video (Suno Native)
```
POST /mp4/generate
{
  "taskId": "taskId_xxx",
  "audioId": "e231****",
  "callBackUrl": "https://...",
  "author": "Artist Name",       // optional, max 50 chars
  "domainName": "music.example.com" // optional watermark, max 50 chars
}
→ {"code":200,"msg":"success","data":{"taskId":"mv_task_id"}}
```

### Poll Music Video Status
```
GET /mp4/record-info?taskId=mv_task_id
→ {
  "code": 200,
  "data": {
    "taskId": "mv_task_id",
    "successFlag": "SUCCESS|PENDING|FAILED",
    "response": {
      "videoUrl": "https://tempfile.aiquickdraw.com/r/xxx.mp4"
    }
  }
}
```
- Generates MP4 with visual effects synced to music
- Generated videos retained 15 days
- Poll via `/mp4/record-info` (NOT `/generate/record-info`)
- 409 = video already exists for this track

### Check Credits
```
GET /get-credits → {"code":200,"data": 100}
```

### Generate Lyrics
```
POST /lyrics
{"prompt": "theme description"}
→ {"code":200,"data":{"taskId":"xxx"}}
```

### Extend Music
```
POST /generate/extend
{
  "audioId": "xxx",
  "continueAt": 120,
  "prompt": "Continue with guitar solo",
  "model": "V4_5ALL"
}
```

## Status Codes
- 200: Success
- 400: Invalid params
- 401: Unauthorized
- 404: Invalid path
- 405: Rate limit exceeded
- 409: Conflict (MP4 already exists)
- 413: Prompt too long
- 429: Insufficient credits
- 430: Rate limited (call frequency)
- 455: Maintenance
- 500: Server error

### Generate Persona
`POST /generate/generate-persona`

페르소나(음악 캐릭터) 생성 — 보컬 스타일을 추출해서 다음 곡에 재사용.

```json
{
  "taskId": "5c79****be8e",
  "audioId": "e231****8cadc7dc",
  "name": "개발자 노동요 싱어",
  "description": "코딩하며 듣기 좋은 인디록/일렉 보컬",
  "vocalStart": 0,
  "vocalEnd": 30,
  "style": "indie rock, electronic"
}
```

**Params:**
- `taskId` (required): 음악 생성 task ID
- `audioId` (required): 분석할 오디오 ID
- `name` (required): 페르소나 이름
- `description` (required): 음악 특성, 스타일, 성격 설명
- `vocalStart` / `vocalEnd` (optional): 분석 구간 (10-30초, default 0-30)
- `style` (optional): 스타일 라벨

**Response:** `personaId` 반환 → generate 시 `personaId` + `personaModel: "style_persona"` 전달

**주의:**
- 음악 생성 완료 후에만 호출 가능
- V4 이상 모델만 지원
- 각 audioId당 1회만 생성 가능 (409 Conflict)

## Notes
- Each generation produces 2 tracks (~10 credits)
- Poll every 15-30s; timeout ~10 min
- Audio files: MP3 format
- Generated files retained 15 days on CDN


path: references/visual-providers.md
content: # Visual Providers Reference

## Image Providers

### BytePlus Seedream 4.5 ⭐ Recommended
- Model: `seedream-4-5-251128`
- Endpoint: `POST https://ark.ap-southeast.bytepluses.com/api/v3/images/generations`
- Auth: `Authorization: Bearer $BYTEPLUS_API_KEY`
- Min size: 1920x1920 (use 2048x2048 for square)
- Pricing: **$0.045/image** (200 free images on signup)
- Sign up: [console.byteplus.com](https://console.byteplus.com) → ModelArk → Activate Seedream

```json
{
  "model": "seedream-4-5-251128",
  "prompt": "scene description",
  "size": "2048x2048",
  "response_format": "url",
  "watermark": false
}
```

### OpenAI GPT Image 1 / 1 Mini
- Models: `gpt-image-1` (premium) / `gpt-image-1-mini` (budget, default)
- Endpoint: `POST https://api.openai.com/v1/images/generations`
- Auth: `Authorization: Bearer $OPENAI_API_KEY`
- Sizes: 1024x1024, 1536x1024, 1024x1536
- Quality: low / medium / high

```json
{
  "model": "gpt-image-1-mini",
  "prompt": "scene description",
  "n": 1,
  "size": "1024x1024",
  "quality": "medium"
}
```

**Token-based pricing (actual cost per image, Feb 2026):**
| Model | Quality | 1024x1024 | 1536x1024 |
|-------|---------|-----------|-----------|
| gpt-image-1-mini | Low | **$0.002** | $0.004 |
| gpt-image-1-mini | Medium | **$0.009** | $0.013 |
| gpt-image-1 | Medium | $0.043 | $0.064 |
| gpt-image-1 | High | $0.167 | $0.250 |

*Note: OpenAI bills images by tokens, not flat per-image rate. Output tokens are fixed per quality (low=272, medium=1056, high=4160). The script tracks actual token usage for precise cost reporting.*

### Google Imagen 4.0 (via Together AI)
- Model: `google/imagen-4.0-generate-preview`
- Endpoint: `POST https://api.together.xyz/v1/images/generations`
- Auth: `Authorization: Bearer $TOGETHER_API_KEY`
- ~$0.04/MP (Preview), $0.02 (Fast), $0.06 (Ultra)

## Video Providers

### OpenAI Sora 2 (direct)
- Endpoint: `POST https://api.openai.com/v1/videos/generations`
- Auth: `Authorization: Bearer $OPENAI_API_KEY`
- Models: `sora-2` (720p, $0.10/sec), `sora-2-pro` (1080p, $0.30/sec)

### Via Together AI (unified endpoint)
- Endpoint: `POST https://api.together.xyz/v2/videos`
- Auth: `Authorization: Bearer $TOGETHER_API_KEY`
- Async: submit → poll by ID → download

**Models & Pricing (per clip, ~5-8 sec):**
| Model ID | Price/clip | Resolution |
|----------|-----------|------------|
| `openai/sora-2` | $0.80 | 720p |
| `openai/sora-2-pro` | $2.40 | 1080p |
| `ByteDance/Seedance-1.0-lite` | $0.14 | 720p |
| `ByteDance/Seedance-1.0-pro` | $0.57 | 1080p |
| `google/veo-3.0-generate-preview` | $1.60 | - |
| `google/veo-3.0-generate-preview` +audio | $3.20 | - |
| `google/veo-3.0-fast` | $0.80 | - |

### Together Video API Flow
```bash
# 1. Submit
curl -X POST https://api.together.xyz/v2/videos \
  -H "Authorization: Bearer $TOGETHER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"ByteDance/Seedance-1.0-pro","prompt":"..."}'
→ {"id": "video_xxx", "status": "processing"}

# 2. Poll
curl https://api.together.xyz/v2/videos/video_xxx \
  -H "Authorization: Bearer $TOGETHER_API_KEY"
→ {"id":"video_xxx","status":"completed","output":{"video_url":"https://..."}}

# 3. Download video_url
```

## Provider Selection Guide

| Need | Best Choice | Fallback |
|------|-------------|----------|
| Best value images | **Seedream 4.5** ($0.045, 2K) | GPT Image 1 Mini medium ($0.009) |
| Cheapest images | GPT Image 1 Mini low ($0.002) | Imagen 4.0 Fast ($0.02) |
| Premium images | GPT Image 1 high ($0.17) | Imagen 4.0 Ultra ($0.06) |
| Budget video | Seedance 1.0 Lite ($0.14) | Sora 2 ($0.80) |
| Quality video | Sora 2 Pro ($2.40) | Seedance Pro ($0.57) |
| Video + audio sync | Veo 3.0 + Audio ($3.20) | - |

## API Key Requirements
| Provider | Env Variable | Required For |
|----------|-------------|--------------|
| sunoapi.org | `SUNO_API_KEY` | Music (always) |
| OpenAI | `OPENAI_API_KEY` | Images (mini/premium), Sora video |
| BytePlus | `BYTEPLUS_API_KEY` | Seedream images (recommended) |
| Together AI | `TOGETHER_API_KEY` | Seedance, Veo, Imagen |


path: scripts/assemble_mv.sh
content: #!/usr/bin/env bash
# assemble_mv.sh — Combine audio + visuals into final music video with ffmpeg
# Usage: assemble_mv.sh [options]
#   --audio /path/to/audio.mp3
#   --outdir /path  (where images/videos + meta live)
#   --output /path/to/final.mp4
#   --mode slideshow|video|hybrid  (auto-detect from visuals_meta.json)
#   --slide-duration 8   (seconds per image, default: auto from audio/count)
#   --transition fade|none  (default: fade)
#   --subtitle /path/to/lyrics.srt  (optional SRT subtitles)
#   --no-subtitle    (disable auto-detected lyrics.srt overlay)
#   --resolution 1920x1080  (default: 1920x1080)
#   --dry-run

set -euo pipefail

# Helper: safely write ffmpeg concat list entry (escapes single quotes in filenames)
safe_concat_entry() {
  local filepath="$1"
  # ffmpeg concat format escapes ' as '\'' inside single-quoted strings
  local escaped="${filepath//\'/\'\\\'\'}"
  echo "file '${escaped}'"
}

# Helper: escape path for ffmpeg subtitle filter (escapes : ' \)
escape_subtitle_path() {
  local p="$1"
  p="${p//\\/\\\\}"
  p="${p//:/\\:}"
  p="${p//\'/\\'}"
  echo "$p"
}

AUDIO=""
OUTDIR="./output"
OUTPUT=""
MODE=""
SLIDE_DUR=0
TRANSITION="fade"
SUBTITLE=""
RESOLUTION="1920x1080"
DRY_RUN=false
NO_SUBTITLE=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --audio) AUDIO="$2"; shift 2 ;;
    --outdir) OUTDIR="$2"; shift 2 ;;
    --output) OUTPUT="$2"; shift 2 ;;
    --mode) MODE="$2"; shift 2 ;;
    --slide-duration) SLIDE_DUR="$2"; shift 2 ;;
    --transition) TRANSITION="$2"; shift 2 ;;
    --subtitle) SUBTITLE="$2"; shift 2 ;;
    --no-subtitle) NO_SUBTITLE=true; shift ;;
    --resolution) RESOLUTION="$2"; shift 2 ;;
    --dry-run) DRY_RUN=true; shift ;;
    *) echo "Unknown: $1" >&2; exit 1 ;;
  esac
done

# Auto-detect lyrics.srt if no subtitle specified and not disabled
if [[ -z "$SUBTITLE" && "$NO_SUBTITLE" != true ]]; then
  AUTO_SRT="$OUTDIR/lyrics.srt"
  if [[ -f "$AUTO_SRT" ]]; then
    SUBTITLE="$AUTO_SRT"
    echo "📝 Auto-detected lyrics: $AUTO_SRT"
  fi
fi

# Validate
if [[ -z "$AUDIO" || ! -f "$AUDIO" ]]; then
  echo "ERROR: --audio required (path to audio file)" >&2; exit 1
fi

if [[ -z "$OUTPUT" ]]; then
  OUTPUT="$OUTDIR/music_video.mp4"
fi

# Check ffmpeg
if ! command -v ffmpeg &>/dev/null; then
  echo "ERROR: ffmpeg not found. Install with: apt install ffmpeg" >&2; exit 1
fi

# Get audio duration
AUDIO_DUR=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "$AUDIO" 2>/dev/null | cut -d. -f1)
echo "🎵 Audio: $AUDIO (${AUDIO_DUR}s)"

IFS='x' read -r OUT_W OUT_H <<< "$RESOLUTION"

# Auto-detect mode from visuals_meta.json
if [[ -z "$MODE" && -f "$OUTDIR/visuals_meta.json" ]]; then
  MODE=$(python3 -c "
import json, sys
m = json.load(open(sys.argv[1]))
print(m.get('mode', 'slideshow'))
" "$OUTDIR/visuals_meta.json")
fi
MODE="${MODE:-slideshow}"
echo "📽 Mode: $MODE"

# Collect files (use find to avoid pipefail issues with ls on missing dirs)
mapfile -t IMAGES < <(find "$OUTDIR/images" -name "scene_*.png" 2>/dev/null | sort)
mapfile -t VIDEOS < <(find "$OUTDIR/videos" -name "scene_*.mp4" 2>/dev/null | sort)

echo "  Images: ${#IMAGES[@]}, Videos: ${#VIDEOS[@]}"

if [[ "$DRY_RUN" = true ]]; then
  echo "DRY_RUN: would assemble ${#IMAGES[@]} images + ${#VIDEOS[@]} videos → $OUTPUT"
  exit 0
fi

# ── Slideshow mode ──
assemble_slideshow() {
  local num_imgs=${#IMAGES[@]}
  if [[ $num_imgs -eq 0 ]]; then
    echo "ERROR: No images found" >&2; exit 1
  fi

  # Calculate duration per slide
  local dur=$SLIDE_DUR
  if [[ $dur -eq 0 ]]; then
    dur=$((AUDIO_DUR / num_imgs))
    [[ $dur -lt 3 ]] && dur=3
  fi
  echo "  Slide duration: ${dur}s each"

  local FADE_DUR=1

  if [[ "$TRANSITION" == "fade" && $num_imgs -gt 1 ]]; then
    # Complex filter with crossfades — build args array
    local -a ffargs=( ffmpeg -y )

    for i in "${!IMAGES[@]}"; do
      ffargs+=( -loop 1 -t "$((dur + FADE_DUR))" -i "${IMAGES[$i]}" )
    done
    ffargs+=( -i "$AUDIO" )

    # Build filter chain
    local FILTER=""
    for i in "${!IMAGES[@]}"; do
      FILTER="${FILTER}[$i:v]scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,setsar=1,fps=30[v$i];"
    done

    # Chain crossfades
    if [[ $num_imgs -eq 1 ]]; then
      FILTER="${FILTER}[v0]trim=0:${AUDIO_DUR}[outv]"
    else
      local prev="v0"
      local offset=$((dur))
      for ((i=1; i<num_imgs; i++)); do
        if ((i == num_imgs - 1)); then
          local next="outv"
        else
          local next="cf$i"
        fi
        FILTER="${FILTER}[$prev][v$i]xfade=transition=fade:duration=${FADE_DUR}:offset=${offset}[$next];"
        prev="$next"
        offset=$((offset + dur - FADE_DUR))
      done
      FILTER="${FILTER%;}"
    fi

    if [[ -n "$SUBTITLE" && -f "$SUBTITLE" ]]; then
      local esc_sub
      esc_sub=$(escape_subtitle_path "$SUBTITLE")
      ffargs+=( -filter_complex "${FILTER};[outv]subtitles='${esc_sub}'[finalv]" -map "[finalv]" -map "$((num_imgs)):a" )
    else
      ffargs+=( -filter_complex "${FILTER}" -map "[outv]" -map "$((num_imgs)):a" )
    fi
    ffargs+=( -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 192k -shortest "$OUTPUT" )

    echo "  Running ffmpeg (crossfade slideshow)..."
    "${ffargs[@]}" 2>/dev/null
  else
    # Simple concat (no transitions)
    local LISTFILE="$OUTDIR/images_list.txt"
    > "$LISTFILE"
    for img in "${IMAGES[@]}"; do
      safe_concat_entry "$img" >> "$LISTFILE"
      echo "duration $dur" >> "$LISTFILE"
    done

    local -a ffargs=( ffmpeg -y -f concat -safe 0 -i "$LISTFILE" -i "$AUDIO" )
    if [[ -n "$SUBTITLE" && -f "$SUBTITLE" ]]; then
      local esc_sub2
      esc_sub2=$(escape_subtitle_path "$SUBTITLE")
      ffargs+=( -vf "scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,fps=30,subtitles='${esc_sub2}'" )
    else
      ffargs+=( -vf "scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,fps=30" )
    fi
    ffargs+=( -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 192k -shortest "$OUTPUT" )

    echo "  Running ffmpeg (simple slideshow)..."
    "${ffargs[@]}" 2>/dev/null
  fi
}

# ── Video mode ──
assemble_video() {
  local num_vids=${#VIDEOS[@]}
  if [[ $num_vids -eq 0 ]]; then
    echo "ERROR: No video clips found" >&2; exit 1
  fi

  # Create concat list
  local LISTFILE="$OUTDIR/videos_list.txt"
  > "$LISTFILE"
  for vid in "${VIDEOS[@]}"; do
    safe_concat_entry "$vid" >> "$LISTFILE"
  done

  # Concat videos, replace audio
  local -a ffargs=( ffmpeg -y -f concat -safe 0 -i "$LISTFILE" -i "$AUDIO" )
  if [[ -n "$SUBTITLE" && -f "$SUBTITLE" ]]; then
    local esc_sub
    esc_sub=$(escape_subtitle_path "$SUBTITLE")
    ffargs+=( -filter_complex "[0:v]scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,subtitles='${esc_sub}'[v]" -map "[v]" -map "1:a" )
  else
    ffargs+=( -filter_complex "[0:v]scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black[v]" -map "[v]" -map "1:a" )
  fi
  ffargs+=( -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 192k -shortest "$OUTPUT" )

  echo "  Running ffmpeg (video concat)..."
  "${ffargs[@]}" 2>/dev/null
}

# ── Hybrid mode ──
assemble_hybrid() {
  # Normalize all clips to same format, then concat
  local TMPDIR="$OUTDIR/tmp_hybrid"
  mkdir -p "$TMPDIR"
  local LISTFILE="$OUTDIR/hybrid_list.txt"
  > "$LISTFILE"

  # Calculate image duration
  local img_dur=$SLIDE_DUR
  if [[ $img_dur -eq 0 ]]; then
    img_dur=$((AUDIO_DUR / (${#IMAGES[@]} + ${#VIDEOS[@]})))
    [[ $img_dur -lt 3 ]] && img_dur=3
  fi

  # Convert images to video segments
  local idx=0
  for img in "${IMAGES[@]}"; do
    local tmpvid="$TMPDIR/img_$(printf '%03d' $idx).mp4"
    ffmpeg -y -loop 1 -i "$img" -t "$img_dur" \
      -vf "scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,fps=30" \
      -c:v libx264 -preset fast -crf 20 -pix_fmt yuv420p "$tmpvid" 2>/dev/null
    safe_concat_entry "$tmpvid" >> "$LISTFILE"
    idx=$((idx + 1))
  done

  # Add video segments (re-encode to same format)
  for vid in "${VIDEOS[@]}"; do
    local tmpvid="$TMPDIR/vid_$(printf '%03d' $idx).mp4"
    ffmpeg -y -i "$vid" \
      -vf "scale=${OUT_W}:${OUT_H}:force_original_aspect_ratio=decrease,pad=${OUT_W}:${OUT_H}:(ow-iw)/2:(oh-ih)/2:black,fps=30" \
      -c:v libx264 -preset fast -crf 20 -pix_fmt yuv420p -an "$tmpvid" 2>/dev/null
    safe_concat_entry "$tmpvid" >> "$LISTFILE"
    idx=$((idx + 1))
  done

  # Final assembly
  local -a ffargs=( ffmpeg -y -f concat -safe 0 -i "$LISTFILE" -i "$AUDIO" )
  if [[ -n "$SUBTITLE" && -f "$SUBTITLE" ]]; then
    local esc_sub
    esc_sub=$(escape_subtitle_path "$SUBTITLE")
    ffargs+=( -vf "subtitles='${esc_sub}'" )
  fi
  ffargs+=( -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 192k -shortest "$OUTPUT" )

  echo "  Running ffmpeg (hybrid assembly)..."
  "${ffargs[@]}" 2>/dev/null

  rm -rf "$TMPDIR"
}

# ── Execute ──
echo ""
case "$MODE" in
  slideshow) assemble_slideshow ;;
  video) assemble_video ;;
  hybrid) assemble_hybrid ;;
  *) echo "ERROR: Unknown mode: $MODE" >&2; exit 1 ;;
esac

if [[ -f "$OUTPUT" ]]; then
  FSIZE=$(du -h "$OUTPUT" | cut -f1)
  echo ""
  echo "🎬 Music Video Complete!"
  echo "━━━━━━━━━━━━━━━━━━━━━━"
  echo "  📁 File: $OUTPUT"
  echo "  📏 Size: $FSIZE"
  echo "  ⏱ Duration: ~${AUDIO_DUR}s"
  echo "━━━━━━━━━━━━━━━━━━━━━━"
else
  echo "❌ Assembly failed — check ffmpeg output" >&2
  exit 1
fi


path: scripts/gen_visuals.sh
content: #!/usr/bin/env bash
# gen_visuals.sh — Generate images or video clips for music video
# Usage: gen_visuals.sh --mode slideshow|video|hybrid [options]
#
# Image providers: openai (default), seedream, google-together
# Video providers: sora (default), seedance, veo
#
# Options:
#   --mode slideshow|video|hybrid
#   --prompts-file /path/to/prompts.json   (array of scene prompts)
#   --image-provider openai|google-together
#   --image-model gpt-image-1|gpt-image-1-mini  (default: gpt-image-1-mini)
#   --video-provider sora|sora-pro|seedance-lite|seedance-pro|veo-fast|veo-audio
#   --image-quality low|medium|high  (default: medium)
#   --image-size 1024x1024|1536x1024|1024x1536  (default: 1536x1024)
#   --outdir /path
#   --dry-run   (cost estimate only)
#
# Env: OPENAI_API_KEY, TOGETHER_API_KEY (optional, for google/seedance/veo)

set -euo pipefail

MODE="slideshow"
IMAGE_PROVIDER="openai"
IMAGE_MODEL="gpt-image-1-mini"
VIDEO_PROVIDER="sora"
IMAGE_QUALITY="medium"
IMAGE_SIZE="1536x1024"
OUTDIR="./output"
DRY_RUN=false
PROMPTS_FILE=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --mode) MODE="$2"; shift 2 ;;
    --prompts-file) PROMPTS_FILE="$2"; shift 2 ;;
    --image-provider) IMAGE_PROVIDER="$2"; shift 2 ;;
    --image-model) IMAGE_MODEL="$2"; shift 2 ;;
    --video-provider) VIDEO_PROVIDER="$2"; shift 2 ;;
    --image-quality) IMAGE_QUALITY="$2"; shift 2 ;;
    --image-size) IMAGE_SIZE="$2"; shift 2 ;;
    --outdir) OUTDIR="$2"; shift 2 ;;
    --dry-run) DRY_RUN=true; shift ;;
    *) echo "Unknown: $1" >&2; exit 1 ;;
  esac
done

if [[ -z "$PROMPTS_FILE" || ! -f "$PROMPTS_FILE" ]]; then
  echo "ERROR: --prompts-file required (JSON array of scene prompts)" >&2; exit 1
fi

mkdir -p "$OUTDIR/images" "$OUTDIR/videos"

# Count prompts
NUM_SCENES=$(python3 -c "import json, sys; print(len(json.load(open(sys.argv[1]))))" "$PROMPTS_FILE")
NUM_IMAGES=0
NUM_VIDEOS=0

case "$MODE" in
  slideshow) NUM_IMAGES=$NUM_SCENES ;;
  video) NUM_VIDEOS=$NUM_SCENES ;;
  hybrid)
    NUM_IMAGES=$((NUM_SCENES / 2))
    NUM_VIDEOS=$((NUM_SCENES - NUM_IMAGES))
    ;;
esac

# Token-based pricing (per 1M tokens, from OpenAI pricing page Feb 2026)
# Image cost = (text_input_tokens × text_rate + image_output_tokens × image_rate) / 1M
# Output tokens are FIXED per quality: low=272, medium=1056, high=4160
# Text input tokens are ~60-100 for typical prompts (negligible cost impact)
get_image_cost() {
  local provider="$1" quality="$2" size="$3"
  case "$provider" in
    openai)
      # Token-based calculation for OpenAI models
      # Output tokens by quality: low=272, medium=1056, high=4160
      # Size multiplier: 1024x1024=1x, 1536x1024/1024x1536=1.5x
      python3 -c "
model = '$IMAGE_MODEL'
quality = '$quality'
size = '$size'

# Image output token rates (per 1M tokens)
rates = {
    'gpt-image-1':      {'text_in': 5.00, 'img_out': 40.00},
    'gpt-image-1-mini': {'text_in': 2.00, 'img_out':  8.00},
}
# Output tokens by quality (measured empirically for 1024x1024)
output_tokens = {'low': 272, 'medium': 1056, 'high': 4160}
# Size multiplier for output tokens
size_mult = 1.5 if size != '1024x1024' else 1.0

r = rates.get(model, rates['gpt-image-1-mini'])
text_tokens = 80  # typical prompt, negligible
img_tokens = int(output_tokens.get(quality, 1056) * size_mult)

cost = (text_tokens * r['text_in'] + img_tokens * r['img_out']) / 1_000_000
print(f'{cost:.6f}')
" ;;
    seedream) echo "0.045" ;;  # BytePlus Seedream 4.5 ($0.045/image)
    google-together) echo "0.040" ;;  # Imagen 4.0 Preview
    *) echo "0.034" ;;
  esac
}

get_video_cost() {
  local provider="$1"
  case "$provider" in
    sora) echo "0.80" ;;
    sora-pro) echo "2.40" ;;
    seedance-lite) echo "0.14" ;;
    seedance-pro) echo "0.57" ;;
    veo-fast) echo "0.80" ;;
    veo-audio) echo "3.20" ;;
    *) echo "0.80" ;;
  esac
}

IMG_COST=$(get_image_cost "$IMAGE_PROVIDER" "$IMAGE_QUALITY" "$IMAGE_SIZE")
VID_COST=$(get_video_cost "$VIDEO_PROVIDER")
TOTAL_IMG=$(python3 -c "print(f'{$NUM_IMAGES * $IMG_COST:.2f}')")
TOTAL_VID=$(python3 -c "print(f'{$NUM_VIDEOS * $VID_COST:.2f}')")
TOTAL=$(python3 -c "print(f'{$NUM_IMAGES * $IMG_COST + $NUM_VIDEOS * $VID_COST:.2f}')")

echo "📊 Visual Generation Cost Estimate"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "  Mode: $MODE ($NUM_SCENES scenes)"
if [[ $NUM_IMAGES -gt 0 ]]; then
  echo "  🎨 Images: ${NUM_IMAGES}× $IMAGE_PROVIDER/$IMAGE_MODEL ($IMAGE_QUALITY, $IMAGE_SIZE)"
  echo "     Cost: ${NUM_IMAGES} × \$${IMG_COST} = \$${TOTAL_IMG}"
fi
if [[ $NUM_VIDEOS -gt 0 ]]; then
  echo "  🎬 Videos: ${NUM_VIDEOS}× $VIDEO_PROVIDER"
  echo "     Cost: ${NUM_VIDEOS} × \$${VID_COST} = \$${TOTAL_VID}"
fi
echo "  💰 Total: \$${TOTAL}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

if [[ "$DRY_RUN" = true ]]; then
  # Write estimate to JSON
  python3 -c "
import json
est = {
    'mode': '$MODE',
    'num_images': $NUM_IMAGES, 'num_videos': $NUM_VIDEOS,
    'image_provider': '$IMAGE_PROVIDER', 'image_model': '$IMAGE_MODEL',
    'video_provider': '$VIDEO_PROVIDER',
    'image_quality': '$IMAGE_QUALITY', 'image_size': '$IMAGE_SIZE',
    'image_cost_each': $IMG_COST, 'video_cost_each': $VID_COST,
    'total_image_cost': $TOTAL_IMG, 'total_video_cost': $TOTAL_VID,
    'total_cost': $TOTAL,
    'pricing_method': 'token-based'
}
with open('$OUTDIR/cost_estimate.json', 'w') as f:
    json.dump(est, f, indent=2)
print('Estimate saved to $OUTDIR/cost_estimate.json')
"
  exit 0
fi

# ── Generate images ──
generate_openai_image() {
  local prompt="$1" outpath="$2"
  # Write prompt to temp file for safe handling
  local pfile=$(mktemp)
  echo -n "$prompt" > "$pfile"
  local resp_file=$(mktemp)

  python3 -c "
import json
with open('$pfile') as f: p = f.read()
print(json.dumps({
    'model': '$IMAGE_MODEL',
    'prompt': p,
    'n': 1,
    'size': '$IMAGE_SIZE',
    'quality': '$IMAGE_QUALITY'
}, ensure_ascii=False))
" > "${resp_file}.body"

  curl -s -X POST "https://api.openai.com/v1/images/generations" \
    -H "Authorization: Bearer $OPENAI_API_KEY" \
    -H "Content-Type: application/json" \
    -d @"${resp_file}.body" \
    -o "$resp_file"

  python3 -c "
import json, base64, sys
with open('$resp_file') as f:
    d = json.load(f)
if 'data' in d and len(d['data']) > 0:
    item = d['data'][0]
    if item.get('b64_json'):
        img = base64.b64decode(item['b64_json'])
        with open('$outpath', 'wb') as f:
            f.write(img)
        # Extract usage for actual cost tracking
        usage = d.get('usage', {})
        details = usage.get('input_tokens_details', {})
        text_in = details.get('text_tokens', 0)
        img_in = details.get('image_tokens', 0)
        img_out = usage.get('output_tokens', 0)
        # Calculate actual token-based cost
        rates = {
            'gpt-image-1':      {'text_in': 5.00, 'img_in': 10.00, 'img_out': 40.00},
            'gpt-image-1-mini': {'text_in': 2.00, 'img_in':  2.50, 'img_out':  8.00},
        }
        r = rates.get('$IMAGE_MODEL', rates['gpt-image-1-mini'])
        actual_cost = (text_in * r['text_in'] + img_in * r['img_in'] + img_out * r['img_out']) / 1_000_000
        # Save usage info alongside image
        usage_path = '$outpath'.replace('.png', '_usage.json')
        with open(usage_path, 'w') as uf:
            json.dump({
                'model': '$IMAGE_MODEL', 'quality': '$IMAGE_QUALITY', 'size': '$IMAGE_SIZE',
                'text_input_tokens': text_in, 'image_input_tokens': img_in,
                'output_tokens': img_out, 'actual_cost': actual_cost,
            }, uf, indent=2)
        print(f'OK|{actual_cost:.6f}|{img_out}')
    elif item.get('url'):
        import urllib.request
        urllib.request.urlretrieve(item['url'], '$outpath')
        print('OK|0|0')
    else:
        print('ERROR: no image data', file=sys.stderr)
        sys.exit(1)
elif 'error' in d:
    print(f'ERROR: {d[\"error\"][\"message\"]}', file=sys.stderr)
    sys.exit(1)
" && echo "  ✅ $(basename "$outpath")" || echo "  ❌ Image gen failed" >&2

  rm -f "$pfile" "$resp_file" "${resp_file}.body"
}

generate_seedream_image() {
  local prompt="$1" outpath="$2"
  if [[ -z "${BYTEPLUS_API_KEY:-}" ]]; then
    echo "ERROR: BYTEPLUS_API_KEY required for seedream provider" >&2; return 1
  fi
  local pfile=$(mktemp)
  echo -n "$prompt" > "$pfile"
  local resp_file=$(mktemp)

  python3 -c "
import json
with open('$pfile') as f: p = f.read()
print(json.dumps({
    'model': 'seedream-4-5-251128',
    'prompt': p,
    'size': '2048x2048',
    'response_format': 'url',
    'watermark': False
}, ensure_ascii=False))
" > "${resp_file}.body"

  curl -s -X POST "https://ark.ap-southeast.bytepluses.com/api/v3/images/generations" \
    -H "Authorization: Bearer $BYTEPLUS_API_KEY" \
    -H "Content-Type: application/json" \
    -d @"${resp_file}.body" \
    -o "$resp_file"

  python3 -c "
import json, urllib.request, sys
with open('$resp_file') as f:
    d = json.load(f)
if 'data' in d and len(d['data']) > 0:
    item = d['data'][0]
    if item.get('url'):
        urllib.request.urlretrieve(item['url'], '$outpath')
        usage = d.get('usage', {})
        imgs = usage.get('generated_images', 1)
        out_tokens = usage.get('output_tokens', 0)
        # Save usage
        usage_path = '$outpath'.replace('.png', '_usage.json')
        with open(usage_path, 'w') as uf:
            json.dump({
                'model': 'seedream-4-5-251128', 'provider': 'byteplus',
                'size': '2048x2048', 'output_tokens': out_tokens,
                'actual_cost': 0.045,
            }, uf, indent=2)
        print(f'OK|0.045000|{out_tokens}')
    else:
        print('ERROR: no url in response', file=sys.stderr)
        sys.exit(1)
elif 'error' in d:
    print(f'ERROR: {d[\"error\"][\"message\"]}', file=sys.stderr)
    sys.exit(1)
" && echo "  ✅ $(basename "$outpath")" || echo "  ❌ Seedream gen failed" >&2

  rm -f "$pfile" "$resp_file" "${resp_file}.body"
}

generate_together_image() {
  local prompt="$1" outpath="$2"
  if [[ -z "${TOGETHER_API_KEY:-}" ]]; then
    echo "ERROR: TOGETHER_API_KEY required for google-together provider" >&2; return 1
  fi
  local resp
  local pfile=$(mktemp)
  echo -n "$prompt" > "$pfile"
  local body_file=$(mktemp)
  python3 -c "
import json
with open('$pfile') as f: p = f.read()
print(json.dumps({
    'model': 'google/imagen-4.0-generate-preview',
    'prompt': p,
    'n': 1,
    'width': 1536, 'height': 1024
}, ensure_ascii=False))
" > "$body_file"
  resp=$(curl -s -X POST "https://api.together.xyz/v1/images/generations" \
    -H "Authorization: Bearer $TOGETHER_API_KEY" \
    -H "Content-Type: application/json" \
    -d @"$body_file")
  rm -f "$pfile" "$body_file"
  local url
  url=$(echo "$resp" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if 'data' in d and len(d['data']) > 0:
    print(d['data'][0].get('url',''))
else:
    print('ERROR', file=sys.stderr); sys.exit(1)
" 2>&1)
  if [[ -z "$url" || "$url" == ERROR* ]]; then
    echo "  ❌ Image gen failed" >&2; return 1
  fi
  curl -s -o "$outpath" "$url"
  echo "  ✅ $(basename "$outpath")"
}

# ── Generate videos ──
generate_video_together() {
  local prompt="$1" outpath="$2" model="$3"
  if [[ -z "${TOGETHER_API_KEY:-}" ]]; then
    echo "ERROR: TOGETHER_API_KEY required for $VIDEO_PROVIDER" >&2; return 1
  fi
  local resp
  local pfile=$(mktemp)
  echo -n "$prompt" > "$pfile"
  local body_file=$(mktemp)
  python3 -c "
import json
with open('$pfile') as f: p = f.read()
print(json.dumps({
    'model': '$model',
    'prompt': p,
}, ensure_ascii=False))
" > "$body_file"
  resp=$(curl -s -X POST "https://api.together.xyz/v2/videos" \
    -H "Authorization: Bearer $TOGETHER_API_KEY" \
    -H "Content-Type: application/json" \
    -d @"$body_file")
  rm -f "$pfile" "$body_file"
  local video_id
  video_id=$(echo "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
  if [[ -z "$video_id" ]]; then
    echo "  ❌ Video gen failed: $resp" >&2; return 1
  fi
  # Poll
  echo "  ⏳ Video generating (id: $video_id)..."
  local status="processing"
  local attempts=0
  while [[ "$status" == "processing" || "$status" == "pending" ]]; do
    sleep 15
    attempts=$((attempts + 1))
    if [[ $attempts -ge 40 ]]; then
      echo "  ❌ Video timeout" >&2; return 1
    fi
    local poll
    poll=$(curl -s "https://api.together.xyz/v2/videos/${video_id}" \
      -H "Authorization: Bearer $TOGETHER_API_KEY")
    status=$(echo "$poll" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status','unknown'))" 2>/dev/null)
    echo "    [${attempts}] $status"
    if [[ "$status" == "completed" ]]; then
      local vid_url
      vid_url=$(echo "$poll" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('output',{}).get('video_url', d.get('result',{}).get('url','')))" 2>/dev/null)
      if [[ -n "$vid_url" ]]; then
        curl -s -o "$outpath" "$vid_url"
        echo "  ✅ $(basename "$outpath")"
        return 0
      fi
    elif [[ "$status" == "failed" ]]; then
      echo "  ❌ Video generation failed" >&2; return 1
    fi
  done
}

generate_sora_video() {
  local prompt="$1" outpath="$2" model_suffix="$3"
  local model="sora-2"
  [[ "$model_suffix" == "pro" ]] && model="sora-2-pro"
  if [[ -n "${TOGETHER_API_KEY:-}" ]]; then
    # Use Together AI for Sora
    local together_model="openai/${model}"
    generate_video_together "$prompt" "$outpath" "$together_model"
  elif [[ -n "${OPENAI_API_KEY:-}" ]]; then
    # Direct OpenAI (if supported)
    local resp
    local pfile=$(mktemp)
    echo -n "$prompt" > "$pfile"
    local body_file=$(mktemp)
    python3 -c "
import json
with open('$pfile') as f: p = f.read()
print(json.dumps({
    'model': '$model',
    'prompt': p,
}, ensure_ascii=False))
" > "$body_file"
    resp=$(curl -s -X POST "https://api.openai.com/v1/videos/generations" \
      -H "Authorization: Bearer $OPENAI_API_KEY" \
      -H "Content-Type: application/json" \
      -d @"$body_file")
    rm -f "$pfile" "$body_file"
    echo "  OpenAI direct response: $(echo "$resp" | head -c 200)"
    # TODO: handle OpenAI direct video response format
  fi
}

# ── Main generation loop ──
echo ""
echo "🎨 Generating visuals ($MODE mode)..."

VISUAL_IDX=0
IMAGE_IDX=0
VIDEO_IDX=0

python3 -c "
import json, sys
prompts = json.load(open(sys.argv[1]))
for i, p in enumerate(prompts):
    prompt = p if isinstance(p, str) else p.get('prompt','')
    ptype = p.get('type','') if isinstance(p, dict) else ''
    print(f'{i}|{ptype}|{prompt}')
" "$PROMPTS_FILE" | while IFS='|' read -r idx ptype prompt; do
  if [[ "$MODE" == "slideshow" ]] || \
     { [[ "$MODE" == "hybrid" ]] && { [[ "$ptype" == "image" ]] || { [[ -z "$ptype" ]] && [[ $IMAGE_IDX -lt $NUM_IMAGES ]]; }; }; }; then
    # Image
    FNAME="scene_$(printf '%03d' "$idx").png"
    echo ""
    echo "  [$((idx+1))/$NUM_SCENES] 🎨 Image: ${prompt:0:60}..."
    case "$IMAGE_PROVIDER" in
      openai) generate_openai_image "$prompt" "$OUTDIR/images/$FNAME" ;;
      seedream) generate_seedream_image "$prompt" "$OUTDIR/images/$FNAME" ;;
      google-together) generate_together_image "$prompt" "$OUTDIR/images/$FNAME" ;;
    esac
    IMAGE_IDX=$((IMAGE_IDX + 1))
  else
    # Video
    FNAME="scene_$(printf '%03d' "$idx").mp4"
    echo ""
    echo "  [$((idx+1))/$NUM_SCENES] 🎬 Video: ${prompt:0:60}..."
    case "$VIDEO_PROVIDER" in
      sora) generate_sora_video "$prompt" "$OUTDIR/videos/$FNAME" "" ;;
      sora-pro) generate_sora_video "$prompt" "$OUTDIR/videos/$FNAME" "pro" ;;
      seedance-lite) generate_video_together "$prompt" "$OUTDIR/videos/$FNAME" "ByteDance/Seedance-1.0-lite" ;;
      seedance-pro) generate_video_together "$prompt" "$OUTDIR/videos/$FNAME" "ByteDance/Seedance-1.0-pro" ;;
      veo-fast) generate_video_together "$prompt" "$OUTDIR/videos/$FNAME" "google/veo-3.0-generate-preview" ;;
      veo-audio) generate_video_together "$prompt" "$OUTDIR/videos/$FNAME" "google/veo-3.0-generate-preview" ;;
    esac
    VIDEO_IDX=$((VIDEO_IDX + 1))
  fi
done

# Write cost tracking
python3 -c "
import json, os, glob

outdir = '$OUTDIR'
images = sorted(glob.glob(os.path.join(outdir, 'images', 'scene_*.png')))
videos = sorted(glob.glob(os.path.join(outdir, 'videos', 'scene_*.mp4')))

# Load actual costs from usage files
actual_image_costs = []
for img_path in images:
    usage_path = img_path.replace('.png', '_usage.json')
    if os.path.exists(usage_path):
        with open(usage_path) as uf:
            u = json.load(uf)
            actual_image_costs.append({
                'file': img_path,
                'estimated_cost': $IMG_COST,
                'actual_cost': u.get('actual_cost', $IMG_COST),
                'output_tokens': u.get('output_tokens', 0),
                'model': u.get('model', '$IMAGE_MODEL'),
            })
    else:
        actual_image_costs.append({
            'file': img_path,
            'estimated_cost': $IMG_COST,
            'actual_cost': $IMG_COST,
            'output_tokens': 0,
            'model': '$IMAGE_MODEL',
        })

total_actual_img = sum(c['actual_cost'] for c in actual_image_costs)
total_est_img = len(images) * $IMG_COST

meta = {
    'mode': '$MODE',
    'image_provider': '$IMAGE_PROVIDER',
    'image_model': '$IMAGE_MODEL',
    'video_provider': '$VIDEO_PROVIDER',
    'pricing_method': 'token-based',
    'images': actual_image_costs,
    'videos': [{'file': f, 'cost': $VID_COST} for f in videos],
    'total_image_cost_estimated': total_est_img,
    'total_image_cost_actual': total_actual_img,
    'total_video_cost': len(videos) * $VID_COST,
    'total_cost_estimated': total_est_img + len(videos) * $VID_COST,
    'total_cost_actual': total_actual_img + len(videos) * $VID_COST,
}
with open(os.path.join(outdir, 'visuals_meta.json'), 'w') as f:
    json.dump(meta, f, indent=2, ensure_ascii=False)

print()
print('📊 Visual Generation Complete')
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
print(f'  Model: {meta[\"image_model\"]}')
tic_est = meta['total_image_cost_estimated']
tic_act = meta['total_image_cost_actual']
tvc = meta['total_video_cost']
tc_est = meta['total_cost_estimated']
tc_act = meta['total_cost_actual']
print(f'  🎨 Images: {len(images)} (estimated \${tic_est:.4f} / actual \${tic_act:.4f})')
print(f'  🎬 Videos: {len(videos)} (\${tvc:.2f})')
print(f'  💰 Total estimated: \${tc_est:.4f}')
print(f'  💰 Total actual:    \${tc_act:.4f}')
if tic_est > 0:
    savings_pct = (1 - tic_act / tic_est) * 100
    print(f'  📐 Estimation accuracy: {savings_pct:+.1f}% vs estimate')
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
"


path: scripts/suno_music.sh
content: #!/usr/bin/env bash
# suno_music.sh — Generate music via sunoapi.org, poll until done, download audio
# Usage: suno_music.sh [options]
#   --prompt "lyrics or description"
#   --style "genre/style tags"
#   --title "song title"
#   --model V4_5ALL|V5|V4_5PLUS|V4_5|V4  (default: V4_5ALL)
#   --instrumental   (flag, no vocals)
#   --custom         (flag, enable custom mode — requires style+title)
#   --vocal-gender m|f  (optional, vocal gender hint)
#   --negative-tags "tags to avoid"  (optional)
#   --outdir /path   (output directory, default: ./output)
#   --timeout 600    (max wait seconds, default: 600)
#   --music-video    (flag, generate Suno native music video after music)
#   --persona-id ID  (use existing persona for consistent style)
#   --create-persona (create persona from generated track)
#   --persona-name "name"  (name for new persona)
#   --persona-desc "desc"  (description for new persona)
#   --persona-style "style" (style label for persona)
#   --dry-run        (show cost estimate only, don't generate)
#
# Env: SUNO_API_KEY (required)
# Output: Downloads .mp3 + writes metadata to outdir/music_meta.json
#         + lyrics.srt (if non-instrumental)
#         + music_video.mp4 (if --music-video)
#         + persona.json (if --create-persona)

set -euo pipefail

API_BASE="https://api.sunoapi.org/api/v1"
MODEL="V4_5ALL"
INSTRUMENTAL=false
CUSTOM_MODE=false
OUTDIR="./output"
TIMEOUT=600
DRY_RUN=false
PROMPT=""
STYLE=""
TITLE=""
VOCAL_GENDER=""
NEGATIVE_TAGS=""
MUSIC_VIDEO=false
PERSONA_ID=""
CREATE_PERSONA=false
PERSONA_NAME=""
PERSONA_DESC=""
PERSONA_STYLE=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --prompt) PROMPT="$2"; shift 2 ;;
    --style) STYLE="$2"; shift 2 ;;
    --title) TITLE="$2"; shift 2 ;;
    --model) MODEL="$2"; shift 2 ;;
    --instrumental) INSTRUMENTAL=true; shift ;;
    --custom) CUSTOM_MODE=true; shift ;;
    --vocal-gender) VOCAL_GENDER="$2"; shift 2 ;;
    --negative-tags) NEGATIVE_TAGS="$2"; shift 2 ;;
    --outdir) OUTDIR="$2"; shift 2 ;;
    --timeout) TIMEOUT="$2"; shift 2 ;;
    --music-video) MUSIC_VIDEO=true; shift ;;
    --persona-id) PERSONA_ID="$2"; shift 2 ;;
    --create-persona) CREATE_PERSONA=true; shift ;;
    --persona-name) PERSONA_NAME="$2"; shift 2 ;;
    --persona-desc) PERSONA_DESC="$2"; shift 2 ;;
    --persona-style) PERSONA_STYLE="$2"; shift 2 ;;
    --dry-run) DRY_RUN=true; shift ;;
    *) echo "Unknown option: $1" >&2; exit 1 ;;
  esac
done

if [[ -z "${SUNO_API_KEY:-}" ]]; then
  echo "ERROR: SUNO_API_KEY not set" >&2; exit 1
fi
if [[ -z "$PROMPT" ]]; then
  echo "ERROR: --prompt required" >&2; exit 1
fi

AUTH="Authorization: Bearer $SUNO_API_KEY"
CT="Content-Type: application/json"

# Check credits (may not be supported by all sunoapi instances)
echo "🔍 Checking Suno credits..."
CREDITS_RESP=$(curl -s -H "$AUTH" "${API_BASE}/get-credits" 2>/dev/null)
CREDITS=$(echo "$CREDITS_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',0))" 2>/dev/null || echo "unknown")
if [[ "$CREDITS" == "unknown" || "$CREDITS" == "0" ]]; then
  CREDITS="N/A (credit API not available)"
fi
echo "💰 Credits: $CREDITS"

# Cost estimate
echo ""
echo "📊 Cost Estimate"
echo "━━━━━━━━━━━━━━━━━━━"
echo "  Model: $MODEL"
echo "  Mode: $([ "$CUSTOM_MODE" = true ] && echo 'Custom' || echo 'Simple')"
echo "  Instrumental: $INSTRUMENTAL"
echo "  Music Video: $MUSIC_VIDEO"
if [[ -n "$PERSONA_ID" ]]; then
  echo "  Persona: $PERSONA_ID"
fi
echo "  Create Persona: $CREATE_PERSONA"
echo "  Est. credits: ~10 per generation (2 tracks)"
echo "  Credits: $CREDITS"
echo "━━━━━━━━━━━━━━━━━━━"

if [[ "$DRY_RUN" = true ]]; then
  echo "DRY_RUN: exiting without generation"
  exit 0
fi

mkdir -p "$OUTDIR"

# Build request body using temp files for safe prompt handling
PROMPT_FILE=$(mktemp)
echo -n "$PROMPT" > "$PROMPT_FILE"
STYLE_FILE=$(mktemp)
echo -n "$STYLE" > "$STYLE_FILE"
TITLE_FILE=$(mktemp)
echo -n "$TITLE" > "$TITLE_FILE"
NEGTAGS_FILE=$(mktemp)
echo -n "$NEGATIVE_TAGS" > "$NEGTAGS_FILE"

BODY=$(python3 -c "
import json, os

with open('$PROMPT_FILE') as f: prompt = f.read()
with open('$STYLE_FILE') as f: style = f.read()
with open('$TITLE_FILE') as f: title = f.read()
with open('$NEGTAGS_FILE') as f: neg_tags = f.read()

body = {
    'prompt': prompt,
    'model': '$MODEL',
    'instrumental': $( [ "$INSTRUMENTAL" = true ] && echo 'True' || echo 'False'),
    'customMode': $( [ "$CUSTOM_MODE" = true ] && echo 'True' || echo 'False'),
}
if $( [ "$CUSTOM_MODE" = true ] && echo 'True' || echo 'False'):
    if style: body['style'] = style
    if title: body['title'] = title
vocal = '$VOCAL_GENDER'
if vocal:
    body['vocalGender'] = vocal
if neg_tags:
    body['negativeTags'] = neg_tags
persona_id = '$PERSONA_ID'
if persona_id:
    body['personaId'] = persona_id
    body['personaModel'] = 'style_persona'
cb_url = os.environ.get('SUNO_CALLBACK_URL', 'https://localhost/noop')
# Validate callback URL scheme (only https allowed to prevent exfiltration)
if cb_url and not cb_url.startswith('https://'):
    cb_url = 'https://localhost/noop'
body['callBackUrl'] = cb_url
print(json.dumps(body, ensure_ascii=False))
")
rm -f "$PROMPT_FILE" "$STYLE_FILE" "$TITLE_FILE" "$NEGTAGS_FILE"

echo ""
echo "🎵 Generating music..."
GEN_RESP=$(curl -s -X POST "${API_BASE}/generate" \
  -H "$AUTH" -H "$CT" \
  -d "$BODY")

TASK_ID=$(echo "$GEN_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if d.get('code') != 200:
    print('ERROR:' + d.get('msg','unknown'), file=sys.stderr)
    sys.exit(1)
print(d['data']['taskId'])
")

if [[ -z "$TASK_ID" || "$TASK_ID" == ERROR* ]]; then
  echo "ERROR: Failed to start generation: $GEN_RESP" >&2
  exit 1
fi

echo "  Task ID: $TASK_ID"
echo "  Polling for completion (timeout: ${TIMEOUT}s)..."

# Poll loop
START=$(date +%s)
STATUS="PENDING"
while [[ "$STATUS" != "SUCCESS" && "$STATUS" != "FAILED" && "$STATUS" != "ERROR" ]]; do
  NOW=$(date +%s)
  ELAPSED=$((NOW - START))
  if [[ $ELAPSED -ge $TIMEOUT ]]; then
    echo "ERROR: Timeout after ${TIMEOUT}s" >&2
    exit 1
  fi

  sleep 15
  POLL_RESP=$(curl -s -H "$AUTH" "${API_BASE}/generate/record-info?taskId=${TASK_ID}")
  STATUS=$(echo "$POLL_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('data',{}).get('status','UNKNOWN'))
" 2>/dev/null || echo "UNKNOWN")
  echo "  [$((ELAPSED))s] Status: $STATUS"
done

if [[ "$STATUS" == "FAILED" ]]; then
  ERR=$(echo "$POLL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('errorMessage','unknown'))" 2>/dev/null)
  echo "ERROR: Generation failed: $ERR" >&2
  exit 1
fi

# Extract results and download
echo ""
echo "✅ Generation complete! Downloading..."

# Save poll response to temp file for safe parsing
RESP_FILE=$(mktemp)
echo "$POLL_RESP" > "$RESP_FILE"

python3 -c "
import json, sys, urllib.request, os

with open('$RESP_FILE') as f:
    resp = json.load(f)
outdir = '$OUTDIR'
data = resp.get('data',{})
response = data.get('response',{}) or {}
tracks = response.get('sunoData', [])
if not tracks:
    tracks = response.get('data', [])

meta = {
    'taskId': '$TASK_ID',
    'model': '$MODEL',
    'tracks': []
}

for i, track in enumerate(tracks):
    audio_url = track.get('audioUrl') or track.get('audio_url', '')
    title = track.get('title', f'track_{i}')
    duration = track.get('duration', 0)
    tags = track.get('tags', '')
    image_url = track.get('imageUrl') or track.get('image_url', '')
    track_id = track.get('id', f'track_{i}')

    # Download audio
    if audio_url:
        fname = f'track_{i}_{track_id}.mp3'
        fpath = os.path.join(outdir, fname)
        print(f'  ⬇ Downloading {title} ({duration:.0f}s)...')
        try:
            req = urllib.request.Request(audio_url, headers={
                'User-Agent': 'Mozilla/5.0 (compatible; MusicBot/1.0)',
                'Accept': '*/*',
            })
            with urllib.request.urlopen(req, timeout=120) as resp_dl:
                with open(fpath, 'wb') as out:
                    out.write(resp_dl.read())
            print(f'    Saved: {fpath}')
        except Exception as e:
            print(f'    ❌ Download failed ({e}): {audio_url}', file=sys.stderr)
            fname = ''
            fpath = ''
    else:
        fname = ''
        fpath = ''

    # Download cover image
    img_fname = ''
    if image_url:
        img_fname = f'cover_{i}_{track_id}.jpg'
        img_path = os.path.join(outdir, img_fname)
        try:
            req = urllib.request.Request(image_url, headers={
                'User-Agent': 'Mozilla/5.0 (compatible; MusicBot/1.0)',
            })
            with urllib.request.urlopen(req, timeout=60) as resp_dl:
                with open(img_path, 'wb') as out:
                    out.write(resp_dl.read())
        except Exception:
            img_fname = ''

    meta['tracks'].append({
        'id': track_id,
        'title': title,
        'duration': duration,
        'tags': tags,
        'audio_file': fname,
        'audio_url': audio_url,
        'image_url': image_url,
        'image_file': img_fname,
        'prompt': track.get('prompt', ''),
    })

# Write metadata
meta_path = os.path.join(outdir, 'music_meta.json')
with open(meta_path, 'w') as f:
    json.dump(meta, f, indent=2, ensure_ascii=False)
print(f'\n📄 Metadata: {meta_path}')
print(f'🎵 Tracks: {len(meta[\"tracks\"])}')
for t in meta['tracks']:
    dur = t['duration']
    print(f'   • {t[\"title\"]} ({dur:.0f}s) — {t[\"tags\"]}')
"
rm -f "$RESP_FILE"

# ── Timestamped Lyrics ──
# 비instrumental 트랙인 경우 자동으로 가사 타임스탬프를 가져와 SRT로 저장
if [[ "$INSTRUMENTAL" != true ]]; then
  echo ""
  echo "📝 Fetching timestamped lyrics..."

  # music_meta.json에서 첫 번째 트랙의 audioId 추출
  AUDIO_ID=$(python3 -c "
import json, os
meta = json.load(open(os.path.join('$OUTDIR', 'music_meta.json')))
tracks = meta.get('tracks', [])
if tracks:
    print(tracks[0].get('id', ''))
else:
    print('')
")

  if [[ -n "$AUDIO_ID" ]]; then
    LYRICS_BODY=$(python3 -c "
import json
print(json.dumps({'taskId': '$TASK_ID', 'audioId': '$AUDIO_ID'}))
")
    LYRICS_RESP=$(curl -s -X POST "${API_BASE}/generate/get-timestamped-lyrics" \
      -H "$AUTH" -H "$CT" \
      -d "$LYRICS_BODY")

    # Parse response and convert to SRT format
    python3 -c "
import json, sys, os

resp = json.loads('''$(echo "$LYRICS_RESP" | python3 -c "import sys; print(sys.stdin.read().replace(\"'\",\"\\\\'\"))")''')
outdir = '$OUTDIR'

if resp.get('code') != 200:
    print(f'  ⚠ Lyrics API returned: {resp.get(\"msg\", \"unknown error\")}', file=sys.stderr)
    sys.exit(0)

data = resp.get('data', {})
lyrics_data = data.get('lyrics', [])

if not lyrics_data:
    print('  ⚠ No lyrics data returned (might be instrumental)')
    sys.exit(0)

# Convert to SRT format
srt_lines = []
for i, item in enumerate(lyrics_data, 1):
    start_sec = item.get('startTime', item.get('start', 0))
    end_sec = item.get('endTime', item.get('end', start_sec + 3))
    text = item.get('text', item.get('words', ''))
    if not text or not text.strip():
        continue

    def fmt_time(s):
        h = int(s // 3600)
        m = int((s % 3600) // 60)
        sec = int(s % 60)
        ms = int((s % 1) * 1000)
        return f'{h:02d}:{m:02d}:{sec:02d},{ms:03d}'

    srt_lines.append(str(i))
    srt_lines.append(f'{fmt_time(start_sec)} --> {fmt_time(end_sec)}')
    srt_lines.append(text.strip())
    srt_lines.append('')

if srt_lines:
    srt_path = os.path.join(outdir, 'lyrics.srt')
    with open(srt_path, 'w', encoding='utf-8') as f:
        f.write('\n'.join(srt_lines))
    print(f'  ✅ Lyrics saved: {srt_path} ({len([l for l in srt_lines if l.strip() and not l.strip().isdigit() and \"-->\" not in l])} lines)')
else:
    print('  ⚠ No lyric lines to save')
" || echo "  ⚠ Lyrics fetch failed (non-fatal)"
  else
    echo "  ⚠ No audio ID found, skipping lyrics"
  fi
fi

# ── Suno Native Music Video ──
if [[ "$MUSIC_VIDEO" = true ]]; then
  echo ""
  echo "🎬 Requesting Suno native music video..."

  AUDIO_ID=$(python3 -c "
import json, os
meta = json.load(open(os.path.join('$OUTDIR', 'music_meta.json')))
tracks = meta.get('tracks', [])
if tracks:
    print(tracks[0].get('id', ''))
else:
    print('')
")

  if [[ -z "$AUDIO_ID" ]]; then
    echo "  ❌ No audio ID found, cannot create music video" >&2
  else
    MV_BODY=$(python3 -c "
import json
print(json.dumps({
    'taskId': '$TASK_ID',
    'audioId': '$AUDIO_ID',
    'callBackUrl': 'https://localhost/noop',
}))
")
    MV_RESP=$(curl -s -X POST "${API_BASE}/mp4/generate" \
      -H "$AUTH" -H "$CT" \
      -d "$MV_BODY")

    MV_CODE=$(echo "$MV_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',0))" 2>/dev/null || echo "0")

    if [[ "$MV_CODE" != "200" ]]; then
      MV_MSG=$(echo "$MV_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('msg','unknown'))" 2>/dev/null || echo "unknown")
      echo "  ❌ Music video request failed: $MV_MSG" >&2
    else
      echo "  ✅ Music video generation started"
      echo "  ⏳ Polling for music video completion..."

      # Poll for music video using /mp4/record-info
      MV_TASK_ID=$(echo "$MV_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('taskId',''))" 2>/dev/null || echo "")
      if [[ -z "$MV_TASK_ID" ]]; then
        MV_TASK_ID="$TASK_ID"
      fi
      MV_START=$(date +%s)
      MV_STATUS="PENDING"
      while [[ "$MV_STATUS" != "SUCCESS" && "$MV_STATUS" != "COMPLETED" && "$MV_STATUS" != "FAILED" ]]; do
        MV_NOW=$(date +%s)
        MV_ELAPSED=$((MV_NOW - MV_START))
        if [[ $MV_ELAPSED -ge $TIMEOUT ]]; then
          echo "  ❌ Music video timeout after ${TIMEOUT}s" >&2
          break
        fi
        sleep 20

        MV_POLL=$(curl -s -H "$AUTH" "${API_BASE}/mp4/record-info?taskId=${MV_TASK_ID}")
        MV_STATUS=$(echo "$MV_POLL" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('successFlag','PENDING'))" 2>/dev/null || echo "PENDING")
        MV_VIDEO_URL=$(echo "$MV_POLL" | python3 -c "
import sys, json
d = json.load(sys.stdin)
data = d.get('data',{})
resp = data.get('response',{}) or {}
url = resp.get('videoUrl') or resp.get('video_url') or data.get('videoUrl', '')
print(url)
" 2>/dev/null || echo "")

        if [[ "$MV_STATUS" == "SUCCESS" && -n "$MV_VIDEO_URL" ]]; then
          echo "  [$((MV_ELAPSED))s] Music video ready!"
        elif [[ "$MV_STATUS" == "FAILED" ]]; then
          echo "  [$((MV_ELAPSED))s] Music video failed!"
        else
          echo "  [$((MV_ELAPSED))s] Status: $MV_STATUS"
        fi
      done

      # Download the music video
      if [[ -n "${MV_VIDEO_URL:-}" ]]; then
        MV_OUTPUT="$OUTDIR/suno_music_video.mp4"
        echo "  ⬇ Downloading music video..."
        if curl -sL -o "$MV_OUTPUT" "$MV_VIDEO_URL" && [[ -f "$MV_OUTPUT" ]] && [[ $(stat -c%s "$MV_OUTPUT" 2>/dev/null || echo 0) -gt 1000 ]]; then
          echo "  ✅ Suno music video saved: $MV_OUTPUT"
        else
          echo "  ❌ Music video download failed" >&2
        fi
      fi
    fi
  fi
fi

# ── Create Persona ──
if [[ "$CREATE_PERSONA" = true ]]; then
  echo ""
  echo "🎭 Creating Persona from generated track..."

  AUDIO_ID_P=$(python3 -c "
import json, os
meta = json.load(open(os.path.join('$OUTDIR', 'music_meta.json')))
tracks = meta.get('tracks', [])
if tracks:
    print(tracks[0].get('id', ''))
else:
    print('')
")

  if [[ -z "$AUDIO_ID_P" ]]; then
    echo "  ❌ No audio ID found, cannot create persona" >&2
  else
    # 기본값: 이름과 설명이 없으면 프롬프트/스타일에서 유추
    P_NAME="${PERSONA_NAME:-$(echo "$TITLE" | head -c 50)}"
    P_NAME="${P_NAME:-Dev BGM Singer}"
    P_DESC="${PERSONA_DESC:-Generated from: $(echo "$PROMPT" | head -c 100)}"
    P_STYLE_VAL="${PERSONA_STYLE:-$STYLE}"

    PERSONA_BODY_FILE=$(mktemp)
    python3 -c "
import json
body = {
    'taskId': '$TASK_ID',
    'audioId': '$AUDIO_ID_P',
    'name': '''$P_NAME''',
    'description': '''$P_DESC''',
}
style = '''$P_STYLE_VAL'''
if style:
    body['style'] = style
print(json.dumps(body, ensure_ascii=False))
" > "$PERSONA_BODY_FILE"

    PERSONA_RESP=$(curl -s -X POST "${API_BASE}/generate/generate-persona" \
      -H "$AUTH" -H "$CT" \
      -d @"$PERSONA_BODY_FILE")
    rm -f "$PERSONA_BODY_FILE"

    PERSONA_CODE=$(echo "$PERSONA_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',0))" 2>/dev/null || echo "0")

    if [[ "$PERSONA_CODE" == "200" ]]; then
      PERSONA_DATA=$(echo "$PERSONA_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin).get('data', {})
pid = d.get('personaId', d.get('id', 'unknown'))
print(pid)
" 2>/dev/null || echo "unknown")

      echo "  ✅ Persona created! ID: $PERSONA_DATA"
      echo "  💡 다음 생성 시 --persona-id $PERSONA_DATA 로 일관된 스타일 유지 가능"

      # Save persona info
      python3 -c "
import json, os
persona = {
    'personaId': '$PERSONA_DATA',
    'name': '''$P_NAME''',
    'description': '''$P_DESC''',
    'style': '''$P_STYLE_VAL''',
    'sourceTaskId': '$TASK_ID',
    'sourceAudioId': '$AUDIO_ID_P',
}
path = os.path.join('$OUTDIR', 'persona.json')
with open(path, 'w') as f:
    json.dump(persona, f, indent=2, ensure_ascii=False)
print(f'  📄 Persona info: {path}')
"
    else
      PERSONA_MSG=$(echo "$PERSONA_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('msg','unknown'))" 2>/dev/null || echo "unknown")
      echo "  ❌ Persona creation failed: $PERSONA_MSG" >&2
    fi
  fi
fi

# Done
echo ""
echo "🎵 Music generation complete!"


path: tests/conftest.py
content: """Shared fixtures for AI Music Video E2E tests."""
import json
import os
import shutil
import subprocess
import tempfile

import pytest

SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS_DIR = os.path.join(SKILL_DIR, "scripts")


@pytest.fixture(scope="session")
def skill_env():
    """Environment with API keys from environment variables."""
    return os.environ.copy()


@pytest.fixture
def work_dir(tmp_path):
    """Fresh temp directory per test."""
    return str(tmp_path)


@pytest.fixture
def sample_prompts_file(work_dir):
    """Create a minimal prompts.json (3 scenes) for testing."""
    prompts = [
        "A neon-lit city street at night with rain reflections on the pavement",
        "A lone figure standing on a hilltop watching a dramatic sunset",
        "Cherry blossom petals falling in slow motion through golden light",
    ]
    path = os.path.join(work_dir, "prompts.json")
    with open(path, "w") as f:
        json.dump(prompts, f)
    return path


@pytest.fixture
def single_prompt_file(work_dir):
    """Create a 1-scene prompts.json for minimal cost testing."""
    prompts = ["A warm sunrise over a calm ocean, peaceful and serene"]
    path = os.path.join(work_dir, "prompts.json")
    with open(path, "w") as f:
        json.dump(prompts, f)
    return path


def run_script(script_name, args, env, timeout=600):
    """Run a bash script and return CompletedProcess."""
    script_path = os.path.join(SCRIPTS_DIR, script_name)
    cmd = ["bash", script_path] + args
    result = subprocess.run(
        cmd, capture_output=True, text=True, env=env, timeout=timeout
    )
    return result


# Markers
def pytest_configure(config):
    config.addinivalue_line("markers", "free: no API calls, no cost")
    config.addinivalue_line("markers", "cheap: minimal API calls (~$0.01-0.05)")
    config.addinivalue_line("markers", "expensive: full pipeline (~$0.30+)")


path: tests/run_tests.sh
content: #!/usr/bin/env bash
# AI Music Video — Test Runner
# Usage:
#   ./run_tests.sh              # Tier 1 only (free, no API)
#   ./run_tests.sh --cheap      # Tier 1 + 2 (minimal API, ~$0.05)
#   ./run_tests.sh --full       # All tiers (full E2E, ~$0.15+)
#   ./run_tests.sh --e2e-only   # Tier 3 only (E2E pipeline)

set -euo pipefail
cd "$(dirname "$0")"

# API keys must be set in the environment before running tests.
# Example: export SUNO_API_KEY=xxx OPENAI_API_KEY=xxx

TIER="${1:-}"

case "$TIER" in
  --cheap)
    echo "🧪 Running Tier 1 (free) + Tier 2 (cheap API calls)..."
    python3 -m pytest test_dry_run.py test_integration.py -v -s -m "free or cheap" --tb=short
    ;;
  --full)
    echo "🧪 Running ALL tiers (including E2E — costs money!)..."
    python3 -m pytest test_dry_run.py test_integration.py test_e2e.py -v -s --tb=short
    ;;
  --e2e-only)
    echo "🧪 Running Tier 3 only (E2E pipeline)..."
    python3 -m pytest test_e2e.py -v -s --tb=short
    ;;
  *)
    echo "🧪 Running Tier 1 only (free, no API calls)..."
    python3 -m pytest test_dry_run.py -v -s -m "free" --tb=short
    ;;
esac


path: tests/test_dry_run.py
content: """
Tier 1: Dry-run tests — no API calls, no cost.
Tests argument parsing, cost estimation, dry-run output.
"""
import json
import os
import pytest
from conftest import run_script, SCRIPTS_DIR


class TestSunoMusicDryRun:
    """suno_music.sh dry-run tests."""

    @pytest.mark.free
    def test_dry_run_shows_cost_estimate(self, skill_env, work_dir):
        """Dry-run should show cost estimate and exit 0."""
        result = run_script("suno_music.sh", [
            "--prompt", "test lyrics",
            "--style", "pop",
            "--title", "Test Song",
            "--custom",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Cost Estimate" in result.stdout
        assert "DRY_RUN" in result.stdout
        assert "Model: V4_5ALL" in result.stdout

    @pytest.mark.free
    def test_dry_run_custom_model(self, skill_env, work_dir):
        """Dry-run with non-default model."""
        result = run_script("suno_music.sh", [
            "--prompt", "test",
            "--model", "V4_5ALL",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Model: V4_5ALL" in result.stdout

    @pytest.mark.free
    def test_dry_run_instrumental_flag(self, skill_env, work_dir):
        """Dry-run with instrumental mode."""
        result = run_script("suno_music.sh", [
            "--prompt", "ambient electronic",
            "--instrumental",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Instrumental: true" in result.stdout

    @pytest.mark.free
    def test_dry_run_with_persona_id(self, skill_env, work_dir):
        """Dry-run with persona-id shows persona info."""
        result = run_script("suno_music.sh", [
            "--prompt", "coding vibe music",
            "--persona-id", "persona_abc123",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Persona: persona_abc123" in result.stdout

    @pytest.mark.free
    def test_dry_run_create_persona_flag(self, skill_env, work_dir):
        """Dry-run with create-persona shows the flag."""
        result = run_script("suno_music.sh", [
            "--prompt", "test track",
            "--create-persona",
            "--persona-name", "Dev Singer",
            "--persona-desc", "Coding music vocalist",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Create Persona: true" in result.stdout

    @pytest.mark.free
    def test_missing_prompt_errors(self, skill_env, work_dir):
        """Should error when --prompt is missing."""
        result = run_script("suno_music.sh", [
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode != 0
        assert "prompt" in result.stderr.lower()

    @pytest.mark.free
    def test_dry_run_music_video_flag(self, skill_env, work_dir):
        """Dry-run with --music-video flag."""
        result = run_script("suno_music.sh", [
            "--prompt", "test",
            "--music-video",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Music Video: true" in result.stdout

    @pytest.mark.free
    def test_dry_run_vocal_gender(self, skill_env, work_dir):
        """Dry-run with --vocal-gender option."""
        result = run_script("suno_music.sh", [
            "--prompt", "test song",
            "--vocal-gender", "f",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "DRY_RUN" in result.stdout

    @pytest.mark.free
    def test_dry_run_negative_tags(self, skill_env, work_dir):
        """Dry-run with --negative-tags option."""
        result = run_script("suno_music.sh", [
            "--prompt", "chill vibes",
            "--negative-tags", "Heavy Metal, Screaming",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "DRY_RUN" in result.stdout

    @pytest.mark.free
    def test_missing_api_key_errors(self, work_dir):
        """Should error when SUNO_API_KEY is not set."""
        env = os.environ.copy()
        env.pop("SUNO_API_KEY", None)
        result = run_script("suno_music.sh", [
            "--prompt", "test",
            "--dry-run",
        ], env)

        assert result.returncode != 0
        assert "SUNO_API_KEY" in result.stderr


class TestGenVisualsDryRun:
    """gen_visuals.sh dry-run tests."""

    @pytest.mark.free
    def test_slideshow_dry_run(self, skill_env, work_dir, sample_prompts_file):
        """Slideshow mode dry-run with 3 scenes."""
        result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", sample_prompts_file,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Cost Estimate" in result.stdout
        assert "Images: 3" in result.stdout
        # Check cost estimate JSON was written
        est_path = os.path.join(work_dir, "cost_estimate.json")
        assert os.path.exists(est_path)
        with open(est_path) as f:
            est = json.load(f)
        assert est["mode"] == "slideshow"
        assert est["num_images"] == 3
        assert est["num_videos"] == 0

    @pytest.mark.free
    def test_video_dry_run(self, skill_env, work_dir, sample_prompts_file):
        """Video mode dry-run."""
        result = run_script("gen_visuals.sh", [
            "--mode", "video",
            "--prompts-file", sample_prompts_file,
            "--video-provider", "seedance-lite",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        est_path = os.path.join(work_dir, "cost_estimate.json")
        with open(est_path) as f:
            est = json.load(f)
        assert est["num_images"] == 0
        assert est["num_videos"] == 3
        assert est["video_cost_each"] == 0.14  # seedance-lite price

    @pytest.mark.free
    def test_hybrid_dry_run(self, skill_env, work_dir, sample_prompts_file):
        """Hybrid mode splits scenes between image and video."""
        result = run_script("gen_visuals.sh", [
            "--mode", "hybrid",
            "--prompts-file", sample_prompts_file,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        est_path = os.path.join(work_dir, "cost_estimate.json")
        with open(est_path) as f:
            est = json.load(f)
        assert est["num_images"] + est["num_videos"] == 3
        assert est["num_images"] == 1  # 3//2 = 1
        assert est["num_videos"] == 2  # 3 - 1 = 2

    @pytest.mark.free
    def test_missing_prompts_file_errors(self, skill_env, work_dir):
        """Should error when prompts file doesn't exist."""
        result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", "/nonexistent/prompts.json",
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode != 0
        assert "prompts-file" in result.stderr.lower()

    @pytest.mark.free
    @pytest.mark.parametrize("provider,model,quality,size,expected_cost", [
        # Token-based pricing (Feb 2026):
        # gpt-image-1-mini: text_in=$2/1M, img_out=$8/1M
        # gpt-image-1:      text_in=$5/1M, img_out=$40/1M
        # Output tokens: low=272, medium=1056, high=4160
        # Size multiplier: 1024x1024=1x, others=1.5x
        # Cost = (80*text_rate + output_tokens*img_rate) / 1M
        ("openai", "gpt-image-1-mini", "low", "1024x1024", 0.002336),
        ("openai", "gpt-image-1-mini", "medium", "1024x1024", 0.008608),
        ("openai", "gpt-image-1-mini", "medium", "1536x1024", 0.012800),
        ("openai", "gpt-image-1", "medium", "1024x1024", 0.042640),
        ("openai", "gpt-image-1", "medium", "1536x1024", 0.063760),
        ("openai", "gpt-image-1", "high", "1024x1024", 0.166800),
        ("google-together", "gpt-image-1-mini", "medium", "1536x1024", 0.040),
    ])
    def test_image_cost_accuracy(self, skill_env, work_dir, single_prompt_file,
                                  provider, model, quality, size, expected_cost):
        """Verify token-based cost calculation matches expected values."""
        result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", single_prompt_file,
            "--image-provider", provider,
            "--image-model", model,
            "--image-quality", quality,
            "--image-size", size,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        est_path = os.path.join(work_dir, "cost_estimate.json")
        with open(est_path) as f:
            est = json.load(f)
        assert abs(est["image_cost_each"] - expected_cost) < 0.0001, \
            f"Expected ~{expected_cost}, got {est['image_cost_each']}"

    @pytest.mark.free
    @pytest.mark.parametrize("provider,expected_cost", [
        ("sora", 0.80),
        ("sora-pro", 2.40),
        ("seedance-lite", 0.14),
        ("seedance-pro", 0.57),
        ("veo-fast", 0.80),
        ("veo-audio", 3.20),
    ])
    def test_video_cost_accuracy(self, skill_env, work_dir, single_prompt_file,
                                  provider, expected_cost):
        """Verify video cost per provider."""
        result = run_script("gen_visuals.sh", [
            "--mode", "video",
            "--prompts-file", single_prompt_file,
            "--video-provider", provider,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        est_path = os.path.join(work_dir, "cost_estimate.json")
        with open(est_path) as f:
            est = json.load(f)
        assert est["video_cost_each"] == expected_cost


class TestAssembleDryRun:
    """assemble_mv.sh dry-run tests."""

    @pytest.mark.free
    def test_dry_run_with_audio(self, skill_env, work_dir):
        """Dry-run should describe what it would do."""
        # Create a short silent audio file for testing
        audio_path = os.path.join(work_dir, "test.mp3")
        os.system(
            f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 10 '
            f'-q:a 9 "{audio_path}" 2>/dev/null'
        )
        # Create dummy image files
        img_dir = os.path.join(work_dir, "images")
        os.makedirs(img_dir, exist_ok=True)
        for i in range(3):
            os.system(
                f'ffmpeg -y -f lavfi -i color=c=blue:s=1024x1024:d=1 '
                f'"{img_dir}/scene_{i:03d}.png" 2>/dev/null'
            )

        result = run_script("assemble_mv.sh", [
            "--audio", audio_path,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "DRY_RUN" in result.stdout
        assert "3 images" in result.stdout

    @pytest.mark.free
    def test_auto_detect_lyrics_srt(self, skill_env, work_dir):
        """Should auto-detect lyrics.srt in outdir."""
        audio_path = os.path.join(work_dir, "test.mp3")
        os.system(
            f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 10 '
            f'-q:a 9 "{audio_path}" 2>/dev/null'
        )
        img_dir = os.path.join(work_dir, "images")
        os.makedirs(img_dir, exist_ok=True)
        for i in range(2):
            os.system(
                f'ffmpeg -y -f lavfi -i color=c=blue:s=1024x1024:d=1 '
                f'"{img_dir}/scene_{i:03d}.png" 2>/dev/null'
            )
        # Create lyrics.srt
        srt_path = os.path.join(work_dir, "lyrics.srt")
        with open(srt_path, "w") as f:
            f.write("1\n00:00:01,000 --> 00:00:03,000\nTest lyric\n\n")

        result = run_script("assemble_mv.sh", [
            "--audio", audio_path,
            "--outdir", work_dir,
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Auto-detected lyrics" in result.stdout

    @pytest.mark.free
    def test_no_subtitle_flag(self, skill_env, work_dir):
        """--no-subtitle should suppress auto-detection."""
        audio_path = os.path.join(work_dir, "test.mp3")
        os.system(
            f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 10 '
            f'-q:a 9 "{audio_path}" 2>/dev/null'
        )
        img_dir = os.path.join(work_dir, "images")
        os.makedirs(img_dir, exist_ok=True)
        os.system(
            f'ffmpeg -y -f lavfi -i color=c=blue:s=1024x1024:d=1 '
            f'"{img_dir}/scene_000.png" 2>/dev/null'
        )
        srt_path = os.path.join(work_dir, "lyrics.srt")
        with open(srt_path, "w") as f:
            f.write("1\n00:00:01,000 --> 00:00:03,000\nTest\n\n")

        result = run_script("assemble_mv.sh", [
            "--audio", audio_path,
            "--outdir", work_dir,
            "--no-subtitle",
            "--dry-run",
        ], skill_env)

        assert result.returncode == 0
        assert "Auto-detected lyrics" not in result.stdout

    @pytest.mark.free
    def test_missing_audio_errors(self, skill_env, work_dir):
        """Should error when audio file missing."""
        result = run_script("assemble_mv.sh", [
            "--audio", "/nonexistent/audio.mp3",
            "--outdir", work_dir,
        ], skill_env)

        assert result.returncode != 0
        assert "audio" in result.stderr.lower()


path: tests/test_e2e.py
content: """
Tier 3: Full end-to-end pipeline test.
Music generation → Visual generation → Assembly → Final video.
Cost: ~$0.05-0.15 (low quality, 3 scenes).

Run with: pytest test_e2e.py -v -s
"""
import json
import os
import glob
import time
import pytest
from conftest import run_script


@pytest.mark.expensive
class TestFullPipeline:
    """End-to-end: Suno music → OpenAI images → ffmpeg assembly."""

    @pytest.fixture
    def project_dir(self, work_dir):
        """Create a project directory with prompts."""
        pdir = os.path.join(work_dir, "mv_project")
        os.makedirs(pdir, exist_ok=True)

        # 3 scenes — minimal for E2E while testing the pipeline
        prompts = [
            "A warm sunrise over a calm ocean, golden light on gentle waves",
            "Cherry blossom petals falling through morning light in a peaceful garden",
            "A cozy window view of rain falling on a city street at twilight",
        ]
        with open(os.path.join(pdir, "prompts.json"), "w") as f:
            json.dump(prompts, f, ensure_ascii=False)

        return pdir

    def test_full_slideshow_pipeline(self, skill_env, project_dir):
        """
        Complete pipeline: music → slideshow images → assembly.
        
        Steps:
        1. Generate music with Suno V5 (simple mode)
        2. Generate 3 images with OpenAI (low quality, cheapest)
        3. Assemble into final MP4 with ffmpeg

        Expected cost: ~10 Suno credits + 3 × $0.009 = ~$0.027 API
        """
        costs = {}
        start_time = time.time()

        # ── Step 1: Generate Music ──
        print("\n" + "=" * 60)
        print("STEP 1: Music Generation (Suno V5)")
        print("=" * 60)

        music_result = run_script("suno_music.sh", [
            "--prompt", "A peaceful melody about morning sunshine and new beginnings",
            "--outdir", project_dir,
            "--timeout", "300",
        ], skill_env, timeout=360)

        assert music_result.returncode == 0, (
            f"Music generation failed:\n"
            f"STDOUT: {music_result.stdout}\n"
            f"STDERR: {music_result.stderr}"
        )

        # Verify music output
        music_meta_path = os.path.join(project_dir, "music_meta.json")
        assert os.path.exists(music_meta_path), "music_meta.json not found"

        with open(music_meta_path) as f:
            music_meta = json.load(f)

        tracks = music_meta["tracks"]
        assert len(tracks) >= 1, "No tracks generated"

        # Pick the first track for assembly
        audio_file = os.path.join(project_dir, tracks[0]["audio_file"])
        assert os.path.exists(audio_file), f"Audio file not found: {audio_file}"
        assert os.path.getsize(audio_file) > 100_000, "Audio file too small"

        costs["music"] = "~10 Suno credits"
        music_duration = tracks[0].get("duration", 0)
        print(f"  ✅ Music done: {tracks[0]['title']} ({music_duration:.0f}s)")
        print(f"  ✅ Tracks: {len(tracks)}")

        # ── Step 2: Generate Visuals ──
        print("\n" + "=" * 60)
        print("STEP 2: Visual Generation (OpenAI, low quality)")
        print("=" * 60)

        prompts_file = os.path.join(project_dir, "prompts.json")
        visuals_result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", prompts_file,
            "--image-provider", "openai",
            "--image-quality", "low",
            "--image-size", "1024x1024",
            "--outdir", project_dir,
        ], skill_env, timeout=300)

        assert visuals_result.returncode == 0, (
            f"Visual generation failed:\n"
            f"STDOUT: {visuals_result.stdout}\n"
            f"STDERR: {visuals_result.stderr}"
        )

        # Verify images
        images = sorted(glob.glob(os.path.join(project_dir, "images", "scene_*.png")))
        assert len(images) == 3, f"Expected 3 images, got {len(images)}"

        for img in images:
            size = os.path.getsize(img)
            assert size > 50_000, f"Image too small ({size}B): {img}"
            print(f"  ✅ {os.path.basename(img)} ({size // 1024}KB)")

        # Verify visuals metadata
        vis_meta_path = os.path.join(project_dir, "visuals_meta.json")
        assert os.path.exists(vis_meta_path)
        with open(vis_meta_path) as f:
            vis_meta = json.load(f)
        costs["visuals"] = f"${vis_meta.get('total_cost', 0):.3f}"

        # ── Step 3: Assemble ──
        print("\n" + "=" * 60)
        print("STEP 3: Assembly (ffmpeg)")
        print("=" * 60)

        output_path = os.path.join(project_dir, "final_mv.mp4")
        assembly_result = run_script("assemble_mv.sh", [
            "--audio", audio_file,
            "--outdir", project_dir,
            "--output", output_path,
            "--mode", "slideshow",
            "--transition", "fade",
        ], skill_env, timeout=120)

        assert assembly_result.returncode == 0, (
            f"Assembly failed:\n"
            f"STDOUT: {assembly_result.stdout}\n"
            f"STDERR: {assembly_result.stderr}"
        )

        # Verify final video
        assert os.path.exists(output_path), "Final video not created"
        video_size = os.path.getsize(output_path)
        assert video_size > 100_000, f"Final video too small ({video_size}B)"

        # ── Final Report ──
        elapsed = time.time() - start_time
        print("\n" + "=" * 60)
        print("🎬 E2E TEST COMPLETE")
        print("=" * 60)
        print(f"  ⏱  Time: {elapsed:.0f}s")
        print(f"  🎵 Music: {tracks[0]['title']} ({music_duration:.0f}s, {len(tracks)} tracks)")
        print(f"  🎨 Images: {len(images)}")
        print(f"  📁 Video: {output_path} ({video_size // 1024}KB)")
        print(f"  💰 Cost: music={costs['music']}, visuals={costs['visuals']}")
        print("=" * 60)

        # Save test report
        report = {
            "test": "full_slideshow_pipeline",
            "status": "PASS",
            "elapsed_seconds": round(elapsed, 1),
            "music": {
                "model": music_meta["model"],
                "tracks": len(tracks),
                "duration": music_duration,
                "title": tracks[0].get("title", ""),
            },
            "visuals": {
                "mode": "slideshow",
                "provider": "openai",
                "quality": "low",
                "count": len(images),
                "cost": vis_meta.get("total_cost", 0),
            },
            "output": {
                "path": output_path,
                "size_bytes": video_size,
            },
            "costs": costs,
        }
        report_path = os.path.join(project_dir, "test_report.json")
        with open(report_path, "w") as f:
            json.dump(report, f, indent=2, ensure_ascii=False)
        print(f"\n  📄 Report: {report_path}")


@pytest.mark.expensive
class TestFullPipelineCustomMusic:
    """E2E with custom mode music (lyrics + style)."""

    def test_custom_music_slideshow(self, skill_env, work_dir):
        """Custom lyrics → images → video."""
        pdir = os.path.join(work_dir, "custom_mv")
        os.makedirs(pdir, exist_ok=True)

        # Lyrics-derived scene prompts
        prompts = [
            "A person walking alone on a rainy city street at night, neon reflections",
            "Close-up of raindrops on a window, blurry city lights in background",
            "Two silhouettes meeting under a streetlight, warm amber glow",
        ]
        prompts_path = os.path.join(pdir, "prompts.json")
        with open(prompts_path, "w") as f:
            json.dump(prompts, f)

        # Step 1: Custom music
        music_result = run_script("suno_music.sh", [
            "--prompt", "빗소리 사이로\n너의 이름을 부르네\n이 거리 끝에서",
            "--style", "korean ballad, soft vocal, piano, rainy mood",
            "--title", "빗소리",
            "--custom",
            "--outdir", pdir,
            "--timeout", "300",
        ], skill_env, timeout=360)

        assert music_result.returncode == 0, f"STDERR: {music_result.stderr}"

        with open(os.path.join(pdir, "music_meta.json")) as f:
            meta = json.load(f)
        audio = os.path.join(pdir, meta["tracks"][0]["audio_file"])

        # Step 2: Images (low cost)
        vis_result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", prompts_path,
            "--image-quality", "low",
            "--image-size", "1024x1024",
            "--outdir", pdir,
        ], skill_env, timeout=300)

        assert vis_result.returncode == 0, f"STDERR: {vis_result.stderr}"

        # Step 3: Assemble
        output = os.path.join(pdir, "빗소리_mv.mp4")
        asm_result = run_script("assemble_mv.sh", [
            "--audio", audio,
            "--outdir", pdir,
            "--output", output,
            "--transition", "fade",
        ], skill_env, timeout=120)

        assert asm_result.returncode == 0, f"STDERR: {asm_result.stderr}"
        assert os.path.exists(output)
        assert os.path.getsize(output) > 100_000
        print(f"\n🎬 Custom MV: {output} ({os.path.getsize(output) // 1024}KB)")


path: tests/test_integration.py
content: """
Tier 2: Integration tests — minimal real API calls.
Each script tested independently with smallest possible input.
Cost: ~$0.04-0.10 per full run.
"""
import json
import os
import glob
import pytest
from conftest import run_script


class TestSunoMusicIntegration:
    """Test actual music generation with Suno API."""

    @pytest.mark.cheap
    def test_simple_generation(self, skill_env, work_dir):
        """Generate music in simple (non-custom) mode.
        Cost: ~10 Suno credits. Produces 2 tracks.
        """
        result = run_script("suno_music.sh", [
            "--prompt", "A short happy melody about sunshine and flowers",
            "--outdir", work_dir,
            "--timeout", "300",
        ], skill_env, timeout=360)

        assert result.returncode == 0, f"Script failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
        assert "Generation complete" in result.stdout

        # Verify output files
        meta_path = os.path.join(work_dir, "music_meta.json")
        assert os.path.exists(meta_path), "music_meta.json not created"

        with open(meta_path) as f:
            meta = json.load(f)

        assert len(meta["tracks"]) >= 1, "Should have at least 1 track"
        assert meta["model"] == "V5"

        # Verify MP3 files downloaded
        mp3_files = glob.glob(os.path.join(work_dir, "track_*.mp3"))
        assert len(mp3_files) >= 1, f"No MP3 files found in {work_dir}"

        for mp3 in mp3_files:
            size = os.path.getsize(mp3)
            assert size > 100_000, f"MP3 too small ({size}B): {mp3}"

        # Verify track metadata
        for track in meta["tracks"]:
            assert track.get("duration", 0) > 0, "Track should have duration"
            assert track.get("audio_file"), "Track should have audio_file"

    @pytest.mark.cheap
    def test_custom_mode_generation(self, skill_env, work_dir):
        """Generate music in custom mode with style and title.
        Cost: ~10 Suno credits.
        """
        result = run_script("suno_music.sh", [
            "--prompt", "Walking through the rain\nSearching for your name\nEvery drop a memory",
            "--style", "indie acoustic, soft vocal, lo-fi",
            "--title", "Rain Memory",
            "--custom",
            "--outdir", work_dir,
            "--timeout", "300",
        ], skill_env, timeout=360)

        assert result.returncode == 0, f"Script failed:\nSTDERR: {result.stderr}"
        meta_path = os.path.join(work_dir, "music_meta.json")
        with open(meta_path) as f:
            meta = json.load(f)
        assert len(meta["tracks"]) >= 1


class TestGenVisualsIntegration:
    """Test actual image generation (cheapest: 1 image, low quality)."""

    @pytest.mark.cheap
    def test_single_image_openai(self, skill_env, work_dir, single_prompt_file):
        """Generate 1 image with OpenAI (low quality).
        Cost: ~$0.009.
        """
        result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", single_prompt_file,
            "--image-provider", "openai",
            "--image-quality", "low",
            "--image-size", "1024x1024",
            "--outdir", work_dir,
        ], skill_env, timeout=120)

        assert result.returncode == 0, f"Script failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"

        # Verify image file created
        images = glob.glob(os.path.join(work_dir, "images", "scene_*.png"))
        assert len(images) == 1, f"Expected 1 image, got {len(images)}"
        assert os.path.getsize(images[0]) > 50_000, "Image too small"

        # Verify metadata
        meta_path = os.path.join(work_dir, "visuals_meta.json")
        assert os.path.exists(meta_path)
        with open(meta_path) as f:
            meta = json.load(f)
        assert meta["mode"] == "slideshow"
        assert len(meta["images"]) == 1
        assert meta["total_cost"] > 0

    @pytest.mark.cheap
    def test_multiple_images_openai(self, skill_env, work_dir, sample_prompts_file):
        """Generate 3 images with OpenAI (low quality).
        Cost: ~$0.027.
        """
        result = run_script("gen_visuals.sh", [
            "--mode", "slideshow",
            "--prompts-file", sample_prompts_file,
            "--image-provider", "openai",
            "--image-quality", "low",
            "--image-size", "1024x1024",
            "--outdir", work_dir,
        ], skill_env, timeout=300)

        assert result.returncode == 0, f"STDERR: {result.stderr}"

        images = glob.glob(os.path.join(work_dir, "images", "scene_*.png"))
        assert len(images) == 3, f"Expected 3 images, got {len(images)}"


class TestAssembleIntegration:
    """Test ffmpeg assembly with synthetic inputs."""

    @pytest.mark.free
    def test_slideshow_assembly_synthetic(self, skill_env, work_dir):
        """Assemble slideshow from synthetic test inputs (no API cost).
        Uses ffmpeg-generated color images + silent audio.
        """
        # Arrange: create synthetic audio (10s silence)
        audio_path = os.path.join(work_dir, "test_audio.mp3")
        os.system(
            f'ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 10 '
            f'-q:a 9 "{audio_path}" 2>/dev/null'
        )
        assert os.path.exists(audio_path)

        # Arrange: create 3 synthetic images (solid color)
        img_dir = os.path.join(work_dir, "images")
        os.makedirs(img_dir, exist_ok=True)
        colors = ["red", "green", "blue"]
        for i, color in enumerate(colors):
            img_path = os.path.join(img_dir, f"scene_{i:03d}.png")
            os.system(
                f'ffmpeg -y -f lavfi -i color=c={color}:s=1024x1024:d=1 '
                f'-frames:v 1 "{img_path}" 2>/dev/null'
            )
            assert os.path.exists(img_path), f"Failed to create {img_path}"

        # Arrange: write visuals_meta.json
        meta = {"mode": "slideshow", "images": [], "videos": []}
        with open(os.path.join(work_dir, "visuals_meta.json"), "w") as f:
            json.dump(meta, f)

        output_path = os.path.join(work_dir, "test_mv.mp4")

        # Act
        result = run_script("assemble_mv.sh", [
            "--audio", audio_path,
            "--outdir", work_dir,
            "--output", output_path,
            "--mode", "slideshow",
            "--transition", "none",
        ], skill_env, timeout=60)

        # Assert
        assert result.returncode == 0, f"Assembly failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
        assert os.path.exists(output_path), "Output video not created"
        assert os.path.getsize(output_path) > 10_000, "Output video too small"
        assert "Music Video Complete" in result.stdout

    @pytest.mark.free
    def test_slideshow_assembly_with_fade(self, skill_env, work_dir):
        """Assemble slideshow with crossfade transitions."""
        # Arrange
        audio_path = os.path.join(work_dir, "audio.mp3")
        os.system(
            f'ffmpeg -y -f lavfi -i "sine=f=440:d=12" '
            f'-q:a 9 "{audio_path}" 2>/dev/null'
        )

        img_dir = os.path.join(work_dir, "images")
        os.makedirs(img_dir, exist_ok=True)
        for i in range(3):
            img_path = os.path.join(img_dir, f"scene_{i:03d}.png")
            os.system(
                f'ffmpeg -y -f lavfi -i color=c=0x{i*80:02x}{i*40:02x}FF:s=1920x1080:d=1 '
                f'-frames:v 1 "{img_path}" 2>/dev/null'
            )

        meta = {"mode": "slideshow", "images": [], "videos": []}
        with open(os.path.join(work_dir, "visuals_meta.json"), "w") as f:
            json.dump(meta, f)

        output_path = os.path.join(work_dir, "fade_mv.mp4")

        # Act
        result = run_script("assemble_mv.sh", [
            "--audio", audio_path,
            "--outdir", work_dir,
            "--output", output_path,
            "--mode", "slideshow",
            "--transition", "fade",
        ], skill_env, timeout=60)

        # Assert
        assert result.returncode == 0, f"STDOUT: {result.stdout}\nSTDERR: {result.stderr}"
        assert os.path.exists(output_path)
        size = os.path.getsize(output_path)
        assert size > 10_000, f"Output too small: {size}B"


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