Skill: yhongm/story-video-skill (version 1.5.1)

--- name: story-video version: 1.5.1 description: > 当用户提到以下内容时触发此技能： 把故事做成视频、生成视频、故事视频、AI视频制作、文生视频、图生视频、剧本转视频、分镜生成、分镜头、导演分镜、编剧分析、故事分镜、需要分镜、给视频分镜、拍成视频、拍成分镜、generate video、story to video、video generation、video script、screenplay analysis、shot breakdown、scene breakdown、film directing、编剧、导演、分镜、分镜头、给个分镜 这是一个AI视频制作流水线，覆盖从故事文本到分镜再到视频生成的全流程： 从分析故事结构、提炼主题，到设计人物对白、构建叙事节奏； 从编写分镜脚本、设计镜头语言，到生成视觉描述词； 从文本到图片、图片到视频的生成，再到ffmpeg合并输出最终成片。 trigger: 故事视频|分镜|剧本|编剧分析|故事分镜|导演分镜|AI视频制作|文生视频|图生视频|generate video|story to video|video generation|shot breakdown|scene breakdown|screenplay|film directing|分镜脚本|镜头语言|视觉描述|视频成片 tags: - video-generation - storytelling - screenplay - filmmaking - minimax - story-structure - shot-breakdown - film-production required_environment_variables: - MINIMAX_API_KEY optional_environment_variables: - MINIMAX_BASE_URL - MINIMAX_IMAGE_URL --- # story-video-skill ## 技能概述 这是一个**分镜脚本**到**完整视频**的 AI 制作流水线。 **分镜脚本：** 分析故事结构，设计人物对白，构建叙事节奏；建立人物串联线索，设计对应场景板；设计对比板式、建立人物关系图；设计对峙场面、构建起承转合。 **视频流水线：** 故事文本 → MiniMax T2I（图片生成）→ MiniMax I2V（图片转视频）→ ffmpeg 合并输出成片。 ## 核心流程 ``` 故事文本 → 分镜JSON → T2I图片 → I2V视频 → ffmpeg合并 → 视频成片 ``` ### 第一步：生成shots.json 用户提供故事文本后，用 `scripts/pipeline.py` 生成 `shots.json`： ``` python3 scripts/pipeline.py "你的故事大纲或剧本" ``` 输出 `output/screenplay/shots.json`，包含每个分镜的： - `shot_number`：镜头编号 - `description`：分镜描述（画面+动作+台词） - `visual_prompt`：视觉生成词（送入T2I） ### 第二步：生成并合并视频 用 `scripts/full_pipeline.py` 或 `scripts/full_pipeline_v2.py` 执行完整流水线： ``` export MINIMAX_API_KEY="你的key" python3 scripts/full_pipeline.py output/screenplay/shots.json ``` 流水线自动： 1. 读取 shots.json 2. 对每个分镜调用 MiniMax T2I 生成图片 3. 对每张图片调用 MiniMax I2V 生成视频 4. 用 ffmpeg 合并所有视频为单个成片 输出目录：`output/videos/` ## 脚本说明 | 脚本 | 功能 | |------|------| | `scripts/pipeline.py` | 故事文本 → shots.json 分镜脚本 | | `scripts/full_pipeline.py` | shots.json → T2I图片 → I2V视频 → ffmpeg合并 | | `scripts/full_pipeline_v2.py` | 同上，模型版本不同 | | `scripts/generate_shot_images.py` | 独立运行T2I图片生成 | | `scripts/generate_shot_videos.py` | 独立运行I2V视频生成 | ## 环境变量 | 变量 | 必需 | 默认值 | 说明 | |------|------|--------|------| | `MINIMAX_API_KEY` | ✅ | — | MiniMax API密钥 | | `MINIMAX_BASE_URL` | ❌ | `https://api.minimaxi.com/v1` | MiniMax API地址 | | `MINIMAX_IMAGE_URL` | ❌ | `https://api.minimaxi.com/v1/image_generation` | 图片生成端点 | ## 目录结构 ``` story-video-skill/ ├── SKILL.md # 本技能说明 ├── README.md # 详细文档 ├── output/ │ ├── frames/ # T2I生成的图片 │ ├── videos/ # I2V生成的视频 + 最终成片 │ └── screenplay/ │ └── shots.json # 分镜脚本 └── scripts/ ├── pipeline.py # 生成shots.json ├── full_pipeline.py # 完整流水线 v1 ├── full_pipeline_v2.py # 完整流水线 v2 ├── generate_shot_images.py # T2I图片生成 └── generate_shot_videos.py # I2V视频生成 ``` ## 分镜设计原则 ### 视觉叙事优先 - 每个分镜有明确的主角视线和注意力焦点 - 用「镜头角度 + 运动方式」代替「人物动作罗列」 - 优先通过场景/道具/表情暗示情绪，少用直接台词 ### 分镜描述格式 ``` 镜头编号 | 镜头类型 | 画面描述 | 台词/声音 ``` ### 情绪节奏把控 - 开场30秒：建立世界规则，展示日常状态 - 中段：矛盾积累，节奏加快 - 高潮：情感爆发，动作密集 - 收束：留白，克制 ## 专业知识：编剧体系 ### 故事结构模板 | 三幕 | 占比 | 核心 | |------|------|------| | 第一幕：建置 | 25% | 开场钩子、日常世界、催化事件 | | 第二幕：对抗 | 50% | 进展升级、中点转折、灵魂黑夜 | | 第三幕：解决 | 25% | 高潮决战、结局收束 | ### 起承转合 | 阶段 | 功能 | 情绪 | |------|------|------| | 起 | 引入 | 好奇 | | 承 | 发展 | 期待 | | 转 | 转折 | 紧张 | | 合 | 解决 | 满足 | ### 人物塑造 - **性格三角**：内在欲望 × 外在行为 × 他人评价 - **关系对位**：主角与对手形成镜像对照 - **成长弧线**：每个重要人物都有从A点到B点的变化 ## 视觉风格指南 ### 色调与光影 | 类型 | 适合场景 | 推荐色调 | |------|----------|----------| | 温暖怀旧 | 回忆、亲情 | 暖黄、褪色 | | 冷峻现实 | 困境、独立 | 低饱和、冷蓝 | | 奇幻冒险 | 超现实、梦境 | 高饱和、撞色 | | 悬疑紧张 | 秘密、危机 | 暗调、阴影 | ### 镜头语言 | 镜头 | 适合场景 | 情绪效果 | |------|----------|----------| | 远景 | 建立场景 | 渺小感/史诗感 | | 中景 | 人物互动 | 亲近感 | | 特写 | 情绪高潮 | 冲击力 | | 俯拍 | 命运转折 | 宿命感 | | 仰拍 | 英雄登场 | 力量感 | ## 故障排除 ### T2I 生成失败 - 检查 `MINIMAX_API_KEY` 是否有效 - 缩短 visual_prompt（过长会被截断） - 避免敏感词（血腥/暴力内容会被拦截） ### I2V 生成卡住 - 确认 model 为 `MiniMax-Hailuo-2.3` - 检查图片URL是否可访问（需要公网可访问的HTTP链接） ### ffmpeg 合并失败 - 确保所有视频文件完整（非0字节） - 确认 ffmpeg 已安装：`ffmpeg -version` - 检查视频格式是否兼容（推荐MP4/H.264） ### 速率限制 - MiniMax API 有速率限制，高并发时会429 - `full_pipeline.py` 内置 exponential backoff 重试 - 大量分镜建议分批处理

path: README.md
content: # Story Video Pipeline

**AI-Endowed Screenwriter · Director · Visual Storyteller**

一条从叙事文本到电影级视频的完整 production line。输入任意故事大纲、剧本或叙事片段，系统自动完成结构解构、节拍设计、人物弧线构建、分镜头规划，并调用 MiniMax 多模态模型生成连贯的视觉序列，最终输出具有专业镜头语言和情绪基调的完整影片。

---

## 核心能力

### 编剧能力

**叙事结构设计** — 内置三幕结构（Three-Act）、Blake Snyder 节拍表、中国戏曲起承转合等多种故事骨架模型。输入任意故事文本，自动分析其戏剧张力和结构完整性，输出具有节奏感的叙事设计方案。

**对白风格构建** — 从 *Pulp Fiction*、*Star Wars* 等经典剧本中蒸馏出五种对白风格模式：自然主义对话、机智对驳、潜台词表达、内心独白、仪式化台词。系统根据故事类型和人物设定，自动匹配最合适的人物语音体系。

**人物弧线设计** — 支持成长弧、堕落弧、救赎弧、循环弧、平线弧五种角色转变路径。每组角色均生成外在目标（可观察行为）与内在需求（心理转变）的双重设计，确保人物立体可信。

**冲突架构** — 内置人物vs人物、人物vs自我、人物vs社会、人物vs自然、人物vs命运五类冲突模板。每个冲突节点自动匹配经典"目标→障碍→行动→转折→结果"结构。

### 导演能力

**分镜头语言** — 景别体系（EWS/WS/FS/MS/CU/ECU）、角度体系（仰拍/俯拍/水平/倾斜）、运镜体系（推/拉/摇/跟踪/升降/手持）均可按场景情绪需求自由组合。

**视觉基调规划** — 系统内置六种情绪（快乐/悲伤/紧张/神秘/浪漫/恐惧）的完整视觉解决方案——色调、构图、运镜、光线四大维度的协同设计规范，确保视觉表达与叙事情绪高度一致。

**场景调度** — 从真实剧本中提炼的 mise-en-scène 四要素框架（角色位置/景框构图/美术设计/灯光照明），为每个镜头输出精确的空间关系和视觉层次指令。

**MiniMax 运镜指令** — 支持 15 种标准运镜指令在视觉描述中直接嵌入：`[左移] [右移] [推进] [拉远] [左摇] [上升] [跟随] [固定]` 等，支持同时生效和顺序生效两种组合模式。

### 视频生成能力

**帧间连贯** — 首个镜头独立生成，后续每个镜头自动以前一帧画面作为参考输入，锁定角色外观、场景元素和视觉风格，实现多镜头间的一致性。

**多模型支持** — 文生图调用 MiniMax `image-01` 模型；图生视频调用 MiniMax `MiniMax-Hailuo-2.3`，支持 6s/10s 两种时长和 768P/1080P 两种分辨率。

**ffmpeg 合并** — 使用 concat demuxer 模式无损合并所有视频片段，自动生成文本故事板记录整个叙事脉络。

---

## 工作流

```
用户输入故事（几行大纲 or 完整剧本）
        │
        ▼
┌───────────────────────────────────────┐
│  Step 0: 智能判断故事完整度           │
│  分析叙事结构：结局/弧线/内容长度      │
│  ┌─ 完整故事 → 直接进入 Step 1       │
│  └─ 非完整 → 询问用户需求类型        │
└───────────────────────────────────────┘
        │
        ▼
   【用户确认制作方式（如需）】
        │
        ▼
┌───────────────────────────────────────┐
│  Step 1: 剧本生成                    │
│  输出3个剧本方案（A写实/B喜剧/C奇幻）  │
│  story_to_shots.py 编剧引擎           │
└───────────────────────────────────────┘
        │
        ▼
   【用户选择剧本方案】
        │
        ▼
┌───────────────────────────────────────┐
│  Step 2: 分镜方案                    │
│  输出3个分镜视觉方案                  │
│  (A纪录片质感/B喜剧风格/C超现实奇幻)   │
└───────────────────────────────────────┘
        │
        ▼
   【用户选择分镜方案】
        │
        ▼
┌───────────────────────────────────────┐
│  Step 3: 生成完整分镜 JSON           │
│  景别/运镜/情绪基调/视觉描述          │
└───────────────────────────────────────┘
        │
        ▼
   【用户逐镜确认】 ← 内容无误后进入制作
        │
        ▼
┌───────────────────────────────────────┐
│  Step 4: generate_shot_images.py      │
│  MiniMax image-01 文生图              │
│  帧①无参考 → 帧②-N传入前帧URL        │
└───────────────────────────────────────┘
        │
        ▼
   【用户抽检】 ← 可检查生成图片质量
        │
        ▼
┌───────────────────────────────────────┐
│  Step 5: generate_shot_videos.py      │
│  MiniMax I2V 图生视频                │
│  MiniMax-Hailuo-2.3                  │
│  支持运镜指令嵌入                     │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│  Step 6: merge_videos.py             │
│  ffmpeg concat 合并                  │
│  + storyboard.txt 故事板             │
└───────────────────────────────────────┘
        │
        ▼
最终视频 (./output/final_story.mp4)
```

### ⚠️ 费用说明

| 操作 | 消耗 | 说明 |
|------|------|------|
| LLM 剧本生图 | ~500 tokens/次 | 用于扩写剧本 |
| T2I 文生图 | ~10~30 tokens/张 | 每个镜头1张图 |
| I2V 图生视频 | ~100 tokens/镜头 | 6秒视频 |
| 合并+烧录 | 免费 | ffmpeg本地执行 |

> ⚠️ **MiniMax 图生视频（I2V）和文生图（T2I）均需要充值 Token**，请确保账户余额充足。

---

## ⚠️ 费用说明

**MiniMax API 需要充值 Token 才能使用。**

| 功能 | 消耗 Token | 说明 |
|------|-----------|------|
| 文生图（T2I） | 是 | image-01 模型，生成图片消耗 |
| 图生视频（I2V） | 是 | MiniMax-Hailuo-2.3，6s/10s 视频消耗 |
| LLM 分镜 | 否 | 使用配额内的对话 Token |

> 💡 **建议**：开始制作视频前确保 MiniMax 账户余额充足。T2I + I2V 组合使用消耗较快。

---

## 快速开始

### 环境配置

```bash
export MINIMAX_API_KEY="***"    # 必需
```

### 一键运行

```bash
cd ~/.hermes/skills/story-video-skill

python scripts/pipeline.py "你的故事大纲..."
```

### 分步运行

```bash
# Step 1: 故事 → 专业分镜
python scripts/story_to_shots.py "故事..." -o shots.json

# Step 2: 分镜 → 图片
python scripts/generate_shot_images.py -i shots.json -o images.json

# Step 3: 图片 → 视频
python scripts/generate_shot_videos.py -i images.json -p minimax -d 6

# Step 4: 合并
python scripts/merge_videos.py -i videos/ -o final.mp4
```

---

## 输出示例

### 分镜输出

```json
{
  "story_title": "勇气的旅程",
  "structure": "三幕结构",
  "shots": [
    {
      "shot_number": 1,
      "type": "establishing",
      "shot_size": "WS",
      "camera_movement": "static",
      "duration_suggestion": 5,
      "description": "小兔子站在黑暗山洞入口，阳光被高山遮挡",
      "visual_description": "一只白色小兔子站在阴暗山洞入口处，远处山峦叠嶂，阳光从山峰缝隙中射入一束金光，超广角镜头，电影感，暖色调与冷色调对比",
      "character_mood": "犹豫但坚定",
      "scene_transition": "CUT_TO"
    }
  ]
}
```

---

## 技术规格

| 模块 | 技术选型 |
|------|---------|
| 文生图 | MiniMax `image-01`，prompt ≤ 1500字符 |
| 图生视频 | MiniMax `MiniMax-Hailuo-2.3`，6s/10s，768P/1080P |
| 运镜控制 | 15种标准指令，支持组合与顺序 |
| 视频合并 | ffmpeg concat demuxer |
| LLM 分镜 | MiniMax `MiniMax-M2.7-highspeed` |

---

## 剧本格式规范参考

```
INT./EXT. 场景位置 - 时间
                        （场景描述）
人物正在做某事。

        角色名
    （动作）
对白内容...

                        CUT TO:
```

本 Pipeline 输出的分镜 JSON 可直接对接标准剧本格式，亦可作为 AI 视频生成的专业 Prompt 依据。

---

## 目录结构

```
story-video-skill/
├── SKILL.md                         # 技能定义
├── README.md                        # 本文件
├── scripts/
│   ├── pipeline.py                  # 主流水线
│   ├── story_to_shots.py            # 编剧+导演引擎
│   ├── generate_shot_images.py      # MiniMax 文生图
│   ├── generate_shot_videos.py      # MiniMax 图生视频
│   └── merge_videos.py             # ffmpeg 合并
└── references/
    ├── minimax-api/                 # MiniMax API 文档
    ├── screenwriting/               # 编剧理论体系
    └── director-knowledge/          # 导演知识体系
```


path: references/director-knowledge/cinematography-glossary.txt
content: CINEMATOGRAPHY GLOSSARY
========================

SHOT TYPES (by distance):
- Extreme Wide Shot (EWS): Vast landscape, subject barely visible
- Wide Shot (WS): Full figure in frame, head to toe
- Full Shot (FS): Entire figure with buffer space
- Medium Shot (MS): Waist to knee up
- Medium Close-Up (MCU): Chest/shoulders up
- Close-Up (CU): Face fills frame
- Extreme Close-Up (ECU): Single feature detail

CAMERA MOVEMENTS:
- Pan: Horizontal rotation on fixed axis
- Tilt: Vertical rotation on fixed axis
- Zoom: Lens focal length change (not physical movement)
- Dolly: Camera moves toward/away from subject
- Tracking/Track: Camera follows alongside subject
- Crane: Camera moves up/down via mechanical arm
- Handheld: Operated without stabilization
- Steadicam: Stabilized camera system for smooth movement
- Whip Pan: Very fast pan (blur effect)
- Pedestal: Camera moves up/down on pedestal (not tilt)

