Skill: hit-cxf/aigc-director (version 1.0.0)

--- name: aigc-director description: AI 视频生成全流程：通过 6 个阶段（剧本→角色/场景设计→分镜→参考图→视频生成→后期剪辑）将用户想法转化为完整视频。支持临时工作台（单独调用 LLM、VLM、文生图、图生图、视频生成）。触发词：视频生成、AI视频、AIGC、创作视频、制作视频、AI画图。 license: MIT License metadata: author: Lychee version: "1.0" --- # AIGC-Director Agent Skill > **本地运行**：这是一个**本地部署**的视频生成项目，**前后端都运行在本机**： > - 后端：`http://localhost:8000` > - 前端：`http://localhost:3000` > - 所有 API 调用都请求本地服务器，不要请求其他地址！ > - 确保在调用任何 API 之前，后端和前端服务都已经启动并运行正常！ > **核心理念**：Agent 应该像"持续陪伴的智能视频制作助理"，每完成一个用户可感知的重要任务，都应立即给用户一条简报，并等待用户确认。 > **核心原则**：每个阶段的产物都必须展示给用户，必须停下来等待用户确认后才能继续下一阶段。 > **防止遗忘**：在整个流程中，Agent 可能会忘记之前的用户输入或之前阶段的产物内容。**每当进入一个新的阶段时，Agent 都必须重新加载这篇SKILL文档，确保不会忘记任何细节**。 --- ## 项目结构 ``` aigc-director/ ← OpenClaw 调用的 skill 根目录 ├── aigc-claw/ ← 前后端项目代码 │ ├── backend/ ← FastAPI 后端（端口 8000） │ │ └── code/result/ ← 模型生成产物存放目录 │ │ ├── script/ ← 剧本产物 │ │ ├── image/ ← 图片产物（角色、场景、参考图） │ │ └── video/ ← 视频产物 │ └── frontend/ ← Next.js 前端（端口 3000） ├── references/ ← OpenClaw 调用时的参考文档 │ ├── init_project/ ← 项目初始化 │ ├── run_project/ ← 服务启动 │ ├── workflow/ ← 六阶段工作流 API │ ├── sandbox/ ← 临时工作台 API │ └── send_message/ ← 消息发送 └── SKILL.md ← skill 正文 ``` > **产物存放目录**：`aigc-claw/backend/code/result/` > - `script/` - 剧本产物 > - `image/` - 图片产物（角色、场景、参考图） > - `video/` - 视频产物 --- ## 阶段与停点（共9个） | 停点 | 阶段 | phase 值 | 描述 | 操作 | |------|------|----------|------|------| | 1 | 项目配置 | - | 确认配置选项 | 展示配置 → 用户确认 | | 2 | 剧本生成 | suggest_expand | 建议扩写 | 等待用户确认 | | 3 | 剧本生成 | logline_selection | 选择情节 | 从3个候选中选择 | | 4 | 剧本生成 | mode_selection | 选择模式 | 电影(4幕) / 微电影(1幕) | | 5 | 剧本生成 | script_generation | 确认剧本 | 确认后继续 | | 6 | 角色/场景设计 | - | 确认角色/场景图片 | 确认后继续 | | 7 | 分镜设计 | - | 确认分镜列表 | 确认后继续 | | 8 | 参考图生成 | - | 确认参考图 | 确认后继续 | | 9 | 视频生成 | - | 确认视频片段 | 确认后继续 | > **注意**：`suggest_expand` 和 `logline_selection` 可能根据输入质量被跳过。 --- ## 工作流程 ### 1. 本地部署(仅初始化时执行) 当用户要求"初始化项目"、"配置项目"、"部署项目"时，需要先进行项目初始化：参考 [init_all.md](references/init_project/init_all.md) 执行完整初始化流程。 > **注意**：仅在用户首次下载项目或需要重新配置环境时使用。项目已初始化过则跳过此步骤，直接检查服务运行状态。 ### 2. 检查本地服务 参考 [start_backend.md](references/run_project/start_backend.md) 和 [start_frontend.md](references/run_project/start_frontend.md) 检查服务是否运行。 > **⚠️ 强制要求**：如果服务未运行，必须先启动服务再继续！ ### 2. 路由判断 | 用户说 | 处理 | |--------|------| | "生成图片" | 临时工作台 (sandbox) | | "生成视频" | 必须先询问：长视频(工作流) 还是 短视频(工作台)？ | | "分析图片" | 临时工作台 (sandbox) | | "问 LLM 问题" | 临时工作台 (sandbox) | | "照片转动漫" | 临时工作台 (sandbox) | ### 3. 执行流程 ``` 1. 检查后端运行状态 → 未运行则参考 start_backend.md 启动 → 等待3秒 → 再次检查 2. 检查前端运行状态 → 未运行则参考 start_frontend.md 启动 → 等待5秒 → 再次检查 3. 检查 API Key 配置 → 读取 .env 文件，确认所需 API Key 已配置 4. 参考 create_project.md 询问用户项目配置 → 停点1（配置确认）→ 创建项目 5. 参考 create_script.md 执行剧本生成 → 停点2-5 6. 参考 create_character.md 执行角色设计 → 停点6 7. 参考 create_storyboard.md 执行分镜设计 → 停点7 8. 参考 create_reference.md 执行参考图生成 → 停点8 9. 参考 create_video.md 执行视频生成 → 停点9 10. 参考 create_post.md 执行后期剪辑 11. 完成 → 发送最终视频给用户 ``` > **注意**：一定要参考 `references/` 目录下的具体文档执行每一步操作，不要凭记忆或想当然去调用 API！ #### 检查 API Key 配置 在创建项目前，必须检查用户选择的模型对应的 API Key 是否已配置： ```bash # 读取 .env 文件检查配置 cat aigc-claw/backend/.env | grep -E "API_KEY|KEY" # 必需的配置（根据选择的模型） # LLM: DASHSCOPE_API_KEY / DEEPSEEK_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY # 图片: ARK_API_KEY / DASHSCOPE_API_KEY # 视频: DASHSCOPE_API_KEY / VOLC_ACCESS_KEY / KLING_ACCESS_KEY ``` 如果 API Key 未配置，需要提醒用户： 1. 告知缺少哪个平台的 API Key 2. 提供获取方式 3. 配置位置（`aigc-claw/backend/.env` 文件） 4. 等待用户配置完成后才能继续 | 平台 | API Key 变量 | 获取链接 | |------|--------------|----------| | DeepSeek | `DEEPSEEK_API_KEY` | https://platform.deepseek.com/api_keys | | 阿里云 DashScope | `DASHSCOPE_API_KEY` | https://bailian.console.aliyun.com/cn-beijing/?tab=home#/home | | 字节火山方舟 | `ARK_API_KEY` 或 `VOLC_ACCESS_KEY`/`VOLC_SECRET_KEY` | https://www.volcengine.com/product/ark | | 快手可灵 Kling | `KLING_ACCESS_KEY`/`KLING_SECRET_KEY` | https://klingai.com/cn/dev | --- ## 🚨 停点处理（强制规则） **当查询状态为 `stage_completed` 或 `waiting_in_stage` 时，必须按以下步骤执行：** ### 步骤1：获取产物 ```bash curl "http://localhost:8000/api/project/{session_id}/artifact/{stage}" ``` ### 步骤2：展示给用户 将 artifact 中的内容（选项列表、建议、产物摘要）**完整展示**给用户 ### 步骤3：询问决策 明确告诉用户： - 选项有哪些 - 每个选项的含义 - 需要用户选择什么 ### 步骤4：等待用户回复 **禁止**在用户回复前自行调用 `intervene` 或 `continue`！ ### 步骤5：用户确认后执行 根据用户的选择，调用相应的 API --- ### ❌ 错误示例（我刚才犯的错） ``` 收到 suggest_expand 停点 → 直接调用 intervene → 跳过用户确认 ``` ### ✅ 正确示例 ``` 1. 阶段内部停点触发（如 suggest_expand） 收到 suggest_expand 停点 → 获取 artifact 查看内容 → 展示给用户："系统建议启用创意扩写模式..." → 询问："是否同意？" → 用户回复"同意" → 调用 intervene 2. 阶段完成停点触发 收到 stage_completed 停点 → 获取 artifact 查看产物内容 → 展示给用户："第一阶段已完成，生成了剧本内容..." → 询问："是否继续下一阶段？" → 用户回复"继续" → 调用 continue ``` **每个停点必须**： 1. 展示产物或选项给用户 2. 询问确认 3. 用户确认后才能继续 --- ## 状态判断 | status | 含义 | 操作 | |--------|------|------| | idle | 新建会话 | 启动项目 | | running | 执行中 | 轮询等待 | | waiting_in_stage | 等待用户介入 | 调用 `intervene` | | stage_completed | 阶段完成 | 调用 `continue` | | session_completed | 全部完成 | 结束 | > **注意**：只有 status 变化时才需要干预，不要反复调用 artifact API 去"确认"！ --- ## 消息发送渠道 根据向用户发送消息的渠道（飞书/微信），读取 `references/send_message/` 下的对应参考文档，获取注意事项和发送方法： - [feishu.md](references/send_message/feishu.md) - 飞书发送消息 - [wechat.md](references/send_message/wechat.md) - 微信发送消息 --- ## 任务简报格式 每个阶段完成后，发送简报必须包含： 1. 刚完成什么 2. 下一步做什么 3. 需要用户决策的内容 4. **Web 界面链接**：`http://[本地IP]:3000/?session={session_id}&stage={stage}`（注意，这里使用本地 IPv4 地址，不要用 localhost！） 5. 产物图片/视频（直接发送文件，禁止只发路径） ### Web 界面链接格式 ```python # 获取本地 IPv4 地址 import socket local_ip = socket.gethostbyname(socket.gethostname()) # 构造前端 URL frontend_url = f"http://{local_ip}:3000/?session={session_id}&stage={stage}" # 发送给用户 send_to_user(f"📊 查看详情：{frontend_url}") ``` > **重要**：必须使用本地 IPv4 地址（如 `192.168.1.x`），不要使用 `localhost` 或 `127.0.0.1`，否则用户无法从其他设备访问！ --- ## 详细参考 根据用户的需求和当前阶段，参考 `references/` 目录下的具体文档执行相应操作： ### references 目录 | 文件 | 用途 | 查看时机 | |------|------|----------| | **init_project/** | 项目初始化 | 用户首次下载或要求"初始化项目"时 | | [init_all.md](references/init_project/init_all.md) | 完整初始化流程 | 用户要求初始化部署时 | | [init_backend.md](references/init_project/init_backend.md) | 后端初始化 | 首次配置后端环境时 | | [init_frontend.md](references/init_project/init_frontend.md) | 前端初始化 | 首次配置前端环境时 | | **run_project/** | 项目启动 | | | [start_backend.md](references/run_project/start_backend.md) | 启动后端服务 | 服务未运行时 | | [start_frontend.md](references/run_project/start_frontend.md) | 启动前端服务 | 服务未运行时 | | **workflow/** | 六阶段工作流 | | | [create_project.md](references/workflow/create_project.md) | 创建新项目 API | 开始新视频项目时 | | [create_script.md](references/workflow/create_script.md) | 剧本生成 API | 执行第一阶段时 | | [create_character.md](references/workflow/create_character.md) | 角色/场景设计 API | 执行第二阶段时 | | [create_storyboard.md](references/workflow/create_storyboard.md) | 分镜设计 API/剧情续写 API | 执行第三阶段时/用户提出续写剧情时 | | [create_reference.md](references/workflow/create_reference.md) | 参考图生成 API | 执行第四阶段时 | | [create_video.md](references/workflow/create_video.md) | 视频生成 API | 执行第五阶段时 | | [create_post.md](references/workflow/create_post.md) | 后期剪辑 API | 执行第六阶段时 | | [modify_character.md](references/workflow/modify_character.md) | 修改角色提示词 | 用户要求修改角色时 | | [modify_storyboard.md](references/workflow/modify_storyboard.md) | 修改/续写分镜 | 用户要求修改/续写分镜时 | | [modify_reference.md](references/workflow/modify_reference.md) | 修改参考图提示词 | 用户要求修改参考图时 | | [modify_video.md](references/workflow/modify_video.md) | 修改视频提示词 | 用户要求修改视频时 | | **sandbox/** | 临时工作台 | | | [generate_image_t2i.md](references/sandbox/generate_image_t2i.md) | 文生图 API | 用户要求生成图片时 | | [generate_image_it2i.md](references/sandbox/generate_image_it2i.md) | 图生图/风格转换 API | 用户要求转换图片风格时 | | [generate_video.md](references/sandbox/generate_video.md) | 短视频生成 API | 用户要求生成15秒内视频时 | | **send_message/** | 消息发送 | | | [feishu.md](references/send_message/feishu.md) | 飞书发送媒体文件 | 用户通过飞书渠道发起对话，并且需要向用户发送图片/视频给用户时 | | [wechat.md](references/send_message/wechat.md) | 微信发送媒体文件 | 用户通过微信渠道发起对话，并且需要向用户发送图片/视频给用户时 |

path: CLAUDE.md
content: # CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

AIGC-Claw is an AI video generation system that transforms user ideas into complete videos through 6 stages: Script → Character/Scene Design → Storyboard → Reference Images → Video Generation → Post-production.