CAMERA ANGLES:
- Eye Level: Neutral perspective
- Low Angle: Camera looks up (powerful subject)
- High Angle: Camera looks down (vulnerable subject)
- Bird's Eye: Directly overhead
- Dutch Angle/Oblique: Tilted horizon

EDITING TERMS:
- Jump Cut: Abrupt cut within same shot
- Match Cut: Cut based on compositional similarity
- Cross-Cut: Alternating between concurrent storylines
- L-Cut: Audio continues after video cut
- J-Cut: Audio starts before corresponding video
- Cut on Action: Cut during movement to hide edit
- Parallel Editing: Cross-cutting for suspense
- Montage: Series of shots conveying information
- Smash Cut: Abrupt transition (often contrast)
- Dissolve: One shot fades into next
- Fade: Image fades to black or from black

LIGHTING TERMS:
- Key Light: Main light source
- Fill Light: Reduces shadows from key
- Back Light: Separates subject from background
- Three-Point Lighting: Key + fill + back
- High Key: Bright, minimal shadows (comedy)
- Low Key: Dark, high contrast (noir, thriller)
- Chiaroscuro: Strong light/dark contrast
- Motivated Lighting: Light sources within story
- Practical: Light sources visible in frame
- Diffusion: Softening light source
- Gel: Colored filter on light

COMPOSITION TERMS:
- Rule of Thirds: Divide frame into 3x3 grid
- Headroom: Space above character's head
- Look Room/Space: Space in direction character looks
- Lead Room: Space in direction of movement
- Foreground/Background: Depth layers
- Deep Focus: Everything in focus (foreground to background)
- Shallow Depth of Field: Blurred background
- Bokeh: Out-of-focus highlights
- Aspect Ratio: Frame width to height proportion

GENRE-SPECIFIC CONVENTIONS:
- Film Noir: Low key, shadows, venetian blind shadows
- Western: Vast landscapes, low angles against sky
- Horror: Point-of-view monster, darkness, dutch angles
- Musical: Wide shots for choreography, integration of music
- War: Handheld, desaturated, extreme wide establishing
- Sci-Fi: Clean minimalist, blue/teal grading, HUD elements


path: references/director-knowledge/directing-fundamentals.txt
content: # 导演知识：场面调度与视觉叙事

## 一、场面调度（Mise-en-scène）

### 四个要素
1. **角色位置（Blocking）**：演员在场景中的位置和移动
2. **景框构图（Framing）**：摄影机位置决定观众看到什么
3. **美术设计（Art Direction）**：布景、服装、道具
4. **灯光照明（Lighting）**：明暗对比、阴影、主光

---

## 二、构图原则

### 三分法（Rule of Thirds）
- 将画面横竖各分三等份
- 主体放在交叉点或线上
- 避免将主体放在正中央

### 前景/后景（Foreground/Background）
- 利用前景元素增加画面深度
- 避免画面过于平面

### 留白（Negative Space）
- 给主体运动方向留白
- 营造情绪（孤独、压迫等）

---

## 三、视觉叙事语言

### 人物关系可视化
| 关系 | 构图方式 |
|------|----------|
| 对立/敌对 | 两人分居画面两侧，中间有障碍 |
| 亲密/联盟 | 两人靠近，或置于同一画框 |
| 孤立/孤独 | 人物单独置于大面积空白中 |
| 依赖/服从 | 一人处于低角度，另一人俯视 |

### 情绪视觉化
| 情绪 | 视觉手段 |
|------|----------|
| 快乐 | 明亮色调，宽画幅，正面角度 |
| 悲伤 | 暗色调，冷色，俯拍，低角度 |
| 紧张 | 倾斜构图，手持摄影，快速剪辑 |
| 神秘 | 阴影，逆光，窄光 |
| 力量 | 低角度，仰拍，深色服装 |

---

## 四、短视频/竖屏导演要点

### 竖屏特点
- 画面高度 > 宽度
- 适合1-2个主体
- 上下区域适合文字/UI
- 视线集中在中下2/3区域

### 竖屏构图
- 主体居中或略偏上（留足头部空间）
- 避免过多横向元素
- 利用纵向深度（前景+后景）

### 快速节奏
- 短视频：1-3秒一切换
- 信息密度高
- 前3秒必须有"钩子"

---

## 五、故事板基础

### 故事板要素
```
┌─────────────────┐
│  Shot 01: MS     │
│  [简笔画或描述]   │
│  角色进入教室     │
│  固定镜头         │
├─────────────────┤
│  Duration: 3s   │
│  Angle: Eye Lev │
│  Movement: None  │
└─────────────────┘
```

### 从剧本到分镜的转化
1. 识别场景目标（这个场景要传达什么）
2. 确定镜头数（根据信息量和节奏）
3. 分配景别（建立→细节→动作）
4. 设计运镜（静态vs动态）
5. 写出视觉描述

---

## 六、视觉连贯性

### 角色一致性
- AI生成多帧时，角色外观可能漂移
- 解决：使用参考图（previous_image）锁定特征
- 服装颜色/款式保持一致描述

### 场景连贯性
- 每次生成明确描述背景元素
- 使用"保持与上一帧相同背景"等提示
- 同一场景的镜头保持色调一致

### 动作连贯性
- AI视频在动作连续性上有局限
- 建议：单个镜头内避免复杂动作
- 复杂动作分多镜头呈现

---

## 七、实用技巧

### AI生成提示词构建
```
[主体] + [动作] + [场景] + [光线] + [色调] + [视角] + [风格]
示例：一位穿红色外套的少女站在清晨的森林中，阳光从树叶间洒落，暖色调，平视角度，电影感
```

### 运镜指令组合
- 开头镜头：`[固定]` 建立场景
- 动作镜头：`[跟随]` 或 `[推进]`
- 结尾镜头：`[拉远]` 收束画面
- 紧张场景：`[左摇,上升]` 增加不安感

### 避免的问题
1. 过多角色（AI容易混淆）
2. 快速复杂动作（AI难以准确生成）
3. 文字/字幕（AI生成文字不可靠）
4. 中文环境（英文提示词效果更好）


path: references/director-knowledge/establishing_shot.txt
content: ESTABLISHING SHOT - DEFINITION AND USAGE
=========================================

DEFINITION:
An establishing shot sets up a scene by showing the location and context before the main action. 
It typically appears at the beginning of a scene to orient the audience.

PURPOSE:
- Orient the audience to a new location
- Establish setting (urban, rural, interior, exterior)
- Set mood and tone for the scene
- Establish spatial relationships between locations
- Signal transition between scenes

TYPES OF ESTABLISHING SHOTS:

1. LOCATION ESTABLISHING SHOT
   - Shows general surroundings
   - Could be a city skyline, building exterior, or room overview
   - Often a wide or extreme wide shot

2. AERIAL SHOTS
   - Drone or helicopter shots
   - Sweeping vistas that set grand scale
   - Often used in film openings

3. CITYSCAPE/TOWNSCAPE
   - Urban environments
   - Shows time period and setting
   - Dawn/dusk for mood

4. INTERIOR ESTABLISHING SHOT
   - Shows room layout before tighter shots
   - Establishes where characters will interact
   - Helps audience understand spatial relationships

5. JUMP CUT ESTABLISHING
   - Quick series of shots showing key elements
   - Modern alternative to single establishing shot
   - Often used in action sequences

CONVENTIONS BY GENRE:

ACTION FILMS:
- Fast cuts between locations
- Dramatic aerial shots
- Maps/locations overlaid on footage

DRAMA:
- Slower, contemplative establishing shots
- Weather and lighting establish mood
- Extended shots before characters enter

COMEDY:
- Quick establishment, often with visual gags
- Exaggerated or unusual angles

HORROR:
- Isolated locations
- Ominous framing
- Slow pushes toward buildings/locations

TECHNICAL NOTES:
- Typically a wide shot or extreme wide shot
- May hold for 3-8 seconds
- Often uses natural lighting to establish time of day
- May include establishing sound before dialogue begins


path: references/director-knowledge/film_directing_terminology.txt
content: FILM DIRECTING TERMINOLOGY
==========================

SHOT TYPES (by distance from subject):

1. EXTREME WIDE SHOT (EWS) / EXTREME LONG SHOT (ELS)
   - Shows vast landscape or crowd scene
   - Establishes setting and scale
   - Character often barely visible or absent

2. WIDE SHOT (WS) / LONG SHOT (LS)
   - Shows full subject from head to toe
   - Establishes location and subject together
   - Common for action and dance sequences

3. FULL SHOT (FS)
   - Entire figure in frame, slight buffer space
   - Shows body language and movement
   - Allows for action within frame

4. MEDIUM SHOT (MS)
   - Waist up or knee up
   - Good for dialogue and character interaction
   - Balance of expression and environment

5. MEDIUM CLOSE-UP (MCU)
   - Chest/shoulders up
   - Slightly more intimate than medium shot
   - Common for conversations

6. CLOSE-UP (CU)
   - Face fills frame
   - Shows emotion, detail, importance
   - Often used for key dramatic moments

7. EXTREME CLOSE-UP (ECU/XCU)
   - Single feature (eye, lips, hand)
   - Maximum emotional intensity
   - Reveals detail or creates tension

CAMERA MOVEMENTS:

1. PAN (Panorama)
   - Horizontal rotation on fixed axis
   - Sweeps across environment
   - Follows moving subject

2. TILT
   - Vertical rotation on fixed axis
   - Up/down movement
   - Can reveal subjects or information

3. ZOOM
   - Lens focal length changes
   - Subject appears closer/farther
   - Note: Different from moving camera closer

4. DOLLY SHOT
   - Camera moves toward or away from subject
   - Creates depth and focus changes
   - Emotional: approaching = intimacy, retreating = isolation

5. TRACKING SHOT / TRACK SHOT
   - Camera follows alongside subject
   - Shows movement through space
   - Often on rails or with dolly

6. CRANE SHOT
   - Camera moves up or down via crane
   - Sweeping vertical movement
   - Often dramatic reveals

7. HANDHELD
   - Camera operated without stabilization
   - Creates gritty, realistic feel
   - Often used in documentary/horror/action

8. STEADICAM
   - Stabilized camera system
   - Smooth movement while walking/running
   - Allows tracking shots without dolly track

CAMERA ANGLES:

1. EYE LEVEL
   - Neutral, balanced perspective
   - Standard for dialogue and narrative

2. LOW ANGLE
   - Camera looks up at subject
   - Makes subject appear powerful/dominant
   - Often used for heroes or authority figures

3. HIGH ANGLE
   - Camera looks down at subject
   - Makes subject appear weak/vulnerable
   - Often used for villains or defeated characters

4. BIRD'S EYE / OVERHEAD
   - Camera directly above
   - Creates abstract, voyeuristic feel
   - Shows patterns and relationships

5. DUTCH ANGLE / OBLIQUE
   - Tilted horizon
   - Creates unease, disorientation
   - Common in thriller/horror

DIRECTING COMMANDS:
- "Action!" - Begin scene
- "Cut!" - Stop recording/performance
- "Roll cameras" - Start camera
- "Marker" - Slate information
- "Hold" - Wait, maintain position
- "Picture up" - Fill frame with subject
- "Background" - Note to background actors
- "Copy" - Acknowledgment

SCREENPLAY FORMAT:
- FADE IN:
- INTERIOR/EXTERIOR
- LOCATION - TIME
- Action description (present tense)
- CHARACTER NAME (dialogue)
- (parenthetical) - acting direction
- FADE OUT / CUT TO:


path: references/director-knowledge/film_editing.txt
content: FILM EDITING - CONTINUITY EDITING
==================================

DEFINITION:
Film editing is the process of selecting and combining shots into a coherent narrative. 
Continuity editing specifically aims to maintain smooth, logical flow that doesn't draw 
attention to itself.

THE 180-DEGREE RULE:
- Imaginary line between characters/objects in a scene
- Camera stays on one side to maintain spatial consistency
- Crossing the line confuses audience about positions

THE 30-DEGREE RULE:
- Camera angle should change by at least 30 degrees between shots
- Prevents jump cuts (abrupt, jarring cuts)
- Creates smooth visual flow

MATCHING ON ACTION:
- Cut occurs during movement
- Hand reaches for door, next shot shows door opening
- Hides the cut, makes transition seamless

EYE-LINE MATCH:
- Character looks at something off-screen
- Next shot shows what they're looking at
- Creates cause-and-effect relationship

SHOT REVERSE SHOT:
- Alternating shots of two characters in conversation
- Each character on opposite sides of frame
- Cut at natural pauses in dialogue

JUMP CUTS:
- Abrupt cut between similar shots
- Removes time, creates unease
- Sometimes used for stylistic effect (e.g., French New Wave)

CROSS-CUTTING (PARALLEL EDITING):
- Alternating between two concurrent storylines
- Creates tension (e.g., rescue race against time)
-montage sequences

MONTAGE:
- Series of short shots combined to convey information
- Compresses time (training sequences, travel)
- Soviet montage theory: editing creates meaning

L-CUTS AND J-CUTS:
- L-cut: Audio continues while video cuts
- J-cut: Audio starts before corresponding video
- Creates smoother audio/video integration

RULE OF SIX:
1. Screen direction must be consistent
2. maintain spatial relationships
3. Maintain temporal continuity
4. Match eyelines properly
5. maintain lighting continuity
6. Maintain sound continuity

MATCH CUT:
- Cut based on compositional similarity
- Object in one shot matches position in next
- Creates visual rhyme/irony

CONTINUITY ERRORS:
- "Cheerleader effect" - errors audiences often miss
- Wine glass moves between shots
- Objects appear/disappear
- Position changes without explanation

CONTEMPORARY TECHNIQUES:
- Quick cutting for fast-paced action
- Long takes with minimal cuts
- found footage style (intentionally rough)
- Digital effects integration with live action


path: references/director-knowledge/film_genre_conventions.txt
content: FILM GENRE CONVENTIONS - SHOT PATTERNS BY GENRE
===============================================

ACTION FILM:
- Quick cuts between medium and close-up
- shaky cam for intensity (though often critiqued)
- Wide establishing shots before action beats
- Point-of-view shots during chases
- Slow motion for key impacts (bullet time, explosions)
- Cross-cutting between parallel action sequences
- Reaction shots after major moments

DRAMA:
- Longer takes, minimal cutting
- Medium shots for dialogue
- Close-ups for emotional beats
- Eye-level camera for empathy
- Natural lighting or motivated sources
- fluid camera movement following characters

HORROR:
- Slow pushes toward subjects (builds dread)
- Dutch angles for disorientation
- Point-of-view monster shots
- Darkness and shadow (limited visibility)
- quick cuts for jump scares
- Cluttered frames (visually busy, hard to see threat)
- found footage style (first person confusion)
- Close-ups on frightened faces

THRILLER:
- Tense close-ups during interrogation/revelation
- Cross-cutting for suspense (ticking clock)
- Long takes for sustained tension
- Restricted view (what character can't see, neither can audience)
- Over-the-shoulder shots for conspiracy feeling
- Dark interiors, limited lighting

ROMANTIC COMEDY:
- Soft focus for romance
- medium shots, warm color palette
- Tracking shots for meet-cutes
- Quick cuts for comedic timing
- Wider shots for comedic setups
- Match cuts on shared characteristics

SCIENCE FICTION:
- Extreme wide shots establishing alien/sci-fi worlds
- Clean, minimalist production design
- Blue/teal color grading common
- CGI integration with practical elements
- HUD overlays and futuristic interfaces
- Hero shots (dramatic subject introduction)

WESTERN:
- Vast landscape establishing shots
- Low camera angle (subjects against big sky)
- Foreboding compositions (threat in distance)
- High contrast black and white (classic era)
- Dust, weather as environmental storytelling
- Two-shot confrontations

FILM NOIR:
- Low key lighting, deep shadows
- Venetian blind shadows
- Rain and wet streets
- Voiceover narration
- Smoke and haze for atmosphere
-不对称 compositions (morally ambiguous world)

COMEDY:
- Two-shot setups for reaction
- Over-the-shoulder for conversation
- Wide shots for physical comedy space
- Quick cuts for visual punchlines
- Medium shots for character-based comedy
- Slow motion for emphasis (reaction shots)

WAR FILM:
- Extreme wide shots establishing battlefield
- Handheld for combat immediacy
- Close-ups of soldier faces
- Cross-cutting between battle and home front
- Desaturated color (gritty realism)
- High angle "god's eye" battle overview shots

MUSICAL:
- Choreographed movement in wide shots
- Integration of music and visual movement
- Jump cuts to close-ups for solo performances
- Dream/fantasy sequences in contrasting style
- Production numbers as emotional expression


path: references/director-knowledge/mise_en_scene.txt
content: MISE-EN-SCÈNE IN CINEMATOGRAPHY
===============================

DEFINITION:
Mise-en-scène (French: "putting on stage") encompasses everything in front of the camera:
set design, lighting, costumes, makeup, actor positioning, and movement. It creates the 
visual world of the film.

THE FOUR MAJOR ELEMENTS:

1. SETTING AND PROPS
   - Location design and construction
   - Furniture, objects, decorations
   - Environmental storytelling (what's present/absent)
   - Period-accurate or deliberately anachronistic

2. COSTUME AND MAKEUP
   - Character identity and development
   - Social status, profession, personality
   - Visual continuity (wardrobe changes)
   - Visual symbolism through clothing

3. LIGHTING
   - Mood and atmosphere creation
   - Three-point lighting: key, fill, back
   - High key vs. low key
   - Chiaroscuro (strong contrast)
   - Natural vs. artificial sources

4. ACTOR POSITIONING (BLOCKING)
   - Where actors stand relative to camera
   - Spacing between characters (proxemics)
   - Movement through space
   - relationship to set elements

ADDITIONAL ELEMENTS:

5. FRAMING / COMPOSITION
   - Rule of thirds
   - Headroom and look space
   - Lead room (space in direction of movement)
   - Foreground and background elements

6. COLOR PALETTE
   - Production design color choices
   - Costume color coordination
   - Psychological associations
   - Genre and mood signaling

MISE-EN-SCÈNE AND GENRE:

FILM NOIR:
- Low key lighting, deep shadows
- Urban locations, rain, neon
- Moral ambiguity in costume (suits, femme fatale dresses)

HORROR:
- Claustrophobic framing
- Dim lighting with sudden bright moments
- domestic spaces made threatening

ROMANTIC COMEDY:
- Bright, warm color palette
- Open, airy spaces
- Costumes signal personality

TECHNICAL REALISM VS. EXPRESSIONISM:
- Realism: Natural lighting, hand-held camera, location shooting
- Expressionism: Artificial colors, dramatic lighting, studio sets

COMPOSITIONAL PRINCIPLES:
- Depth: Foreground, midground, background
- Balance: Symmetrical vs. asymmetrical
- lines: Leading lines draw eye
- Patterns: Repetition creates visual interest
- Contrast: Size, color, light/dark relationships

CHOREOGRAPHY OF ACTORS:
- Blocking patterns create visual designs
- Actors as compositional elements
- Group positioning for dialogue vs. solo moments
- distance communicates relationship (intimate vs. distant)


path: references/director-knowledge/shot_reverse_shot.txt
content: SHOT REVERSE SHOT - DEFINITION AND USAGE
========================================

DEFINITION:
Shot reverse shot is a film editing technique where two characters are shown in conversation, 
with the camera alternating between them. The characters are typically positioned on opposite 
sides of the line of action (an invisible line between them).

KEY CHARACTERISTICS:
- Two-shot: Both characters shown together, establishing their relationship
- Shot/Reverse shot pattern: Character A speaks, then Character B responds
- The cut occurs at natural pauses in dialogue
- Characters always face opposite directions within the frame

THE 180-DEGREE RULE:
The line of action (also called the 180-degree line) is an imaginary line drawn between 
two characters in a scene. The camera stays on one side of this line to maintain:
- Spatial consistency (Character A always left, Character B right)
- Screen direction (characters face each other properly)
- Audience orientation

BREAKING THE RULE:
Directors sometimes cross the line deliberately for effect:
- Sudden cuts can indicate confusion, conflict, or disorientation
- Creates unease or dramatic tension
- Often used in thriller/horror genres

POINT-OF-VIEW (POV) SHOTS:
Variation where we see what a character sees
- Character looks off-screen, then we see their perspective
- Used to maintain continuity while showing reactions

EMOTIONAL IMPACT:
- Creates conversational rhythm and flow
- Builds tension through shot duration
- Eye-line match: Character looks off-screen, next shot shows what they see
- Helps audience connect with both speakers equally

NOTABLE EXAMPLES:
- Classic Hollywood dialogue scenes
- Romantic comedies (two-person conversations)
- Interview scenes
- Confrontation scenes


path: references/director-knowledge/visual-storytelling-principles.txt
content: VISUAL STORYTELLING PRINCIPLES
===============================

HOW DIRECTORS USE VISUALS TO TELL STORIES:
==========================================

1. VISUAL NARRATIVE
- Everything in frame should serve the story
- Each shot should advance plot, reveal character, or establish mood
- Silent films established this through title cards and intertitles
- Modern cinema relies more on pure visual storytelling

2. THE DECISIVE MOMENT
- Directors choose exact moment to cut
- What you show when determines meaning
- What you don't show creates mystery or implies off-screen action

3. SUBJECTIVE VS OBJECTIVE CAMERA
- Subjective: Audience sees through character's eyes
- Objective: Observational, detached perspective
- First-person creates identification, third-person creates analysis

4. CONTINUITY AND DISCONTINUITY
- Continuity editing: Seamless, invisible cuts maintain reality
- Discontinuity: Cuts draw attention, fragment reality
- Both are tools with different storytelling purposes

EMOTIONAL IMPACT OF SHOT COMPOSITION:
====================================

1. CLOSER IS MORE INTIMATE/INTENSE
- Close-ups create emotional connection
- Wide shots create distance, objectivity
- Moving closer (dolly in) increases emotional stakes

2. SPACE COMMUNICATES RELATIONSHIP
- Actors close together = intimacy, conflict, tension
- Actors far apart = emotional distance
- Actors at different heights = power dynamics

3. DIRECTION OF GAZE
- Characters looking same direction = shared focus, alliance
- Characters looking opposite = confrontation
- Character looking off-frame = curiosity, worry, anticipation

4. FRAMING AS MENTAL STATE
- Cluttered frame = confusion, overwhelm
- Empty frame = isolation, loneliness, peace
- Tight frame = claustrophobia, pressure
- Open frame = freedom, possibility

5. MOVEMENT DIRECTION
- Left to right = forward progress, natural reading direction
- Right to left = resistance, going against flow
- Movement toward camera = approach, threat, intimacy
- Movement away = retreat, rejection

COLOR THEORY IN FILM:
=====================

1. COLOR EMOTIONS (general associations):
- Red: Passion, danger, aggression, warmth
- Blue: Cold, sadness, calm, technology, isolation
- Yellow: Happiness, warning, energy, instability
- Green: Nature, growth, jealousy, sickness
- Orange: Warmth, comfort, autumn
- Purple: Luxury, mystery, spirituality, corruption
- White: Purity, cleanliness, death (East), clinical
- Black: Death, formality, power, mystery, evil

2. COLOR PALETTES BY GENRE:
- Film Noir: High contrast black and white, selective color
- Western: Earth tones, warm browns and oranges
- Science Fiction: Blue, teal, cold metallic
- Horror: Desaturated, dark greens and blues
- Romantic Comedy: Warm, saturated, oranges and pinks
- War: Desaturated, muddy, green/brown

3. COLOR AS CHARACTER:
- Protagonist often in warm tones
- Antagonist often in cool or dark tones
- Color changes with character development
- Wardrobe color reflects internal state

4. TECHNICAL APPLICATIONS:
- Color grading: Digital color manipulation in post
- Color timing: Analog color correction (film era)
- Complementary colors: Opposite on color wheel (creates contrast)
- Analogous colors: Adjacent on wheel (creates harmony)

GENRE-SPECIFIC VISUAL LANGUAGES:
================================

1. WESTERN
- Monumental landscapes, vast sky
- Low camera angles
- Sun as natural light source
- Dust, weather, open space
- Black and white era: high contrast

2. FILM NOIR
- Urban night settings
- Rain, shadows, venetian blind patterns
- Smoke and haze
- High contrast lighting
- Asymmetrical compositions
- Voiceover narration

3. HORROR
- Limited visibility (darkness, fog)
- Point-of-view monster shots
- Dutch angles for disorientation
- Close-ups on terrified faces
- Slow pushes to build dread
- Quick cuts for shock

4. SCIENCE FICTION
- Alien or futuristic environments
- Clean, minimal design
- Special effects integration
- Blue/teal color grading
- HUD and interface graphics
- Wide establishing shots of new worlds

5. ROMANTIC COMEDY
- Bright, warm lighting
- Soft focus on couples
- Tracking shots for meet-cutes
- Dream/fantasy sequences
- Pastel color palette
- Open, airy spaces

6. ACTION
- Quick cutting
- Shaky cam (though critiqued)
- Wide establishing before action
- Slow motion on impacts
- Cross-cutting parallel action
- Point-of-view shots in chases

7. THRILLER
- Sustained tension through long takes
- Restricted information (what characters can't see)
- Dark interiors, low lighting
- Close-ups during interrogation/revelation
- Cross-cutting for suspense
- Over-the-shoulder shots

PRINCIPLES OF VISUALrhythm:
- Shot length affects pacing
- Variety in shot types keeps interest
- Repetition of shots creates pattern (can be broken for effect)
- Rhythm matches music and dialogue pacing
- Visual beats should align with emotional beats


path: references/minimax-api/api-overview.txt
content: # 接口概览 - MiniMax 开放平台文档中心

接口概览 - MiniMax 开放平台文档中心
🎉 MiniMax-M2.7 全新发布！订阅 Token Plan，尽享全模态模型。 立即体验  ➔
MiniMax 开放平台文档中心 home page
Anthropic API 兼容（推荐）
OpenAI API 兼容
文本对话（Anthropic API 兼容）
文本对话（OpenAI API 兼容）
获取 API Key
同步语音合成（T2A）
异步长文本语音生成（T2A Async）
音色快速复刻（Voice Cloning）
音色设计（Voice Design）
视频生成（Video Generation）
图像生成（Image Generation）
音乐生成 (Music Generation)
文件管理 (File)
按量付费 ：通过  接口密钥 > 创建新的 API Key ，获取  API Key
按量付费支持使用所有模态模型，包括文本、视频、语音、图像等
Token Plan ：通过  接口密钥 > 创建 Token Plan Key ，获取  API Key
Token Plan 支持使用 MiniMax 全模态模型，详情见  Token Plan 概要
文本生成接口使用  MiniMax M2.7 ， MiniMax M2.7-highspeed ， MiniMax M2.5 ， MiniMax M2.5-highspeed ， MiniMax M2.1 ， MiniMax M2.1-highspeed ， MiniMax M2  根据输入的上下文，让模型生成对话内容、工具调用。
可通过  HTTP  请求、 Anthropic SDK （推荐） 或  OpenAI SDK  接入。
模型名称  输入输出总 token  模型介绍
开启模型的自我迭代 (输出速度约60tps)
M2.7 极速版：效果不变，更快，更敏捷  (输出速度约100tps)
顶尖性能与极致性价比，轻松驾驭复杂任务 (输出速度约60tps)
M2.5 极速版：效果不变，更快，更敏捷  (输出速度约100tps)
强大多语言编程能力，全面升级编程体验 (输出速度约60tps)
M2.1 极速版：效果不变，更快，更敏捷  (输出速度约100tps)
专为高效编码与Agent工作流而生
如果在使用模型过程中遇到任何问题：
通过邮箱  [REDACTED_SECRET]  等官方渠道联系我们的技术支持团队
在我们的  Github  仓库提交Issue
通过 Anthropic SDK 调用 MiniMax 模型
通过 OpenAI SDK 调用 MiniMax 模型
本接口支持基于文本到语音的同步生成，单次可处理最长  10,000 字符  的文本。
接口本身为无状态接口，即单次调用时，模型仅处理单次传入内容，不涉及业务逻辑，同时模型也不存储您传入的数据。 该接口支持以下功能：
支持 300+ 系统音色、复刻音色自主选择；
支持音量、语调、语速、输出格式调整；
支持按比例混音功能；
支持固定间隔时间控制；
支持多种音频规格、格式，包括：mp3, pcm, flac, wav。注： wav 仅在非流式输出下支持 ；
以下为 MiniMax 提供的语音模型及其特性说明。
最新的 HD 模型，情绪渲染融合语气词，重塑自然听感
最新的 Turbo 模型，极致生成速度，更自然逼真的音频效果
HD 模型，韵律表现出色，极致音质与韵律表现，生成更快更自然
Turbo 模型，音质优异，超低时延，响应更灵敏
拥有出色的韵律、稳定性和复刻相似度，音质表现突出
拥有出色的韵律和稳定性，小语种能力加强，性能表现出色
同步语音合成功能，共包含 2 个接口，可根据需求，选择使用。
HTTP 同步语音合成
WebSocket 同步语音合成
MiniMax 的语音合成模型具备卓越的跨语言能力，全面支持 40 种全球广泛使用的语言。我们致力于打破语言壁垒，构建真正意义上的全球通用人工智能模型。
目前支持的语言包含：
1. 中文（Chinese）
15. 土耳其语（Turkish）
28. 马来语（Malay）
2. 粤语（Cantonese）
16. 荷兰语（Dutch）
29. 波斯语（Persian）
3. 英语（English）
17. 乌克兰语（Ukrainian）
30. 斯洛伐克语（Slovak）
4. 西班牙语（Spanish）
18. 泰语（Thai）
31. 瑞典语（Swedish）
5. 法语（French）
19. 波兰语（Polish）
32. 克罗地亚语（Croatian）
6. 俄语（Russian）
20. 罗马尼亚语（Romanian）
33. 菲律宾语（Filipino）
7. 德语（German）
21. 希腊语（Greek）
34. 匈牙利语（Hungarian）
8. 葡萄牙语（Portuguese）
22. 捷克语（Czech）
35. 挪威语（Norwegian）
9. 阿拉伯语（Arabic）
23. 芬兰语（Finnish）
36. 斯洛文尼亚语（Slovenian）
10. 意大利语（Italian）
24. 印地语（Hindi）
37. 加泰罗尼亚语（Catalan）
11. 日语（Japanese）
25. 保加利亚语（Bulgarian）
38. 尼诺斯克语（Nynorsk）
12. 韩语（Korean）
26. 丹麦语（Danish）
39. 泰米尔语（Tamil）
13. 印尼语（Indonesian）
27. 希伯来语（Hebrew）
40. 阿非利卡语（Afrikaans）
14. 越南语（Vietnamese）
通过 HTTP 请求进行语音合成
通过 WebSocket 进行流式语音合成
该 API 支持基于文本到语音的异步生成，单次文本生成传输最大支持  100 万字符 ，生成的完整音频结果支持异步的方式进行检索。
该接口支持以下功能：
支持 100+系统音色、复刻音色自主选择；
支持语调、语速、音量、比特率、采样率、输出格式自主调整；
支持音频时长、音频大小等返回参数；
支持时间戳（字幕）返回，精确到句；
支持直接传入字符串与上传文本文件 file_id 两种方式进行待合成文本的输入；
支持非法字符检测：非法字符不超过 10%（包含 10%），音频会正常生成并返回非法字符占比；非法字符超过 10%，接口不返回结果（返回报错码），请检测后再次进行请求【非法字符定义：ascii 码中的控制符（不含制表符和换行符）】。
提交长文本语音合成请求后，会生成 file_id，生成任务完成后，可通过 file_id 使用文件检索接口进行下载。
⚠️ 注意：返回的 url 的有效期为：自 url 返回开始的  9 个小时 （即 32400 秒），超过有效期后 url 便会失效，生成的信息便会丢失，请注意下载信息的时间。
适用场景：整本书籍等长文本的语音生成。
最新的 Turbo 模型，情绪渲染融合语气词，重塑自然听感
整体包含 2 个 API：创建 语音生成任务 、 查询语音生成任务状态 。使用步骤如下：
创建语音生成任务得到 task_id（如果选择以 file_id 的形式传入待合成文本，需要前置使用 File(Upload)接口进行文件上传）；
基于 taskid 查询语音生成任务状态；
如果发现任务生成成功，那么可以使用本接口返回的 file_id 通过 File API 进行结果查看和下载。
创建长文本语音生成任务
查询语音生成任务状态
本接口支持基于用户上传需要复刻音频的音频，以及示例音频，进行音色的复刻。
使用本接口需要完成个人认证及企业认证用户后，方可调用。 请在  账户管理 -> 账户信息  中，完成个人用户认证或企业用户认证，以确保可以正常使用本功能。
本接口适用场景：IP 音色复刻、音色克隆等需要快速复刻某一音色的相关场景。
本接口支持单、双声道复刻声音，支持按照指定音频文件快速复刻相同音色的语音。
上传待克隆音频  调用  上传复刻音频  上传待克隆的音频文件并获取  file_id 。
上传示例音频 (可选)  若需要提供示例音频以增强克隆效果，需要再次调用  上传示例音频  上传示例音频文件并获得对应的  file_id 。填写在 clone_prompt 中的 prompt_audio 中。
调用复刻接口  基于获取的  file_id  和自定义的  voice_id  作为输入参数，调用  快速复刻  克隆音色。
调用本接口进行音色克隆时，不会立即收取音色复刻费用。音色的复刻费用将在首次使用此复刻音色进行语音合成时收取（不包含本接口内的试听行为）。
本接口产出的快速复刻音色为临时音色，若希望永久保留某复刻音色，请于 168 小时（7 天）内在任意 T2A 语音合成接口中调用该音色（不包含本接口内的试听行为）。若超过时限，该音色将被删除。
接口采用无状态设计：每次调用仅处理传入数据，且不存储用户上传内容，不涉及任何业务逻辑状态。
上传待克隆的音频文件
该 API 支持基于用户输入的声音描述 prompt，生成个性化定制音色。
本接口支持使用生成的音色（voice_id）在 同步语音合成接口 和 异步长文本语音合成接口 中进行语音生成
推荐使用 speech-02-hd 以获得最佳效果
调用本接口获得音色时，不会立即收取生成音色的费用，生成音色的费用将在首次使用此音色进行语音合成时收取（不包含本接口内的试听行为）。
本接口产出的音色为临时音色，如您希望永久保留某音色，请于 168 小时（7 天）内在任意语音合成接口中调用该音色（不包含本接口内的试听行为），超过有效期未被使用的音色将自动删除。
基于描述生成个性化音色
本接口支持基于用户提供的文本、图片（包括首帧、尾帧、主体参考图）进行视频生成。
全新视频生成模型，肢体动作、物理表现与指令遵循能力全面升级
图生视频模型，生成速度大幅提升，以更高性价比兼顾画质与表现力
新一代视频生成模型，支持更高分辨率 (1080P) 和更长时长 (10s)，指令遵循能力更强
视频生成采用异步方式，整体包含 3 个 API： 创建视频生成任务 、 查询视频生成任务状态 、 文件管理 。使用步骤如下：
使用 创建视频生成任务 接口，创建视频生成任务，成功创建后会返回一个  task_id ；
使用 查询视频生成任务状态 接口，基于返回的 task_id 查询视频生成任务状态；当状态为成功时，将获得对应的文件 ID（file_id）；
使用 文件管理 接口，基于步骤 2 查询接口返回的 file_id 进行视频生成结果的查看和下载。
基于文本描述生成视频
本接口支持基于用户选择的不同视频 Agent 模板和输入来进行视频生成任务。
视频 Agent 接口采用异步方式，整体包含 2 个 API： 创建视频 Agent 任务  和  查询视频 Agent 任务状态 。使用步骤如下：
使用 创建视频 Agent 任务 接口，创建视频 Agent 任务，并得到 task_id；
使用 查询视频 Agent 任务状态 接口，基于 task_id 查询视频 Agent 任务状态；当状态为成功时，将获得对应的文件下载地址。
详细信息可以参考  视频 Agent 模板列表 ，查阅模板内容及示例效果。
模板 ID  模板名称  模板说明  media_inputs  text_inputs
上传你的图片，生成图中主体完成完美跳水动作的视频
上传宠物照片，生成图中主体完成完美吊环动作的视频
上传宠物图片并输入野兽种类，生成宠物野外绝地求生视频
万物皆可 labubu
上传人物/宠物照片，生成 labubu 换脸视频
上传爱宠照片，生成麦当劳宠物外卖员视频
上传面部参考图，生成藏族风视频写真
输入各类主角痛苦做某事，一键生成角色痛苦生活的小动画
上传照片生成冬日雪景写真
上传服装图片，生成女模特试穿广告
上传人脸照片生成四季写真
上传服装图片，生成男模特试穿广告
创建视频Agent任务
创建视频Agent生成任务
查询视频Agent任务状态
本接口支持基于用户提供的文本或参考图片，进行创意图像生成。支持设置不同图片比例和长宽像素设置，满足不同场景下图像需求。
通过创建图片生成任务接口，使用文本描述和参考图片，进行图像生成。
图像生成模型，画面表现细腻，支持文生图、图生图（人物主体参考）
图像生成模型，在 image-01 基础上额外支持多种画风设置
基于文本描述生成图像
基于参考图片生成图像
本接口根据歌曲描述（prompt）和歌词（lyrics），生成一首人声的歌曲。
模型名称  使用方法
最新音乐生成模型，支持用户输入音乐灵感和歌词，生成 AI 音乐
根据描述和歌词生成音乐
本接口是作为文件管理接口，配合 MiniMax 开放平台的其他接口使用。
本接口是作为文件管理接口，配合其他接口使用。共包含 5 个接口： 上传 、 列出 、 检索 、 下载 、 删除 。
限制内容  限制大小
获取已上传的文件列表
MiniMax 提供官方的  Python 版本  和  JavaScript 版本  模型上下文协议（MCP）服务器实现代码，支持语音合成、音色克隆、视频生成、音乐生成等功能，详细说明请参考  MiniMax MCP 使用指南
立即体验语音合成能力
立即体验音色快速复刻能力
此页面对您有帮助吗？
是        否

path: references/minimax-api/errorcode.txt
content: # 错误码查询 - MiniMax 开放平台文档中心

错误码查询 - MiniMax 开放平台文档中心
🎉 MiniMax-M2.7 全新发布！订阅 Token Plan，尽享全模态模型。 立即体验  ➔
MiniMax 开放平台文档中心 home page
Anthropic API 兼容（推荐）
OpenAI API 兼容
文本对话（Anthropic API 兼容）
文本对话（OpenAI API 兼容）
如需反馈问题，请提供 Header 中的 trace_id，以便我们为您排查。
错误码  含义  解决方法
未知错误/系统默认错误
未授权/Token 不匹配/Cookie 缺失
请检查 API Key
系统错误/下游服务错误
请调整 max_tokens
不可见字符比例超限/非法字符超过 10%
请检查输入内容，是否包含不可见字符或非法字符
ASR 相似度检查失败
请检查 file_id 与 text_validation 匹配度
克隆提示词相似度检查失败
请检查克隆提示音频和提示词
语音克隆样本或 voice_id 参数错误
请检查 Voice Cloning 接口下的 file_id 和 T2A v2，T2A Large v2 接口下的 voice_id 参数
语音时长不符合要求(太长或太短)
请检查 voice_clone file_id 文件时长，最少应不低于 10 秒，最长应不超过 5 分钟
用户语音克隆功能被禁用
使用语音克隆功能需要完成账户身份认证，请根据您的使用需求在账户系管理》账户信息中进行个人或企业认证
语音克隆 voice_id 重复
请修改 voice_id，确保未和已有 voice_id 重复
无权访问该 voice_id
请确认是否为该 voice_id 创建者
请避免请求骤增骤减情况
语音克隆提示音频太长
请调整 prompt_audio 音频文件时长（＜ 8s）
无效的 API Key
超出Token Plan资源限制
请等待下一个时间段资源释放后，再次尝试
如有其他疑问，请联系我们  [REDACTED_SECRET] 。
此页面对您有帮助吗？
是        否

path: references/minimax-api/image-generation-i2i.txt
content: # 图生图 - MiniMax 开放平台文档中心

图生图 - MiniMax 开放平台文档中心
🎉 MiniMax-M2.7 全新发布！订阅 Token Plan，尽享全模态模型。 立即体验  ➔
MiniMax 开放平台文档中心 home page
Anthropic API 兼容（推荐）
OpenAI API 兼容
文本对话（Anthropic API 兼容）
文本对话（OpenAI API 兼容）
https://api.minimaxi.com/v1/image_generation
HTTP: Bearer Auth
HTTP Authorization Scheme: Bearer API_key，用于验证账户信息，可在  账户管理>接口密钥  中查看。
请求体的媒介类型，请设置为   application/json
可用选项:   application/json
模型名称。可选值：  image-01
可用选项:   image-01 ,
图像的文本描述，最长 1500 字符
人物主体参考，用于图生图
画风设置，仅当   model
图像宽高比，默认为   1:1
可用选项:   1:1 ,
生成图片的宽度（像素）。仅当 model 为   image-01
生成图片的高度（像素）。仅当 model 为   image-01
返回图片的形式，默认为 url。可选值：url, base64。
⚠️ 注意：url 的有效期为 24 小时
可用选项:   url ,
随机种子。使用相同的 seed 和参数，可以生成内容相近的图片，用于复现结果。如未提供，算法会对 n 张图单独生成随机种子
单次请求生成的图片数量，取值范围[1, 9]，默认为 1
必填范围:  1 <= x <= 9
是否开启 prompt 自动优化，默认为   false
是否在生成的图片中添加水印，默认为   false
因内容安全检查失败而未返回的图片数量
生成任务的 ID，用于后续查询任务状态
此页面对您有帮助吗？
是        否

path: references/minimax-api/image-generation-t2i.txt
content: # 文生图 - MiniMax 开放平台文档中心

文生图 - MiniMax 开放平台文档中心
🎉 MiniMax-M2.7 全新发布！订阅 Token Plan，尽享全模态模型。 立即体验  ➔
MiniMax 开放平台文档中心 home page
Anthropic API 兼容（推荐）
OpenAI API 兼容
文本对话（Anthropic API 兼容）
文本对话（OpenAI API 兼容）
https://api.minimaxi.com/v1/image_generation
HTTP: Bearer Auth
HTTP Authorization Scheme: Bearer API_key，用于验证账户信息，可在  账户管理>接口密钥  中查看。
请求体的媒介类型，请设置为   application/json
可用选项:   application/json
模型名称。可选值：  image-01
可用选项:   image-01 ,
图像的文本描述，最长 1500 字符
画风设置，仅当   model
图像宽高比，默认为 1:1。可选值：
可用选项:   1:1 ,
生成图片的宽度（像素）。仅当 model 为   image-01
生成图片的高度（像素）。仅当 model 为   image-01
返回图片的形式，默认为 url。可选值：url, base64。
⚠️ 注意：url 的有效期为 24 小时
可用选项:   url ,
随机种子。使用相同的 seed 和参数，可以生成内容相近的图片，用于复现结果。如未提供，算法会对 n 张图单独生成随机种子
单次请求生成的图片数量，取值范围[1, 9]，默认为 1
必填范围:  1 <= x <= 9
是否开启 prompt 自动优化，默认为   false
是否在生成的图片中添加水印，默认为   false
因内容安全检查失败而未返回的图片数量
生成任务的 ID，用于后续查询任务状态
此页面对您有帮助吗？
是        否
查询视频Agent任务状态

path: references/minimax-api/models-intro.txt
content: # 概览

在此页面旗舰模型模型概览语音模型视频模型图片模型音乐模型推荐阅读开始使用概览复制页面MiniMax 模型体系涵盖文本、语音、视频、图像与音乐五大方向。旗舰模型性能领先，助力开发者高效构建智能应用。复制页面​旗舰模型 MiniMax M2.7开启模型的自我迭代Music2.6以声传情：翻唱入心，器乐入魂MiniMax Hailuo 2.3 & 2.3-Fast极致动态，入微传情MiniMax Speech 2.8自然语气词，逼真音色，通透音质MiniMax M2-her多角色沉浸扮演，驾驭长轮次复杂场景 ​模型概览 文本模型 模型名称            介绍                                                        MiniMax-M2.7开启模型的自我迭代MiniMax-M2.7-highspeed与 M2.7 效果不变，速度大幅提升MiniMax-M2.5顶尖性能与极致性价比，轻松驾驭复杂任务MiniMax-M2.5-highspeed与 M2.5 效果不变，速度大幅提升M2-her文本对话模型，专为角色扮演、多轮对话等场景设计 历史模型模型名称            介绍                                                        MiniMax-M2.1强大多语言编程能力，全面升级代码工程体验MiniMax-M2.1-highspeed与 M2.1 效果不变，速度大幅提升MiniMax-M2专为高效编码与Agent工作流而生 ​语音模型 模型名称            介绍                                                        Speech-2.8-HD新一代语音 HD 模型，情绪渲染融合语气词，重塑自然听感Speech-2.8-Turbo新一代语音 Turbo 模型，极致生成速度，更自然逼真的音频效果Speech-2.6-HD极致音质与韵律表现，生成更快更自然Speech-2.6-Turbo音质优异，超低时延，响应更灵敏Speech-02-HD语音 HD 模型，拥有出色的韵律和稳定性，复刻相似度和音质表现突出Speech-02-Turbo语音 Turbo 模型，小语种能力增强，性能表现出色 ​视频模型 模型名称            介绍MiniMax Hailuo 2.3全新视频生成模型，肢体动作、面部表情、物理表现与指令遵循再度突破MiniMax Hailuo 2.3 Fast全新图生视频模型，物理表现与指令遵循具佳，更快更优惠MiniMax Hailuo 02新一代视频生成模型，1080p 原生，SOTA 指令遵循，极致物理表现 ​图片模型 模型名称           介绍                                                        image-01图像生成模型，画面表现细腻，支持文生图、图生图image-01-live图像生成模型，手绘、卡通等画风增强，支持文生图并进行画风设置 ​音乐模型 模型名称           介绍                                                         music-2.6以声传情：翻唱入心，器乐入魂music-cover基于参考音频生成翻唱版本，支持一步翻唱和两步翻唱（可修改歌词），支持风格迁移和自动歌词提取 ​推荐阅读 快速开始可参考快速开始指南，体验MiniMax模型能力点击查看Anthropic API 兼容通过 Anthropic SDK 调用 MiniMax 模型点击查看此页面对您有帮助吗？是否前置准备⌘I

path: references/minimax-api/overview.txt
content: # 产品定价

产品定价概览复制页面MiniMax 自主研发了一系列包括文本、音频、图像、视频和音乐在内的多种模态，并提供不同的计价方案，满足不同使用场景下的用量需求复制页面MiniMax 致力于提供全面的 AI 能力，自主研发了覆盖文本、音频、图像、视频和音乐等多种模态模型。为了满足用户多样化的使用需求和用量考量，MiniMax 推出了灵活多样的计费方案。用户可以根据自身的使用场景和预算，从中选择最经济、最高效的付费模式，实现对 AI 能力的最佳利用。 Token PlanToken Plan 让您尽享 MiniMax 全模态模型的强大能力点击查看详情 语音资源包提供HD、 Turbo 两类语音模型资源包，满足不同场景需求点击查看详情 视频资源包提供视频生成资源包，支持全量视频生成模型点击查看详情 按量计费提供文本、语音、视频、音乐、图像能力下丰富API能力，支持按量计费点击查看详情此页面对您有帮助吗？是否语音资源包⌘I

path: references/minimax-api/quickstart-preparation.txt
content: # 快速开始

快速开始前置准备复制页面在开始使用 MiniMax API 之前，需要完成账户注册和 API Key 获取。复制页面1账户注册/登录API 调用前，需在 MiniMax 开放平台进行账户注册，企业团队注册请参考页面底部说明.2获取 API Key 按量付费：通过 接口密钥 > 创建新的 API Key，获取 API Key 按量付费支持使用所有模态模型，包括文本、视频、语音、图像等 Token Plan：通过 接口密钥 > 创建 Token Plan Key，获取 API Key Token Plan 支持使用 MiniMax 全模态模型，详情见 Token Plan 概要 生成 API Key 后，建议将其存储为环境变量或保存到 .env 文件中# 推荐使用 Anthropic API 兼容 export ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic export ANTHROPIC_API_KEY=${YOUR_API_KEY} 3账户充值通过 账户管理 > 余额，按需充值 企业团队注册说明建议采用主账号+子账号的形式创建和管理。 在 MiniMax 开放平台 注册一个账号（此账号即为主账号，注册时填写的姓名与手机号会成为本企业账号的管理员信息） 登录该主账号，在 账户管理 > 子账号，创建您所需要数量的子账户（子账号的创建数量暂时没有限制） 为您企业的人员，分配不同的子账户，进行登录使用 子账户权限说明： 子账号和主账号享用相同的使用权益与速率限制，子账号和主账号的 API 消耗共享，统一结算 子账号无查看和管理”支付”权限

path: references/screenwriting/shot-breakdown.txt
content: # 分镜头：Shot Breakdown 技术指南

## 一、镜头景别（Shot Size）

| 景别 | 英文 | 画框范围 | 叙事功能 |
|------|------|----------|----------|
| 极远景 | Extreme Wide Shot (EWS) | 全部环境，人如蚂蚁 | 建立场景，交代环境 |
| 远景 | Wide Shot / Long Shot (WS/LS) | 人占画幅1/2以下 | 展示人与环境关系 |
| 全景 | Full Shot (FS) | 人物全身 | 展示肢体语言，动作 |
| 中景 | Medium Shot (MS) | 膝盖以上 | 对话、动作混合 |
| 中近景 | Medium Close-Up (MCU) | 腰部/胸部以上 | 强调情绪 |
| 特写 | Close-Up (CU) | 脸部或物体局部 | 揭示细节、情绪 |
| 大特写 | Extreme Close-Up (ECU) | 眼睛、嘴唇等 | 极度聚焦，紧张感 |

---

## 二、镜头角度（Camera Angle）

| 角度 | 英文 | 效果 |
|------|------|------|
| 鸟瞰/俯拍 | Bird's Eye / High Angle | 脆弱、无力感，环境主导 |
| 俯拍 | Overhead / High Angle | 观众主导地位 |
| 水平 | Eye Level | 客观、中性 |
| 仰拍 | Low Angle / Dutch Angle | 强大、压迫感，权威 |
| 倾斜/德国式 | Dutch Tilt / Canting | 不稳定、紧张、焦虑 |
| 顶拍 | Top-Down | 特殊视角，手工感 |

---

## 三、运镜方式（Camera Movement）

| 运镜 | 英文 | 描述 |
|------|------|------|
| 推 | Dolly In / Push In | 靠近主体，增强紧张/聚焦 |
| 拉 | Dolly Out / Pull Out | 远离主体，释放/退后 |
| 摇 | Pan | 水平旋转（左右） |
| 倾斜 | Tilt | 垂直旋转（上下） |
| 跟踪 | Tracking / Truck | 跟随主体移动 |
| 升降 | Crane / Boom | 上下升降 |
| 手持 | Handheld | 不稳定感，真实感 |
| 稳定器 | Steadicam / Gimbal | 平滑跟踪 |
| 变焦 | Zoom | 改变焦距（非物理移动）|
| 旋转 | Roll | 镜头自身旋转 |

---

## 四、MiniMax 运镜指令（视频生成）

15种标准运镜指令：
```
[左移] [右移]           — 横向移动
[左摇] [右摇]           — 水平旋转
[推进] [拉远]           — 推拉镜头
[上升] [下降]           — 垂直升降
[上摇] [下摇]           — 垂直摇镜头
[变焦推近] [变焦拉远]   — 光学变焦
[晃动] [跟随] [固定]    — 其他效果
```

**组合规则**：`[左摇,上升]` 同时生效（建议≤3个）
**顺序规则**：前后出现的指令依次生效

---

## 五、镜头清单格式（Shot List Template）

```json
{
  "shot_number": 1,
  "scene_number": "1A",
  "shot_type": "MS",
  "camera_angle": "eye_level",
  "camera_movement": "static",
  "description": "主角走进教室",
  "visual_description": "中景，水平机位，固定镜头。阳光从窗户射入，教室后排坐着几个学生。主角推门进入，背着书包，表情略带紧张。暖色调。",
  "characters": ["主角A", "配角B"],
  "location": "教室",
  "time_of_day": "上午",
  "duration_suggestion": 4,
  "mood": "平静中带紧张",
  "props": ["书包", "课桌"]
}
```

---

## 六、场景转换（Scene Transition）

| 方式 | 效果 |
|------|------|
| 硬切（Hard Cut） | 直接切换，快节奏 |
| 叠化（Dissolve/Cross Fade） | 时间流逝，暗示 |
| 淡入淡出（Fade In/Out） | 段落开始/结束 |
| 划像（Wipe） | 复古感，空间转换 |
| 匹配剪辑（Match Cut） | 相似形状/动作连接 |
| 跳切（Jump Cut） | 打破时间连续性，不安感 |

---

## 七、连续性原则（Continuity）

1. **180度规则**：摄影机在动作轴线一侧
2. **30度规则**：相邻镜头角度差≥30度
3. **视线匹配**：视线方向一致
4. **服装/道具**：同一场景内保持一致
5. **空间关系**：保持角色间空间逻辑

---

## 八、分镜头数量估算

| 时长 | 典型镜头数 | 平均镜头时长 |
|------|-----------|-------------|
| 短视频（15-60秒） | 5-15个 | 3-5秒 |
| 短片（3-5分钟） | 20-40个 | 3-5秒 |
| 微电影（10-15分钟） | 60-120个 | 3-5秒 |
| 短剧（3-5分钟） | 30-60个 | 3-5秒 |

**AI视频生成建议**：每个镜头6-10秒，便于MiniMax生成且连贯性好


path: references/screenwriting/story-structure.txt
content: # 编剧：故事结构体系

## 一、三幕结构（Three-Act Structure）

### 第一幕：建置（Setup）— 通常占全片25%
- **开场钩子**：用3-5分钟抓住观众注意力
- **设定世界**：介绍主角、时空、基调
- **日常世界**：展示主角的"正常生活"状态
- **催化事件**：打破平衡的事件发生（inciting incident）
- **第一情节点**：迫使主角做出选择的转折点

### 第二幕：对抗（Confrontation）— 占50%
- **进展/升级**：冲突不断加深，障碍越来越多
- **中点（Midpoint）**：重大转折，通常主角获得关键信息或失败
- **反派逼近**：反派或危机升级
- **第二情节点**：主角几乎被击败的低谷（all is lost）
- **灵魂黑夜**：主角内心挣扎，重新审视目标

### 第三幕：解决（Resolution）— 占25%
- **高潮**：最终对决/冲突爆发
- **新平衡**：建立新的世界状态
- **结局**：角色归宿展示

---

## 二、布莱克·斯奈德节拍表（SavePoint Beat Sheet）

| 节拍 | 时间点 | 内容 |
|------|--------|------|
| 1. 开场画面 | 0分钟 | 确立基调、风格、主角状态 |
| 2. 主题陈述 | 1-5分钟 | 通过角色或对白暗示主题 |
| 3. 设定 | 1-12分钟 | 主角日常世界 |
| 4. 催化事件 | 12分钟 | 打破平衡的事件 |
| 5. 辩论/讨论 | 12-25分钟 | 主角是否接受挑战？内心挣扎 |
| 6. 跨入第二幕 | 25分钟 | 主角做出决定，进入新世界 |
| 7. 配角登场 | 随第2幕 | 导师、盟友、反对者 |
| 8. Fun & Games | 25-50分钟 | 追求主线目标，主要情节展开 |
| 9. 中点 | 50分钟 | 重大转折：假胜利或假失败 |
| 10. 反派逼近 | 50-75分钟 | 压力升级，反派逼近 |
| 11. 灵魂黑夜 | 75分钟 | 最黑暗时刻，主角几乎放弃 |
| 12. 跨入第三幕 | 75-85分钟 | 汲取教训，找到解决方案 |
| 13. 最终对决 | 85-110分钟 | 高潮戏 |
| 14. 结局画面 | 110分钟+ | 新世界、新状态 |

---

## 三、英雄之旅（Hero's Journey / Monomyth）

### 正常世界（Ordinary World）
- 主角在平凡世界中的状态

### 冒险召唤（Call to Adventure）
- 主角收到挑战或机遇

### 拒绝召唤（Refusal of the Call）
- 主角犹豫、恐惧

### 遇见导师（Meeting with the Mentor）
- 导师出现，提供帮助或指引

### 跨越第一道门槛（Crossing the First Threshold）
- 正式进入冒险世界

### 试炼、盟友、敌人（Tests, Allies, Enemies）
- 面对挑战，结识伙伴，遭遇敌人

### 接近最深处的洞穴（Approach to the Inmost Cave）
- 准备面对最大挑战

### 严峻考验（Ordeal）
- 最黑暗时刻，死亡或重大牺牲

### 获得奖赏（Reward）
- 获得目标之物

### 复活之路（The Road Back）
- 返回途中遭遇最终考验

### 回归与复活（Return with the Elixir）
- 带回新力量，惠及旧世界

---

## 四、中国戏曲结构：起承转合

| 阶段 | 功能 | 戏剧功能 |
|------|------|----------|
| **起** | 开端 | 交代背景，引出人物 |
| **承** | 发展 | 矛盾展开，冲突升级 |
| **转** | 高潮 | 转折突变，情感爆发 |
| **合** | 结局 | 解决收束，余韵留存 |

---

## 五、分场景工作表

### 场景要素
- **地点（Location）**：室内/室外，具体场所
- **时间（Time）**：日/夜，季节，年代
- **角色在场**：谁出现在这个场景
- **戏剧目的**：这个场景推动什么情节/主题
- **情感基调**：场景的氛围

### 场景类型
| 类型 | 英文 | 用途 |
|------|------|------|
| 动作场景 | Action Sequence | 展示而非叙述 |
| 对话场景 | Dialogue Scene | 揭示人物关系和内心 |
| 转折场景 | Turning Point | 改变故事方向 |
| 主题场景 | Thematic Scene | 深化主题 |
| 过渡场景 | Transition | 连接情节 |

---

## 六、视觉叙事原则

1. **展示，不告诉（Show, Don't Tell）**：用画面和动作传达信息
2. **冲突驱动**：每个场景必须有戏剧张力
3. **因果链条**：情节逻辑严密，环环相扣
4. **情感弧线**：主角必须有内心成长或转变
5. **节奏控制**：张弛有度，快慢交替


path: scripts/full_pipeline.py
content: #!/usr/bin/env python3
"""
full_pipeline.py — MiniMax T2I→I2V 全自动流水线
调用 MiniMax 文生图（图生视频 API，将故事分镜转化为完整视频。
用法：
    export MINIMAX_API_KEY="your_key"
    export MINIMAX_BASE_URL="https://api.minimaxi.com/v1"  # 可选
    python full_pipeline.py -i shots.json [-o output.mp4]
"""
import os, sys, json, time, requests, subprocess
from pathlib import Path
from argparse import ArgumentParser

# ── Config ──────────────────────────────────────────────────────────────────
BASE = os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
T2I_URL  = f"{BASE}/image_generation"
I2V_URL  = f"{BASE}/video_generation"
POLL_URL = f"{BASE}/query/video_generation"
RETRIEVE_URL = f"{BASE}/files/retrieve"


def get_api_key():
    key = os.environ.get("MINIMAX_API_KEY")
    if not key:
        sys.stderr.write("ERROR: MINIMAX_API_KEY environment variable not set\n")
        sys.exit(1)
    return key


def get_headers():
    return {"Authorization": f"Bearer {get_api_key()}", "Content-Type": "application/json"}


def get_output_dirs(script_dir):
    """相对于脚本所在目录创建 output 子目录"""
    out = script_dir / "output"
    return out / "frames", out / "videos", out / "final_story.mp4"


def parse_args():
    p = ArgumentParser(description="MiniMax T2I→I2V 全自动流水线")
    p.add_argument("-i", "--input", required=True, help="分镜 JSON 文件路径")
    p.add_argument("-o", "--output", default=None, help="最终输出 MP4 路径（默认: output/final_story.mp4）")
    p.add_argument("--fps", default=30, type=int, help="输出帧率（默认: 30）")
    p.add_argument("--crf", default="23", help="ffmpeg CRF（默认: 23）")
    return p.parse_args()


# ── API helpers ─────────────────────────────────────────────────────────────
def t2i_generate(visual_prompt):
    """调用 MiniMax T2I，返回图片 URL"""
    resp = requests.post(T2I_URL, headers=get_headers(), json={
        "model": "image-01",
        "prompt": visual_prompt,
        "aspect_ratio": "16:9"
    }, timeout=60)
    try:
        data = resp.json()
    except Exception:
        print(f"  ❌ T2I: non-JSON [{resp.status_code}]")
        return None
    base = data.get("base_resp", {})
    if base.get("status_code") != 0:
        print(f"  ❌ T2I error [{base.get('status_code')}]: {base.get('status_msg')}")
        return None
    img_data = data.get("data")
    if not img_data:
        print(f"  ❌ T2I: data is null")
        return None
    img_url = img_data.get("image_urls", [None])[0]
    if not img_url:
        print(f"  ❌ T2I: no image_urls")
        return None
    return img_url


def i2v_submit(img_url, prompt):
    """提交 I2V 任务，返回 task_id"""
    resp = requests.post(I2V_URL, headers=get_headers(), json={
        "model": "MiniMax-Hailuo-2.3",
        "first_frame_image": img_url,
        "prompt": prompt[:200],
        "duration": 6,
        "resolution": "768P"
    }, timeout=60)
    resp.raise_for_status()
    return resp.json().get("task_id")


def poll_task(task_id, timeout=600):
    """轮询视频任务状态，返回 file_id"""
    start = time.time()
    while time.time() - start < timeout:
        resp = requests.get(POLL_URL, headers=get_headers(),
                            params={"task_id": task_id}, timeout=30)
        resp.raise_for_status()
        result = resp.json()
        status = result.get("status", "")
        print(f"  [{status}]", end=" ", flush=True)
        if status == "Success":
            print(" ✅")
            return result.get("file_id")
        if status == "Fail":
            print(f" ❌ {result.get('base_resp', {}).get('status_msg')}")
            return None
        time.sleep(10)
    print(" ❌ timeout")
    return None


def get_download_url(file_id):
    resp = requests.get(RETRIEVE_URL, headers=get_headers(),
                        params={"file_id": file_id}, timeout=30)
    resp.raise_for_status()
    return resp.json().get("file", {}).get("download_url")


def download_video(download_url, output_path):
    resp = requests.get(download_url, timeout=300, stream=True)
    resp.raise_for_status()
    with open(output_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)
    return output_path


def merge_videos(video_paths, output_path, crf="23"):
    """ffmpeg 合并多个视频"""
    list_file = Path("/tmp/story_video_list.txt")
    with open(list_file, "w") as f:
        for p in video_paths:
            if p and Path(p).exists():
                f.write(f"file '{p}'\n")
    existing = [p for p in video_paths if p and Path(p).exists()]
    if not existing:
        print("❌ No videos to merge!")
        sys.exit(1)
    if len(existing) == 1:
        import shutil
        shutil.copy(existing[0], output_path)
        print(f"✅ Single video copied → {output_path}")
        return
    cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
           "-i", str(list_file),
           "-c:v", "libx264", "-crf", crf,
           "-preset", "fast", "-c:a", "aac", str(output_path)]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"❌ ffmpeg: {result.stderr[-300:]}")
    else:
        print(f"✅ Merged → {output_path}")


# ── Main ────────────────────────────────────────────────────────────────────
def main():
    args = parse_args()
    script_dir = Path(__file__).parent.resolve()
    frame_dir, video_dir, default_final = get_output_dirs(script_dir)

    if args.output:
        final_video = Path(args.output)
    else:
        final_video = default_final

    frame_dir.mkdir(parents=True, exist_ok=True)
    video_dir.mkdir(parents=True, exist_ok=True)

    with open(args.input) as f:
        shots = json.load(f)

    video_files = {}
    for shot in shots:
        shot_num = shot["shot_number"]
        idx = str(shot_num).zfill(3)
        video_path = video_dir / f"shot_{idx}.mp4"

        # 跳过已有视频
        if video_path.exists() and video_path.stat().st_size > 10000:
            print(f"⏭  Skip {idx}: already exists ({video_path.stat().st_size // 1024}KB)")
            video_files[shot_num] = video_path
            continue

        desc = shot.get("description", "")[:200]
        visual = shot.get("visual_description", desc)[:300]

        print(f"\n{'=' * 60}")
        print(f"Shot {idx}: {desc[:60]}")
        print(f"{'=' * 60}")

        # T2I
        print(f"  [T2I] Generating image...")
        img_url = t2i_generate(visual)
        if not img_url:
            print(f"  ❌ T2I failed, skipping")
            continue
        print(f"  [T2I] OK")

        # I2V
        print(f"  [I2V] Submitting...")
        task_id = i2v_submit(img_url, desc)
        if not task_id:
            print(f"  ❌ I2V submit failed")
            continue
        print(f"  [I2V] task_id={task_id}")

        # Poll
        file_id = poll_task(task_id)
        if not file_id:
            continue

        # Download
        print(f"  [DOWN] Getting URL...")
        dl_url = get_download_url(file_id)
        if not dl_url:
            continue
        try:
            download_video(dl_url, video_path)
            size = video_path.stat().st_size
            print(f"  ✅ Saved: {video_path} ({size / 1024:.0f}KB)")
            video_files[shot_num] = video_path
        except Exception as e:
            print(f"  ❌ Download: {e}")
            continue

        time.sleep(1)

    # Merge
    print(f"\n{'=' * 60}")
    print("Merging all videos...")
    videos = [video_files.get(s["shot_number"]) for s in shots]
    merge_videos(videos, final_video, crf=args.crf)
    if final_video.exists():
        sz = final_video.stat().st_size
        print(f"\n🎉 DONE! {final_video} ({sz / 1024 / 1024:.1f}MB)")


if __name__ == "__main__":
    main()


path: scripts/full_pipeline_v2.py
content: #!/usr/bin/env python3
"""
full_pipeline_v2.py — MiniMax T2I→I2V 全自动流水线（支持 I2V 重试）
与 full_pipeline.py 的区别：I2V 失败时会自动用脱敏 prompt 重试一次。
用法：
    export MINIMAX_API_KEY="your_key"
    export MINIMAX_BASE_URL="https://api.minimaxi.com/v1"  # 可选
    python full_pipeline_v2.py -i shots.json [-o output.mp4]
"""
import os, sys, json, time, requests, subprocess
from pathlib import Path
from argparse import ArgumentParser

# ── Config ──────────────────────────────────────────────────────────────────
BASE = os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
T2I_URL  = f"{BASE}/image_generation"
I2V_URL  = f"{BASE}/video_generation"
POLL_URL = f"{BASE}/query/video_generation"
RETRIEVE_URL = f"{BASE}/files/retrieve"


def get_api_key():
    key = os.environ.get("MINIMAX_API_KEY")
    if not key:
        sys.stderr.write("ERROR: MINIMAX_API_KEY environment variable not set\n")
        sys.exit(1)
    return key


def get_headers():
    return {"Authorization": f"Bearer {get_api_key()}", "Content-Type": "application/json"}


def get_output_dirs(script_dir):
    out = script_dir / "output"
    return out / "frames", out / "videos", out / "final_story.mp4"


def parse_args():
    p = ArgumentParser(description="MiniMax T2I→I2V 全自动流水线（支持 I2V 重试）")
    p.add_argument("-i", "--input", required=True, help="分镜 JSON 文件路径")
    p.add_argument("-o", "--output", default=None, help="最终输出 MP4 路径")
    p.add_argument("--crf", default="23", help="ffmpeg CRF（默认: 23）")
    return p.parse_args()


# ── API helpers ─────────────────────────────────────────────────────────────
def t2i_generate(visual_prompt):
    """调用 MiniMax T2I，返回 (img_url, error_msg)"""
    resp = requests.post(T2I_URL, headers=get_headers(), json={
        "model": "image-01",
        "prompt": visual_prompt,
        "aspect_ratio": "16:9"
    }, timeout=60)
    try:
        data = resp.json()
    except Exception:
        return None, f"non-JSON [{resp.status_code}]"
    base = data.get("base_resp", {})
    if base.get("status_code") != 0:
        return None, f"[{base.get('status_code')}]: {base.get('status_msg')}"
    img_data = data.get("data")
    if not img_data:
        return None, "data is null"
    img_url = img_data.get("image_urls", [None])[0]
    if not img_url:
        return None, "no image_urls"
    return img_url, None


def i2v_submit(img_url, prompt):
    """提交 I2V 任务，返回 (task_id, error_msg)"""
    try:
        resp = requests.post(I2V_URL, headers=get_headers(), json={
            "model": "MiniMax-Hailuo-2.3",
            "first_frame_image": img_url,
            "prompt": prompt[:200],
            "duration": 6,
            "resolution": "768P"
        }, timeout=60)
        resp.raise_for_status()
        task_id = resp.json().get("task_id")
        return task_id, None
    except Exception as e:
        return None, str(e)


def poll_task(task_id, timeout=600):
    """轮询视频任务状态，返回 file_id"""
    start = time.time()
    while time.time() - start < timeout:
        try:
            resp = requests.get(POLL_URL, headers=get_headers(),
                                params={"task_id": task_id}, timeout=30)
            resp.raise_for_status()
            result = resp.json()
        except Exception as e:
            print(f"  ⚠ poll error: {e}, retrying...")
            time.sleep(5)
            continue
        status = result.get("status", "")
        print(f"  [{status}]", end=" ", flush=True)
        if status == "Success":
            print(" ✅")
            return result.get("file_id")
        if status == "Fail":
            print(f" ❌ {result.get('base_resp', {}).get('status_msg')}")
            return None
        time.sleep(10)
    print(" ❌ timeout")
    return None


def get_download_url(file_id):
    try:
        resp = requests.get(RETRIEVE_URL, headers=get_headers(),
                           params={"file_id": file_id}, timeout=30)
        resp.raise_for_status()
        return resp.json().get("file", {}).get("download_url")
    except Exception:
        return None


def download_video(download_url, output_path):
    resp = requests.get(download_url, timeout=300, stream=True)
    resp.raise_for_status()
    with open(output_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)


def merge_videos(video_paths, output_path, crf="23"):
    """ffmpeg 合并多个视频"""
    list_file = Path("/tmp/story_video_list.txt")
    with open(list_file, "w") as f:
        for p in video_paths:
            if p and Path(p).exists():
                f.write(f"file '{p}'\n")
    existing = [p for p in video_paths if p and Path(p).exists()]
    if not existing:
        print("❌ No videos to merge!")
        sys.exit(1)
    if len(existing) == 1:
        import shutil
        shutil.copy(existing[0], output_path)
        print(f"✅ Single video copied → {output_path}")
        return
    cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
           "-i", str(list_file),
           "-c:v", "libx264", "-crf", crf,
           "-preset", "fast", "-c:a", "aac", str(output_path)]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"❌ ffmpeg: {result.stderr[-300:]}")
    else:
        print(f"✅ Merged → {output_path}")