This repository (`aigc-director/`) is an **OpenClaw Agent Skill** that wraps the actual code project:
- **aigc-claw/**: The actual code project containing the backend (Python FastAPI) and frontend (Next.js)
- **SKILL.md**: Workflow rules for the OpenClaw agent
- **references/**: API documentation

Both run locally: backend at `http://localhost:8000`, frontend at `http://localhost:3000`.

## Commands

### Backend
```bash
cd aigc-claw/backend
source venv/bin/activate
python api_server.py
```

### Frontend
```bash
cd aigc-claw/frontend
npm install  # first time only
npm run build
npm start
```

### Health Check
```bash
curl http://localhost:8000/api/health
```

## Architecture

### Backend Core
- **[orchestrator.py](aigc-claw/backend/core/orchestrator.py)**: Workflow engine managing the 6-stage state machine. Controls session state (idle/running/waiting_in_stage/stage_completed/session_completed), persists to `aigc-claw/backend/code/data/sessions/`, and coordinates agent execution.

- **[base_agent.py](aigc-claw/backend/core/agents/base_agent.py)**: Abstract base class for all stage agents. Defines `process(input_data, intervention)` interface that returns `{"payload": ..., "requires_intervention": bool, "stage_completed": bool}`.

### 6 Stage Agents
Each agent handles one workflow stage:
| Agent | File | Stage |
|-------|------|-------|
| ScriptWriterAgent | script_agent.py | script_generation |
| CharacterDesignerAgent | character_agent.py | character_design |
| StoryboardAgent | storyboard_agent.py | storyboard |
| ReferenceGeneratorAgent | reference_agent.py | reference_generation |
| VideoDirectorAgent | video_agent.py | video_generation |
| VideoEditorAgent | editor_agent.py | post_production |

### Tool Clients
External API integrations in `aigc-claw/backend/tool/`:
- **LLM clients**: llm_dashscope.py, llm_deepseek.py, llm_gpt.py, llm_gemini.py
- **Image clients**: image_dashscope.py, image_client.py (Seedream, Jimeng, Wan)
- **Video clients**: video_wan.py, video_kling.py (Wan, Kling)
- **VLM clients**: vlm_dashscope.py, vlm_gemini.py

### Data Storage
- Results: `aigc-claw/backend/code/result/` (image/, video/, script/)
- Session state: `aigc-claw/backend/code/data/sessions/{session_id}.json`

## Workflow (from SKILL.md)

The system uses **9 stop points** where the agent MUST pause and wait for user confirmation before proceeding:

1. Project config confirmation
2. Script suggest_expand (optional)
3. Script logline selection
4. Script mode selection (movie/micro-film)
5. Script generation confirmation
6. Character/scene design confirmation
7. Storyboard confirmation
8. Reference image confirmation
9. Video clip confirmation

After each stage completes, the agent must:
1. Get artifact via `GET /api/project/{session_id}/artifact/{stage}`
2. Present results to user
3. Wait for user confirmation
4. Call `POST /api/project/{session_id}/continue` to proceed

## Key References

The `references/` folder contains detailed API documentation:
- `run_project/` - Service startup instructions
- `workflow/` - 6-stage workflow API docs
- `sandbox/` - Single-shot tools (image generation, video generation)
- `send_message/` - Feishu/WeChat integration

Important files:
- [SKILL.md](SKILL.md) - Contains the complete workflow rules for OpenClaw agent execution
- [README.md](README.md) - Full project documentation including model configuration

path: README.md
content: # 🎬 AIGC-Claw

AI 视频生成全流程系统，通过 6 个阶段将用户想法转化为完整视频。

## 功能特性

- **剧本生成**：输入创意自动生成结构化剧本
- **角色设计**：AI 生成角色设定图（四视图）
- **场景设计**：自动生成场景背景图
- **分镜设计**：智能拆分镜头脚本
- **参考图生成**：为每个镜头生成高精度参考图
- **视频生成**：文生视频 / 图生视频
- **后期剪辑**：自动拼接视频片段，添加转场

## 环境要求

- **Python**: 3.9+
- **Node.js**: 18+
- **npm**: 9+

## 技术栈

- **前端**：Next.js 14 + TypeScript + Tailwind CSS
- **后端**：Python FastAPI
- **AI 模型**：阿里云 DashScope (Qwen)、字节跳动 Seedream、即梦 Jimeng、快手可灵 Kling、DeepSeek、OpenAI、Google Gemini

## 快速开始

### 方式一：手动安装

#### 1. 克隆项目

```bash
git clone https://github.com/hit-cxf/AIGC-Claw.git
cd AIGC-Claw # 完整项目根目录(包括FilmAgent和aigc-director)
```

#### 2. 配置并启动后端

先确保进入完整项目目录 `AIGC-Claw`
此时目录下应当有 `aigc-director` 和 `FilmAgent` 两个子目录

##### 配置后端

```bash
cd aigc-director # skill目录
cd aigc-claw # 项目目录
cd backend # 后端目录

# 创建虚拟环境
python -m venv venv

# 根据操作系统选择启动虚拟环境的命令
source venv/bin/activate  # Linux/Mac
.\venv\Scripts\activate  # Windows

# 安装依赖
pip install -r requirements.txt

# 配置环境变量
cp .env.example .env
# 编辑 .env 填入 API Key
# 支持的模型见“配置说明”部分
```

##### 启动后端

```bash
# 根据操作系统选择启动虚拟环境的命令
source venv/bin/activate  # Linux/Mac
.\venv\Scripts\activate  # Windows

python api_server.py
# 服务运行在 http://localhost:8000
```

终端显示

```
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
```

说明启动成功。保持当前终端运行，新建终端以启动前端

#### 3. 配置并启动前端

在新的终端下完成配置
先确保进入完整项目目录 `AIGC-Claw`
此时目录下应当有 `aigc-director` 和 `FilmAgent` 两个子目录

##### 配置前端

```bash
cd aigc-director # skill目录
cd aigc-claw # 项目目录
cd frontend
npm install
# 首次启动或代码变更后需要 build
npm run build
# 启动生产服务（开销小，只需 build 一次）
```

##### 启动前端

```bash
npm start
# 访问 http://localhost:3000
```

---

### 方式二：OpenClaw 自动配置

向openclaw发送消息：

```
帮我克隆git仓库：https://github.com/hit-cxf/AIGC-Claw.git
然后把AIGC-Claw中的aigc-director文件夹递归复制到workspace/skills中，用作AIGC相关的skill
复制完成后，检查aigc-director是否加载到了技能列表中
```

之后使用时，建议在向openclaw发送指令的同时，指明“使用aigc-director”，如：

```
你用aigc-director来帮我生成一个视频，内容是“一条狗的使命”
```

## 项目结构

```
aigc-director/                    # OpenClaw Agent Skill（供 OpenClaw 调用的 AI 视频制作助手）
├── SKILL.md                      # Agent 工作流规则定义
├── CLAUDE.md                     # Claude Code 开发指引
├── README.md                     # 项目说明
├── references/                   # API 参考文档
│   ├── run_project/              # 服务启动指南
│   ├── workflow/                 # 六阶段工作流 API 文档
│   ├── sandbox/                  # 临时工作台 API 文档
│   └── send_message/             # 消息推送集成
└── aigc-claw/                    # 实际代码项目
    ├── backend/                  # Python FastAPI 后端
    │   ├── api_server.py         # API 入口
    │   ├── config.py             # 配置管理
    │   ├── core/
    │   │   ├── orchestrator.py   # 工作流引擎
    │   │   └── agents/           # 6 个阶段 Agent
    │   │       ├── script_agent.py      # 剧本生成
    │   │       ├── character_agent.py  # 角色设计
    │   │       ├── storyboard_agent.py # 分镜设计
    │   │       ├── reference_agent.py  # 参考图生成
    │   │       ├── video_agent.py      # 视频生成
    │   │       └── editor_agent.py     # 后期剪辑
    │   └── tool/                 # 外部 API 客户端
    └── frontend/                 # Next.js 前端
        ├── app/                  # App Router 页面
        ├── components/           # React 组件
        └── config/               # 配置文件
```

> **注意**：整个 AI 视频生成系统代码在 `aigc-claw/` 子目录中，`aigc-director/` 目录是提供给 OpenClaw 平台调用的 Skill 包装。

## 工作流阶段

| 阶段 | Agent | 说明 |
|------|-------|------|
| 1 | 剧本生成 | 将灵感转化为结构化剧本 |
| 2 | 角色设计 | 生成角色设计图和场景背景 |
| 3 | 分镜设计 | 设计镜头语言和分镜脚本 |
| 4 | 参考图生成 | 生成高精度参考图 |
| 5 | 视频生成 | 将参考图转化为视频 |
| 6 | 后期剪辑 | 拼接视频片段为最终成片 |

## 数据存储

### 产物存储位置

所有生成的资产存储在 `aigc-claw/backend/code/result/` 目录下：

| 目录 | 说明 |
|------|------|
| `code/result/image/{session_id}/` | 角色/场景/参考图 |
| `code/result/video/{session_id}/` | 视频片段 |
| `code/result/sandbox/` | 临时工作台生成的文件 |
| `code/result/script/` | LLM 生成的剧本初始数据 |

### 会话数据存储

会话状态和产物元数据存储在 `aigc-claw/backend/code/data/sessions/` 目录下：

- `{session_id}.json` - 包含会话状态、已完成阶段、产物信息等

### 数据读取优先级

1. **会话数据** (`sessions/`) - 用户修改和当前状态（权威数据）
2. **剧本数据** (`result/script/`) - LLM 生成的初始数据

API 返回的资产路径使用相对路径格式：`code/result/...`

## API 接口

| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/project/start` | POST | 创建新项目 |
| `/api/project/{session_id}/execute/{stage}` | POST | 执行指定阶段 |
| `/api/project/{session_id}/status` | GET | 获取项目状态 |
| `/api/project/{session_id}/artifact/{stage}` | GET | 获取阶段产物 |
| `/api/project/{session_id}/intervene` | POST | 干预阶段 |
| `/api/project/{session_id}/continue` | POST | 确认并继续 |
| `/api/project/{session_id}/stop` | POST | 停止执行 |
| `/api/sessions` | GET | 获取会话列表 |
| `/api/stages` | GET | 获取阶段列表 |

## 配置说明

### 后端环境变量

主要配置项（详见 `aigc-claw/backend/.env`）：

```bash
# LLM 配置（剧本生成）
LLM_MODEL=qwen3.5-plus

# VLM 配置（图像评估）
VLM_MODEL=qwen-vl-plus

# 图像生成（默认：doubao-seedream-5-0-260128，支持高并发）
IMAGE_T2I_MODEL=doubao-seedream-5-0-260128
IMAGE_IT2I_MODEL=doubao-seedream-5-0-260128

# 视频生成
VIDEO_MODEL=wan2.6-i2v-flash
VIDEO_RATIO=16:9
```

### API Keys 配置

在 `aigc-claw/backend/.env` 中配置各平台 API Key：

| API Key | 提供商 | 可用模型 |
|---------|------|---------|
| `DASHSCOPE_API_KEY` | 阿里云DashScope | qwen3.5-plus, qwen-vl-plus, wan2.6-t2i, wan2.6-i2v-flash |
| `ARK_API_KEY` | 字节跳动Seedream | doubao-seedream-5-0-260128 (500次/分钟，高并发) |
| `VOLC_ACCESS_KEY/SECRET` | 火山引擎即梦 | jimeng_t2i_v40, jimeng_ti2v_v30_pro |
| `KLING_ACCESS_KEY/SECRET` | 快手可灵 | kling-v3, kling-v2-6 |
| `DEEPSEEK_API_KEY` | DeepSeek | deepseek-chat, deepseek-reasoner |
| `OPENAI_API_KEY` | OpenAI | gpt-4o, gpt-5, o3 |
| `GEMINI_API_KEY` | Google Gemini | gemini-2.5-flash, gemini-2.5-flash-image |

### 可用模型

- **LLM 模型**: deepseek-chat, deepseek-reasoner, gpt-4o, gpt-4, gpt-5, o3, gemini-3-flash-preview, qwen3.5-plus, qwen3.5-max
- **VLM 评估模型**: qwen3.5-plus, qwen-vl-plus, qwen3.5-max, gemini-2.5-flash-image (性价比最高), gemini-2.0-flash
- **文生图模型**: doubao-seedream-5-0-260128, jimeng_t2i_v40, wan2.6-t2i, sora_image, gpt-image-1.5
- **图生图模型**: doubao-seedream-5-0-260128, jimeng_t2i_v40, wan2.6-image
- **视频生成模型**: wan2.6-i2v-flash, kling-v3, kling-v2-6, kling-v2-5-turbo
- **视频比例**: 16:9, 9:16, 1:1, 4:3, 3:4, 21:9

### 并发配置

`aigc-claw/backend/config_model.json` 定义了每个模型的并发限制：

```json
{
  "models": {
    "doubao-seedream-5-0-260128": {
      "concurrency": 10,  // 高并发
      "provider": "seedream"
    },
    "wan2.6-i2v-flash": {
      "concurrency": 5,
      "provider": "dashscope"
    }
  }
}
```

修改此文件可调整模型的最大并发数。

#### 图像生成

| 模型 | 调用限制 | 并发数上限 | 备注 |
|------|---------|-----------|------|
| **wan2.6-t2i** | 1次/秒 | 5个 | 文生图 |
| **wan2.6-image** | 5次/秒 | 5个 | 图像生成 |
| **jimeng_t2i_v40** | - | 2-5个 | 即梦系列 |
| **doubao-seedream-*** | 500次/分钟 | 高并发 | 字节跳动Seedream |
| **qwen-image** | 2次/秒 | 同步无限制 | 需开通 |

#### 视频生成

| 模型 | 调用限制 | 并发数上限 | 备注 |
|------|------|------|------|
| **wan2.6-i2v-flash** | 5次/秒 | 5个 | 首帧生视频 |
| **wan2.6-i2v** | 5次/秒 | 5个 | 首帧生视频 |
| **jimeng_ti2v_v30_pro** | 即梦视频，需实测限流 | | |
| **kling-v3/v2-6** | 快手可灵，需查阅官方文档 | | |

#### LLM / VLM

| 模型 | RPM | TPM |
|------|-----|-----|
| **qwen3.5-plus** | 30,000 | 5,000,000 |
| **qwen-plus** | 30,000 | 5,000,000 |
| **qwen-vl-plus** | 1,200 | 1,000,000 |
| **deepseek-chat** | 15,000 | 1,200,000 |

> **RPM**: 每分钟请求数 | **TPM**: 每分钟Token数

## 文档

- [API 文档](./docs/)
- [SKILL.md](./SKILL.md) - OpenClaw Agent 工作流规则
- [CLAUDE.md](./CLAUDE.md) - Claude Code 开发指引

## 许可证

MIT License


path: aigc-claw/backend/__init__.py
content: # code package
# This file makes the code directory a Python package


path: aigc-claw/backend/api_server.py
content: # -*- coding: utf-8 -*-
"""
AI导演工作室 - API Server
六阶段工作流: 剧本→角色设计→分镜→参考图→视频→后期
"""
import os
import sys
import json
import asyncio
import time
import uvicorn
import queue
import threading
import logging
from logging.handlers import QueueHandler, QueueListener

# ========== 并发日志配置 ==========
def setup_concurrent_logging():
    """配置并发安全的日志系统"""
    # 创建日志队列
    log_queue = queue.Queue(-1)

    # 控制台处理器（主线程输出）
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(logging.Formatter(
        '[%(asctime)s] [%(threadName)-12s] %(levelname)-8s %(message)s',
        datefmt='%H:%M:%S'
    ))

    # 创建 QueueListener（主线程运行，安全输出）
    listener = QueueListener(log_queue, console_handler, respect_handler_level=True)
    listener.start()

    # 为所有现有 logger 添加 QueueHandler
    root_logger = logging.getLogger()
    root_logger.setLevel(logging.DEBUG)

    # 给 root logger 也添加 QueueHandler
    queue_handler = QueueHandler(log_queue)
    root_logger.addHandler(queue_handler)

    # 让所有模块的 logger 都使用 QueueHandler
    for name in logging.Logger.manager.loggerDict:
        logger = logging.getLogger(name)
        # 清除原有 handlers，添加 QueueHandler
        logger.handlers.clear()
        logger.addHandler(queue_handler)

    return listener

# 启动并发日志监听器
_log_listener = setup_concurrent_logging()
# =================================
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Any, Optional, Dict, List

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from config import settings
from core.orchestrator import WorkflowEngine, WorkflowStage

app = FastAPI(title="AI导演工作室", version="2.0.0")
workflow_engine = WorkflowEngine()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

os.makedirs(settings.CODE_DIR, exist_ok=True)
app.mount("/code", StaticFiles(directory=settings.CODE_DIR), name="code")


# ============================== 请求模型 ==============================

class ProjectStartRequest(BaseModel):
    idea: str
    style: Optional[str] = "anime"
    video_ratio: Optional[str] = "16:9"
    expand_idea: Optional[bool] = True  # 默认启用创意扩写
    llm_model: Optional[str] = None
    vlm_model: Optional[str] = None
    image_t2i_model: Optional[str] = None
    image_it2i_model: Optional[str] = None
    video_model: Optional[str] = None
    enable_concurrency: Optional[bool] = True
    web_search: Optional[bool] = False

class InterventionRequest(BaseModel):
    stage: str
    modifications: Dict[str, Any] = {}


# ============================== API ==============================

@app.get("/api/health")
async def health_check():
    """健康检查端点"""
    return {"status": "ok", "timestamp": time.time()}


@app.post("/api/project/start")
async def start_project(req: ProjectStartRequest):
    session_id = str(int(time.time() * 1000))
    state = workflow_engine.get_or_create_state(session_id)
    state.started_at = __import__('datetime').datetime.now()
    state.status = "stage_completed"

    # 保存会话元数据（未传参数时使用 config.py 中的默认值）
    meta = {
        "idea": req.idea,
        "style": req.style or "anime",
        "video_ratio": req.video_ratio or "16:9",
        "expand_idea": req.expand_idea if req.expand_idea is not None else True,
        "llm_model": req.llm_model or settings.LLM_MODEL,
        "vlm_model": req.vlm_model or settings.VLM_MODEL,
        "image_t2i_model": req.image_t2i_model or settings.IMAGE_T2I_MODEL,
        "image_it2i_model": req.image_it2i_model or settings.IMAGE_IT2I_MODEL,
        "video_model": req.video_model or settings.VIDEO_MODEL,
        "enable_concurrency": req.enable_concurrency if req.enable_concurrency is not None else True,
        "web_search": req.web_search if req.web_search is not None else False,
    }
    state.meta = meta
    workflow_engine.save_session_to_disk(session_id, meta)

    return {
        "session_id": session_id,
        "status": state.status,
        "params": {
            "idea": req.idea,
            "style": req.style,
            "llm_model": req.llm_model,
            "vlm_model": req.vlm_model,
        }
    }


def _inject_user_selections(state, stage: str, data: dict):
    """从已持久化的 artifacts 中读取用户选项，注入到 input_data。

    video_generation 需要:
    - selected_images: 用户选择的参考图版本（来自 reference_generation）
    - clips: 包含用户修改的 description（来自 video_generation）

    post_production 需要:
    - selected_clips: 用户选择的视频版本（来自 video_generation）
    """
    # 注入用户选择的参考图
    if stage == 'video_generation' and 'selected_images' not in data:
        ref_art = state.artifacts.get('reference_generation', {})
        if isinstance(ref_art, dict):
            scenes = ref_art.get('scenes', [])
            selected_images = {s['id']: s['selected'] for s in scenes
                              if isinstance(s, dict) and s.get('id') and s.get('selected')}
            if selected_images:
                data['selected_images'] = selected_images

    # 注入已有的 clips（包含用户修改的 description 和 duration）
    if stage == 'video_generation' and 'clips' not in data:
        vid_art = state.artifacts.get('video_generation', {})
        if isinstance(vid_art, dict):
            clips = vid_art.get('clips', [])
            if clips:
                data['clips'] = clips

    # 注入用户选择的视频
    if stage == 'post_production' and 'selected_clips' not in data:
        vid_art = state.artifacts.get('video_generation', {})
        if isinstance(vid_art, dict):
            clips = vid_art.get('clips', [])
            selected_clips = {c['id']: c['selected'] for c in clips
                             if isinstance(c, dict) and c.get('id') and c.get('selected')}
            if selected_clips:
                data['selected_clips'] = selected_clips


@app.post("/api/project/{session_id}/execute/{stage}")
async def execute_stage(session_id: str, stage: str, request: Request):
    state = workflow_engine.get_or_create_state(session_id)

    try:
        body = await request.json()
    except Exception:
        body = {}

    # 确保 body 中有 session_id（agent 需要）
    body["session_id"] = session_id

    # 注入会话级元数据（模型配置等），确保模型参数始终可用
    if state.meta:
        for k, v in state.meta.items():
            if v is not None and k not in body:  # 区分 None 和 False
                body[k] = v

    # 从已持久化的 artifacts 中注入用户选项（selected_images / selected_clips）
    _inject_user_selections(state, stage, body)

    workflow_engine.reset_stop_event(session_id)
    session_stop = workflow_engine.get_stop_event(session_id)
    stop_event = threading.Event()
    cancellation_check = lambda: stop_event.is_set() or session_stop.is_set()

    progress_events = queue.Queue()

    def progress_callback(phase, step, percent, data=None):
        event = {"phase": phase, "step": step, "percent": percent}
        if data:
            event["data"] = data
        progress_events.put(event)

    async def stream_execution():
        stage_enum = WorkflowStage(stage)

        try:
            task = asyncio.create_task(
                workflow_engine.execute_stage(
                    state, stage_enum, body,
                    cancellation_check=cancellation_check,
                    progress_callback=progress_callback
                )
            )

            while not task.done():
                while not progress_events.empty():
                    try:
                        p = progress_events.get_nowait()
                        evt = {
                            "type": "progress",
                            "message": f"{p['phase']}: {p['step']}",
                            "phase": p["phase"],
                            "step_desc": p["step"],
                            "percent": p["percent"],
                        }
                        if p.get("data"):
                            evt["data"] = p["data"]
                        yield json.dumps(evt) + "\n"
                        await asyncio.sleep(0)  # 强制立即发送 SSE 事件
                    except queue.Empty:
                        break

                if await request.is_disconnected():
                    stop_event.set()
                    yield json.dumps({"type": "error", "content": "Client disconnected"}) + "\n"
                    return

                yield json.dumps({"type": "heartbeat", "time": time.time()}) + "\n"
                await asyncio.sleep(0.1)  # 减少心跳间隔，加快事件处理

            # Drain any remaining progress events after task completion
            while not progress_events.empty():
                try:
                    p = progress_events.get_nowait()
                    evt = {
                        "type": "progress",
                        "message": f"{p['phase']}: {p['step']}",
                        "phase": p["phase"],
                        "step_desc": p["step"],
                        "percent": p["percent"],
                    }
                    if p.get("data"):
                        evt["data"] = p["data"]
                    yield json.dumps(evt) + "\n"
                    await asyncio.sleep(0)  # 强制立即发送 SSE 事件
                except queue.Empty:
                    break

            result = task.result()

            # 保存会话到磁盘
            workflow_engine.save_session_to_disk(session_id)

            requires_intervention = result.get("requires_intervention", False)
            # 生成 OpenCLAW 提示文本
            # 优先使用 agent 返回的 openclaw_hint
            openclaw_msg = result.get("openclaw_hint", "")
            if not openclaw_msg and requires_intervention:
                stage_name_map = {
                    "script_generation": "剧本生成",
                    "character_design": "角色/场景设计",
                    "storyboard": "分镜设计",
                    "reference_generation": "参考图生成",
                    "video_generation": "视频生成",
                    "post_production": "后期剪辑",
                }
                stage_name = stage_name_map.get(stage, stage)
                openclaw_msg = f"{stage_name}完成，需要用户确认。请展示给用户并等待用户确认后才能调用 /continue。"

            yield json.dumps({
                "type": "stage_complete",
                "stage": stage,
                "status": state.status,
                "requires_intervention": requires_intervention,
                # OpenCLAW 提示
                "openclaw": openclaw_msg,
                "payload_summary": result.get("payload"),
            }) + "\n"

        except Exception as e:
            # 即使出错也保存会话，保留已生成的部分结果
            try:
                workflow_engine.save_session_to_disk(session_id)
            except Exception:
                pass
            yield json.dumps({"type": "error", "content": str(e)}) + "\n"

    return StreamingResponse(
        stream_execution(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache, no-transform",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        },
    )


@app.get("/api/project/{session_id}/status")
async def get_project_status(session_id: str):
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")
    return state.to_dict()


@app.get("/api/project/{session_id}/status/from_disk")
async def get_project_status_from_disk(session_id: str):
    """从 sessions json 文件读取状态，供前端轮询使用（即使后端重启也能获取状态）"""
    import os
    # backend/api_server.py -> backend/code/data/sessions
    session_dir = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), 'code', 'data', 'sessions'
    )
    session_file = os.path.join(session_dir, f"{session_id}.json")
    if not os.path.exists(session_file):
        raise HTTPException(404, "Session not found")
    with open(session_file, 'r', encoding='utf-8') as f:
        data = json.load(f)
    return data


@app.get("/api/project/{session_id}/artifact/{stage}")
async def get_artifact(session_id: str, stage: str):
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")
    artifact = state.artifacts.get(stage)

    if artifact is None:
        raise HTTPException(404, f"Artifact for stage '{stage}' not found")
    return {"stage": stage, "artifact": artifact}


@app.patch("/api/project/{session_id}/models")
async def update_models(session_id: str, request: Request):
    """更新会话的模型配置"""
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")
    body = await request.json()
    allowed_keys = ("llm_model", "vlm_model", "image_t2i_model", "image_it2i_model", "video_model", "video_ratio", "enable_concurrency")
    if not state.meta:
        state.meta = {}
    for k in allowed_keys:
        if k in body:
            state.meta[k] = body[k]
    workflow_engine.save_session_to_disk(session_id)
    return {"status": "ok"}


@app.patch("/api/project/{session_id}/artifact/{stage}")
async def update_artifact(session_id: str, stage: str, request: Request):
    """保存用户在某阶段的选择（如选定的图片/视频版本）

    数据存储策略：
    - 用户修改只保存到 sessions json（state.artifacts）
    - result/script json 只作为 LLM 初始生成，不接受用户修改
    """
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")
    body = await request.json()

    # 第三阶段分镜修改：同步更新 video_generation 的 clips duration
    if stage == "storyboard" and "shots" in body:
        shot_durations = {s['shot_id']: s.get('duration', 10) for s in body['shots'] if 'shot_id' in s}
        video_art = state.artifacts.get('video_generation', {})
        if isinstance(video_art, dict) and 'clips' in video_art:
            for clip in video_art['clips']:
                shot_id = clip.get('id')
                if shot_id in shot_durations:
                    clip['duration'] = shot_durations[shot_id]
                    logger.info(f"Synced duration {shot_durations[shot_id]}s to video_generation clip {shot_id}")
        # 移除 shots，避免合并到 storyboard artifact
        body = {k: v for k, v in body.items() if k != "shots"}

    # 第三阶段确认新分镜：清除 is_new 标记
    if stage == "storyboard" and "shots" in body:
        updated_shots = body.get('shots', [])
        # 清除所有 is_new 标记
        for shot in updated_shots:
            if 'is_new' in shot:
                shot['is_new'] = False
        logger.info(f"Confirmed new shots, cleared is_new flag for {len(updated_shots)} shots")

    # 第三阶段清除 new_shot_ids 标记
    if stage == "storyboard" and "new_shot_ids" in body and body.get('new_shot_ids') == []:
        # 清除 new_shot_ids 标记
        logger.info("Cleared new_shot_ids marker")

    # 第五阶段视频提示词修改：更新 video_generation 的 clips
    if stage == "reference_generation" and "shots" in body:
        shot_prompts = {s['shot_id']: s.get('video_prompt', s.get('visual_prompt', ''))
                        for s in body['shots'] if 'shot_id' in s}
        shot_durations = {s['shot_id']: s.get('duration', 10) for s in body['shots'] if 'shot_id' in s}
        video_art = state.artifacts.get('video_generation', {})
        if isinstance(video_art, dict) and 'clips' in video_art:
            for clip in video_art['clips']:
                shot_id = clip.get('id')
                if shot_id in shot_prompts:
                    clip['description'] = shot_prompts[shot_id]
                if shot_id in shot_durations:
                    clip['duration'] = shot_durations[shot_id]
        # 移除 shots，避免合并到 reference_generation artifact
        body = {k: v for k, v in body.items() if k != "shots"}

    # 第四阶段选择图片版本：需要更新 scenes 数组中的 selected
    if stage == "reference_generation" and body:
        ref_art = state.artifacts.get('reference_generation', {})
        if isinstance(ref_art, dict):
            scenes = ref_art.get('scenes', [])
            # 检查 body 是否是 {sceneId: path} 格式
            is_selection_format = any(
                isinstance(k, str) and not isinstance(v, (list, dict))
                for k, v in body.items()
            )
            if is_selection_format and scenes:
                # body 是 {sceneId: path} 格式，需要更新 scenes 数组
                for scene in scenes:
                    scene_id = scene.get('id')
                    if scene_id and scene_id in body:
                        scene['selected'] = body[scene_id]
                # 清空 body，避免覆盖 scenes
                body = {}

    # 第五阶段选择视频版本：需要更新 clips 数组中的 selected
    if stage == "video_generation" and body:
        vid_art = state.artifacts.get('video_generation', {})
        if isinstance(vid_art, dict):
            clips = vid_art.get('clips', [])
            # 检查 body 是否是 {clipId: path} 格式
            is_selection_format = any(
                isinstance(k, str) and not isinstance(v, (list, dict))
                for k, v in body.items()
            )
            if is_selection_format and clips:
                # body 是 {clipId: path} 格式，需要更新 clips 数组
                for clip in clips:
                    clip_id = clip.get('id')
                    if clip_id and clip_id in body:
                        clip['selected'] = body[clip_id]
                # 清空 body，避免覆盖 clips
                body = {}

    # 更新 state.artifacts 并保存到 sessions json
    current = state.artifacts.get(stage)
    if current is None:
        state.artifacts[stage] = body
    elif isinstance(current, dict):
        current.update(body)
    else:
        state.artifacts[stage] = body

    workflow_engine.save_session_to_disk(session_id)

    return {"status": "ok"}


@app.post("/api/project/{session_id}/intervene")
async def intervene(session_id: str, req: InterventionRequest, request: Request):
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")

    workflow_engine.reset_stop_event(session_id)
    session_stop = workflow_engine.get_stop_event(session_id)
    stop_event = threading.Event()
    cancellation_check = lambda: stop_event.is_set() or session_stop.is_set()

    progress_events = queue.Queue()

    def progress_callback(phase, step, percent, data=None):
        event = {"phase": phase, "step": step, "percent": percent}
        if data:
            event["data"] = data
        progress_events.put(event)

    async def stream_intervention():
        stage_enum = WorkflowStage(req.stage)
        try:
            current_artifact = state.artifacts.get(req.stage, {})
            input_data = current_artifact if isinstance(current_artifact, dict) else {}
            # 确保 input_data 中有 session_id（agent 需要）
            input_data["session_id"] = session_id
            # 注入会话级元数据（模型配置等），确保 intervene 时也能读到正确的模型
            if state.meta:
                for k, v in state.meta.items():
                    if v is not None and k not in input_data:
                        input_data[k] = v
            # 从已持久化的 artifacts 中注入用户选项
            _inject_user_selections(state, req.stage, input_data)
            input_data.update(req.modifications)

            task = asyncio.create_task(
                workflow_engine.execute_stage(
                    state, stage_enum, input_data,
                    cancellation_check=cancellation_check,
                    progress_callback=progress_callback,
                    intervention=req.modifications,
                )
            )

            while not task.done():
                while not progress_events.empty():
                    try:
                        p = progress_events.get_nowait()
                        evt = {
                            "type": "progress",
                            "message": f"{p['phase']}: {p['step']}",
                            "phase": p["phase"],
                            "step_desc": p["step"],
                            "percent": p["percent"],
                        }
                        if p.get("data"):
                            evt["data"] = p["data"]
                        yield json.dumps(evt) + "\n"
                        await asyncio.sleep(0)  # 强制立即发送 SSE 事件
                    except queue.Empty:
                        break

                if await request.is_disconnected():
                    stop_event.set()
                    yield json.dumps({"type": "error", "content": "Client disconnected"}) + "\n"
                    return

                yield json.dumps({"type": "heartbeat", "time": time.time()}) + "\n"
                await asyncio.sleep(0.1)  # 减少心跳间隔，加快事件处理

            # Drain remaining
            while not progress_events.empty():
                try:
                    p = progress_events.get_nowait()
                    evt = {
                        "type": "progress",
                        "message": f"{p['phase']}: {p['step']}",
                        "phase": p["phase"],
                        "step_desc": p["step"],
                        "percent": p["percent"],
                    }
                    if p.get("data"):
                        evt["data"] = p["data"]
                    yield json.dumps(evt) + "\n"
                except queue.Empty:
                    break

            result = task.result()
            workflow_engine.save_session_to_disk(session_id)

            requires_intervention = result.get("requires_intervention", False)
            # 生成 OpenCLAW 提示文本
            # 优先使用 agent 返回的 openclaw_hint
            openclaw_msg = result.get("openclaw_hint", "")
            if not openclaw_msg and requires_intervention:
                stage_name_map = {
                    "script_generation": "剧本生成",
                    "character_design": "角色/场景设计",
                    "storyboard": "分镜设计",
                    "reference_generation": "参考图生成",
                    "video_generation": "视频生成",
                    "post_production": "后期剪辑",
                }
                stage_name = stage_name_map.get(req.stage, req.stage)
                openclaw_msg = f"{stage_name}完成，需要用户确认。请展示给用户并等待用户确认后才能调用 /continue。"

            yield json.dumps({
                "type": "stage_complete",
                "stage": req.stage,
                "status": state.status,
                "requires_intervention": requires_intervention,
                # OpenCLAW 提示
                "openclaw": openclaw_msg,
            }) + "\n"

        except Exception as e:
            try:
                workflow_engine.save_session_to_disk(session_id)
            except Exception:
                pass
            yield json.dumps({"type": "error", "content": str(e)}) + "\n"

    return StreamingResponse(
        stream_intervention(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache, no-transform",
            "X-Accel-Buffering": "no",
            "Connection": "keep-alive",
        },
    )


@app.post("/api/project/{session_id}/continue")
async def continue_workflow(session_id: str):
    state = workflow_engine.get_state(session_id)
    if not state:
        raise HTTPException(404, "Session not found")
    return await workflow_engine.continue_workflow(session_id)


@app.post("/api/project/{session_id}/stop")
async def stop_project(session_id: str):
    workflow_engine.stop_session(session_id)
    return {"status": "stopped", "session_id": session_id}


@app.get("/api/sessions")
async def list_sessions():
    return {"sessions": workflow_engine.list_saved_sessions()}


@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str, request: Request):
    """删除历史记录（需要管理员密码）"""
    try:
        body = await request.json()
    except Exception:
        body = {}
    [REDACTED_SECRET]"password", "")
    if not settings.ADMIN_PASSWORD or password != settings.ADMIN_PASSWORD:
        raise HTTPException(403, "密码错误")
    deleted = workflow_engine.delete_session(session_id)
    if not deleted:
        raise HTTPException(404, "Session not found")
    return {"status": "deleted", "session_id": session_id}


@app.delete("/api/sessions")
async def cleanup_orphan_files(request: Request):
    """清理孤立的结果文件（需要管理员密码）"""
    try:
        body = await request.json()
    except Exception:
        body = {}
    [REDACTED_SECRET]"password", "")
    if not settings.ADMIN_PASSWORD or password != settings.ADMIN_PASSWORD:
        raise HTTPException(403, "密码错误")

    # 获取所有 session ID
    session_ids = set()
    for f in os.listdir(workflow_engine._session_dir):
        if f.endswith('.json'):
            session_ids.add(f.replace('.json', ''))

    # 清理孤立文件
    cleaned = {"scripts": [], "images": [], "videos": []}
    result_base = settings.RESULT_DIR

    # 清理孤立剧本
    script_dir = os.path.join(result_base, 'script')
    for f in os.listdir(script_dir):
        if f.startswith('script_') and f.endswith('.json'):
            sid = f.replace('script_', '').replace('.json', '')
            if sid not in session_ids:
                os.remove(os.path.join(script_dir, f))
                cleaned["scripts"].append(sid)

    # 清理孤立图片
    image_dir = os.path.join(result_base, 'image')
    for d in os.listdir(image_dir):
        if d != 'test_avail' and d not in session_ids:
            import shutil
            shutil.rmtree(os.path.join(image_dir, d))
            cleaned["images"].append(d)

    # 清理孤立视频
    video_dir = os.path.join(result_base, 'video')
    for d in os.listdir(video_dir):
        if d != 'test_avail' and d not in session_ids:
            import shutil
            shutil.rmtree(os.path.join(video_dir, d))
            cleaned["videos"].append(d)

    return {"status": "cleaned", "cleaned": cleaned}


@app.get("/api/project/{session_id}/scene/{scene_number}/assets")
async def check_scene_assets(session_id: str, scene_number: int):
    """检查某场景是否有生成的参考图或视频"""
    import os
    from config import settings

    result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{session_id}.json')
    if not os.path.exists(result_file):
        return {"scene_number": scene_number, "reference_images": 0, "videos": 0}

    with open(result_file, 'r', encoding='utf-8') as f:
        results = json.load(f)

    storyboard = results.get(session_id, {}).get('storyboard', {})
    shots = storyboard.get('shots', [])

    # 筛选该场景的分镜
    scene_shots = [s for s in shots if s.get('scene_number') == scene_number]
    shot_ids = [s.get('shot_id') for s in scene_shots if s.get('shot_id')]

    # 检查参考图
    ref_artifact = results.get(session_id, {}).get('reference_generation', {})
    ref_scenes = ref_artifact.get('scenes', [])
    ref_image_count = 0
    for sc in ref_scenes:
        if sc.get('id') in shot_ids:
            selected = sc.get('selected')
            if selected and os.path.exists(os.path.join(settings.CODE_DIR, selected.lstrip('/'))):
                ref_image_count += 1
            versions = sc.get('versions', [])
            for v in versions:
                if v and os.path.exists(os.path.join(settings.CODE_DIR, v.lstrip('/'))):
                    ref_image_count += 1

    # 检查视频
    video_artifact = results.get(session_id, {}).get('video_generation', {})
    video_clips = video_artifact.get('clips', [])
    video_count = 0
    for vc in video_clips:
        if vc.get('id') in shot_ids:
            selected = vc.get('selected')
            if selected and os.path.exists(os.path.join(settings.CODE_DIR, selected.lstrip('/'))):
                video_count += 1

    return {
        "scene_number": scene_number,
        "reference_images": ref_image_count,
        "videos": video_count,
        "shot_count": len(scene_shots),
    }


@app.get("/api/stages")
async def list_stages():
    return {
        "stages": [
            {"id": "script_generation", "name": "剧本生成", "order": 1, "description": "将灵感���化为结构化剧本"},
            {"id": "character_design", "name": "角色/场景设计", "order": 2, "description": "生成角色设计图和场景背景"},
            {"id": "storyboard", "name": "分镜设计", "order": 3, "description": "设计镜头语言和分镜脚本"},
            {"id": "reference_generation", "name": "参考图生成", "order": 4, "description": "生成高精度参考图"},
            {"id": "video_generation", "name": "视频生成", "order": 5, "description": "将参考图/分镜图生成视频"},
            {"id": "post_production", "name": "后期剪辑", "order": 6, "description": "拼接视频片段为最终成片"},
        ]
    }


# ============================== 临时工作台 API ==============================

import json
import uuid
from datetime import datetime

# 临时工作台数据目录
SANDBOX_DIR = os.path.join(settings.CODE_DIR, "result", "sandbox")
SANDBOX_HISTORY_FILE = os.path.join(SANDBOX_DIR, "history.json")

# 确保目录存在
os.makedirs(SANDBOX_DIR, exist_ok=True)


def _load_history() -> List[dict]:
    """加载历史记录"""
    if os.path.exists(SANDBOX_HISTORY_FILE):
        try:
            with open(SANDBOX_HISTORY_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            return []
    return []


def _save_history(history: List[dict]):
    """保存历史记录"""
    with open(SANDBOX_HISTORY_FILE, 'w', encoding='utf-8') as f:
        json.dump(history, f, ensure_ascii=False, indent=2)


def _normalize_path(path: str) -> str:
    """将绝对路径转换为相对路径格式 result/..."""
    if not path:
        return path
    # 如果已经是相对路径，直接返回
    if not path.startswith('/'):
        # 确保以 result/ 开头
        if not path.startswith('result/'):
            return f"result/{path}"
        return path
    # 绝对路径，提取相对于 CODE_DIR 的部分
    code_dir = settings.CODE_DIR
    if path.startswith(code_dir):
        relative = path[len(code_dir):].lstrip('/')
        # 直接返回 result/... 格式，因为 /code/ 会映射到 CODE_DIR
        return relative
    # 其他绝对路径，尝试提取文件名
    return path.split('/')[-1]


def _convert_output_paths(output_data: dict) -> dict:
    """转换 output 中的路径为相对路径格式"""
    if not output_data:
        return output_data
    converted = output_data.copy()
    # 转换 images
    if 'images' in converted and isinstance(converted['images'], list):
        converted['images'] = [_normalize_path(img) for img in converted['images']]
    # 转换 video_path
    if 'video_path' in converted and converted['video_path']:
        converted['video_path'] = _normalize_path(converted['video_path'])
    # 转换 input 中的 reference_image
    if 'reference_image' in converted.get('input', {}):
        input_copy = converted['input'].copy()
        input_copy['reference_image'] = _normalize_path(input_copy['reference_image'])
        converted['input'] = input_copy
    return converted


def _add_record(tool: str, model: str, input_data: dict, output_data: dict, files: List[str] = None) -> str:
    """添加历史记录"""
    record_id = str(uuid.uuid4().hex[:8])
    # 转换路径为相对路径格式
    output_data = _convert_output_paths(output_data)
    record = {
        "id": record_id,
        "tool": tool,
        "model": model,
        "input": input_data,
        "output": output_data,
        "files": files or [],
        "created_at": datetime.now().isoformat(),
    }
    history = _load_history()
    history.insert(0, record)  # 最新记录放在最前面
    _save_history(history)
    return record_id


def _delete_record_files(files: List[str]):
    """删除记录关联的文件"""
    for f in files:
        if f and os.path.exists(f):
            try:
                os.remove(f)
            except:
                pass


# 请求模型
class SandboxLLMRequest(BaseModel):
    model: str
    prompt: str
    temperature: Optional[float] = 0.7
    web_search: Optional[bool] = False


class SandboxVLMRequest(BaseModel):
    model: str
    prompt: str
    images: List[str]  # 图片URL或base64


class SandboxT2IRequest(BaseModel):
    model: str
    prompt: str
    style: Optional[str] = "anime"
    ratio: Optional[str] = "16:9"


class SandboxI2IRequest(BaseModel):
    model: str
    prompt: str
    image: str  # 参考图片URL或base64


class SandboxVideoRequest(BaseModel):
    model: str
    prompt: str
    image: Optional[str] = None  # 参考图片


@app.get("/api/sandbox/history")
async def sandbox_get_history():
    """获取历史记录列表"""
    history = _load_history()
    # 返回完整信息（包括 output）
    return {
        "success": True,
        "records": [
            {
                "id": r["id"],
                "tool": r["tool"],
                "model": r["model"],
                "input": r["input"],
                "output": r.get("output"),
                "created_at": r["created_at"],
            }
            for r in history
        ]
    }


@app.get("/api/sandbox/history/{record_id}")
async def sandbox_get_record(record_id: str):
    """获取单条历史记录详情"""
    history = _load_history()
    for r in history:
        if r["id"] == record_id:
            return {"success": True, "record": r}
    return {"success": False, "error": "记录不存在"}


@app.delete("/api/sandbox/history/{record_id}")
async def sandbox_delete_record(record_id: str):
    """删除历史记录"""
    history = _load_history()
    record_to_delete = None
    new_history = []
    for r in history:
        if r["id"] == record_id:
            record_to_delete = r
        else:
            new_history.append(r)

    if record_to_delete is None:
        return {"success": False, "error": "记录不存在"}

    # 删除关联的文件
    _delete_record_files(record_to_delete.get("files", []))
    _save_history(new_history)
    return {"success": True}


@app.post("/api/sandbox/llm")
async def sandbox_llm(req: SandboxLLMRequest):
    """临时工作台 - LLM 文字生成"""
    from tool.llm_client import LLM
    client = LLM()
    try:
        result = client.query(req.prompt, model=req.model, web_search=req.web_search)
        # ��存到历史记录
        record_id = _add_record(
            tool="llm",
            model=req.model,
            input_data={"prompt": req.prompt, "web_search": req.web_search},
            output_data={"response": result}
        )
        return {"success": True, "result": result, "record_id": record_id}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/sandbox/vlm")
async def sandbox_vlm(req: SandboxVLMRequest):
    """临时工作台 - VLM 图片理解"""
    from tool.vlm_client import VLM
    client = VLM()
    try:
        result = client.query(req.prompt, image_paths=req.images, model=req.model)
        # 保存到历史记录
        record_id = _add_record(
            tool="vlm",
            model=req.model,
            input_data={"prompt": req.prompt, "images": req.images},
            output_data={"response": result}
        )
        return {"success": True, "result": result, "record_id": record_id}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/sandbox/t2i")
async def sandbox_t2i(req: SandboxT2IRequest):
    """临时工作台 - 文生图"""
    from tool.image_client import ImageClient
    import traceback
    client = ImageClient()
    try:
        print(f"[T2I] Generating image with model: {req.model}, prompt: {req.prompt[:50]}...")
        result = client.generate_image(req.prompt, model=req.model, image_paths=None)
        print(f"[T2I] Result: {result}")
        # result 是图片路径列表
        # 保存到历史记录
        record_id = _add_record(
            tool="t2i",
            model=req.model,
            input_data={"prompt": req.prompt, "style": req.style, "ratio": req.ratio},
            output_data={"images": result},
            files=result if isinstance(result, list) else []
        )
        return {"success": True, "result": result, "record_id": record_id}
    except Exception as e:
        error_detail = traceback.format_exc()
        print(f"[T2I] Error: {error_detail}")
        return {"success": False, "error": str(e)}


@app.post("/api/sandbox/i2i")
async def sandbox_i2i(req: SandboxI2IRequest):
    """临时工作台 - 图生图"""
    from tool.image_client import ImageClient
    client = ImageClient()
    try:
        result = client.generate_image(req.prompt, image_paths=[req.image], model=req.model)
        # 保存到历史记录
        record_id = _add_record(
            tool="i2i",
            model=req.model,
            input_data={"prompt": req.prompt, "reference_image": req.image},
            output_data={"images": result},
            files=result if isinstance(result, list) else []
        )
        return {"success": True, "result": result, "record_id": record_id}
    except Exception as e:
        return {"success": False, "error": str(e)}


@app.post("/api/sandbox/video")
async def sandbox_video(req: SandboxVideoRequest):
    """临时工作台 - 视频生成"""
    from tool.video_client import VideoClient
    client = VideoClient()
    try:
        # 生成唯一的保存路径
        save_dir = os.path.join(SANDBOX_DIR, "videos")
        os.makedirs(save_dir, exist_ok=True)
        save_path = os.path.join(save_dir, f"{uuid.uuid4().hex[:8]}.mp4")

        result = client.generate_video(
            prompt=req.prompt,
            image_path=req.image or "",
            save_path=save_path,
            model=req.model,
            duration=5,
            shot_type="multi"
        )
        # 保存到历史记录
        record_id = _add_record(
            tool="video",
            model=req.model,
            input_data={"prompt": req.prompt, "reference_image": req.image},
            output_data={"video": result, "video_path": save_path},
            files=[save_path]
        )
        return {"success": True, "result": result, "video_path": save_path, "record_id": record_id}
    except Exception as e:
        return {"success": False, "error": str(e)}


if __name__ == "__main__":
    # 强制监听 0.0.0.0，允许其他电脑访问
    uvicorn.run(app, host="0.0.0.0", port=settings.PORT)


path: aigc-claw/backend/config.py
content: import os

# 尝试加载 .env 文件 (需要: pip install python-dotenv)
try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

class Config:
    # ==========================
    # 基础服务配置
    # ==========================
    HOST = os.getenv("HOST", "0.0.0.0")
    PORT = int(os.getenv("PORT", 8000))
    DEBUG = os.getenv("DEBUG", "True").lower() == "true"
    
    # 控制是否打印发送给模型的提示词/输入 (LLM, Image, Video)
    PRINT_MODEL_INPUT = os.getenv("PRINT_MODEL_INPUT", "False").lower() == "true"
    
    # ==========================
    # 路径配置 (自动计算绝对路径)
    # ==========================
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    
    # 生成结果存放目录
    CODE_DIR = os.path.join(BASE_DIR, 'code')
    RESULT_DIR = os.path.join(CODE_DIR, 'result')
    
    # 临时文件目录
    TEMP_DIR = os.path.join(BASE_DIR, 'temp')

    # ==========================
    # AI 模型 API 配置
    # ==========================
    # LLM (OpenAI / Gemini)
    OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
    OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
    # 官方 OpenAI API Key（用于 gpt-image-1.5 等必须直连官方的模型）
    OPENAI_OFFICIAL_API_KEY = os.getenv("OPENAI_OFFICIAL_API_KEY", "")
    GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
    GOOGLE_GEMINI_BASE_URL = os.getenv("GOOGLE_GEMINI_BASE_URL", "")

    # Dashscope(Aliyun) API
    DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
    DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/api/v1")

    # 可灵 (Kling AI) API
    KLING_ACCESS_KEY = os.getenv("KLING_ACCESS_KEY", "")
    KLING_SECRET_KEY = os.getenv("KLING_SECRET_KEY", "")
    KLING_BASE_URL = os.getenv("KLING_BASE_URL", "https://api-beijing.klingai.com")

    # 字节跳动 ARK (Seedream) API
    ARK_API_KEY = os.getenv("ARK_API_KEY", "")
    ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")

    # 管理员密码（用于删除历史记录等管理操作）
    ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")

    # 代理设置
    PROXY = os.getenv("PROXY", "")
    LOCAL_PROXY = os.getenv("LOCAL_PROXY", "http://127.0.0.1:7897")
    HTTP_PROXY = os.getenv("HTTP_PROXY", "")
    HTTPS_PROXY = os.getenv("HTTPS_PROXY", "")
    
    # ==========================
    # 视频生成参数配置
    # ==========================
    # 1. 剧本生成
    LLM_MODEL = os.getenv("LLM_MODEL", "qwen3.5-plus") # LLM 模型选择

    # 2. VLM 评估模型
    VLM_MODEL = os.getenv("VLM_MODEL", "qwen3.5-plus")

    # 3. 图片生成 (分镜)
    IMAGE_IT2I_MODEL = os.getenv("IMAGE_IT2I_MODEL", "doubao-seedream-5-0-260128")
    IMAGE_T2I_MODEL = os.getenv("IMAGE_T2I_MODEL", "doubao-seedream-5-0-260128")

    # 3. 视频生成
    # 可选: "wan2.6-i2v-flash"
    VIDEO_MODEL = os.getenv("VIDEO_MODEL", "wan2.6-i2v-flash")
    VIDEO_RATIO = os.getenv("VIDEO_RATIO", "16:9") 

    @classmethod
    def check_dirs(cls):
        """自动创建必要的目录"""
        for directory in [cls.CODE_DIR, cls.RESULT_DIR, cls.TEMP_DIR]:
            if not os.path.exists(directory):
                os.makedirs(directory)
                print(f"Created directory: {directory}")

# 初始化目录结构
Config.check_dirs()

# 导出一个单例
settings = Config()


path: aigc-claw/backend/config_model.json
content: {
  "models": {
    "deepseek-chat": {
      "name": "DeepSeek Chat",
      "provider": "deepseek",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.002,
      "price_per_1k_output_token": 0.008
    },
    "deepseek-reasoner": {
      "name": "DeepSeek Reasoner",
      "provider": "deepseek",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.004,
      "price_per_1k_output_token": 0.016
    },
    "gpt-4o": {
      "name": "GPT-4o",
      "provider": "openai",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.01813,
      "price_per_1k_output_token": 0.0725
    },
    "gpt-4": {
      "name": "GPT-4",
      "provider": "openai",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.02175,
      "price_per_1k_output_token": 0.087
    },
    "gpt-5": {
      "name": "GPT-5",
      "provider": "openai",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.01813,
      "price_per_1k_output_token": 0.10875
    },
    "gpt-5.1": {
      "name": "GPT-5.1",
      "provider": "openai",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.01813,
      "price_per_1k_output_token": 0.10875
    },
    "o3": {
      "name": "OpenAI o3",
      "provider": "openai",
      "type": ["llm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.0145,
      "price_per_1k_output_token": 0.058
    },
    "qwen3.5-plus": {
      "name": "Qwen 3.5 Plus",
      "provider": "qwen",
      "type": ["llm", "vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.0008,
      "price_per_1k_output_token": 0.002
    },
    "qwen3.5-max": {
      "name": "Qwen 3.5 Max",
      "provider": "qwen",
      "type": ["llm", "vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.006,
      "price_per_1k_output_token": 0.024
    },
    "qwen-vl-plus": {
      "name": "Qwen VL Plus",
      "provider": "qwen",
      "type": ["vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.0008,
      "price_per_1k_output_token": 0.002
    },
    "gemini-3-flash-preview": {
      "name": "Gemini 3 Flash Preview",
      "provider": "gemini",
      "type": ["llm", "vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.00181,
      "price_per_1k_output_token": 0.01088
    },
    "gemini-3-pro-preview": {
      "name": "Gemini 3 Pro Preview",
      "provider": "gemini",
      "type": ["llm", "vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.0145,
      "price_per_1k_output_token": 0.087
    },
    "gemini-2.5-flash-image": {
      "name": "Gemini 2.5 Flash Image",
      "provider": "gemini",
      "type": ["vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.00218,
      "price_per_1k_output_token": 0.01813
    },
    "gemini-2.5-pro-image": {
      "name": "Gemini 2.5 Pro Image",
      "provider": "gemini",
      "type": ["vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.00906,
      "price_per_1k_output_token": 0.0725
    },
    "gemini-3-pro-image-preview": {
      "name": "Gemini 3 Pro Image Preview",
      "provider": "gemini",
      "type": ["vlm"],
      "concurrency": 10,
      "price_per_1k_input_token": 0.0145,
      "price_per_1k_output_token": 0.087
    },
    "jimeng_t2i_v40": {
      "name": "JiMeng T2I V4.0",
      "provider": "jimeng",
      "type": ["t2i", "i2i"],
      "concurrency": 1,
      "price_per_image": 0.22
    },
    "wan2.6-t2i": {
      "name": "Wan 2.6 T2I",
      "provider": "dashscope",
      "type": ["t2i"],
      "concurrency": 5,
      "price_per_image": 0.2
    },
    "wan2.6-image": {
      "name": "Wan 2.6 Image",
      "provider": "dashscope",
      "type": ["i2i"],
      "concurrency": 5,
      "price_per_second": 0.2
    },
    "doubao-seedream-5-0-260128": {
      "name": "Seedream 5.0",
      "provider": "seedream",
      "type": ["t2i", "i2i"],
      "concurrency": 10,
      "price_per_image": 0.22
    },
    "doubao-seedream-4-5-251128": {
      "name": "Seedream 4.5",
      "provider": "seedream",
      "type": ["t2i", "i2i"],
      "concurrency": 10,
      "price_per_image": 0.25
    },
    "doubao-seedream-4-0-250828": {
      "name": "Seedream 4.0",
      "provider": "seedream",
      "type": ["t2i", "i2i"],
      "concurrency": 10,
      "price_per_image": 0.2
    },
    "sora_image": {
      "name": "Sora Image",
      "provider": "openai",
      "type": ["t2i"],
      "concurrency": 3,
      "price_per_image": 1.45
    },
    "gpt-image-1.5": {
      "name": "GPT Image 1.5",
      "provider": "openai",
      "type": ["t2i"],
      "concurrency": 3,
      "price_per_image": 1.09
    },
    "wan2.6-i2v-flash": {
      "name": "Wan 2.6 I2V Flash",
      "provider": "dashscope",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 0.6
    },
    "wan2.6-i2v": {
      "name": "Wan 2.6 I2V",
      "provider": "dashscope",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 0.6
    },
    "jimeng_ti2v_v30_pro": {
      "name": "JiMeng Ti2V V30 Pro",
      "provider": "jimeng",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 0.4
    },
    "kling-v3": {
      "name": "Kling V3",
      "provider": "kling",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 1.0
    },
    "kling-v2-6": {
      "name": "Kling V2.6",
      "provider": "kling",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 0.5
    },
    "kling-v2-5-turbo": {
      "name": "Kling V2.5 Turbo",
      "provider": "kling",
      "type": ["video"],
      "concurrency": 3,
      "price_per_second": 0.3
    }
  }
}

path: aigc-claw/backend/config_model.py
content: """
模型配置表 - 从 JSON 文件加载
包含每个模型的：价格、并发数、提供商、类型
"""
import os
import json
from typing import List, Optional

# 加载配置
_config = None

def _load_config():
    global _config
    if _config is None:
        config_path = os.path.join(os.path.dirname(__file__), "config_model.json")
        with open(config_path, "r", encoding="utf-8") as f:
            _config = json.load(f)
    return _config

def get_model_config(model: str) -> dict:
    """获取模型配置"""
    config = _load_config()
    models = config.get("models", {})

    # 精确匹配
    if model in models:
        return models[model]

    # 前缀匹配
    model_lower = model.lower()
    for key, value in models.items():
        if key in model_lower or model_lower in key:
            return value

    # 默认配置
    return {
        "name": model,
        "provider": "unknown",
        "type": [],
        "concurrency": 3,
    }

def get_max_concurrency(model: str, enable_concurrency: bool = False) -> int:
    """获取模型最大并发数"""
    if not enable_concurrency:
        return 1

    config = get_model_config(model)
    return config.get("concurrency", 3)

def get_models_by_type(model_type: str) -> List[dict]:
    """获取指定类型的所有模型"""
    config = _load_config()
    models = config.get("models", {})

    result = []
    for key, value in models.items():
        types = value.get("type", [])
        if model_type in types:
            result.append({
                "id": key,
                **value
            })
    return result


path: aigc-claw/backend/core/__init__.py
content: # -*- coding: utf-8 -*-
from .orchestrator import WorkflowEngine, WorkflowStage

__all__ = ["WorkflowEngine", "WorkflowStage"]


path: aigc-claw/backend/core/agents/__init__.py
content: # -*- coding: utf-8 -*-
from .base_agent import AgentInterface
from .script_agent import ScriptWriterAgent
from .character_agent import CharacterDesignerAgent
from .storyboard_agent import StoryboardAgent
from .reference_agent import ReferenceGeneratorAgent
from .video_agent import VideoDirectorAgent
from .editor_agent import VideoEditorAgent

__all__ = [
    "AgentInterface",
    "ScriptWriterAgent",
    "CharacterDesignerAgent",
    "StoryboardAgent",
    "ReferenceGeneratorAgent",
    "VideoDirectorAgent",
    "VideoEditorAgent",
]


path: aigc-claw/backend/core/agents/base_agent.py
content: # -*- coding: utf-8 -*-
"""
智能体基类 - 所有阶段智能体的抽象接口
"""

import logging
from abc import ABC, abstractmethod
from typing import Any, Optional, Dict, Callable

logger = logging.getLogger(__name__)


class AgentInterface(ABC):
    """所有智能体必须实现的接口"""

    def __init__(self, name: str = ""):
        self.name = name
        self.cancellation_check: Optional[Callable] = None
        self.progress_callback: Optional[Callable] = None

    def set_cancellation_check(self, fn: Callable):
        self.cancellation_check = fn

    def set_progress_callback(self, fn: Callable):
        self.progress_callback = fn

    def _report_progress(self, phase: str, step_desc: str, percent: float, data: dict = None):
        if self.progress_callback:
            self.progress_callback(phase, step_desc, percent, data)

    def _check_cancel(self):
        if self.cancellation_check and self.cancellation_check():
            raise RuntimeError(f"Agent [{self.name}] cancelled by user")

    def _cancellable_query(self, llm, prompt: str, **kwargs):
        """在 LLM 调用前后检查取消状态"""
        self._check_cancel()
        result = llm.query(prompt, **kwargs)
        self._check_cancel()
        return result

    # -------- 抽象方法 --------

    @abstractmethod
    async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
        """
        核心处理逻辑

        Args:
            input_data: 来自上一阶段的输入数据
            intervention: 用户介入修改内容

        Returns:
            dict: { "payload": ..., "requires_intervention": bool }
        """
        pass


path: aigc-claw/backend/core/agents/character_agent.py
content: # -*- coding: utf-8 -*-
"""
阶段2: 角色/场景设计智能体
基于阶段1的剧本JSON生成角色4视图和场景全景图
支持单项重新生成、历史版本切换
图片以 character_id / setting_id 命名
"""

import os
import re
import json
import glob
import asyncio
import logging
from typing import Any, Optional, Dict, List
from concurrent.futures import ThreadPoolExecutor, as_completed

from .base_agent import AgentInterface
from prompts.loader import load_prompt, load_style_prompt

logger = logging.getLogger(__name__)


class CharacterDesignerAgent(AgentInterface):
    """角色/场景设计：从剧本JSON读取描述 → 生成角色4视图 + 场景全景图"""

    def __init__(self):
        super().__init__(name="CharacterDesigner")

    # ─── 提示词模板 ───

    @staticmethod
    def _char_prompt(name: str, desc: str, style: str, species: str = "") -> str:
        """角色4视图提示词 - 从风格提示词文件加载"""
        # 加载风格提示词模板
        template = load_style_prompt('character', style)
        # 替换角色信息
        return template.format(name=name, desc=desc)

    @staticmethod
    def _setting_prompt(name: str, desc: str, style: str) -> str:
        """场景全景图提示词 - 从风格提示词文件加载"""
        # 加载风格提示词模板
        template = load_style_prompt('setting', style)
        # 替换场景信息
        return template.format(name=name, desc=desc)

    # ─── 文件管理（基于唯一ID） ───

    @staticmethod
    def _asset_base(sid: str) -> str:
        return os.path.join('code/result/image', str(sid), 'Assets')

    def _list_versions(self, sid: str, asset_type: str, asset_id: str) -> List[str]:
        """列出某个素材的所有历史版本文件路径，按时间排序
        文件命名: {asset_id}.png, {asset_id}_v2.png, {asset_id}_v3.png, ...
        """
        adir = os.path.join(self._asset_base(sid), asset_type)
        pattern = os.path.join(adir, f"{asset_id}*.png")
        files = sorted(glob.glob(pattern), key=os.path.getmtime)
        return files

    def _next_version_path(self, sid: str, asset_type: str, asset_id: str) -> str:
        """获取下一个版本的文件路径"""
        adir = os.path.join(self._asset_base(sid), asset_type)
        os.makedirs(adir, exist_ok=True)

        existing = self._list_versions(sid, asset_type, asset_id)
        if not existing:
            return os.path.join(adir, f"{asset_id}.png")

        max_v = 1
        for fp in existing:
            bn = os.path.splitext(os.path.basename(fp))[0]
            m = re.search(r'_v(\d+)$', bn)
            if m:
                max_v = max(max_v, int(m.group(1)))

        return os.path.join(adir, f"{asset_id}_v{max_v + 1}.png")

    def _build_asset_info(self, sid: str, asset_type: str,
                          asset_id: str, name: str, desc: str,
                          selected_path: str = "") -> dict:
        """构建单个素材的信息（含所有历史版本）"""
        versions = self._list_versions(sid, asset_type, asset_id)
        if not selected_path and versions:
            selected_path = versions[-1]
        return {
            "id": asset_id,
            "name": name,
            "description": desc,
            "selected": selected_path,
            "versions": versions,
        }

    # ─── 图片生成 ───

    def _build_preview(self, sid: str, chars_desc: dict, sets_desc: dict) -> dict:
        """构建素材预览列表（含当前状态）用于前端实时显示"""
        preview = {"characters": [], "settings": []}
        for asset_id, info in chars_desc.items():
            existing = self._list_versions(sid, 'characters', asset_id)
            preview["characters"].append({
                "id": asset_id,
                "name": info.get("name", ""),
                "description": info.get("description", ""),
                "selected": existing[-1] if existing else "",
                "versions": existing,
                "status": "done" if existing else "pending",
            })
        for asset_id, info in sets_desc.items():
            existing = self._list_versions(sid, 'settings', asset_id)
            name = info.get("name", "") if isinstance(info, dict) else ""
            desc = info.get("description", "") if isinstance(info, dict) else str(info)
            preview["settings"].append({
                "id": asset_id,
                "name": name,
                "description": desc,
                "selected": existing[-1] if existing else "",
                "versions": existing,
                "status": "done" if existing else "pending",
            })
        return preview

    def _generate_one(self, img_client, asset_id: str, name: str, desc: str,
                      asset_type: str, style: str, species: str,
                      t2i_model: str, vlm_model: str, sid: str, max_iterations: int = 3) -> tuple:
        """生成单个素材图并返回 (asset_id, path_or_None, eval_result)

        评估-生成循环：如果 VLM 评估发现问题，最多重新生成 max_iterations 次
        """
        self._check_cancel()

        # 初始提示词
        if asset_type == 'characters':
            base_prompt = self._char_prompt(name, desc, style, species)
        else:
            base_prompt = self._setting_prompt(name, desc, style)

        size = "1920*1080"
        current_prompt = base_prompt

        for iteration in range(max_iterations):
            self._check_cancel()

            save_path = self._next_version_path(sid, asset_type, asset_id)
            save_dir = os.path.dirname(save_path)

            try:
                paths = img_client.generate_image(
                    prompt=current_prompt, model=t2i_model,
                    session_id=str(sid), save_dir=save_dir, size=size,
                )
                if not paths:
                    continue

                gen = paths[0]
                if gen != save_path:
                    if os.path.exists(save_path):
                        os.remove(save_path)
                    os.rename(gen, save_path)

                # VLM 评估
                eval_result = self._evaluate_with_vlm(save_path, desc, asset_type, vlm_model)

                # 使用固定阈值判断是否接受（8分及以上通过）
                score = eval_result.get('score', 0)
                issues = eval_result.get('issues', [])
                suggestion = eval_result.get('suggestion', '')
                is_acceptable = score >= 8

                if is_acceptable:
                    logger.info(f"[{asset_type}] {name} ✓ VLM评估通过 - 评分: {score}/10")
                else:
                    logger.warning(f"[{asset_type}] {name} ✗ VLM评估不通过 - 评分: {score}/10")
                    logger.warning(f"[{asset_type}] 问题: {issues}")
                    if suggestion:
                        logger.warning(f"[{asset_type}] 建议: {suggestion}")

                # 检查是否需要重新生成
                if is_acceptable:
                    # 评估通过，返回结果
                    return asset_id, save_path, eval_result
                else:
                    # 评估不通过，记录问题并继续循环
                    # 报告进度
                    self._report_progress("角色设计", f"重新生成中 ({iteration + 2}/{max_iterations}): {name}", 0)

            except Exception as e:
                logger.error(f"Asset gen failed for {asset_type} {name}({asset_id}): {e}")

        # 达到最大迭代次数，尝试使用 VLM 选择最佳图片
        logger.warning(f"[{asset_type}] {name} reached max iterations ({max_iterations}), trying VLM selection")

        # 收集所有生成过的版本
        all_versions = self._list_versions(sid, asset_type, asset_id)
        if len(all_versions) > 1:
            # 有多个版本，调用 VLM 选择最好的
            best_path, best_eval = self._select_best_with_vlm(
                all_versions, name, desc, asset_type, species, vlm_model
            )
            if best_path:
                logger.info(f"[{asset_type}] {name} VLM selected best version: {best_path}")
                return asset_id, best_path, best_eval

        # 没有多个版本或 VLM 选择失败，返回最后一次结果
        return asset_id, save_path if os.path.exists(save_path) else None, eval_result if 'eval_result' in locals() else None

    def _evaluate_with_vlm(self, image_path: str, description: str, asset_type: str, vlm_model: str = "qwen3.5-plus") -> dict:
        """使用 VLM 评估生成的图片"""
        try:
            from tool.vlm_client import VLM
            vlm = VLM()

            # 选择评估提示词
            if asset_type == 'characters':
                eval_prompt = load_prompt('character', 'eval_character', 'zh').format(
                    character_description=description
                )
            else:
                eval_prompt = load_prompt('setting', 'eval_setting', 'zh').format(
                    setting_description=description
                )

            result = vlm.query(
                prompt=eval_prompt,
                image_paths=[image_path],
                model=vlm_model
            )

            # 解析结果
            if result and isinstance(result, list):
                result_text = result[0] if result else ""
            elif isinstance(result, str):
                result_text = result
            else:
                result_text = str(result)

            # 尝试提取 JSON
            import json
            try:
                # 找到 JSON 部分
                import re
                json_match = re.search(r'\{[^{}]*\}', result_text, re.DOTALL)
                if json_match:
                    eval_result = json.loads(json_match.group())
                    return eval_result
            except:
                pass

            return {"score": 5, "issues": ["评估解析失败"], "is_acceptable": True}

        except Exception as e:
            logger.warning(f"VLM evaluation failed: {e}")
            return {"score": 5, "issues": [str(e)], "is_acceptable": True}

    def _select_best_with_vlm(self, image_paths: List[str], name: str, description: str,
                               asset_type: str, species: str = "", vlm_model: str = "qwen3.5-plus") -> tuple:
        """使用 VLM 从多个版本中选择最好的一张"""
        from tool.vlm_client import VLM
        import re

        if not image_paths:
            return None, None

        try:
            vlm = VLM()

            # 选择评估提示词
            if asset_type == 'characters':
                select_prompt = load_prompt('character', 'eval_select_best', 'zh').format(
                    num_images=len(image_paths),
                    num_images_minus_1=len(image_paths) - 1,
                    character_name=name,
                    character_description=description,
                    species=species,
                    images_list="\n".join([f"图片{i}: {p}" for i, p in enumerate(image_paths)])
                )
            else:
                select_prompt = load_prompt('setting', 'eval_select_best', 'zh').format(
                    num_images=len(image_paths),
                    num_images_minus_1=len(image_paths) - 1,
                    setting_name=name,
                    setting_description=description,
                    images_list="\n".join([f"图片{i}: {p}" for i, p in enumerate(image_paths)])
                )

            result = vlm.query(select_prompt, image_paths=image_paths, model=vlm_model)
            logger.info(f"[{asset_type}] {name} VLM selection result: {result}")

            # 解析 JSON 结果
            if result and isinstance(result, list):
                result_text = result[0] if result else ""
            elif isinstance(result, str):
                result_text = result
            else:
                result_text = str(result)

            logger.info(f"[{asset_type}] {name} VLM selection raw response: {result_text[:500]}")

            # 解析 JSON，提取 best_index
            best_index = 0
            try:
                # 找到 JSON 开始和结束
                json_start = result_text.find('{')
                json_end = result_text.rfind('}') + 1
                if json_start >= 0 and json_end > json_start:
                    json_str = result_text[json_start:json_end]
                    selection_result = json.loads(json_str)
                    best_index = selection_result.get('best_index', 0)
                    logger.info(f"[{asset_type}] {name} Parsed best_index: {best_index}")
            except Exception as e:
                logger.warning(f"[{asset_type}] {name} JSON parse failed: {e}, using last image")
                best_index = len(image_paths) - 1

            # 如果找到了 best_index，选择对应的图片
            if best_index is not None and 0 <= best_index < len(image_paths):
                best_path = image_paths[best_index]
                best_eval = {"score": 8, "issues": [], "is_acceptable": True, "reason": f"VLM selected image {best_index + 1} of {len(image_paths)}"}
                logger.info(f"[{asset_type}] {name} Selected image: {best_path}")
                return best_path, best_eval
            else:
                logger.warning(f"[{asset_type}] {name} Invalid best_index: {best_index}, available images: {len(image_paths)}")

        except Exception as e:
            logger.warning(f"VLM selection failed: {e}")

        return None, None

    # ─── 从剧本JSON读取角色/场景数据 ───

    @staticmethod
    def _read_script_data(sid: str) -> dict:
        """从结果文件的 script_json 字段读取角色和场景数据（含唯一ID）

        Returns:
            {
                "characters": { character_id: { "name", "description", "species" } },
                "settings":   { setting_id:   { "name", "description" } },
            }
        """
        from config import settings

        script_json_path = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')
        if not os.path.exists(script_json_path):
            return {"characters": {}, "settings": {}}

        with open(script_json_path, 'r', encoding='utf-8') as f:
            data = json.load(f)

        sid_data = data.get(str(sid), {})

        # 优先从 script_json 读取（包含完整ID信息）
        sj = sid_data.get('script_json')
        if sj:
            chars = {}
            for c in sj.get("characters", []):
                cid = c.get("character_id", "")
                if cid:
                    chars[cid] = {
                        "name": c.get("name", ""),
                        "description": c.get("description", ""),
                        "species": c.get("species", ""),
                    }
            sets = {}
            for s in sj.get("settings", []):
                sid_val = s.get("setting_id", "")
                if sid_val:
                    sets[sid_val] = {
                        "name": s.get("name", ""),
                        "description": s.get("description", ""),
                    }
            return {"characters": chars, "settings": sets}

        return {"characters": {}, "settings": {}}

    # ─── 核心流程 ───

    async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
        from config import settings
        from tool.image_client import ImageClient

        sid = input_data["session_id"]
        style = input_data.get("style", "anime")
        t2i_model = input_data.get("image_t2i_model", "") or settings.IMAGE_T2I_MODEL
        vlm_model = input_data.get("vlm_model", "qwen3.5-plus")
        # 根据 enable_concurrency 决定并发数
        enable_concurrency = input_data.get("enable_concurrency", True)
        logger.info(f"[CharacterAgent] enable_concurrency={enable_concurrency}")
        from config_model import get_max_concurrency
        max_concurrency = get_max_concurrency(t2i_model, enable_concurrency)
        logger.info(f"[CharacterAgent] 使用并发数={max_concurrency}")
        concurrency = max_concurrency

        img_client = ImageClient(
            dashscope_api_key=settings.DASHSCOPE_API_KEY,
            dashscope_base_url=settings.DASHSCOPE_BASE_URL,
            gpt_api_key=os.getenv("OPENAI_API_KEY"),
            gpt_base_url=os.getenv("OPENAI_BASE_URL"),
            gpt_official_api_key=settings.OPENAI_OFFICIAL_API_KEY,
            local_proxy=settings.LOCAL_PROXY,
            ark_api_key=settings.ARK_API_KEY,
            ark_base_url=settings.ARK_BASE_URL,
        )

        # ═══════════ 介入: 重新生成指定素材 ═══════════
        if intervention:
            regen_chars = intervention.get("regenerate_characters", [])   # list of asset_id
            regen_sets = intervention.get("regenerate_settings", [])      # list of asset_id
            select_chars = intervention.get("select_characters", {})      # {asset_id: path}
            select_sets = intervention.get("select_settings", {})         # {asset_id: path}
            update_descriptions = intervention.get("update_descriptions", {})  # {characters: {}, settings: {}}

            script_data = self._read_script_data(sid)
            chars_desc = script_data["characters"]
            sets_desc = script_data["settings"]

            # 处理描述更新
            if update_descriptions:
                updated_chars = update_descriptions.get("characters", {})
                updated_sets = update_descriptions.get("settings", {})

                # 更新角色描述 (支持两种格式：字符串或字典)
                for asset_id, info in updated_chars.items():
                    if asset_id in chars_desc:
                        if isinstance(info, dict):
                            if "name" in info:
                                chars_desc[asset_id]["name"] = info["name"]
                            if "description" in info:
                                chars_desc[asset_id]["description"] = info["description"]
                            if "species" in info:
                                chars_desc[asset_id]["species"] = info["species"]
                        else:
                            # 简单字符串格式：直接更新 description
                            chars_desc[asset_id]["description"] = str(info)

                # 更新场景描述 (支持两种格式：字符串或字典)
                for asset_id, info in updated_sets.items():
                    if asset_id in sets_desc:
                        if isinstance(info, dict):
                            if "name" in info:
                                sets_desc[asset_id]["name"] = info["name"]
                            if "description" in info:
                                sets_desc[asset_id]["description"] = info["description"]
                        else:
                            # 简单字符串格式：直接更新 description
                            sets_desc[asset_id]["description"] = str(info)

                # 写回剧本数据文件
                script_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')
                with open(script_file, 'r', encoding='utf-8') as f:
                    full_data = json.load(f)

                if str(sid) in full_data:
                    full_data[str(sid)]["characters"] = chars_desc
                    full_data[str(sid)]["settings"] = sets_desc

                with open(script_file, 'w', encoding='utf-8') as f:
                    json.dump(full_data, f, ensure_ascii=False, indent=2)

                logger.info(f"[CharacterAgent] Updated descriptions for session {sid}")

            if regen_chars or regen_sets:
                self._report_progress("角色设计", "重新生成中...", 10)
                tasks = []
                for asset_id in regen_chars:
                    info = chars_desc.get(asset_id, {})
                    tasks.append(("characters", asset_id, info.get("name", ""), info.get("description", ""), info.get("species", "")))
                for asset_id in regen_sets:
                    info = sets_desc.get(asset_id, {})
                    tasks.append(("settings", asset_id, info.get("name", ""), info.get("description", ""), ""))

                def regen_run():
                    total = len(tasks)
                    done = 0
                    with ThreadPoolExecutor(max_workers=concurrency) as executor:
                        futs = {}
                        for atype, aid, name, desc, species in tasks:
                            fut = executor.submit(
                                self._generate_one, img_client,
                                aid, name, desc, atype, style, species, t2i_model, vlm_model, sid
                            )
                            futs[fut] = (atype, aid, name)
                        for fut in as_completed(futs):
                            atype, aid, fname = futs[fut]
                            _, result_path, eval_result = fut.result()
                            done += 1
                            pct = 10 + int(85 * done / max(total, 1))
                            if result_path:
                                versions = self._list_versions(sid, atype, aid)
                                self._report_progress("角色设计", f"完成: {fname}", pct, data={
                                    "asset_complete": {
                                        "type": atype, "id": aid, "status": "done",
                                        "selected": result_path, "versions": versions,
                                        "evaluation": eval_result,
                                    }
                                })
                            else:
                                self._report_progress("角色设计", f"失败: {fname}", pct, data={
                                    "asset_complete": {
                                        "type": atype, "id": aid, "status": "failed",
                                        "selected": "", "versions": [],
                                    }
                                })

                loop = asyncio.get_running_loop()
                await loop.run_in_executor(None, regen_run)

            self._report_progress("角色设计", "完成", 100)
            return self._build_payload(sid, chars_desc, sets_desc, select_chars, select_sets)

        # ═══════════ 正常流程: 全量首次生成 ═══════════
        self._report_progress("角色设计", "读取剧本数据...", 5)

        script_data = self._read_script_data(sid)
        chars_desc = script_data["characters"]
        sets_desc = script_data["settings"]

        if not chars_desc and not sets_desc:
            raise Exception("未能从剧本中读取到角色或场景描述数据")

        # 发送素材预览（含所有素材和当前状态）
        preview = self._build_preview(sid, chars_desc, sets_desc)
        self._report_progress("角色设计", "加载素材列表", 8, data={"assets_preview": preview})

        def run():
            all_tasks = []
            for asset_id, info in chars_desc.items():
                existing = self._list_versions(sid, 'characters', asset_id)
                if existing:
                    continue
                all_tasks.append(("characters", asset_id, info.get("name", ""), info.get("description", ""), info.get("species", "")))

            for asset_id, info in sets_desc.items():
                desc = info.get("description", "") if isinstance(info, dict) else info
                existing = self._list_versions(sid, 'settings', asset_id)
                if existing:
                    continue
                all_tasks.append(("settings", asset_id, info.get("name", "") if isinstance(info, dict) else "", desc, ""))

            if not all_tasks:
                self._report_progress("角色设计", "所有素材已存在", 95)
                return

            total = len(all_tasks)
            done = 0

            with ThreadPoolExecutor(max_workers=concurrency) as executor:
                futs = {}
                for atype, aid, name, desc, species in all_tasks:
                    fut = executor.submit(
                        self._generate_one, img_client,
                        aid, name, desc, atype, style, species, t2i_model, vlm_model, sid,
                    )
                    futs[fut] = (atype, aid, name)

                for fut in as_completed(futs):
                    atype, aid, fname = futs[fut]
                    _, result_path, _ = fut.result()
                    done += 1
                    pct = 10 + int(85 * done / max(total, 1))
                    if result_path:
                        versions = self._list_versions(sid, atype, aid)
                        self._report_progress("角色设计", f"完成: {fname}", pct, data={
                            "asset_complete": {
                                "type": atype, "id": aid, "status": "done",
                                "selected": result_path, "versions": versions,
                            }
                        })
                    else:
                        self._report_progress("角色设计", f"失败: {fname}", pct, data={
                            "asset_complete": {
                                "type": atype, "id": aid, "status": "failed",
                                "selected": "", "versions": self._list_versions(sid, atype, aid),
                            }
                        })

            self._report_progress("角色设计", "完成", 100)

        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, run)

        return self._build_payload(sid, chars_desc, sets_desc)

    def _build_payload(self, sid: str, chars_desc: dict, sets_desc: dict,
                       selected_chars: dict = None, selected_sets: dict = None) -> dict:
        """构建返回给前端的 payload"""
        selected_chars = selected_chars or {}
        selected_sets = selected_sets or {}

        characters = []
        for asset_id, info in chars_desc.items():
            desc = info.get("description", "") if isinstance(info, dict) else info
            name = info.get("name", "") if isinstance(info, dict) else ""
            sel = selected_chars.get(asset_id, "")
            characters.append(self._build_asset_info(sid, 'characters', asset_id, name, desc, sel))

        settings_list = []
        for asset_id, info in sets_desc.items():
            desc = info.get("description", "") if isinstance(info, dict) else info
            name = info.get("name", "") if isinstance(info, dict) else ""
            sel = selected_sets.get(asset_id, "")
            settings_list.append(self._build_asset_info(sid, 'settings', asset_id, name, desc, sel))

        # 图片生成完成即为阶段完成，用户选择图片只是更新数据
        return {
            "payload": {
                "session_id": sid,
                "characters": characters,
                "settings": settings_list,
            },
            "stage_completed": True,
        }


path: aigc-claw/backend/core/agents/editor_agent.py
content: # -*- coding: utf-8 -*-
"""
阶段6: 后期制作智能体
拼接用户在阶段5选定的视频片段 → 最终成片
"""

import os
import re
import subprocess
import asyncio
import logging
from typing import Any, Optional, Dict

from .base_agent import AgentInterface

logger = logging.getLogger(__name__)


class VideoEditorAgent(AgentInterface):
    """后期制作：拼接用户选择的视频片段 → 最终成片"""

    def __init__(self):
        super().__init__(name="VideoEditor")

    async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
        sid = input_data["session_id"]
        selected_clips: dict = input_data.get("selected_clips", {})

        if not selected_clips:
            raise Exception("未收到用户选择的视频片段(selected_clips)，请先完成阶段5")

        self._report_progress("后期制作", "准备视频片段...", 5)

        def run():
            video_dir = os.path.join('code/result/video', str(sid))

            # 按 shot_id 中的数字排序
            def sort_key(k: str) -> tuple:
                return tuple(int(n) for n in re.findall(r'\d+', k)) or (999,)

            clip_paths = []
            for shot_id in sorted(selected_clips.keys(), key=sort_key):
                path = selected_clips[shot_id]
                if os.path.exists(path):
                    clip_paths.append(path)
                else:
                    logger.warning(f"[{sid}] Clip missing: {shot_id} → {path}")

            if not clip_paths:
                raise Exception("所有选定的视频片段文件均不存在")

            logger.info(f"[{sid}] Concat {len(clip_paths)} clips")
            self._report_progress("后期制作", f"拼接 {len(clip_paths)} 个片段...", 15)

            list_path = os.path.join(video_dir, 'file_list.txt')
            with open(list_path, 'w', encoding='utf-8') as f:
                for p in clip_paths:
                    f.write(f"file '{os.path.abspath(p)}'\n")

            self._report_progress("后期制作", "ffmpeg 拼接中...", 30)

            output = os.path.join(video_dir, f'{sid}_final.mp4')
            cmd = ['ffmpeg', '-f', 'concat', '-safe', '0',
                   '-i', list_path, '-c', 'copy', '-y', output]
            subprocess.run(cmd, check=True, capture_output=True)
            logger.info(f"[{sid}] Concat success: {output}")
            return output

        loop = asyncio.get_running_loop()
        final_path = await loop.run_in_executor(None, run)

        self._report_progress("后期制作", "成片完成", 100)

        return {
            "payload": {"session_id": sid, "final_video": final_path},
            "stage_completed": True,
        }

path: aigc-claw/backend/core/agents/reference_agent.py
content: # -*- coding: utf-8 -*-
"""
阶段4: 参考图生成智能体
- 基于阶段3分镜(shots)，为每个分镜生成「首帧图像提示词」，再据此生成参考图
- 首帧提示词由 LLM 根据 shot 的 plot、visual_prompt、duration 生成
- 阶段5生视频时使用阶段3的原始分镜描述，而非首帧提示词
- 支持逐项实时预览、重新生成、多版本管理
"""

import os
import re
import glob
import json
import asyncio
import logging
from typing import Any, Optional, Dict, List
from concurrent.futures import ThreadPoolExecutor, as_completed

from .base_agent import AgentInterface
from prompts.loader import load_prompt

logger = logging.getLogger(__name__)


def ratio_to_size(ratio: str) -> str:
    """将视频比例转换为图像尺寸"""
    size_map = {
        "16:9": "1920*1080",
        "9:16": "1080*1920",
        "1:1": "1024*1024",
        "4:3": "1024*768",
        "3:4": "768*1024",
        "21:9": "2560*1080",
    }
    return size_map.get(ratio, "1920*1080")


class ReferenceGeneratorAgent(AgentInterface):
    """参考图生成：分镜(阶段3) → 首帧提示词(LLM) → 参考图(图像模型)"""

    def __init__(self):
        super().__init__(name="ReferenceGenerator")

    # ─── 版本管理 ───

    @staticmethod
    def _scenes_base(sid: str) -> str:
        return os.path.join('code/result/image', str(sid), 'Scenes')

    def _list_versions(self, sid: str, shot_id: str) -> List[str]:
        """列出某个分镜的所有历史版本
        命名: shot_001_01.jpg, shot_001_01_v2.jpg, ...
        """
        return self._list_versions_static(sid, shot_id)

    @staticmethod
    def _list_versions_static(sid: str, shot_id: str) -> List[str]:
        """列出某个分镜的所有历史版本（静态方法，供外部调用）"""
        scenes_dir = os.path.join('code/result/image', str(sid), 'Scenes')
        pattern = os.path.join(scenes_dir, f"{shot_id}*.jpg")
        files = sorted(glob.glob(pattern), key=os.path.getmtime)
        return files

    def _next_version_path(self, sid: str, shot_id: str) -> str:
        """获取下一个版本路径"""
        scenes_dir = self._scenes_base(sid)
        os.makedirs(scenes_dir, exist_ok=True)

        existing = self._list_versions(sid, shot_id)
        if not existing:
            return os.path.join(scenes_dir, f"{shot_id}.jpg")

        max_v = 1
        for fp in existing:
            bn = os.path.splitext(os.path.basename(fp))[0]
            m = re.search(r'_v(\d+)$', bn)
            if m:
                max_v = max(max_v, int(m.group(1)))

        return os.path.join(scenes_dir, f"{shot_id}_v{max_v + 1}.jpg")

    # ─── 素材匹配 ───

    @staticmethod
    def _build_asset_map(sid: str) -> Dict[str, Dict[str, str]]:
        """扫描阶段2生成的素材文件"""
        base = os.path.join('code/result/image', str(sid), 'Assets')
        am: Dict[str, Dict[str, str]] = {'characters': {}, 'settings': {}}
        for sub, key in [('characters', 'characters'), ('settings', 'settings')]:
            d = os.path.join(base, sub)
            if os.path.isdir(d):
                files = sorted(
                    [f for f in os.listdir(d) if f.endswith('.png')],
                    key=lambda f: os.path.getmtime(os.path.join(d, f))
                )
                for fn in files:
                    name = os.path.splitext(fn)[0]
                    base_id = re.sub(r'_v\d+$', '', name)
                    am[key][base_id] = os.path.join(d, fn)
        return am

    def _collect_refs(self, shot: dict, asset_map: dict,
                      char_id_map: dict, setting_id_map: dict) -> List[str]:
        """为一个分镜收集参考原图路径（角色 + 场景素材）"""
        refs = []
        for cn in shot.get('characters', []):
            cid = char_id_map.get(cn)
            if cid and cid in asset_map['characters']:
                refs.append(os.path.abspath(asset_map['characters'][cid]))
                logger.info(f"[{shot.get('shot_id', '')}] 添加角色参考图: {cn} -> {cid}")
        loc = shot.get('location', '')
        set_id = setting_id_map.get(loc)
        if set_id and set_id in asset_map['settings']:
            refs.append(os.path.abspath(asset_map['settings'][set_id]))
            logger.info(f"[{shot.get('shot_id', '')}] 添加场景参考图: {loc} -> {set_id}")
        else:
            logger.warning(f"[{shot.get('shot_id', '')}] 未找到场景参考图: location={loc}, set_id={set_id}, available_settings={list(asset_map['settings'].keys())}")
        logger.info(f"[{shot.get('shot_id', '')}] 共收集 {len(refs)} 张参考图")
        return refs[:10]

    def _get_descriptions(self, shot: dict, char_id_map: dict, setting_id_map: dict,
                          script_json: dict) -> tuple:
        """获取分镜中涉及的角色和场景描述

        Returns:
            (character_description, setting_description)
        """
        # 角色描述
        char_descs = []
        for cn in shot.get('characters', []):
            cid = char_id_map.get(cn, '')
            if cid:
                for c in script_json.get('characters', []):
                    if c.get('character_id') == cid:
                        desc = c.get('description', '')
                        visual = c.get('visual_description', '')
                        if visual:
                            char_descs.append(f"{cn}: {visual}")
                        elif desc:
                            char_descs.append(f"{cn}: {desc}")
                        break

        # 场景描述
        loc = shot.get('location', '')
        set_id = setting_id_map.get(loc)
        setting_desc = ""
        if set_id:
            for s in script_json.get('settings', []):
                if s.get('setting_id') == set_id:
                    setting_desc = s.get('description', '') or s.get('visual_description', '')
                    break

        return "； ".join(char_descs), setting_desc

    # ─── 首帧提示词生成 ───

    # ─── 预览构建 ───

    def _build_preview(self, sid: str, shots: list) -> list:
        """构建分镜预览列表（含当前状态）"""
        preview = []
        for idx, shot in enumerate(shots, 1):
            shot_id = shot['shot_id']
            versions = self._list_versions(sid, shot_id)
            preview.append({
                "id": shot_id,
                "name": f"场景{shot['scene_number']}-镜头{shot['shot_number']}",
                "index": idx,
                "description": shot.get('visual_prompt', ''),
                "selected": versions[-1] if versions else "",
                "versions": versions,
                "status": "done" if versions else "pending",
            })
        return preview

    # ─── 单张生成 ───

    def _generate_one(self, img_client, sid: str, shot: dict,
                      first_frame_prompt: str, refs: List[str],
                      style: str, it2i_model: str, t2i_model: str,
                      ref_size: str = "1920*1080", vlm_model: str = "qwen3.5-plus",
                      character_description: str = "", setting_description: str = "",
                      max_versions: int = 3) -> tuple:
        """生成单个分镜参考图，返回 (shot_id, path_or_None, eval_result)

        最多生成 max_versions 个版本，如果所有版本都没有达到8分，
        使用 VLM 选择最好的一张作为最终参考图。
        """
        shot_id = shot.get('shot_id', '')
        plot = shot.get('plot', '')
        visual_prompt = shot.get('visual_prompt', '')

        # 取消时直接跳过，不抛异常，以保留已生成的部分结果
        if self.cancellation_check and self.cancellation_check():
            logger.info(f"ReferenceGeneratorAgent: {shot_id} 跳过（用户取消）")
            return shot_id, None, None

        model = it2i_model if refs else t2i_model
        logger.info(f"[{shot_id}] 使用模型: {model}, 参考图数量: {len(refs) if refs else 0}")
        if refs:
            for i, r in enumerate(refs):
                logger.info(f"[{shot_id}] 参考图[{i}]: {r}")

        # 收集所有生成的版本
        all_versions = []
        all_eval_results = []

        for version in range(max_versions):
            self._check_cancel()

            full_prompt = (
                f"{style} style, masterpiece, cinematic composition, "
                f"best quality, high resolution, "
                f"{first_frame_prompt}"
            )

            save_path = self._next_version_path(sid, shot_id)
            save_dir = os.path.dirname(save_path)

            try:
                paths = img_client.generate_image(
                    prompt=full_prompt,
                    image_paths=refs if refs else None,
                    model=model,
                    session_id=str(sid),
                    save_dir=save_dir,
                    size=ref_size,
                )
                if not paths:
                    continue

                gen = paths[0]
                if gen != save_path:
                    if os.path.exists(save_path):
                        os.remove(save_path)
                    os.rename(gen, save_path)

                # VLM 评估
                eval_result = self._evaluate_with_vlm(save_path, shot,
                                                      character_description=character_description,
                                                      setting_description=setting_description,
                                                      vlm_model=vlm_model)

                score = eval_result.get('score', 0)
                is_acceptable = score >= 8

                logger.info(f"[{shot_id}] 版本{version + 1}: 评分 {score}/10, {'✓通过' if is_acceptable else '✗不通过'}")

                # 记录版本信息
                all_versions.append(save_path)
                all_eval_results.append(eval_result)

                # 如果达到8分，立即返回
                if is_acceptable:
                    return shot_id, save_path, eval_result

                # 报告进度
                if version < max_versions - 1:
                    self._report_progress("参考图", f"重新生成中 ({version + 2}/{max_versions}): {shot_id}", 0)

            except Exception as e:
                logger.error(f"Shot {shot_id} image generation failed: {e}")

        # 所有版本都没有达到8分，使用 VLM 选择最好的
        if all_versions:
            logger.warning(f"[{shot_id}] 所有版本都未达到8分，使用VLM选择最佳...")
            best_path, best_eval = self._select_best_with_vlm(
                all_versions, shot,
                character_description=character_description,
                setting_description=setting_description,
                vlm_model=vlm_model
            )
            if best_path:
                return shot_id, best_path, best_eval

        # 如果没有任何生成成功
        logger.warning(f"[{shot_id}] 没有成功生成任何图片")
        return shot_id, None, None

    def _select_best_with_vlm(self, image_paths: List[str], shot: dict,
                              character_description: str = "", setting_description: str = "",
                              vlm_model: str = "qwen3.5-plus") -> tuple:
        """使用 VLM 从多个版本中选择最好的一张"""
        from tool.vlm_client import VLM

        if not image_paths:
            return None, None

        shot_id = shot.get('shot_id', '')
        plot = shot.get('plot', '')
        visual_prompt = shot.get('visual_prompt', '')

        # 加载评估提示词
        select_prompt = load_prompt('reference', 'eval_select_best', 'zh').format(
            num_images=len(image_paths),
            num_images_minus_1=len(image_paths) - 1,
            plot=plot,
            visual_prompt=visual_prompt,
            character_description=character_description,
            setting_description=setting_description,
            images_list="\n".join([f"图片{i}: {p}" for i, p in enumerate(image_paths)])
        )

        try:
            vlm = VLM()
            result = vlm.query(select_prompt, image_paths=image_paths, model=vlm_model)
            logger.info(f"[{shot_id}] VLM选择结果: {result}")

            # 解析 JSON 结果
            import re
            json_match = re.search(r'\{[^{}]*\}', result, re.DOTALL)
            if json_match:
                selected = json.loads(json_match.group())
                selected_idx = selected.get('selected_index', 0)
                if 0 <= selected_idx < len(image_paths):
                    best_path = image_paths[selected_idx]
                    logger.info(f"[{shot_id}] VLM选择第{selected_idx + 1}张作为最佳图片")
                    # 构建评估结果
                    best_eval = {
                        "score": selected.get('score', 5),
                        "issues": selected.get('issues', []),
                        "is_acceptable": True,
                        "selected_by_vlm": True,
                        "reason": selected.get('reason', '')
                    }
                    return best_path, best_eval

        except Exception as e:
            logger.error(f"[{shot_id}] VLM选择最佳图片失败: {e}")

        # 如果失败，返回第一个版本
        return image_paths[0], {"score": 5, "issues": [], "selected_by_vlm": False}

    def _evaluate_with_vlm(self, image_path: str, shot: dict,
                          character_description: str = "", setting_description: str = "",
                          vlm_model: str = "qwen3.5-plus") -> dict:
        """使用 VLM 评估首帧参考图"""
        try:
            from tool.vlm_client import VLM
            vlm = VLM()

            eval_prompt = load_prompt('reference', 'eval_first_frame', 'zh').format(
                plot=shot.get('plot', ''),
                visual_prompt=shot.get('visual_prompt', ''),
                character_description=character_description,
                setting_description=setting_description
            )

            result = vlm.query(
                prompt=eval_prompt,
                image_paths=[image_path],
                model=vlm_model
            )

            if result and isinstance(result, list):
                result_text = result[0] if result else ""
            elif isinstance(result, str):
                result_text = result
            else:
                result_text = str(result)

            import json
            try:
                import re
                json_match = re.search(r'\{[^{}]*\}', result_text, re.DOTALL)
                if json_match:
                    eval_result = json.loads(json_match.group())
                    return eval_result
            except:
                pass

            return {"score": 5, "issues": ["评估解析失败"], "is_acceptable": True}

        except Exception as e:
            logger.warning(f"VLM evaluation failed: {e}")
            return {"score": 5, "issues": [str(e)], "is_acceptable": True}

    # ─── 构建最终 payload ───

    def _build_payload(self, sid: str, shots: list) -> dict:
        """构建最终 payload"""
        scenes = []
        for idx, shot in enumerate(shots, 1):
            shot_id = shot['shot_id']
            versions = self._list_versions(sid, shot_id)
            # status: 有图片=done, 无图片=pending(待生成)
            status = "done" if versions else "pending"
            scenes.append({
                "id": shot_id,
                "name": f"场景{shot['scene_number']}-镜头{shot['shot_number']}",
                "index": idx,
                "description": shot.get('visual_prompt', ''),
                "selected": versions[-1] if versions else "",
                "versions": versions,
                "status": status,
            })
        return {
            "payload": {
                "session_id": sid,
                "scenes": scenes,
            },
            "stage_completed": True,
        }

    def _update_scene2image(self, sid: str, shots: list, result_file: str,
                            first_frame_prompts: dict) -> None:
        """写回 scene2image 到结果文件
        scene2image[shot_id] = {
            local_path: 当前最新版本图片路径,
            prompt: 首帧图像提示词(阶段4生成),
            plot: 原始分镜剧情描述(阶段3，供阶段5视频使用),
            video_prompt: 原始分镜视觉描述(阶段3),
            duration: 分镜时长,
        }
        """
        with open(result_file, 'r', encoding='utf-8') as f:
            res = json.load(f)
        scene_images = {}
        for shot in shots:
            shot_id = shot['shot_id']
            versions = self._list_versions(sid, shot_id)
            if versions:
                scene_images[shot_id] = {
                    "local_path": versions[-1],
                    "prompt": first_frame_prompts.get(shot_id, shot.get('visual_prompt', '')),
                    "plot": shot.get('plot', ''),
                    "video_prompt": shot.get('visual_prompt', ''),
                    "duration": shot.get('duration', 10),
                }
        res[str(sid)]['scene2image'] = scene_images
        with open(result_file, 'w', encoding='utf-8') as f:
            json.dump(res, f, indent=4, ensure_ascii=False)

    # ─── 核心流程 ───

    async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
        from config import settings
        from tool.image_client import ImageClient
        from tool.llm_client import LLM

        sid = input_data["session_id"]
        style = input_data.get("style", "anime")
        video_ratio = input_data.get("video_ratio", "16:9")
        ref_size = ratio_to_size(video_ratio)
        llm_model = input_data.get("llm_model", "") or settings.LLM_MODEL
        t2i = input_data.get("image_t2i_model", "") or settings.IMAGE_T2I_MODEL
        it2i = input_data.get("image_it2i_model", "") or settings.IMAGE_IT2I_MODEL
        vlm_model = input_data.get("vlm_model", "") or settings.VLM_MODEL
        # 根据 enable_concurrency 决定并发数
        enable_concurrency = input_data.get("enable_concurrency", True)
        logger.info(f"[ReferenceAgent] enable_concurrency={enable_concurrency}")
        # 取 t2i 和 it2i 中的最大并发数
        from config_model import get_max_concurrency
        max_t2i = get_max_concurrency(t2i, enable_concurrency)
        max_it2i = get_max_concurrency(it2i, enable_concurrency)
        concurrency = max(max_t2i, max_it2i)
        logger.info(f"[ReferenceAgent] 使用并发数={concurrency}")

        result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')

        img_client = ImageClient(
            dashscope_api_key=settings.DASHSCOPE_API_KEY,
            dashscope_base_url=settings.DASHSCOPE_BASE_URL,
            gpt_api_key=os.getenv("OPENAI_API_KEY"),
            gpt_base_url=os.getenv("OPENAI_BASE_URL"),
            gpt_official_api_key=settings.OPENAI_OFFICIAL_API_KEY,
            local_proxy=settings.LOCAL_PROXY,
            ark_api_key=settings.ARK_API_KEY,
            ark_base_url=settings.ARK_BASE_URL,
        )

        # 读取数据
        with open(result_file, 'r', encoding='utf-8') as f:
            results = json.load(f)
        story_data = results[str(sid)]

        storyboard = story_data.get('storyboard', {})
        shots = storyboard.get('shots', [])
        if not shots:
            raise Exception("未找到分镜数据(storyboard.shots)，请先完成阶段3")

        script_json = story_data.get('script_json', {})

        # 判断中英文
        is_zh = any('\u4e00' <= c <= '\u9fff' for c in script_json.get("title", ""))

        # 构建 name → id 映射（用于素材匹配）
        char_id_map = {}
        for c in script_json.get('characters', []):
            char_id_map[c['name']] = c.get('character_id', '')

        setting_id_map = {}
        for s in script_json.get('settings', []):
            setting_id_map[s['name']] = s.get('setting_id', '')

        asset_map = self._build_asset_map(sid)

        # ═══ 介入：重新生成指定分镜 ═══
        if intervention:
            regen_scenes = intervention.get("regenerate_scenes", [])

            if regen_scenes:
                self._report_progress("参考图", "重新生成中...", 2)

                # 重新从 session JSON 文件读取最新的 storyboard 数据
                with open(result_file, 'r', encoding='utf-8') as f:
                    fresh_data = json.load(f)
                fresh_storyboard = fresh_data.get(str(sid), {}).get('storyboard', {})
                fresh_shots = fresh_storyboard.get('shots', [])
                fresh_shot_map = {s['shot_id']: s for s in fresh_shots}

                llm = LLM()

                def regen_run():
                    total = len(regen_scenes)
                    done = 0
                    # 每分镜5个步骤：准备(1)、生成(3)、完成(1)
                    steps_per_shot = 5
                    total_steps = total * steps_per_shot

                    def calc_pct_regen(step: int) -> int:
                        return min(2 + int(98 * step / total_steps), 100)

                    # 从最新读取的 storyboard JSON 中获取 visual_prompt
                    prompt_map = {}  # shot_id → first_frame_prompt
                    for i, shot_id in enumerate(regen_scenes):
                        shot = fresh_shot_map.get(shot_id, {})
                        ff_prompt = shot.get('visual_prompt', '')
                        prompt_map[shot_id] = ff_prompt
                        logger.info(f"[{shot_id}] first-frame prompt (from JSON): {ff_prompt[:80]}...")
                        self._report_progress("参考图", f"准备提示词: {shot_id}", calc_pct_regen(i * steps_per_shot + 1))

                    # 并发生成图像
                    self._report_progress("参考图", "生成参考图...", calc_pct_regen(total * 2))
                    with ThreadPoolExecutor(max_workers=concurrency) as executor:
                        futs = {}
                        for shot_id in regen_scenes:
                            shot = fresh_shot_map.get(shot_id, {})
                            refs = self._collect_refs(shot, asset_map, char_id_map, setting_id_map)
                            char_desc, set_desc = self._get_descriptions(
                                shot, char_id_map, setting_id_map, script_json
                            )
                            fut = executor.submit(
                                self._generate_one, img_client, sid,
                                shot, prompt_map[shot_id], refs,
                                style, it2i, t2i, ref_size, vlm_model,
                                character_description=char_desc, setting_description=set_desc
                            )
                            futs[fut] = shot_id
                        for fut in as_completed(futs):
                            shot_id_done = futs[fut]
                            try:
                                _, result_path, eval_result = fut.result()
                            except Exception as e:
                                logger.error(f"Regen future error for {shot_id_done}: {e}")
                                result_path = None
                            done += 1
                            step = done * steps_per_shot
                            pct = calc_pct_regen(step)
                            if result_path:
                                versions = self._list_versions(sid, shot_id_done)
                                self._report_progress("参考图", f"完成: {shot_id_done}", pct, data={
                                    "asset_complete": {
                                        "type": "scenes", "id": shot_id_done,
                                        "status": "done",
                                        "selected": result_path,
                                        "versions": versions,
                                    }
                                })
                            else:
                                self._report_progress("参考图", f"失败: {shot_id_done}", pct, data={
                                    "asset_complete": {
                                        "type": "scenes", "id": shot_id_done,
                                        "status": "failed",
                                        "selected": "", "versions": [],
                                    }
                                })
                            # 检查取消
                            if self.cancellation_check and self.cancellation_check():
                                logger.info("ReferenceGeneratorAgent: 用户取消重新生成，停止等待剩余任务")
                                for f in futs:
                                    if not f.done():
                                        f.cancel()
                                break

                loop = asyncio.get_running_loop()
                await loop.run_in_executor(None, regen_run)

            # 重新生成后更新 scene2image（同步最新版本信息）
            self._update_scene2image(sid, shots, result_file, {})

            self._report_progress("参考图", "完成", 100)
            return self._build_payload(sid, shots)

        # ═══ 正常流程：全量生成 ═══
        self._report_progress("参考图", "加载分镜数据...", 5)

        # 发送预览列表
        preview = self._build_preview(sid, shots)
        self._report_progress("参考图", "加载分镜列表", 8, data={"assets_preview": {"scenes": preview}})

        llm = LLM()

        def run():
            # 筛选需要生成的（跳过已有图的）
            pending_shots = []
            for shot in shots:
                shot_id = shot['shot_id']
                existing = self._list_versions(sid, shot_id)
                if existing:
                    continue
                pending_shots.append(shot)

            if not pending_shots:
                self._report_progress("参考图", "所有分镜图已存在", 95)
                return

            total = len(pending_shots)
            # 每分镜5个步骤：准备(1)、生成(3)、完成(1)
            steps_per_shot = 5
            total_steps = total * steps_per_shot + 1  # +1 是加载数据步骤

            def calc_pct(step: int) -> int:
                """根据步骤计算进度百分比"""
                return min(2 + int(98 * step / total_steps), 100)

            done = 0

            # 步骤1：加载数据
            self._report_progress("参考图", "准备生成...", calc_pct(0))

            # 步骤2-6(每分镜)：准备提示词
            first_frame_prompts = {}  # shot_id → prompt
            for i, shot in enumerate(pending_shots):
                shot_id = shot['shot_id']
                ff_prompt = shot.get('visual_prompt', '')
                first_frame_prompts[shot_id] = ff_prompt
                logger.info(f"[{shot_id}] first-frame prompt: {ff_prompt[:80]}...")
                step = i * steps_per_shot + 1
                self._report_progress("参考图", f"准备提示词: {shot_id}", calc_pct(step))

            # 步骤7+(每分镜3步)：并发生成图像
            self._report_progress("参考图", "生成参考图...", calc_pct(total * 2))
            tasks = []
            for shot in pending_shots:
                shot_id = shot['shot_id']
                refs = self._collect_refs(shot, asset_map, char_id_map, setting_id_map)
                char_desc, set_desc = self._get_descriptions(
                    shot, char_id_map, setting_id_map, script_json
                )
                tasks.append((shot, first_frame_prompts[shot_id], refs, char_desc, set_desc))

            with ThreadPoolExecutor(max_workers=concurrency) as executor:
                futs = {}
                for shot, ff_prompt, refs, char_desc, set_desc in tasks:
                    shot_id = shot['shot_id']
                    fut = executor.submit(
                        self._generate_one, img_client, sid,
                        shot, ff_prompt, refs,
                        style, it2i, t2i, ref_size, vlm_model,
                        character_description=char_desc, setting_description=set_desc
                    )
                    futs[fut] = shot_id
                cancelled = False
                for fut in as_completed(futs):
                    shot_id_done = futs[fut]
                    try:
                        _, result_path, eval_result = fut.result()
                    except Exception as e:
                        logger.error(f"Image future error for {shot_id_done}: {e}")
                        result_path = None
                    done += 1
                    # 每个分镜完成时更新进度
                    step = done * steps_per_shot
                    pct = calc_pct(step)
                    if result_path:
                        versions = self._list_versions(sid, shot_id_done)
                        self._report_progress("参考图", f"完成: {shot_id_done}", pct, data={
                            "asset_complete": {
                                "type": "scenes", "id": shot_id_done,
                                "status": "done",
                                "selected": result_path,
                                "versions": versions,
                            }
                        })
                    else:
                        self._report_progress("参考图", f"失败: {shot_id_done}", pct, data={
                            "asset_complete": {
                                "type": "scenes", "id": shot_id_done,
                                "status": "failed",
                                "selected": "", "versions": [],
                            }
                        })
                    # 检查取消
                    if self.cancellation_check and self.cancellation_check():
                        logger.info("ReferenceGeneratorAgent: 用户取消，停止等待剩余任务")
                        for f in futs:
                            if not f.done():
                                f.cancel()
                        cancelled = True
                        break

            if cancelled:
                self._report_progress("参考图", "已取消（保留已完成图片）", 96)
            else:
                self._report_progress("参考图", "保存结果...", 96)

            # 写回结果文件
            self._update_scene2image(sid, shots, result_file, first_frame_prompts)

        loop = asyncio.get_running_loop()
        try:
            await loop.run_in_executor(None, run)
        except Exception as e:
            if "cancel" in str(e).lower():
                logger.info("ReferenceGeneratorAgent: 用户取消，返回已完成���部分结果")
                self._report_progress("参考图", "已取消（保留已完成图片）", 100)
                return self._build_payload(sid, shots)
            raise

        self._report_progress("参考图", "完成", 100)
        return self._build_payload(sid, shots)


path: aigc-claw/backend/core/agents/storyboard_agent.py
content: # -*- coding: utf-8 -*-
"""
阶段3: 分镜智能体
基于剧本JSON，逐场景拆分为带时长标签的分镜（shots），按幕分组输出。
"""

import os
import re
import json
import asyncio
import logging
from typing import Any, Optional, Dict, List
from concurrent.futures import ThreadPoolExecutor, as_completed

from .base_agent import AgentInterface
from prompts.loader import load_prompt

logger = logging.getLogger(__name__)

# ─── 幕名 ───
ACT_NAMES = {1: "激励事件", 2: "进入新世界", 3: "灵魂黑夜", 4: "高潮决战"}


def _get_shot_prompt(lang: str = 'zh') -> str:
    """获取分镜提示词"""
    return load_prompt('storyboard', 'shot', lang)


def _get_continue_prompt(lang: str = 'zh') -> str:
    """获取续写分镜提示词"""
    from prompts.loader import load_prompt_with_fallback
    return load_prompt_with_fallback('storyboard', 'continue', lang, 'zh')


def _get_prompt(name: str) -> str:
    """Helper to get prompts"""
    if name == 'SHOT_PROMPT_ZH':
        return load_prompt('storyboard', 'shot', 'zh')
    elif name == 'SHOT_PROMPT_EN':
        return load_prompt('storyboard', 'shot', 'en')
    raise AttributeError(f"module has no attribute {name!r}")


class StoryboardAgent(AgentInterface):
    """分镜智能体：逐场景拆分为带时长标签的分镜"""

    def __init__(self):
        super().__init__(name="Storyboard")

    # ─── 辅助方法 ───

    @staticmethod
    def _read_script_json(sid: str) -> dict:
        """从结果文件读取 script_json"""
        from config import settings
        result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')
        if not os.path.exists(result_file):
            return {}
        with open(result_file, 'r', encoding='utf-8') as f:
            data = json.load(f)
        return data.get(str(sid), {}).get('script_json', {})

    @staticmethod
    def _extract_json_array(text: str) -> Optional[List[dict]]:
        """从LLM输出中提取JSON数组"""
        text = text.strip()
        text = re.sub(r'^```(?:json)?\s*', '', text)
        text = re.sub(r'\s*```$', '', text)
        text = text.strip()
        try:
            result = json.loads(text)
            if isinstance(result, list):
                return result
        except json.JSONDecodeError:
            pass
        # 尝试找到 [ ... ] 部分
        m = re.search(r'\[.*\]', text, re.DOTALL)
        if m:
            try:
                result = json.loads(m.group())
                if isinstance(result, list):
                    return result
            except json.JSONDecodeError:
                pass
        return None

    @staticmethod
    def _validate_shots(shots: List[dict]) -> List[dict]:
        """校验并清洗分镜数据"""
        valid = []
        for s in shots:
            if not isinstance(s, dict):
                continue
            dur = s.get("duration", 10)
            if dur not in (5, 10, 15):
                dur = 10  # 默认10秒
            valid.append({
                "shot_number": s.get("shot_number", len(valid) + 1),
                "duration": dur,
                "characters": s.get("characters", []),
                "location": s.get("location", ""),
                "plot": s.get("plot", ""),
                "visual_prompt": s.get("visual_prompt", ""),
            })
        return valid

    async def _continue_story(self, input_data: Dict, continue_info: Dict) -> Dict:
        """智能续写：根据已有剧情续写新场景"""
        from config import settings
        from tool.llm_client import LLM

        sid = input_data["session_id"]
        style = input_data.get("style", "anime")
        llm_model = input_data.get("llm_model", "qwen3.5-plus")

        result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')

        # 读取已有分镜
        with open(result_file, 'r', encoding='utf-8') as f:
            results = json.load(f)
        existing_shots = results.get(str(sid), {}).get('storyboard', {}).get('shots', [])
        if not existing_shots:
            raise Exception("没有已有分镜，无法续写")

        # 读取剧本信息
        script_json = self._read_script_json(sid)
        if not script_json:
            raise Exception("未找到剧本数据")

        title = script_json.get("title", "")
        characters = script_json.get("characters", [])
        settings_list = script_json.get("settings", [])

        # 取所有分镜作为上下文
        last_shots = existing_shots

        # 构建上下文描述
        char_names = [c.get("name", "") for c in characters]
        # 只使用现有分镜中实际使用的场景位置
        used_locations = set(s.get("location", "") for s in existing_shots if s.get("location"))
        setting_names = [s.get("name", "") for s in settings_list if s.get("name", "") in used_locations]

        # 获取续写提示词
        is_zh = any('\u4e00' <= c <= '\u9fff' for c in title)
        prompt_template = _get_continue_prompt('zh' if is_zh else 'en')

        # 格式化已有分镜信息
        shots_context = ""
        for shot in last_shots:
            shots_context += f"- 场景{shot.get('scene_number')} 分镜{shot.get('shot_number')}: {shot.get('plot', '')}\n"

        prompt = prompt_template.format(
            title=title,
            style=style,
            characters=", ".join(char_names),
            settings=", ".join(setting_names),
            existing_shots=shots_context,
        )

        self._report_progress("分镜", "智能续写中...", 10)

        def run():
            llm = LLM()
            raw = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid)
            new_shots = self._extract_json_array(raw)
            if not new_shots:
                raise Exception("续写失败，无法解析LLM输出")
            new_shots = self._validate_shots(new_shots)
            return new_shots

        loop = asyncio.get_running_loop()
        new_shots = await loop.run_in_executor(None, run)

        # 为新分镜添加全局标识
        last_scene = last_shots[-1] if last_shots else None
        next_scene_num = (last_scene.get("scene_number", 0) + 1) if last_scene else 1
        next_act = last_scene.get("act", 1) if last_scene else 1

        for i, shot in enumerate(new_shots):
            shot["shot_id"] = f"shot_{next_scene_num:03d}_{i + 1:02d}"
            shot["scene_number"] = next_scene_num
            shot["act"] = next_act
            shot["is_new"] = True  # 标记为新添加的分镜

        # 追加到已有分镜
        all_shots = existing_shots + new_shots

        # 保存（带有 is_new 标记，供前端识别）
        results.setdefault(str(sid), {})['storyboard'] = {
            'shots': all_shots,
            'user_modified': True,
            'new_shot_ids': [s["shot_id"] for s in new_shots],  # 记录新分镜ID
        }
        with open(result_file, 'w', encoding='utf-8') as f:
            json.dump(results, f, indent=4, ensure_ascii=False)

        self._report_progress("分镜", "续写完成", 100)

        return {
            "payload": {
                "session_id": sid,
                "shots": all_shots,
                "new_shots": new_shots,
                "new_shot_ids": [s["shot_id"] for s in new_shots],
                "continued": True,
            },
            "requires_intervention": True,
        }

    # ─── 核心流程 ───

    async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
        from config import settings
        from tool.llm_client import LLM

        sid = input_data["session_id"]
        style = input_data.get("style", "anime")
        llm_model = input_data.get("llm_model", "qwen3.5-plus")

        result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')

        # ── 用户修改了分镜 ──
        if intervention and "modified_storyboard" in intervention:
            modified_shots = intervention["modified_storyboard"]
            if isinstance(modified_shots, str):
                modified_shots = json.loads(modified_shots)

            with open(result_file, 'r', encoding='utf-8') as f:
                results = json.load(f)

            # 同步更新 script_json 中的 scenes
            # 获取修改后的 shots 中包含的场景编号
            new_scene_numbers = set(s.get("scene_number") for s in modified_shots if s.get("scene_number"))

            # 更新 script_json.scenes
            script_json = results.get(str(sid), {}).get('script_json', {})
            if script_json and 'scenes' in script_json:
                original_scenes = script_json.get('scenes', [])
                # 保留新分镜中包含的场景，按原始顺序
                filtered_scenes = [s for s in original_scenes if s.get('scene_number') in new_scene_numbers]
                # 重新编号（确保 scene_number 连续）
                for i, scene in enumerate(filtered_scenes, 1):
                    scene['scene_number'] = i
                script_json['scenes'] = filtered_scenes

            # 清除 new_shot_ids 标记，同时清除 is_new 标志
            for shot in modified_shots:
                if 'is_new' in shot:
                    shot['is_new'] = False
            storyboard_data = {
                'shots': modified_shots,
                'user_modified': True,
                'new_shot_ids': [],  # 清除新分镜标记
            }

            results.setdefault(str(sid), {})['storyboard'] = storyboard_data
            if script_json:
                results.setdefault(str(sid), {})['script_json'] = script_json
            with open(result_file, 'w', encoding='utf-8') as f:
                json.dump(results, f, indent=4, ensure_ascii=False)

            # 返回完整的 payload 包含 user_modified 和 new_shot_ids
            return {
                "payload": {
                    "session_id": sid,
                    "shots": modified_shots,
                    "user_modified": True,
                    "new_shot_ids": [],
                },
                "stage_completed": True,
            }

        # ── 用户点击"智能续写" ──
        if intervention and "continue_story" in intervention:
            return await self._continue_story(input_data, intervention["continue_story"])

        # ── 正常流程 ──
        script_json = self._read_script_json(sid)
        if not script_json:
            raise Exception("未找到剧本数据(script_json)，请先完成阶段1")

        scenes = script_json.get("scenes", [])
        characters = {c["name"]: c for c in script_json.get("characters", [])}
        settings_map = {s["name"]: s for s in script_json.get("settings", [])}

        is_zh = any('\u4e00' <= c <= '\u9fff' for c in script_json.get("title", ""))
        prompt_template = _get_prompt('SHOT_PROMPT_ZH') if is_zh else _get_prompt('SHOT_PROMPT_EN')

        self._report_progress("分镜", "读取剧本数据...", 5)

        # 获取并发数
        enable_concurrency = input_data.get("enable_concurrency", True)
        from config_model import get_max_concurrency
        # LLM 调用的并发数
        concurrency = get_max_concurrency("llm", enable_concurrency)
        logger.info(f"[StoryboardAgent] enable_concurrency={enable_concurrency}, concurrency={concurrency}")

        # 用于按场景顺序存储结果的字典
        shots_results: Dict[int, List[dict]] = {}
        done_count = 0
        total = len(scenes)

        def generate_one_scene(scene_index: int, scene: dict) -> tuple:
            """生成单个场景的分镜"""
            llm = LLM()
            sn = scene.get("scene_number", scene_index + 1)
            act = scene.get("act", 1)

            location = scene.get("location", "")
            scene_chars = scene.get("characters", [])
            plot = scene.get("plot", "")

            # 角色外貌描述
            char_descs = []
            for cn in scene_chars:
                c = characters.get(cn)
                if c:
                    char_descs.append(f"{cn}: {c.get('description', '')}")
            char_desc_text = "\n".join(char_descs) if char_descs else "无"

            # 场景环境描述
            setting_info = settings_map.get(location, {})
            setting_desc = setting_info.get("description", location)

            prompt = prompt_template.format(
                style=style,
                scene_number=sn,
                location=location,
                characters=", ".join(scene_chars),
                plot=plot,
                char_descriptions=char_desc_text,
                setting_description=setting_desc,
            )

            # 最多重试3次
            scene_shots = None
            for attempt in range(3):
                if hasattr(self, 'cancellation_check') and self.cancellation_check:
                    if self.cancellation_check():
                        break
                raw = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid)
                parsed = self._extract_json_array(raw)
                if parsed:
                    scene_shots = self._validate_shots(parsed)
                    if scene_shots:
                        break
                logger.warning(f"Scene {sn} shot parse attempt {attempt+1} failed")

            if not scene_shots:
                # 降级: 整个场景作为一个分镜
                scene_shots = [{
                    "shot_number": 1,
                    "duration": 15,
                    "characters": scene_chars,
                    "location": location,
                    "plot": plot,
                    "visual_prompt": plot,
                }]

            # 为每个分镜添加全局标识
            for shot in scene_shots:
                shot["shot_id"] = f"shot_{sn:03d}_{shot['shot_number']:02d}"
                shot["scene_number"] = sn
                shot["act"] = act

            return scene_index, scene_shots, sn, act

        def run():
            nonlocal done_count
            # 检测是否有多幕结构（微电影模式只有一幕或无 act 字段）
            act_values = {s.get("act") for s in scenes}
            multi_act = len(act_values - {None}) > 1

            with ThreadPoolExecutor(max_workers=concurrency) as executor:
                futs = {}
                for i, scene in enumerate(scenes):
                    fut = executor.submit(generate_one_scene, i, scene)
                    futs[fut] = i

                # 按提交顺序收集结果（不是按完成顺序）
                for scene_index in range(len(scenes)):
                    # 找到对应的 future
                    found_future = None
                    for fut in futs:
                        if futs[fut] == scene_index:
                            found_future = fut
                            break

                    if found_future:
                        try:
                            idx, scene_shots, sn, act = found_future.result()
                            shots_results[idx] = scene_shots

                            # 报告进度
                            done_count += 1
                            pct = 5 + int(90 * done_count / max(total, 1))
                            if multi_act:
                                act_label = ACT_NAMES.get(act, f"第{act}幕")
                                self._report_progress("分镜", f"第{act}幕「{act_label}」 场景{sn}/{total}完成", pct)
                            else:
                                self._report_progress("分镜", f"场景{sn}完成 ({len(scene_shots)}个分镜)", pct)

                            # 发送逐场完成事件
                            self._report_progress(
                                "分镜",
                                f"场景{sn}完成 ({len(scene_shots)}个分镜)",
                                pct,
                                data={"scene_shots_complete": {
                                    "scene_number": sn,
                                    "act": act,
                                    "shots": scene_shots,
                                }},
                            )
                        except Exception as e:
                            logger.error(f"Scene {scene_index} generation error: {e}")
                            shots_results[scene_index] = []

            # 按场景顺序拼接所有分镜
            all_shots = []
            for i in range(len(scenes)):
                if i in shots_results:
                    all_shots.extend(shots_results[i])

            self._report_progress("分镜", "保存结果...", 96)

            # 写入结果文件
            with open(result_file, 'r', encoding='utf-8') as f:
                results = json.load(f)
            results.setdefault(str(sid), {})['storyboard'] = {
                'shots': all_shots,
            }
            with open(result_file, 'w', encoding='utf-8') as f:
                json.dump(results, f, indent=4, ensure_ascii=False)

            self._report_progress("分镜", "完成", 100)
            return {
                "payload": {
                    "session_id": sid,
                    "shots": all_shots,
                },
                "stage_completed": True,
            }

        loop = asyncio.get_running_loop()
        all_shots = await loop.run_in_executor(None, run)

        return {
            "payload": {
                "session_id": sid,
                "shots": all_shots,
            },
            "stage_completed": True,
        }


path: aigc-claw/backend/prompts/character/character_zh.txt
content: Character reference sheet, {style} style, white background,
4 views of the same character arranged in a single row from left to right:
1) front-facing close-up portrait (head and upper body),
2) front-facing full body ({pose_note}),
3) side-view full body ({pose_note}),
4) back-view full body ({pose_note}).
All views show the SAME character with consistent appearance and outfit.
The close-up shows head-to-waist; the other three show complete body from head to feet.
Pure image only, absolutely NO text, NO labels, NO annotations, NO captions, NO watermarks on the image.
High quality, masterpiece, clean line art.
Character: {name} — {desc}


path: aigc-claw/backend/prompts/character/eval_character_zh.txt
content: 你是一个专业的AI图像质量评估师。请根据以下信息评估生成的4视图角色参考图。

角色描述：
{character_description}

注意：这是一张4视图角色参考图，包含：
1. 正面特写（头部及上半身）
2. 正面全身照
3. 侧面全身照
4. 背面全身照

请严格按照以下标准评估并给出1-10分：

【评分标准】
满分10分，每个检查项符合得1分，不符合扣1分，最高10分：

1. 四视图一致性（+1/-1）：四个视图是否为同一角色（面貌、服装、体型是否一致）
2. 面貌还原（+1/-1）：面孔是否与描述一致
3. 发型还原（+1/-1）：发型是否与描述一致
4. 服装还原（+1/-1）：服装颜色、款式是否与描述一致
5. 体型还原（+1/-1）：体型是否与描述一致
6. 完整性（+1/-1）：四个视图是否都存在且完整
7. 光线质量（+1/-1）：光线是否自然、合理
8. 构图质量（+1/-1）：构图是否合理、均衡
9. 图片质量（+1/-1）：清晰度良好，无模糊、无畸变
10. 无无关元素（+1/-1）：无文字、标签、注释、水印等

【扣分项】（发现以下问题必须扣分并在issues中列出）：
- 四个视图角色面貌不一致：-1分
- 服装颜色与描述不符：-1分
- 发型与描述不符：-1分
- 面孔与描述不符：-1分
- 缺少某个视图：-1分
- 出现文字/水印：-1分

【最终评分规则】
- 8分及以上：可接受（is_acceptable: true）
- 7分及以下：需要重新生成（is_acceptable: false）

请输出纯JSON格式（不要用```包裹）：
{{
  "score": 1-10,
  "issues": ["问题1", "问题2", ...],
  "is_acceptable": true/false,
  "suggestion": "修改建议（如果有问题）"
}}

如果图片可接受，issues 返回空数组 []。


path: aigc-claw/backend/prompts/character/eval_select_best_zh.txt
content: 你是一个专业的AI图像质量评估师。请从以下{num_images}张角色设计图中选择最好的一张。

角色名称：{character_name}
角色描述：{character_description}
角色物种：{species}

图片列表：
{images_list}

请严格按照以下标准评估并选择最好的一张：

【评分标准】
满分10分，每个检查项符合得1分，不符合扣1分，最高10分：

1. 外观一致性（+1/-1）：角色外观是否与描述一致（发型、服装、配饰）
2. 面部特征（+1/-1）：面部特征是否清晰、符合描述
3. 身体比例（+1/-1）：身体比例是否正常、协调
4. 风格一致性（+1/-1）：整体风格是否符合设定风格
5. 细节完整（+1/-1）：细节是否丰富，无明显缺失
6. 色彩还原（+1/-1）：色彩是否与描述一致
7. 画面质量（+1/-1）：清晰度良好，无模糊、无畸变
8. 构图质量（+1/-1）：构图是否均衡、美观
9. 视角一致性（+1/-1）：四个视角是否一致
10. 无无关元素（+1/-1）：无文字、水印、标签等无关内容

【最终选择规则】
- 选择分数最高的一张作为最终选择
- 如果最高分有多张相同，选择第一张

请输出纯JSON格式（不要用```包裹）：
{{
  "scores": [
    {{"image_index": 0, "score": 8, "reasons": ["原因1", "原因2"]}},
    {{"image_index": 1, "score": 6, "reasons": ["原因1"]}},
    ...
  ],
  "best_index": 0,
  "best_reason": "选择这张的原因"
}}

path: aigc-claw/backend/prompts/logline/check_en.txt
content: Determine if the following text contains enough narrative elements for a complete story Logline.
(Needs: protagonist, goal, conflict, twist, and theme)

Text: {idea}

Answer only Yes or No.


path: aigc-claw/backend/prompts/logline/check_zh.txt
content: 请判断以下文本是否包含足够的叙事要素，能够从中总结出一个完整故事的 Logline。
（完整 Logline 需涵盖：主角、目标、核心障碍、反转和主题）

文本：{idea}

只回答 Yes 或 No，不要添加任何其他内容。


path: aigc-claw/backend/prompts/logline/extract_zh.txt
content: 你是资深制片人。请从以下文本中总结提取 Logline 及故事五要素。
Logline 请使用"如果…会怎样"的句式。

文本：{idea}

请严格按如下 JSON 格式输出（直接输出纯JSON，不要用```包裹，不要添加任何其他文字）：
{{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}


path: aigc-claw/backend/prompts/logline/generate_en.txt
content: You are a senior producer. Expand the following idea into 3 different Loglines.
Requirements:
- Define the protagonist (Who), Goal, core Conflict, and Twist
- Identify the story's Theme
- Each Logline should use 'What if...' format
- The 3 Loglines should be diverse in style and focus

Input: {idea}

Output ONLY a JSON array with exactly 3 elements (no code block markers, no other text):
[{{"logline":"What if...","who":"protagonist","goal":"goal","conflict":"conflict","twist":"twist","theme":"theme"}}]


path: aigc-claw/backend/prompts/logline/generate_zh.txt
content: 你是资深制片人。请将以下灵感扩展为 3 个不同的 Logline（故事大纲）。
要求：
- 明确主角（Who）、目标（Goal）、核心障碍（Conflict）和反转（Twist）
- 确定故事的 Theme（潜在主题）
- Logline 按照"如果…会怎样"的句式描述
- 3 个 Logline 应风格各异、各有侧重

输入内容：{idea}

请严格按如下 JSON 数组格式输出（直接输出纯JSON，不要用```包裹，不要添加任何其他文字）：
[{{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}]
输出恰好 3 个元素的 JSON 数组。


path: aigc-claw/backend/prompts/reference/eval_select_best_zh.txt
content: 你是一个专业的AI图像质量评估师。请从以下{num_images}张图片中选择最好的一张作为视频参考图。

分镜剧情：
{plot}

分镜视觉描述：
{visual_prompt}

角色描述：
{character_description}

场景描述：
{setting_description}

{images_list}

【选择标准】
请严格按照以下标准选择最佳图片：

1. 角色外观一致性：角色发型、服装、面孔是否与角色描述一致
2. 场景一致性：场景地点、布局是否与场景描述一致
3. 剧情相关性：图片是否体现分镜剧情的核心内容和情感
4. 姿态合理性：角色姿态是否体现动作的起始瞬间
5. 光线质量：光线是否自然、合理、有层次
6. 色调氛围：色调是否符合分镜设定的氛围（日/夜、暖/冷等）
7. 构图美感：构图是否平衡、美观、符合影视镜头语言
8. 图片质量：无模糊、无畸变、无明显瑕疵
9. 细节完整：画面细节丰富，无缺失或残缺
10. 无无关元素：无文字、水印、标签等无关内容

【排除原则】
以下情况必须排除：
- 角色服装颜色与描述严重不符
- 角色发型/面孔与描述严重不符
- 场景环境与描述严重不符
- 角色数量错误（多或少）
- 光线过暗或过亮导致无法看清内容
- 构图严重偏斜导致主体不清晰
- 出现文字/水印/标签等无关元素

请输出纯JSON格式（不要用```包裹）：
{{
  "selected_index": 0到{num_images_minus_1}之间的整数,
  "reason": "选择该图片的主要原因",
  "score": 1-10,
  "issues": ["该图片存在的问题列表，如果完美则为空数组"]
}}

selected_index 必须是从 0 到 {num_images_minus_1} 的整数，表示你选择的图片编号。

path: aigc-claw/backend/prompts/script/micro_beat_sheet_en.txt
content: You are a micro-film screenwriting expert. Compress the following storyline into a compact single-act plot summary.

Requirements:
- Fast narrative pacing, concise and tight plot
- All content in a single act, no act divisions
- Keep core conflict and emotional turns, remove unnecessary setup
- 3-6 scenes total
- Suitable for a 1-3 minute short film

Storyline:
{draft}

Story style: {style}

Output a compact plot summary directly, describing scene progression, core actions and emotional shifts.


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