# ── Main ────────────────────────────────────────────────────────────────────
def main():
    args = parse_args()
    script_dir = Path(__file__).parent.resolve()
    frame_dir, video_dir, default_final = get_output_dirs(script_dir)

    if args.output:
        final_video = Path(args.output)
    else:
        final_video = default_final

    frame_dir.mkdir(parents=True, exist_ok=True)
    video_dir.mkdir(parents=True, exist_ok=True)

    with open(args.input) as f:
        shots = json.load(f)

    results = {}
    for shot in shots:
        num = shot["shot_number"]
        idx = str(num).zfill(3)
        video_path = video_dir / f"shot_{idx}.mp4"

        # 跳过已有视频
        if video_path.exists() and video_path.stat().st_size > 10000:
            print(f"⏭  Skip {idx}: already exists ({video_path.stat().st_size // 1024}KB)")
            results[num] = video_path
            continue

        desc = shot.get("description", "")[:200]
        visual = shot.get("visual_description", desc)[:300]
        # 脱敏备用 prompt（I2V 失败重试时使用）
        safe_prompt = shot.get("safe_prompt", desc[:200])

        print(f"\n{'=' * 60}")
        print(f"Shot {idx}: {desc[:60]}")
        print(f"{'=' * 60}")

        # T2I
        print(f"  [T2I] Generating...", end=" ", flush=True)
        img_url, err = t2i_generate(visual)
        if not img_url:
            print(f"❌ {err}")
            continue
        print(f"✅")

        # I2V — 最多 2 次重试
        i2v_ok = False
        for attempt in range(2):
            p = desc[:200] if attempt == 0 else safe_prompt
            retry_label = "(retry)" if attempt == 1 else ""
            print(f"  [I2V{retry_label}] Submitting...", end=" ", flush=True)
            task_id, err = i2v_submit(img_url, p)
            if not task_id:
                print(f"❌ {err}")
                if attempt == 0:
                    print(f"  ⚠ I2V failed, retrying with safe prompt...")
                    time.sleep(2)
                continue

            file_id = poll_task(task_id)
            if not file_id:
                if attempt == 0:
                    print(f"  ⚠ Retrying with safe prompt...")
                    time.sleep(2)
                continue

            print(f"  [DOWN] Downloading...", end=" ", flush=True)
            dl_url = get_download_url(file_id)
            if not dl_url:
                print(f"❌ get_download_url failed")
                if attempt == 0:
                    time.sleep(2)
                continue
            try:
                download_video(dl_url, video_path)
                print(f"✅ {video_path.stat().st_size // 1024}KB")
                results[num] = video_path
                i2v_ok = True
            except Exception as e:
                print(f"❌ {e}")
            break

        if not i2v_ok:
            print(f"  ❌ Shot {idx} failed after retries")

        time.sleep(1)

    # Merge
    print(f"\n{'=' * 60}")
    print(f"Merging {len(results)}/{len(shots)} videos...")
    videos = [results.get(s["shot_number"]) for s in shots]
    merge_videos(videos, final_video, crf=args.crf)
    if final_video.exists():
        sz = final_video.stat().st_size
        print(f"\n🎉 DONE! {final_video} ({sz / 1024 / 1024:.1f}MB)")


if __name__ == "__main__":
    main()


path: scripts/generate_shot_images.py
content: #!/usr/bin/env python3
"""
generate_shot_images.py
Generates images for each shot using MiniMax T2I API.
Handles async polling for task completion.
First shot has no previous image, subsequent shots pass previous image URL
as `referenced_image_urls` for visual continuity (character/setting consistency).

API flow:
  POST /v1/image_generation  → { id: task_id }
  GET  /v1/image_generation/{id}  → { status, data: { image_url } }

Note: referenced_image_urls for continuity is based on MiniMax i2i capability;
verify it against latest API docs if images appear inconsistent.
"""

import os
import json
import time
import requests
import uuid
from pathlib import Path
from typing import List, Dict, Any

# Configuration
API_ENDPOINT = os.environ.get("MINIMAX_IMAGE_URL", "https://api.minimaxi.com/v1/image_generation")
MODEL = "image-01"

# Retry settings
MAX_RETRIES = 3
RETRY_DELAY = 5  # seconds
POLL_INTERVAL = 3  # seconds
POLL_TIMEOUT = 300  # seconds per image

# Output directories
OUTPUT_DIR = Path("./output")
FRAMES_DIR = OUTPUT_DIR / "frames"


def get_api_key() -> str:
    """Get API key from environment."""
    [REDACTED_SECRET]"MINIMAX_API_KEY")
    if not api_key:
        raise ValueError("MINIMAX_API_KEY environment variable not set")
    return api_key


def load_shots(input_path: str) -> List[Dict[str, Any]]:
    """Load shots from JSON file."""
    path = Path(input_path)
    if not path.exists():
        raise FileNotFoundError(f"Shots file not found: {input_path}")
    
    with open(path, 'r', encoding='utf-8') as f:
        data = json.load(f)
    
    if isinstance(data, dict) and "shots" in data:
        return data["shots"]
    elif isinstance(data, list):
        return data
    else:
        raise ValueError(f"Unexpected JSON structure in {input_path}")


def generate_image(
    prompt: str,
    api_key: str,
    timeout: int = 120
) -> Dict[str, Any]:
    """
    Generate image synchronously using MiniMax T2I API.
    Returns dict with image_url and local_path on success.
    NOTE: No polling needed — result returned in initial response.
    """
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    }
    
    payload = {
        "model": MODEL,
        "prompt": prompt,
        "parameters": {"aspect_ratio": "16:9"}
    }
    
    response = requests.post(
        API_ENDPOINT,
        headers=headers,
        json=payload,
        timeout=timeout
    )
    response.raise_for_status()
    result = response.json()
    
    # Check for API error
    base_resp = result.get("base_resp", {})
    if base_resp.get("status_code") != 0:
        raise RuntimeError(f"API error {base_resp.get('status_code')}: {base_resp.get('status_msg')}")
    
    # Extract image URL — correct path: data.image_urls[0]
    image_urls = result.get("data", {}).get("image_urls", [])
    if not image_urls:
        raise RuntimeError(f"No image_urls in response: {result}")
    
    return {"image_url": image_urls[0], "result": result}


def poll_task_status(task_id: str, api_key: str, timeout: int = POLL_TIMEOUT) -> Dict[str, Any]:
    """Poll task status until completion."""
    status_endpoint = f"https://api.minimaxi.com/v1/image_generation/{task_id}"
    headers = {
        "Authorization": f"Bearer {api_key}"
    }
    
    start_time = time.time()
    last_status = "processing"
    
    while time.time() - start_time < timeout:
        try:
            response = requests.get(status_endpoint, headers=headers, timeout=30)
            response.raise_for_status()
            result = response.json()
            
            status = result.get("status", result.get("task_status", ""))
            last_status = status
            
            if status == "success" or status == "completed":
                return {"success": True, "data": result}
            elif status == "failed" or status == "error":
                error_msg = result.get("error", {}).get("message", "Unknown error")
                return {"success": False, "error": error_msg}
            
            print(f"    Status: {status}, waiting...")
            time.sleep(POLL_INTERVAL)
            
        except requests.exceptions.RequestException as e:
            print(f"    Poll error: {e}, retrying...")
            time.sleep(POLL_INTERVAL)
    
    return {"success": False, "error": f"Timeout after {timeout}s (last status: {last_status})"}


def download_image(url: str, output_path: Path, timeout: int = 60) -> bool:
    """Download image from URL to file path."""
    try:
        response = requests.get(url, timeout=timeout, stream=True)
        response.raise_for_status()
        
        output_path.parent.mkdir(parents=True, exist_ok=True)
        
        with open(output_path, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)
        
        return True
    except Exception as e:
        print(f"    Download failed: {e}")
        return False


def generate_shot_image(
    shot: Dict[str, Any],
    shot_index: int,
    total_shots: int,
    api_key: str
) -> Dict[str, Any]:
    """Generate image for a single shot with retry logic."""
    shot_num = shot.get("shot_number", shot_index + 1)
    visual_desc = shot.get("visual_description", shot.get("description", ""))
    
    print(f"\n[Shot {shot_num} ({shot_index + 1}/{total_shots})]")
    print(f"  Description: {shot.get('description', 'N/A')[:60]}...")
    print(f"  Visual: {visual_desc[:80]}...")
    
    # Build enhanced prompt
    enhanced_prompt = f"{visual_desc}. Cinematic, photorealistic, 4K quality, professional film production."
    
    last_error = None
    for attempt in range(MAX_RETRIES):
        try:
            print(f"  [Attempt {attempt + 1}/{MAX_RETRIES}] Generating...")
            
            # Generate synchronously (no polling needed)
            result = generate_image(enhanced_prompt, api_key)
            image_url = result["image_url"]
            
            print(f"  Image URL: {image_url[:60]}...")
            
            # Download image
            output_filename = f"shot_{shot_num:03d}.png"
            output_path = FRAMES_DIR / output_filename
            
            print(f"  Downloading to {output_path}...")
            if download_image(image_url, output_path):
                return {
                    "success": True,
                    "shot_number": shot_num,
                    "image_url": image_url,
                    "local_path": str(output_path)
                }
            else:
                last_error = "Image download failed"
            
        except requests.exceptions.RequestException as e:
            last_error = f"Request error: {e}"
            print(f"  {last_error}")
        except Exception as e:
            last_error = f"Unexpected error: {e}"
            print(f"  {last_error}")
        
        if attempt < MAX_RETRIES - 1:
            print(f"  Retrying in {RETRY_DELAY}s...")
            time.sleep(RETRY_DELAY)
    
    return {
        "success": False,
        "shot_number": shot_num,
        "error": last_error
    }


def generate_all_images(
    shots_path: str = "./output/shots.json",
    output_path: str = "./output/shot_images.json"
) -> List[Dict[str, Any]]:
    """
    Generate images for all shots.
    
    Args:
        shots_path: Path to shots JSON file
        output_path: Path to save output JSON
        
    Returns:
        List of generated image info
    """
    print("=" * 60)
    print("GENERATE SHOT IMAGES")
    print("=" * 60)
    
    # Get API key
    [REDACTED_SECRET])
    print(f"[OK] API key loaded")
    
    # Load shots
    shots = load_shots(shots_path)
    print(f"[OK] Loaded {len(shots)} shots")
    
    # Ensure output directory exists
    FRAMES_DIR.mkdir(parents=True, exist_ok=True)
    print(f"[OK] Output directory: {FRAMES_DIR}")
    
    # Results
    results = []
    successful = 0
    failed = 0
    
    for i, shot in enumerate(shots):
        result = generate_shot_image(
            shot=shot,
            shot_index=i,
            total_shots=len(shots),
            [REDACTED_SECRET]
        )
        
        results.append(result)
        
        if result["success"]:
            successful += 1
            print(f"  [OK] Shot {result['shot_number']} complete")
        else:
            failed += 1
            print(f"  [FAIL] Shot {shot.get('shot_number', i+1)} failed: {result.get('error')}")
    
    # Save results
    output_data = {
        "shots": results,
        "summary": {
            "total": len(shots),
            "successful": successful,
            "failed": failed
        }
    }
    
    output_file = Path(output_path)
    output_file.parent.mkdir(parents=True, exist_ok=True)
    with open(output_file, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)
    
    print(f"\n{'=' * 60}")
    print(f"COMPLETE: {successful} successful, {failed} failed")
    print(f"Output saved to: {output_path}")
    print(f"{'=' * 60}")
    
    return results


if __name__ == "__main__":
    import argparse
    
    parser = argparse.ArgumentParser(description="Generate images for shots")
    parser.add_argument("-i", "--input", default="./output/shots.json", help="Input shots JSON")
    parser.add_argument("-o", "--output", default="./output/shot_images.json", help="Output JSON path")
    
    args = parser.parse_args()
    
    try:
        results = generate_all_images(args.input, args.output)
        success_count = sum(1 for r in results if r.get("success"))
        print(f"\nGenerated {success_count}/{len(results)} images successfully")
    except Exception as e:
        print(f"\nERROR: {e}")
        exit(1)


path: scripts/generate_shot_videos.py
content: #!/usr/bin/env python3
"""
generate_shot_videos.py
Generates videos from images using MiniMax I2V API.
MiniMax video generation is a 3-step async flow:
  1. POST /v1/video_generation       → { task_id }
  2. GET  /v1/video_generation/{id} → { status, file_id }
  3. GET  /v1/files/{file_id}        → video file binary
"""

import os
import json
import time
import requests
from pathlib import Path
from typing import List, Dict, Any, Optional

# ── MiniMax Video Generation Endpoints ──────────────────────────────────────
MINIMAX_BASE_V2 = os.environ.get("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
CREATE_ENDPOINT  = f"{MINIMAX_BASE_V2}/video_generation"
QUERY_ENDPOINT   = f"{MINIMAX_BASE_V2}/query/video_generation"
FILE_ENDPOINT    = lambda file_id: f"{MINIMAX_BASE_V2}/files/{file_id}"

MODEL        = "MiniMax-Hailuo-2.3"
MODEL_FAST   = "MiniMax-Hailuo-2.3-Fast"  # cheaper/faster alternative

# ── Retry / Polling ──────────────────────────────────────────────────────────
MAX_RETRIES   = 3
RETRY_DELAY   = 5    # seconds between retries
POLL_INTERVAL = 10   # seconds between status polls
POLL_TIMEOUT  = 600  # seconds (10 min max per video)

# ── Output ────────────────────────────────────────────────────────────────────
OUTPUT_DIR   = Path("./output")
VIDEOS_DIR   = OUTPUT_DIR / "videos"


def get_api_key() -> str:
    key = os.environ.get("MINIMAX_API_KEY")
    if not key:
        raise ValueError("MINIMAX_API_KEY environment variable not set")
    return key


def load_image_data(input_path: str) -> List[Dict[str, Any]]:
    path = Path(input_path)
    if not path.exists():
        raise FileNotFoundError(f"Image data file not found: {input_path}")
    with open(path, 'r', encoding='utf-8') as f:
        data = json.load(f)
    if isinstance(data, dict):
        for key in ("shots", "images"):
            if key in data:
                return data[key]
    elif isinstance(data, list):
        return data
    raise ValueError(f"Unexpected JSON structure in {input_path}")


# ── Step 1: Create video generation task ─────────────────────────────────────
def create_video_task(
    image_url: str,
    api_key: str,
    duration: int = 6,
    resolution: str = "768P",
    model: str = MODEL,
    timeout: int = 60
) -> Dict[str, Any]:
    """
    POST /v1/video_generation
    Returns { task_id: "..." } on success.
    """
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    }
    payload = {
        "model": model,
        "first_frame_image": image_url,
        "duration": duration,
        "resolution": resolution
    }
    resp = requests.post(CREATE_ENDPOINT, headers=headers, json=payload, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


# ── Step 2: Poll task status ─────────────────────────────────────────────────
def poll_video_status(
    task_id: str,
    api_key: str,
    timeout: int = POLL_TIMEOUT
) -> Dict[str, Any]:
    """
    GET /v1/query/video_generation?task_id={task_id}
    Returns { success: True, file_id: "..." } or { success: False, error: "..." }.
    """
    headers = {"Authorization": f"Bearer {api_key}"}
    start = time.time()
    last_status = "processing"

    while time.time() - start < timeout:
        resp = requests.get(QUERY_ENDPOINT, headers=headers,
                           params={"task_id": task_id}, timeout=30)
        resp.raise_for_status()
        result = resp.json()

        status = result.get("status", result.get("task_status", ""))
        last_status = status

        if status == "Success":
            file_id = result.get("file_id")
            if not file_id:
                return {"success": False, "error": f"No file_id in success response: {result}"}
            return {"success": True, "file_id": file_id, "data": result}

        if status == "Fail":
            err = result.get("base_resp", {}).get("status_msg", "Unknown error")
            return {"success": False, "error": err}

        print(f"    Status: {status}, waiting...")
        time.sleep(POLL_INTERVAL)

    return {"success": False, "error": f"Timeout after {timeout}s (last: {last_status})"}


# ── Step 3: Download video file ──────────────────────────────────────────────
def download_video(file_id: str, api_key: str, output_path: Path, timeout: int = 300) -> bool:
    """
    GET /v1/files/{file_id}
    Saves the binary video to output_path.
    """
    headers = {"Authorization": f"Bearer {api_key}"}
    resp = requests.get(FILE_ENDPOINT(file_id), headers=headers, timeout=timeout, stream=True)
    resp.raise_for_status()

    output_path.parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, 'wb') as f:
        for chunk in resp.iter_content(chunk_size=65536):
            f.write(chunk)
    return True


# ── Main generation function ──────────────────────────────────────────────────
def generate_shot_video(
    image_url: str,
    shot_number: int,
    shot_desc: str,
    api_key: str,
    duration: int = 6,
    resolution: str = "768P",
    model: str = MODEL,
    timeout: int = 60
) -> Dict[str, Any]:
    """Generate one video from an image URL with retry logic."""
    print(f"\n[Shot {shot_number}]")
    print(f"  Image: {image_url[:80]}...")
    print(f"  Model: {model} | Duration: {duration}s | Resolution: {resolution}")

    last_error = None
    for attempt in range(1, MAX_RETRIES + 1):
        try:
            print(f"  [Attempt {attempt}/{MAX_RETRIES}] Creating task...")
            create_result = create_video_task(image_url, api_key, duration, resolution, model, timeout)

            task_id = (
                create_result.get("task_id")
                or create_result.get("id")
                or create_result.get("data", {}).get("task_id")
            )
            if not task_id:
                raise ValueError(f"No task_id in create response: {create_result}")
            print(f"  Task ID: {task_id}")

            print(f"  Waiting for completion...")
            poll_result = poll_video_status(task_id, api_key)

            if not poll_result["success"]:
                last_error = poll_result["error"]
                print(f"  Poll failed: {last_error}")
                time.sleep(RETRY_DELAY)
                continue

            file_id = poll_result["file_id"]
            output_file = VIDEOS_DIR / f"video_{shot_number:03d}.mp4"
            print(f"  Downloading video to {output_file}...")
            if download_video(file_id, api_key, output_file):
                return {
                    "success": True,
                    "shot_number": shot_number,
                    "task_id": task_id,
                    "file_id": file_id,
                    "local_path": str(output_file)
                }
            else:
                last_error = "Download failed"

        except requests.exceptions.RequestException as e:
            last_error = f"Request error: {e}"
            print(f"  {last_error}")
        except Exception as e:
            last_error = f"Unexpected error: {e}"
            print(f"  {last_error}")

        if attempt < MAX_RETRIES:
            print(f"  Retrying in {RETRY_DELAY}s...")
            time.sleep(RETRY_DELAY)

    return {"success": False, "shot_number": shot_number, "error": last_error}


def generate_all_videos(
    input_path: str = "./output/shot_images.json",
    output_path: str = "./output/shot_videos.json",
    duration: int = 6,
    resolution: str = "768P",
    model: str = MODEL
) -> List[Dict[str, Any]]:
    print("=" * 60)
    print("GENERATE SHOT VIDEOS  (MiniMax I2V)")
    print("=" * 60)

    [REDACTED_SECRET])
    print(f"[OK] API key loaded\n")

    images = load_image_data(input_path)
    print(f"[OK] Loaded {len(images)} image entries\n")

    VIDEOS_DIR.mkdir(parents=True, exist_ok=True)

    results = []
    success = fail = 0

    for entry in images:
        shot_number = entry.get("shot_number", 1)
        image_url = (
            entry.get("image_url")
            or entry.get("url")
            or (entry.get("data", {}).get("image_url"))
        )
        if not image_url:
            print(f"[WARN] Shot {shot_number}: no image_url, skipping")
            continue

        result = generate_shot_video(
            image_url=image_url,
            shot_number=shot_number,
            shot_desc=entry.get("description", ""),
            [REDACTED_SECRET],
            duration=duration,
            resolution=resolution,
            model=model
        )
        results.append(result)

        if result["success"]:
            success += 1
            print(f"  [OK] Shot {shot_number} → {result['local_path']}")
        else:
            fail += 1
            print(f"  [FAIL] Shot {shot_number}: {result.get('error')}")

    # Save results
    output_data = {
        "shots": results,
        "summary": {"total": len(results), "successful": success, "failed": fail}
    }
    out_file = Path(output_path)
    out_file.parent.mkdir(parents=True, exist_ok=True)
    with open(out_file, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)

    print(f"\n{'=' * 60}")
    print(f"COMPLETE: {success} successful, {fail} failed")
    print(f"Output saved to: {output_path}")
    print(f"{'=' * 60}")
    return results


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Generate videos from images via MiniMax I2V")
    parser.add_argument("-i", "--input",  default="./output/shot_images.json", help="Input images JSON")
    parser.add_argument("-o", "--output", default="./output/shot_videos.json", help="Output JSON path")
    parser.add_argument("-d", "--duration", type=int, default=6, choices=[6, 10], help="Video duration in seconds")
    parser.add_argument("-r", "--resolution", default="768P", choices=["768P", "1080P"], help="Video resolution")
    parser.add_argument("-m", "--model", default=MODEL, help=f"Model name (default: {MODEL})")

    args = parser.parse_args()
    try:
        generate_all_videos(args.input, args.output, args.duration, args.resolution, args.model)
    except Exception as e:
        print(f"\nERROR: {e}")
        exit(1)


path: scripts/merge_videos.py
content: #!/usr/bin/env python3
"""
merge_videos.py
Merges all videos in ./output/videos/ into a single video using ffmpeg concat demuxer.
Also creates a text-based storyboard summary.
"""

import os
import json
import subprocess
from pathlib import Path
from typing import List, Dict, Any, Optional

# Output directories
OUTPUT_DIR = Path("./output")
VIDEOS_DIR = OUTPUT_DIR / "videos"
FRAMES_DIR = OUTPUT_DIR / "frames"

# Final output
FINAL_VIDEO = OUTPUT_DIR / "final_story.mp4"
STORYBOARD_FILE = OUTPUT_DIR / "storyboard.txt"
SRT_FILE = OUTPUT_DIR / "subtitles.srt"


def find_videos() -> List[Path]:
    """Find all video files in the videos directory."""
    if not VIDEOS_DIR.exists():
        return []
    
    video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv'}
    videos = []
    
    for ext in video_extensions:
        videos.extend(VIDEOS_DIR.glob(f"*{ext}"))
    
    # Sort by name for consistent ordering
    videos.sort()
    return videos


def check_ffmpeg() -> bool:
    """Check if ffmpeg is available."""
    try:
        result = subprocess.run(
            ['ffmpeg', '-version'],
            capture_output=True,
            text=True,
            timeout=10
        )
        return result.returncode == 0
    except (subprocess.SubprocessError, FileNotFoundError):
        return False


def install_ffmpeg() -> bool:
    """
    Attempt to install ffmpeg based on the detected OS.
    Returns True if installation appears successful.
    """
    import platform
    system = platform.system().lower()

    print("[INFO] FFmpeg not found. Attempting auto-install...")

    if system == "linux":
        # Try apt (Debian/Ubuntu/WSL)
        print("[INFO] Detected Linux — running: sudo apt-get update && sudo apt-get install -y ffmpeg")
        try:
            result = subprocess.run(
                ['sudo', 'apt-get', 'update'],
                capture_output=True, text=True, timeout=120
            )
            if result.returncode != 0:
                print(f"[WARN] apt-get update failed: {result.stderr.strip()}")
            result = subprocess.run(
                ['sudo', 'apt-get', 'install', '-y', 'ffmpeg'],
                capture_output=True, text=True, timeout=180
            )
            if result.returncode == 0:
                print("[OK] FFmpeg installed via apt-get")
                return True
            print(f"[WARN] apt-get install ffmpeg failed: {result.stderr.strip()}")
        except subprocess.SubprocessError as e:
            print(f"[WARN] apt-get install failed: {e}")

    elif system == "darwin":
        # macOS — try brew
        print("[INFO] Detected macOS — running: brew install ffmpeg")
        try:
            result = subprocess.run(
                ['brew', 'install', 'ffmpeg'],
                capture_output=True, text=True, timeout=300
            )
            if result.returncode == 0:
                print("[OK] FFmpeg installed via Homebrew")
                return True
            print(f"[WARN] brew install ffmpeg failed: {result.stderr.strip()}")
        except subprocess.SubprocessError as e:
            print(f"[WARN] brew install failed: {e}")

    elif system == "windows":
        print("[WARN] Windows detected — please install FFmpeg manually:")
        print("  Option 1: winget install ffmpeg")
        print("  Option 2: Download from https://ffmpeg.org/download.html#build-windows")
        print("  Option 3: choco install ffmpeg (if Chocolatey is installed)")

    else:
        print(f"[WARN] Unknown OS ({system}) — cannot auto-install ffmpeg")
        print("  Please install ffmpeg manually: https://ffmpeg.org/download.html")

    return False


def get_video_duration(video_path: Path) -> Optional[float]:
    """Get video duration in seconds using ffprobe."""
    try:
        cmd = [
            'ffprobe',
            '-v', 'error',
            '-show_entries', 'format=duration',
            '-of', 'default=noprint_wrappers=1:nokey=1',
            str(video_path)
        ]
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=30
        )
        if result.returncode == 0:
            return float(result.stdout.strip())
    except (subprocess.SubprocessError, ValueError):
        pass
    return None


def merge_videos_concat_demuxer(videos: List[Path], output_path: Path) -> bool:
    """
    Merge videos using ffmpeg concat demuxer.
    Creates a temporary file list and feeds it to ffmpeg.
    """
    if not videos:
        print("[ERROR] No videos to merge")
        return False
    
    # Create file list for concat demuxer
    list_file = OUTPUT_DIR / "concat_list.txt"
    
    with open(list_file, 'w', encoding='utf-8') as f:
        for video in videos:
            f.write(f"file '{video.absolute()}'\n")
    
    try:
        print(f"[...] Merging {len(videos)} videos...")
        
        # Use concat demuxer
        cmd = [
            'ffmpeg',
            '-y',  # Overwrite output
            '-f', 'concat',
            '-safe', '0',  # Allow unsafe file names
            '-i', str(list_file),
            '-c', 'copy',  # Copy streams without re-encoding
            '-bsf:a', 'aac_adtstoasc',  # Fix AAC audio
            str(output_path)
        ]
        
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=600  # 10 minutes max
        )
        
        if result.returncode != 0:
            # Try with re-encoding if copy fails
            print(f"  Copy failed, trying with re-encoding...")
            cmd = [
                'ffmpeg',
                '-y',
                '-f', 'concat',
                '-safe', '0',
                '-i', str(list_file),
                '-c:v', 'libx264',
                '-crf', '23',
                '-preset', 'fast',
                '-c:a', 'aac',
                '-b:a', '128k',
                str(output_path)
            ]
            
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=600
            )
        
        if result.returncode == 0:
            print(f"[OK] Merged video saved to {output_path}")
            return True
        else:
            print(f"[ERROR] FFmpeg error:\n{result.stderr}")
            return False
            
    except subprocess.TimeoutExpired:
        print(f"[ERROR] FFmpeg timed out after 600s")
        return False
    except subprocess.SubprocessError as e:
        print(f"[ERROR] FFmpeg error: {e}")
        return False
    finally:
        # Clean up list file
        if list_file.exists():
            list_file.unlink()


def load_shots_and_images() -> tuple:
    """Load shots and image data if available."""
    shots_path = OUTPUT_DIR / "shots.json"
    images_path = OUTPUT_DIR / "shot_images.json"
    
    shots = []
    images = []
    
    if shots_path.exists():
        try:
            with open(shots_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
                shots = data if isinstance(data, list) else data.get("shots", [])
        except Exception:
            pass
    
    if images_path.exists():
        try:
            with open(images_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
                images = data if isinstance(data, list) else data.get("shots", [])
        except Exception:
            pass
    
    return shots, images


def build_shot_timeline(videos: List[Path], shots: List[Dict]) -> List[tuple]:
    """
    Build a timeline of (start_time, end_time, description) for each video.
    Uses ffprobe to get actual durations, accumulates time offsets.
    Returns list of (start_sec, end_sec, text) tuples.
    """
    timeline = []
    current_time = 0.0

    for i, video in enumerate(videos):
        shot_num = i + 1
        duration = get_video_duration(video) or 6.0  # fallback to 6s

        # Find matching shot description
        text = f"Shot {shot_num}"
        if shots:
            # Match by shot_number or by index
            matched = next(
                (s for s in shots if s.get("shot_number") == shot_num),
                shots[i] if i < len(shots) else None
            )
            if matched:
                text = matched.get("description", text)

        timeline.append((current_time, current_time + duration, text))
        current_time += duration

    return timeline


def format_timestamp(seconds: float) -> str:
    """Format seconds as SRT timestamp: HH:MM:SS,mmm"""
    hours = int(seconds // 3600)
    minutes = int((seconds % 3600) // 60)
    secs = int(seconds % 60)
    millis = int((seconds % 1) * 1000)
    return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"


def generate_srt(timeline: List[tuple], output_path: Path) -> bool:
    """Generate SRT subtitle file from timeline data."""
    if not timeline:
        return False

    lines = []
    for i, (start, end, text) in enumerate(timeline, 1):
        lines.append(str(i))
        lines.append(f"{format_timestamp(start)} --> {format_timestamp(end)}")
        lines.append(text)
        lines.append("")

    output_path.write_text("\n".join(lines), encoding="utf-8")
    print(f"[OK] SRT saved to {output_path}")
    return True


def burn_subtitles(input_video: Path, srt_path: Path, output_video: Path) -> bool:
    """
    Burn SRT subtitles into video using ffmpeg subtitles filter.
    """
    if not srt_path.exists():
        print("[WARN] SRT file not found, skipping subtitles")
        return False

    cmd = [
        'ffmpeg', '-y',
        '-i', str(input_video),
        '-vf', f"subtitles={srt_path}",
        '-c:a', 'copy',
        str(output_video)
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
    if result.returncode == 0:
        print(f"[OK] Subtitles burned → {output_video}")
        return True
    else:
        print(f"[WARN] subtitles filter failed: {result.stderr.strip()[:200]}")
        return False


def create_storyboard_summary(
    videos: List[Path],
    shots: List[Dict],
    images: List[Dict]
) -> str:
    """Create text-based storyboard summary."""
    lines = []
    lines.append("=" * 70)
    lines.append("STORYBOARD SUMMARY")
    lines.append("=" * 70)
    lines.append("")
    
    # Build lookup maps
    shots_by_num = {shot.get("shot_number", i+1): shot for i, shot in enumerate(shots)}
    images_by_num = {img.get("shot_number", i+1): img for i, img in enumerate(images)}
    
    total_duration = 0
    
    for i, video in enumerate(videos):
        shot_num = i + 1
        
        lines.append(f"SHOT {shot_num}")
        lines.append("-" * 40)
        
        # Shot info
        shot = shots_by_num.get(shot_num, {})
        if shot:
            lines.append(f"Description: {shot.get('description', 'N/A')}")
            lines.append(f"Visual: {shot.get('visual_description', 'N/A')[:100]}...")
            lines.append(f"Duration: {shot.get('duration_suggestion', 'N/A')}s")
            lines.append(f"Camera: {shot.get('camera_movement', 'N/A')}")
        else:
            lines.append(f"Description: (no data)")
        
        # Video file info
        lines.append(f"Video: {video.name}")
        
        duration = get_video_duration(video)
        if duration:
            total_duration += duration
            lines.append(f"Actual Duration: {duration:.2f}s")
        
        # Image file
        image = images_by_num.get(shot_num, {})
        if image:
            local_path = image.get("local_path")
            if local_path:
                lines.append(f"Image: {Path(local_path).name}")
        
        lines.append("")
    
    lines.append("=" * 70)
    lines.append(f"TOTAL SHOTS: {len(videos)}")
    if total_duration > 0:
        lines.append(f"TOTAL DURATION: {total_duration:.2f}s ({total_duration/60:.1f} min)")
    lines.append(f"OUTPUT VIDEO: {FINAL_VIDEO.name}")
    lines.append("=" * 70)
    
    return "\n".join(lines)


def merge_videos(
    videos_dir: str = "./output/videos",
    output_path: str = "./output/final_story.mp4",
    create_summary: bool = True,
    subtitles: bool = False
) -> bool:
    """
    Merge all videos in directory into a single video.

    Args:
        videos_dir: Directory containing video files
        output_path: Path for merged output video
        create_summary: Whether to create storyboard summary
        subtitles: Whether to generate and burn SRT subtitles from shots.json descriptions

    Returns:
        True if successful, False otherwise
    """
    print("=" * 60)
    print("MERGE VIDEOS")
    print("=" * 60)

    # Check ffmpeg; auto-install if missing
    if not check_ffmpeg():
        print("[WARN] FFmpeg not found.")
        if not install_ffmpeg():
            print("[ERROR] FFmpeg installation failed or not supported on this OS.")
            print("  Please install ffmpeg manually and try again.")
            return False
        if not check_ffmpeg():
            print("[ERROR] FFmpeg still not found after installation attempt.")
            return False
    print("[OK] FFmpeg available")

    # Find videos
    global VIDEOS_DIR
    VIDEOS_DIR = Path(videos_dir)

    videos = find_videos()
    if not videos:
        print(f"[ERROR] No videos found in {VIDEOS_DIR}")
        print("  Expected files like: video_001.mp4, video_002.mp4, ...")
        return False

    print(f"[OK] Found {len(videos)} videos:")
    for v in videos:
        print(f"  - {v.name}")

    # Load metadata if available
    shots, images = load_shots_and_images()
    if shots:
        print(f"[OK] Loaded {len(shots)} shot descriptions")
    if images:
        print(f"[OK] Loaded {len(images)} image records")

    # Merge videos
    output_file = Path(output_path)
    output_file.parent.mkdir(parents=True, exist_ok=True)

    success = merge_videos_concat_demuxer(videos, output_file)

    if not success:
        return False

    # Get final video info
    if output_file.exists():
        size = output_file.stat().st_size
        duration = get_video_duration(output_file)

        print(f"\n[OK] Final video created:")
        print(f"  Path: {output_file}")
        print(f"  Size: {size / (1024*1024):.2f} MB")
        if duration:
            print(f"  Duration: {duration:.2f}s ({duration/60:.1f} min)")

    # Generate and optionally burn subtitles
    if subtitles and shots:
        print("\n[...] Generating subtitles...")
        timeline = build_shot_timeline(videos, shots)
        if timeline:
            generate_srt(timeline, SRT_FILE)
            # Try to burn subtitles
            temp_output = output_file.parent / "final_with_subs.mp4"
            if burn_subtitles(output_file, SRT_FILE, temp_output):
                # Replace original with subtitled version
                temp_output.replace(output_file)
                print(f"[OK] Subtitles burned into final video")
            else:
                print(f"[WARN] Could not burn subtitles — ffmpeg may lack ASS support")

    # Create storyboard summary
    if create_summary:
        print("\n[...] Creating storyboard summary...")
        summary = create_storyboard_summary(videos, shots, images)

        summary_file = Path("./output/storyboard.txt")
        summary_file.write_text(summary, encoding='utf-8')
        print(f"[OK] Storyboard saved to: {summary_file}")

        print("\n" + summary)

    print("\n" + "=" * 60)
    print("COMPLETE")
    print("=" * 60)

    return True


if __name__ == "__main__":
    import argparse
    
    parser = argparse.ArgumentParser(description="Merge videos into single file")
    parser.add_argument("-i", "--input", default="./output/videos", help="Input videos directory")
    parser.add_argument("-o", "--output", default="./output/final_story.mp4", help="Output video path")
    parser.add_argument("--no-summary", action="store_true", help="Skip storyboard summary")
    parser.add_argument("--subtitles", action="store_true", help="Generate and burn SRT subtitles from shots.json")
    
    args = parser.parse_args()
    
    try:
        success = merge_videos(
            videos_dir=args.input,
            output_path=args.output,
            create_summary=not args.no_summary,
            subtitles=args.subtitles
        )
        if success:
            print("\nVideos merged successfully!")
        else:
            print("\nFailed to merge videos.")
            exit(1)
    except Exception as e:
        print(f"\nERROR: {e}")
        exit(1)


path: scripts/pipeline.py
content: #!/usr/bin/env python3
"""
pipeline.py — 故事视频完整流水线
一键执行：故事→分镜→图片→视频→合并
"""

import os
import sys
import json
import time
import argparse
from pathlib import Path
from datetime import datetime

# Add scripts directory to path
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))

# Import individual step modules
try:
    import story_to_shots
    import generate_shot_images
    import generate_shot_videos
    import merge_videos
    IMPORTS_OK = True
except ImportError as e:
    IMPORTS_OK = False
    IMPORT_ERROR = str(e)


def run_step(name: str, fn, *args, **kwargs):
    """Run a pipeline step with timing and error handling."""
    print(f"\n{'=' * 60}")
    print(f"STEP: {name}")
    print(f"{'=' * 60}")
    start = time.time()
    try:
        result = fn(*args, **kwargs)
        elapsed = time.time() - start
        print(f"[OK] {name} 完成 ({elapsed:.1f}s)")
        return result
    except Exception as e:
        elapsed = time.time() - start
        print(f"[FAIL] {name} 失败 ({elapsed:.1f}s): {e}")
        raise


def main():
    parser = argparse.ArgumentParser(description="Story → Video 完整流水线")
    parser.add_argument("story", nargs="?", help="故事文本或 .txt 文件路径")
    parser.add_argument("-o", "--output-dir", default="./output", help="输出目录 (默认: ./output)")
    parser.add_argument("--shots", default=None, help="跳过故事→分镜，使用已有分镜JSON")
    parser.add_argument("--images", default=None, help="跳过图片生成，使用已有图片JSON")
    parser.add_argument("--provider", default="minimax", choices=["minimax", "doubao"], help="视频生成 provider (默认: minimax)")
    parser.add_argument("--duration", type=int, default=6, choices=[6, 10], help="视频时长 秒 (默认: 6)")
    parser.add_argument("--shots-count", type=int, default=None, help="强制分镜数量 (默认: 模型自动决定)")
    parser.add_argument("--skip-video", action="store_true", help="只生成到图片，跳过视频")
    args = parser.parse_args()

    # Check imports
    if not IMPORTS_OK:
        print(f"[ERROR] 无法导入子模块: {IMPORT_ERROR}")
        print("请确保所有脚本都在同一目录下")
        sys.exit(1)

    # Validate story input
    if not args.story and not args.shots:
        parser.print_help()
        print("\n[ERROR] 必须提供故事文本或 --shots 参数")
        sys.exit(1)

    # Setup output
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    frames_dir = output_dir / "frames"
    videos_dir = output_dir / "videos"
    frames_dir.mkdir(exist_ok=True)
    videos_dir.mkdir(exist_ok=True)

    print(f"Story Video Pipeline")
    print(f"Output directory: {output_dir}")
    print(f"Provider: {args.provider}")
    print(f"Video duration: {args.duration}s")
    print(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

    # ===== STEP 1: Story → Shots =====
    if args.shots:
        shots_path = args.shots
        print(f"[INFO] 使用已有分镜: {shots_path}")
    else:
        story_text = args.story
        # If story is a file path, read it
        story_path = Path(story_text)
        if story_path.exists() and story_path.is_file():
            story_text = story_path.read_text(encoding="utf-8")
            print(f"[INFO] 从文件读取故事: {story_path}")

        shots_path = str(output_dir / "shots.json")
        run_step("故事 → 分镜", story_to_shots.story_to_shots, story_text, "-o", shots_path)

    # ===== STEP 2: Shots → Images =====
    if args.images:
        images_path = args.images
        print(f"[INFO] 使用已有图片结果: {images_path}")
    else:
        images_path = str(output_dir / "shot_images.json")
        run_step("分镜 → 图片", generate_shot_images.generate_all_images,
                 shots_path, images_path)

    # ===== STEP 3: Images → Videos =====
    if not args.skip_video:
        videos_path = str(output_dir / "shot_videos.json")
        run_step("图片 → 视频", generate_shot_videos.generate_all_videos,
                 images_path, videos_path,
                 args.provider, args.duration)

    # ===== STEP 4: Merge =====
    if not args.skip_video:
        final_path = str(output_dir / "final_story.mp4")
        run_step("合并视频", merge_videos.merge_all_videos,
                 str(videos_dir), final_path)

        # Generate storyboard
        storyboard_path = output_dir / "storyboard.txt"
        run_step("生成故事板", merge_videos.generate_storyboard,
                 shots_path, images_path, str(storyboard_path))

    print(f"\n{'=' * 60}")
    print("PIPELINE COMPLETE")
    print(f"{'=' * 60}")
    print(f"Output directory: {output_dir}")
    if not args.skip_video:
        print(f"Final video: {output_dir / 'final_story.mp4'}")
    print(f"Finished: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")


if __name__ == "__main__":
    main()


path: scripts/story_to_shots.py
content: #!/usr/bin/env python3
"""
story_to_shots.py
Breaks a story outline into individual shot descriptions using MiniMax LLM API.
Outputs JSON list of shots with detailed visual descriptions.
"""

import os
import json
import time
import requests
from pathlib import Path
from typing import List, Dict, Any, Optional

# Configuration
API_ENDPOINT = "https://api.minimaxi.com/v1/text/chatcompletion_v2"
MODEL = "MiniMax-M2.7"  # MiniMax-M2.7, MiniMax-M2.5-highspeed also available

# Retry settings
MAX_RETRIES = 3
RETRY_DELAY = 5  # seconds


def get_api_key() -> str:
    """Get API key from environment."""
    [REDACTED_SECRET]"MINIMAX_API_KEY")
    if not api_key:
        raise ValueError("MINIMAX_API_KEY environment variable not set")
    return api_key


def load_story(story_path: str) -> str:
    """Load story text from file or return as-is if it's direct text."""
    path = Path(story_path)
    if path.exists() and path.is_file():
        return path.read_text(encoding="utf-8")
    # Assume it's direct text content
    return story_path


def build_prompt(story_text: str) -> str:
    """Build the prompt for shot breakdown."""
    return f"""You are a professional film director and storyboard artist. 
Break down the following story into individual shots for video production.

For each shot, provide:
1. shot_number: Sequential number
2. description: Brief narrative description of what happens in this shot
3. visual_description: Detailed visual description for image generation (describe scene, characters, camera angle, lighting, mood, colors, composition)
4. duration_suggestion: Estimated duration in seconds (2-8 seconds typical)
5. camera_movement: e.g., "static", "pan left", "zoom in", "tracking shot", "tilt up"

Story:
---
{story_text}
---

Output ONLY a valid JSON array of shots. No markdown, no explanation.
Example format:
[
  {{
    "shot_number": 1,
    "description": "Wide establishing shot of a mountain village at sunrise",
    "visual_description": "Aerial view of ancient stone houses clustered on a hillside, golden hour lighting, mist rising from valleys below, warm orange and purple sky, cinematic wide lens, photorealistic",
    "duration_suggestion": 5,
    "camera_movement": "static"
  }}
]
"""


def call_llm_with_retry(prompt: str, api_key: str, timeout: int = 120) -> Dict[str, Any]:
    """Call MiniMax LLM API with retry logic."""
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    }
    
    payload = {
        "model": MODEL,
        "messages": [
            {"role": "user", "content": prompt}
        ],
        "max_tokens": 4000,
        "temperature": 0.7
    }
    
    last_error = None
    for attempt in range(MAX_RETRIES):
        try:
            print(f"  [Attempt {attempt + 1}/{MAX_RETRIES}] Calling LLM API...")
            response = requests.post(
                API_ENDPOINT,
                headers=headers,
                json=payload,
                timeout=timeout
            )
            response.raise_for_status()
            result = response.json()
            
            # Extract content from MiniMax response format
            content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
            if not content:
                # Try alternative format
                content = result.get("content", "")
            
            return {"success": True, "content": content, "raw": result}
            
        except requests.exceptions.Timeout:
            last_error = f"Request timeout after {timeout}s"
            print(f"  Timeout: {last_error}")
        except requests.exceptions.RequestException as e:
            last_error = str(e)
            print(f"  Request error: {last_error}")
        except json.JSONDecodeError as e:
            last_error = f"JSON decode error: {e}"
            print(f"  Response parse error: {last_error}")
        
        if attempt < MAX_RETRIES - 1:
            print(f"  Retrying in {RETRY_DELAY}s...")
            time.sleep(RETRY_DELAY)
    
    return {"success": False, "error": last_error}


def parse_shots(content: str) -> List[Dict[str, Any]]:
    """Parse JSON shots from LLM response content."""
    # Try to extract JSON from markdown code blocks first
    import re
    
    # Remove markdown code blocks if present
    content = re.sub(r'^```json\s*', '', content, flags=re.MULTILINE)
    content = re.sub(r'^```\s*', '', content, flags=re.MULTILINE)
    
    # Find JSON array
    json_match = re.search(r'\[\s*\{.*\}\s*\]', content, re.DOTALL)
    if json_match:
        json_str = json_match.group(0)
    else:
        # Try the whole content
        json_str = content.strip()
    
    try:
        shots = json.loads(json_str)
        # Validate structure
        for shot in shots:
            if "shot_number" not in shot:
                shot["shot_number"] = shots.index(shot) + 1
        return shots
    except json.JSONDecodeError as e:
        raise ValueError(f"Failed to parse shots JSON: {e}\nContent: {content[:500]}")


def generate_shots(story_input: str, output_path: str = "./output/shots.json") -> List[Dict[str, Any]]:
    """
    Main function to convert story to shots.
    
    Args:
        story_input: Path to story file or direct story text
        output_path: Path to save the JSON output
        
    Returns:
        List of shot dictionaries
    """
    print("=" * 60)
    print("STORY TO SHOTS")
    print("=" * 60)
    
    # Get API key
    [REDACTED_SECRET])
    print(f"[OK] API key loaded")
    
    # Load story
    story_text = load_story(story_input)
    print(f"[OK] Story loaded ({len(story_text)} characters)")
    print(f"  Preview: {story_text[:100]}...")
    
    # Build prompt
    prompt = build_prompt(story_text)
    
    # Call LLM
    print("[...] Calling LLM API to generate shots...")
    result = call_llm_with_retry(prompt, api_key)
    
    if not result["success"]:
        raise RuntimeError(f"LLM API call failed: {result['error']}")
    
    content = result["content"]
    print(f"[OK] Received response ({len(content)} characters)")
    
    # Parse shots
    print("[...] Parsing shot descriptions...")
    shots = parse_shots(content)
    print(f"[OK] Parsed {len(shots)} shots")
    
    # Save output
    output_file = Path(output_path)
    output_file.parent.mkdir(parents=True, exist_ok=True)
    output_file.write_text(json.dumps(shots, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"[OK] Saved to {output_path}")
    
    # Print summary
    print("\nSHOT SUMMARY:")
    print("-" * 40)
    for shot in shots:
        print(f"  Shot {shot['shot_number']}: {shot['description'][:50]}...")
        print(f"    Duration: {shot.get('duration_suggestion', 'N/A')}s | Camera: {shot.get('camera_movement', 'N/A')}")
    
    print("\n" + "=" * 60)
    print("COMPLETE")
    print("=" * 60)
    
    return shots


if __name__ == "__main__":
    import argparse
    
    parser = argparse.ArgumentParser(description="Convert story to shot list")
    parser.add_argument("story", nargs="?", default=None, help="Story text or path to story file")
    parser.add_argument("-o", "--output", default="./output/shots.json", help="Output JSON path")
    
    args = parser.parse_args()
    
    if not args.story:
        # Interactive mode
        print("Enter your story (Ctrl+D to finish):")
        story_text = ""
        try:
            while True:
                story_text += input() + "\n"
        except EOFError:
            pass
        args.story = story_text
    
    try:
        shots = generate_shots(args.story, args.output)
        print(f"\nSuccessfully generated {len(shots)} shots!")
    except Exception as e:
        print(f"\nERROR: {e}")
        exit(1)


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