Claude Cursor Skill

aigc-director

AI 视频生成全流程:通过 6 个阶段(剧本→角色/场景设计→分镜→参考图→视频生成→后期剪辑)将用户想法转化为完整视频。支持临时工作台(单独调用 LLM、VLM、文生图、图生图、视频生成)。触发词:视频生成、AI视频、AIGC、创作视频、制作视频、AI画图。

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download aaaaqwq-agi-super-team-skills_aigc-director-cdb04e8.zip · 323 KB
Part of aaaaqwq/agi-super-team — 46 skills

Install

skills CLI npx skills add https://github.com/aAAaqwq/AGI-Super-Team/tree/main/skills/aigc-director
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aaaaqwq-agi-super-team@llmmart
Git git clone https://github.com/aAAaqwq/AGI-Super-Team.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aaaaqwq/agi-super-team collection as a plugin from our marketplace. Git is the plain clone.

README

🎬 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. 克隆项目

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

2. 配置并启动后端

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

配置后端
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
# 支持的模型见“配置说明”部分
启动后端
# 根据操作系统选择启动虚拟环境的命令
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 两个子目录

配置前端
cd aigc-director # skill目录
cd aigc-claw # 项目目录
cd frontend
npm install
# 首次启动或代码变更后需要 build
npm run build
# 启动生产服务(开销小,只需 build 一次)
启动前端
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):

# 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 定义了每个模型的并发限制:

{
  "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数

文档

许可证

MIT License

Skill manifest

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 执行完整初始化流程。

注意:仅在用户首次下载项目或需要重新配置环境时使用。项目已初始化过则跳过此步骤,直接检查服务运行状态。

2. 检查本地服务

参考 start_backend.md 和 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 是否已配置:

# 读取 .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:获取产物

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/ 下的对应参考文档,获取注意事项和发送方法:


任务简报格式

每个阶段完成后,发送简报必须包含:

  1. 刚完成什么
  2. 下一步做什么
  3. 需要用户决策的内容
  4. Web 界面链接:http://[本地IP]:3000/?session={session_id}&stage={stage}(注意,这里使用本地 IPv4 地址,不要用 localhost!)
  5. 产物图片/视频(直接发送文件,禁止只发路径)

Web 界面链接格式

# 获取本地 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 完整初始化流程 用户要求初始化部署时
init_backend.md 后端初始化 首次配置后端环境时
init_frontend.md 前端初始化 首次配置前端环境时
run_project/ 项目启动
start_backend.md 启动后端服务 服务未运行时
start_frontend.md 启动前端服务 服务未运行时
workflow/ 六阶段工作流
create_project.md 创建新项目 API 开始新视频项目时
create_script.md 剧本生成 API 执行第一阶段时
create_character.md 角色/场景设计 API 执行第二阶段时
create_storyboard.md 分镜设计 API/剧情续写 API 执行第三阶段时/用户提出续写剧情时
create_reference.md 参考图生成 API 执行第四阶段时
create_video.md 视频生成 API 执行第五阶段时
create_post.md 后期剪辑 API 执行第六阶段时
modify_character.md 修改角色提示词 用户要求修改角色时
modify_storyboard.md 修改/续写分镜 用户要求修改/续写分镜时
modify_reference.md 修改参考图提示词 用户要求修改参考图时
modify_video.md 修改视频提示词 用户要求修改视频时
sandbox/ 临时工作台
generate_image_t2i.md 文生图 API 用户要求生成图片时
generate_image_it2i.md 图生图/风格转换 API 用户要求转换图片风格时
generate_video.md 短视频生成 API 用户要求生成15秒内视频时
send_message/ 消息发送
feishu.md 飞书发送媒体文件 用户通过飞书渠道发起对话,并且需要向用户发送图片/视频给用户时
wechat.md 微信发送媒体文件 用户通过微信渠道发起对话,并且需要向用户发送图片/视频给用户时
Files (agi-super-team)
  • aigc-claw
    • backend
      • core
        • agents
          • base_agent.py 1.7 KB
            # -*- 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
            
          • character_agent.py 26.9 KB
            # -*- 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,
                    }
            
          • editor_agent.py 2.7 KB
            # -*- 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,
                    }
          • reference_agent.py 30.9 KB
            # -*- 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)
            
          • script_agent.py 67.7 KB
            # -*- coding: utf-8 -*-
            """
            阶段1: 编剧智能体 - 多轮LLM交互生成结构化剧本JSON
            流程: Logline → 节拍表(4幕) → 分场大纲(逐幕) → JSON结构化提取(带校验重试)
            """
            
            import os
            import re
            import json
            import asyncio
            import logging
            import string
            import random
            from datetime import datetime, timezone
            from typing import Any, Optional, Dict
            
            from .base_agent import AgentInterface
            
            # 导入提示词加载器
            from prompts.loader import load_prompt
            
            logger = logging.getLogger(__name__)
            
            
            # 懒加载提示词 - 优先从外部文件加载,失败则返回空
            def _p(category: str, name: str, lang: str = 'zh') -> str:
                """Load prompt from external file, return empty string if not found"""
                try:
                    return load_prompt(category, name, lang)
                except FileNotFoundError:
                    return ""
            
            
            # 从外部文件加载提示词的辅助函数
            def _load_prompt(category: str, name: str, fallback: str) -> str:
                """Load prompt from external file, fallback to hardcoded string"""
                try:
                    return load_prompt(category, name, 'zh')
                except FileNotFoundError:
                    return fallback
            
            # =============================================================
            #  Phase 1A: Logline 生成(扩写创意 → 3 个 Logline 方案)
            # =============================================================
            
            LOGLINE_GENERATE_PROMPT_ZH = (
                "你是资深制片人。请将以下灵感扩展为 3 个不同的 Logline(故事大纲)。\n"
                "要求:\n"
                "- 明确主角(Who)、目标(Goal)、核心障碍(Conflict)和反转(Twist)\n"
                "- 确定故事的 Theme(潜在主题)\n"
                "- Logline 按照\"如果…会怎样\"的句式描述\n"
                "- 3 个 Logline 应风格各异、各有侧重\n\n"
                "输入内容:{idea}\n\n"
                "请严格按如下 JSON 数组格式输出(直接输出纯JSON,不要用```包裹,不要添加任何其他文字):\n"
                '[{{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}]\n'
                "输出恰好 3 个元素的 JSON 数组。"
            )
            
            LOGLINE_GENERATE_PROMPT_EN = (
                "You are a senior producer. Expand the following idea into 3 different Loglines.\n"
                "Requirements:\n"
                "- Define the protagonist (Who), Goal, core Conflict, and Twist\n"
                "- Identify the story's Theme\n"
                "- Each Logline should use 'What if...' format\n"
                "- The 3 Loglines should be diverse in style and focus\n\n"
                "Input: {idea}\n\n"
                "Output ONLY a JSON array with exactly 3 elements (no code block markers, no other text):\n"
                '[{{"logline":"What if...","who":"protagonist","goal":"goal","conflict":"conflict","twist":"twist","theme":"theme"}}]'
            )
            
            # =============================================================
            #  Phase 1B: Logline 检测 & 提取(未勾选扩写时)
            # =============================================================
            
            LOGLINE_CHECK_PROMPT_ZH = (
                "请判断以下文本是否包含足够的叙事要素,能够从中总结出一个完整故事的 Logline。\n"
                "(完整 Logline 需涵盖:主角、目标、核心障碍、反转和主题)\n\n"
                "文本:{idea}\n\n"
                "只回答 Yes 或 No,不要添加任何其他内容。"
            )
            
            LOGLINE_CHECK_PROMPT_EN = (
                "Determine if the following text contains enough narrative elements for a complete story Logline.\n"
                "(Needs: protagonist, goal, conflict, twist, and theme)\n\n"
                "Text: {idea}\n\n"
                "Answer only Yes or No."
            )
            
            LOGLINE_EXTRACT_PROMPT_ZH = (
                "你是资深制片人。请从以下文本中总结提取 Logline 及故事五要素。\n"
                "Logline 请使用\"如果…会怎样\"的句式。\n\n"
                "文本:{idea}\n\n"
                "请严格按如下 JSON 格式输出(直接输出纯JSON,不要用```包裹,不要添加任何其他文字):\n"
                '{{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}'
            )
            
            LOGLINE_EXTRACT_PROMPT_EN = (
                "You are a senior producer. Extract the Logline and five story elements from the following text.\n"
                "The Logline should use 'What if...' format.\n\n"
                "Text: {idea}\n\n"
                "Output ONLY valid JSON (no code block markers, no other text):\n"
                '{{"logline":"What if...","who":"protagonist","goal":"goal","conflict":"conflict","twist":"twist","theme":"theme"}}'
            )
            
            # =============================================================
            #  Phase 2A: 节拍表 (Save the Cat! Beat Sheet)
            # =============================================================
            
            BEAT_SHEET_PROMPT_ZH = (
                "你是好莱坞编剧导师。请根据以下故事线,使用 Save the Cat! 节拍表将故事拆解为四幕结构。\n\n"
                "必须包含以下四个关键节拍(起承转合):\n"
                "第一幕 - 激励事件(Inciting Incident):建立世界观和主角现状,发生打破平衡的事件\n"
                "第二幕 - 进入新世界(Break into Two):主角踏上旅程,面对挑战和考验,副线展开\n"
                "第三幕 - 灵魂黑夜(Dark Night of the Soul):主角遭受最大打击,陷入低谷\n"
                "第四幕 - 高潮决战(Finale):主角获得顿悟,最终决战,故事收束\n\n"
                "请确保:逻辑严密、冲突逐步升级、每一幕有清晰的转折点。\n\n"
                "故事线:\n{draft}\n\n"
                "故事风格:{style}\n\n"
                "请直接输出四幕节拍表,每一幕用\"【第X幕 - 名称】\"标记开始,详细描述该幕的情节要点、角色发展和关键转折。"
            )
            
            BEAT_SHEET_PROMPT_EN = (
                "You are a Hollywood screenwriting mentor. Break down the following storyline into a 4-act structure "
                "using the Save the Cat! Beat Sheet.\n\n"
                "Must include these four key beats:\n"
                "Act 1 - Inciting Incident: Establish the world and protagonist's status quo, then a disruptive event\n"
                "Act 2 - Break into Two: Protagonist embarks on journey, faces challenges, B-story unfolds\n"
                "Act 3 - Dark Night of the Soul: Protagonist suffers the biggest blow, hits rock bottom\n"
                "Act 4 - Finale: Protagonist gains epiphany, final confrontation, story resolves\n\n"
                "Ensure: tight logic, escalating conflict, clear turning points in each act.\n\n"
                "Storyline:\n{draft}\n\n"
                "Story style: {style}\n\n"
                "Output the 4-act beat sheet directly. Start each act with '[Act X - Name]' heading. "
                "Detail the plot points, character development and key turning points for each act."
            )
            
            # =============================================================
            #  Phase 2B: 分场大纲 (Step Outline, 逐幕生成)
            # =============================================================
            
            STEP_OUTLINE_PROMPT_ZH = (
                "你是分场导演。以下是完整的四幕节拍表:\n\n"
                "{beat_sheet}\n\n"
                "请将第{act_number}幕({act_name})转化为详细的分场大纲。\n\n"
                "格式要求(每场一段):\n"
                "[场次编号]. [地点(室内/室外)] - [日/夜]\n"
                "[核心动作]:详细描述该场戏发生了什么,包含完整的对话、动作和表情描写。\n"
                "[情感转变]:描述主角在本场戏开始到结束的情绪变化(+/-)。\n"
                "[出场角色]:列出本场出现的所有角色。\n\n"
                "要求:\n"
                "- 每个角色都要有详细的外貌描写(发型、眼睛颜色、体型、服装颜色和款式等视觉特征)\n"
                "- **重要:外貌描写必须是静态的、贯穿全剧保持一致的特征,不要随剧情发展而变化**\n"
                "  - 例如:不要写\"他穿着破碎的衣服\"这种随情节变化的描写\n"
                "  - 应该写:\"他身穿蓝色衬衫,黑色长裤\"这种固定的服装描述\n"
                "- 每个场景都要有详细的环境、布局、色彩与氛围描写\n"
                "- 分场数量根据情节长度和节奏自行决定,确保叙事节奏合理\n"
                "- 对话用双引号标注,对话内容真实生动\n"
                "- 场次编号从 {scene_start} 开始递增\n"
                "- 故事风格:{style}\n\n"
                "请直接输出分场大纲,不要添加幕次标题或额外说明。"
            )
            
            STEP_OUTLINE_PROMPT_EN = (
                "You are a scene director. Here is the complete 4-act beat sheet:\n\n"
                "{beat_sheet}\n\n"
                "Convert Act {act_number} ({act_name}) into a detailed step outline.\n\n"
                "Format for each scene:\n"
                "[Scene number]. [Location (Indoor/Outdoor)] - [Day/Night]\n"
                "[Core Action]: Detailed description of what happens, including dialogue, actions and expressions.\n"
                "[Emotional Shift]: Describe the protagonist's emotional change from start to end (+/-).\n"
                "[Characters Present]: List all characters appearing in this scene.\n\n"
                "Requirements:\n"
                "- Each character needs detailed physical descriptions (hair, eyes, build, clothing details)\n"
                "- **IMPORTANT: Physical descriptions must be STATIC and consistent throughout the entire story - do NOT change with plot development**\n"
                "  - For example: do NOT write \"wearing torn clothes\" which changes with the plot\n"
                "  - Instead write: \"wearing a blue shirt and black pants\" which is a fixed clothing description\n"
                "- Each scene needs detailed environment, layout, color and atmosphere descriptions\n"
                "- Number of scenes based on plot length and pacing\n"
                "- Dialogue marked with double quotes, natural and vivid\n"
                "- Scene numbers start from {scene_start} and increment\n"
                "- Story style: {style}\n\n"
                "Output the step outline directly, without act headings or extra notes."
            )
            
            ACT_NAMES_ZH = {1: "激励事件", 2: "进入新世界", 3: "灵魂黑夜", 4: "高潮决战"}
            ACT_NAMES_EN = {1: "Inciting Incident", 2: "Break into Two", 3: "Dark Night of the Soul", 4: "Finale"}
            
            # =============================================================
            #  Micro-film 微电影模式专用提示词
            # =============================================================
            
            MICRO_BEAT_SHEET_PROMPT_ZH = (
                "你是微电影编剧专家。请根据以下故事线,将故事压缩为一个紧凑的单幕剧情概要。\n\n"
                "要求:\n"
                "- 叙事节奏快,情节紧凑精炼,没有拖沓的铺垫\n"
                "- 全部内容在一幕内完成,不分幕\n"
                "- 保留核心冲突和情感转折,去掉多余叙事\n"
                "- 场景数量控制在 3-6 场\n"
                "- 适合 1-3 分钟的微电影\n\n"
                "故事线:\n{draft}\n\n"
                "故事风格:{style}\n\n"
                "请直接输出紧凑的剧情概要,描述场景发展、核心动作和情感转折。"
            )
            
            MICRO_BEAT_SHEET_PROMPT_EN = (
                "You are a micro-film screenwriting expert. Compress the following storyline "
                "into a compact single-act plot summary.\n\n"
                "Requirements:\n"
                "- Fast narrative pacing, concise and tight plot\n"
                "- All content in a single act, no act divisions\n"
                "- Keep core conflict and emotional turns, remove unnecessary setup\n"
                "- 3-6 scenes total\n"
                "- Suitable for a 1-3 minute short film\n\n"
                "Storyline:\n{draft}\n\n"
                "Story style: {style}\n\n"
                "Output a compact plot summary directly, describing scene progression, "
                "core actions and emotional shifts."
            )
            
            MICRO_STEP_OUTLINE_PROMPT_ZH = (
                "你是分场导演。以下是微电影剧情概要:\n\n"
                "{beat_sheet}\n\n"
                "请将其转化为详细的分场大纲。\n\n"
                "格式要求(每场一段):\n"
                "[场次编号]. [地点(室内/室外)] - [日/夜]\n"
                "[核心动作]:详细描述该场戏发生了什么,包含完整的对话、动作和表情描写。\n"
                "[情感转变]:描述主角在本场戏开始到结束的情绪变化(+/-)。\n"
                "[出场角色]:列出本场出现的所有角色。\n\n"
                "要求:\n"
                "- 每个角色都要有详细的外貌描写(发型、眼睛颜色、体型、服装颜色和款式等视觉特征)\n"
                "- **重要:外貌描写必须是静态的、贯穿全剧保持一致的特征,不要随剧情发展而变化**\n"
                "  - 例如:不要写\"他穿着破碎的衣服\"这种随情节变化的描写\n"
                "  - 应该写:\"他身穿蓝色衬衫,黑色长裤\"这种固定的服装描述\n"
                "- 每个场景都要有详细的环境、布局、色彩与氛围描写\n"
                "- 分场数量 3-6 场,叙事紧凑快节奏\n"
                "- 对话用双引号标注,对话内容真实生动\n"
                "- 场次编号从 1 开始递增\n"
                "- 故事风格:{style}\n\n"
                "请直接输出分场大纲,不要添加额外说明。"
            )
            
            MICRO_STEP_OUTLINE_PROMPT_EN = (
                "You are a scene director. Here is the micro-film plot summary:\n\n"
                "{beat_sheet}\n\n"
                "Convert it into a detailed step outline.\n\n"
                "Format for each scene:\n"
                "[Scene number]. [Location (Indoor/Outdoor)] - [Day/Night]\n"
                "[Core Action]: Detailed description including dialogue, actions and expressions.\n"
                "[Emotional Shift]: Protagonist's emotional change from start to end (+/-).\n"
                "[Characters Present]: All characters appearing in this scene.\n\n"
                "Requirements:\n"
                "- Each character needs detailed physical descriptions (hair, eyes, build, clothing)\n"
                "- **IMPORTANT: Physical descriptions must be STATIC and consistent throughout the entire story - do NOT change with plot development**\n"
                "  - For example: do NOT write \"wearing torn clothes\" which changes with the plot\n"
                "  - Instead write: \"wearing a blue shirt and black pants\" which is a fixed clothing description\n"
                "- Each scene needs detailed environment, layout, color and atmosphere descriptions\n"
                "- 3-6 scenes total, compact and fast-paced\n"
                "- Dialogue marked with double quotes, natural and vivid\n"
                "- Scene numbers start from 1 and increment\n"
                "- Story style: {style}\n\n"
                "Output the step outline directly, without extra notes."
            )
            
            MICRO_META_EXTRACT_PROMPT_ZH = (
                "你是专业的剧本分析师。以下是微电影剧情概要:\n\n"
                "{beat_sheet}\n\n"
                "请从中提取以下信息,以纯JSON格式输出(不要用```包裹):\n"
                '{{"title":"故事标题(2-8字)","logline":"一句话概括故事(30字以内)",'
                '"genre":["类型1","类型2"],"synopsis":"完整故事梗概(50-100字)",'
                '"mood":"影片情绪基调"}}'
            )
            
            MICRO_META_EXTRACT_PROMPT_EN = (
                "You are a professional script analyst. Here is the micro-film plot summary:\n\n"
                "{beat_sheet}\n\n"
                "Extract the following information as pure JSON (no code blocks):\n"
                '{{"title":"Story title (short)","logline":"One sentence summary",'
                '"genre":["genre1","genre2"],"synopsis":"Complete synopsis (50-100 words)",'
                '"mood":"Overall mood"}}'
            )
            
            # =============================================================
            #  Phase 3A: 单幕结构化 JSON 提取
            # =============================================================
            
            ACT_EXTRACT_INTRO_ZH = (
                "你是一个专业的剧本分析师。请仔细阅读以下第{act_number}幕的分场大纲,从中提取并整理为标准JSON格式。\n\n"
                "分场大纲:\n{outline}\n\n"
                "请严格按照以下JSON结构输出(直接输出纯JSON,不要用```包裹,不要添加注释或任何其他文字):\n\n"
            )
            
            ACT_EXTRACT_INTRO_EN = (
                "You are a professional script analyst. Read the following Act {act_number} step outline carefully "
                "and extract it into a structured JSON format.\n\n"
                "Step outline:\n{outline}\n\n"
                "Output ONLY valid JSON in the following structure (no code block markers, no comments, no other text):\n\n"
            )
            
            ACT_EXTRACT_SCHEMA_ZH = """{{
              "characters": [
                {{
                  "name": "角色全名",
                  "character_id": "char_加8位随机字母数字",
                  "description": "外貌描写: 含发型、体型、服装颜色和款式等视觉特征, 不要描述眼睛颜色, 面部不要有文字等特殊符号, 形象要正常(可以前卫时髦但不能恐怖灵异), 50-80字, 不要用比喻",
                  "personality": ["性格特征1", "性格特征2", "性格特征3"],
                  "motivation": "角色核心动机",
                  "arc_description": "角色成长弧线",
                  "role": "主角 或 配角 或 背景",
                  "age": "年龄",
                  "species": "人类 或 具体动物种类"
                }}
              ],
              "settings": [
                {{
                  "name": "场景名称(室内) 或 场景名称(室外)",
                  "setting_id": "set_加8位随机字母数字",
                  "description": "环境布局、色彩、光线、氛围等视觉细节, 80-120字, 不要包含人物或动物"
                }}
              ],
              "scenes": [
                {{
                  "scene_number": {scene_start},
                  "act": {act_number},
                  "location": "必须是settings中已定义的场景名称",
                  "characters": ["出场角色名(必须与characters中的name一致)"],
                  "plot": "该场景完整详细的剧情, 含对话、动作和表情描写, 必须完整表达分场内容, 无字数限制"
                }}
              ]
            }}"""
            
            ACT_EXTRACT_SCHEMA_EN = """{{
              "characters": [
                {{
                  "name": "Full name",
                  "character_id": "char_ plus 8 random alphanumeric",
                  "description": "Visual description: hair, eyes, build, clothing details, 50-80 words",
                  "personality": ["trait1", "trait2", "trait3"],
                  "motivation": "Core motivation",
                  "arc_description": "Character growth arc",
                  "role": "protagonist or supporting or background",
                  "age": "age",
                  "species": "human or specific animal"
                }}
              ],
              "settings": [
                {{
                  "name": "Location name (Indoor) or Location name (Outdoor)",
                  "setting_id": "set_ plus 8 random alphanumeric",
                  "description": "Layout, colors, lighting, atmosphere details, 80-120 words, no people or animals"
                }}
              ],
              "scenes": [
                {{
                  "scene_number": {scene_start},
                  "act": {act_number},
                  "location": "Must be a name defined in settings",
                  "characters": ["character names matching characters array"],
                  "plot": "Complete detailed scene plot with dialogue, actions and expressions, no word limit"
                }}
              ]
            }}"""
            
            ACT_EXTRACT_RULES_ZH = (
                "\n\n重要规则:\n"
                "1. characters中的name必须与scenes中的角色名完全一致\n"
                "2. scenes中的location必须是settings中已定义的场景名称之一\n"
                "3. settings的name需标注(室内)或(室外)\n"
                "4. 所有scene的act字段必须为{act_number}\n"
                "5. scene_number从{scene_start}开始递增\n"
                "6. 角色的description要有足够视觉细节用于AI图像生成\n"
                "7. setting的description要有足够视觉细节用于AI背景图生成\n"
                "8. 不要生成群体角色(如\"邻居们\"),每个角色都是独立个体\n"
                "9. scene的plot字段必须完整表达该分场的全部内容\n"
                "10. 只输出纯JSON\n"
            )
            
            ACT_EXTRACT_RULES_EN = (
                "\n\nImportant rules:\n"
                "1. Character names in scenes must exactly match names in characters array\n"
                "2. Scene locations must be names defined in settings array\n"
                "3. Setting names must include (Indoor) or (Outdoor)\n"
                "4. All scenes' act field must be {act_number}\n"
                "5. scene_number starts from {scene_start} and increments\n"
                "6. Character descriptions need enough visual detail for AI image generation\n"
                "7. Setting descriptions need enough visual detail for AI background generation\n"
                "8. No group characters, every character is an individual\n"
                "9. Scene 'plot' field must fully express all content, do not truncate\n"
                "10. Output ONLY the JSON\n"
            )
            
            # =============================================================
            #  Phase 3B: 最终合并补充提示词(生成 title / logline / synopsis 等顶层字段)
            # =============================================================
            
            META_EXTRACT_PROMPT_ZH = (
                "你是专业的剧本分析师。以下是完整的四幕节拍表:\n\n"
                "{beat_sheet}\n\n"
                "请从中提取以下信息,以纯JSON格式输出(不要用```包裹):\n"
                '{{"title":"故事标题(2-8字)","logline":"一句话概括故事(30字以内)",'
                '"genre":["类型1","类型2"],"synopsis":"完整故事梗概(100-200字)",'
                '"mood":"影片情绪基调"}}'
            )
            
            META_EXTRACT_PROMPT_EN = (
                "You are a professional script analyst. Here is the complete 4-act beat sheet:\n\n"
                "{beat_sheet}\n\n"
                "Extract the following information as pure JSON (no code blocks):\n"
                '{{"title":"Story title (short)","logline":"One sentence summary",'
                '"genre":["genre1","genre2"],"synopsis":"Complete synopsis (100-200 words)",'
                '"mood":"Overall mood"}}'
            )
            
            # =============================================================
            #  Phase 4: 场景 & 角色合并(去重相似条目)
            # =============================================================
            
            CONSOLIDATE_PROMPT_ZH = (
                "你是剧本审校专家。请审查以下剧本中的场景列表和角色列表,找出可以合并的条目。\n\n"
                "合并规则:\n"
                "- 场景合并:如果两个场景在物理空间上是同一个地点(只是拍摄角度、景别不同),应合并为一个\n"
                "  例如:「车厢内」「车厢过道」「车厢全景」都是同一节车厢 → 合并为「车厢内」\n"
                "  例如:「咖啡馆吧台」「咖啡馆角落」→ 合并为「咖啡馆」\n"
                "  注意:不同物理空间不能合并(如「车厢内」和「车厢连接处」是不同空间)\n"
                "- 角色合并:如果同名角色出现多次(可能描述略有不同),合并为一个\n"
                "- 合并后保留最详细的描述\n\n"
                "当前场景列表:\n{settings_json}\n\n"
                "当前角色列表:\n{characters_json}\n\n"
                "请输出纯JSON(不要用```包裹),格式如下:\n"
                '{{\n'
                '  "setting_merges": {{"被合并的场景名": "合并到的目标场景名", ...}},\n'
                '  "character_merges": {{"被合并的角色名": "合并到的目标角色名", ...}}\n'
                '}}\n\n'
                "如果没有需要合并的条目,对应字段返回空对象 {{}}。\n"
                "只输出纯JSON,不要添加任何其他文字。"
            )
            
            CONSOLIDATE_PROMPT_EN = (
                "You are a script review expert. Review the following settings and characters lists "
                "and identify entries that should be merged.\n\n"
                "Merge rules:\n"
                "- Settings merge: If two settings are the same physical location (just different camera angles), "
                "merge them into one. E.g., 'Train interior', 'Train aisle', 'Train panorama' are all the same car.\n"
                "  Different physical spaces should NOT be merged.\n"
                "- Character merge: If the same character appears with slightly different descriptions, merge them.\n"
                "- Keep the most detailed description after merging.\n\n"
                "Current settings:\n{settings_json}\n\n"
                "Current characters:\n{characters_json}\n\n"
                "Output ONLY valid JSON (no code blocks):\n"
                '{{\n'
                '  "setting_merges": {{"merged_setting_name": "target_setting_name", ...}},\n'
                '  "character_merges": {{"merged_char_name": "target_char_name", ...}}\n'
                '}}\n\n'
                "If nothing to merge, return empty objects {{}}. Output ONLY the JSON."
            )
            
            class ScriptWriterAgent(AgentInterface):
                """编剧智能体:多轮LLM交互 → 结构化剧本JSON"""
            
                def __init__(self):
                    super().__init__(name="ScriptWriter")
            
                # ─── JSON 提取 ───
            
                @staticmethod
                def _extract_json_from_text(text: str) -> Optional[dict]:
                    """从LLM输出中提取JSON对象"""
                    text = text.strip()
                    text = re.sub(r'^```(?:json)?\s*', '', text)
                    text = re.sub(r'\s*```$', '', text)
                    text = text.strip()
            
                    try:
                        return json.loads(text)
                    except json.JSONDecodeError:
                        pass
            
                    start = text.find('{')
                    end = text.rfind('}')
                    if start != -1 and end != -1 and end > start:
                        try:
                            return json.loads(text[start:end + 1])
                        except json.JSONDecodeError:
                            pass
                    return None
            
                @staticmethod
                def _extract_json_array_from_text(text: str) -> Optional[list]:
                    """从LLM输出中提取JSON数组"""
                    text = text.strip()
                    # 去除 markdown 代码块标记(支持多行)
                    text = re.sub(r'```(?:json)?\s*\n?', '', text)
                    text = text.strip()
            
                    try:
                        result = json.loads(text)
                        if isinstance(result, list):
                            return result
                        # LLM 有时返回对象而非数组,尝试包装
                        if isinstance(result, dict):
                            return [result]
                    except json.JSONDecodeError:
                        pass
            
                    start = text.find('[')
                    end = text.rfind(']')
                    if start != -1 and end != -1 and end > start:
                        try:
                            result = json.loads(text[start:end + 1])
                            if isinstance(result, list):
                                return result
                        except json.JSONDecodeError:
                            pass
            
                    # 尝试匹配多个独立 JSON 对象
                    objects = []
                    for m in re.finditer(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text):
                        try:
                            obj = json.loads(m.group())
                            if isinstance(obj, dict) and 'logline' in obj:
                                objects.append(obj)
                        except json.JSONDecodeError:
                            continue
                    if objects:
                        return objects
            
                    return None
            
                @staticmethod
                def _gen_id(prefix: str = "char") -> str:
                    return f"{prefix}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=8))}"
            
                # ─── 保存结果 ───
            
                def _save_result(self, json_data: dict, sid: str, is_zh: bool):
                    """保存结构化剧本JSON到结果文件"""
                    from config import settings
                    script_dir = os.path.join(settings.RESULT_DIR, 'script')
                    os.makedirs(script_dir, exist_ok=True)
            
                    result_file = os.path.join(script_dir, f'script_{sid}.json')
                    results = {}
                    if os.path.exists(result_file) and os.path.getsize(result_file) > 0:
                        with open(result_file, 'r', encoding='utf-8') as f:
                            results = json.load(f)
            
                    results.setdefault(str(sid), {})['script_json'] = json_data
                    with open(result_file, 'w', encoding='utf-8') as f:
                        json.dump(results, f, indent=4, ensure_ascii=False)
            
                def _save_progress(self, sid: str, phase: str, data: dict):
                    """保存剧本生成的中间进度状态到结果文件"""
                    from config import settings
                    script_dir = os.path.join(settings.RESULT_DIR, 'script')
                    os.makedirs(script_dir, exist_ok=True)
            
                    result_file = os.path.join(script_dir, f'script_{sid}.json')
                    results = {}
                    if os.path.exists(result_file) and os.path.getsize(result_file) > 0:
                        with open(result_file, 'r', encoding='utf-8') as f:
                            results = json.load(f)
            
                    # 初始化 progress 字段
                    results.setdefault(str(sid), {}).setdefault('progress', {})
            
                    # 保存当前阶段信息
                    results[str(sid)]['progress']['current_phase'] = phase
                    results[str(sid)]['progress']['updated_at'] = datetime.now(timezone.utc).isoformat()
            
                    # 根据阶段保存对应的数据
                    if phase == 'logline_check':
                        # 创意检查结果
                        results[str(sid)]['progress']['idea_analyzed'] = data.get('idea_analyzed', False)
                        results[str(sid)]['progress']['logline_extracted'] = data.get('logline_summary')
                    elif phase == 'logline_generation':
                        # 生成的候选 logline
                        results[str(sid)]['progress']['logline_options'] = data.get('logline_options', [])
                    elif phase == 'logline_confirmed':
                        # 用户选择的 logline
                        results[str(sid)]['progress']['selected_logline'] = data.get('selected_logline')
                    elif phase == 'mode_selection':
                        # 用户选择的模式
                        results[str(sid)]['progress']['selected_mode'] = data.get('selected_mode')
                    elif phase == 'script_generation':
                        # 完整剧本已生成
                        results[str(sid)]['progress']['script_generated'] = True
                        results[str(sid)]['progress']['title'] = data.get('title')
            
                    with open(result_file, 'w', encoding='utf-8') as f:
                        json.dump(results, f, indent=4, ensure_ascii=False)
            
                # ─── 核心流程 ───
            
                async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
            
                    # 检查当前阶段,避免重复生成 logline
                    current_phase = input_data.get("phase", "")
                    selected_logline = input_data.get("selected_logline")
                    selected_mode = input_data.get("selected_mode")
            
                    # 如果已选择模式,直接生成剧本
                    if selected_mode:
                        # 如果 selected_logline 是索引(数字),需要转换为 logline 对象
                        if isinstance(selected_logline, int):
                            logline_options = input_data.get("logline_options", [])
                            if 0 <= selected_logline < len(logline_options):
                                logline_data = logline_options[selected_logline]
                            else:
                                logline_data = {}
                        else:
                            logline_data = selected_logline if isinstance(selected_logline, dict) else {}
            
                        if selected_mode == "micro":
                            return await self._phase_micro_script_gen(input_data, logline_data)
                        else:
                            return await self._phase_script_gen(input_data, logline_data)
            
                    # 如果已选择 logline 但未选择模式,返回模式选择
                    if selected_logline and current_phase == "mode_selection":
                        return {
                            "payload": {
                                "phase": "mode_selection",
                                "selected_logline": selected_logline,
                                "session_id": input_data.get("session_id", ""),
                            },
                            "requires_intervention": True,
                            "openclaw_hint": "用户已选择情节,需要选择拍摄模式(电影模式4幕/微电影模式1幕)。请展示给用户并等待用户选择。",
                        }
            
                    # (A) 用户介入修改最终剧本
                    if intervention and "modified_script" in intervention:
                        modified = intervention["modified_script"]
                        sid = input_data.get("session_id", "")
                        if isinstance(modified, str):
                            modified = self._extract_json_from_text(modified) or {}
                        is_zh = any('\u4e00' <= c <= '\u9fff' for c in modified.get("title", ""))
                        modified["session_id"] = sid
                        self._save_result(modified, sid, is_zh)
                        return {"payload": modified, "requires_intervention": False, "stage_completed": True}
            
                    # (B1) 用户选择了创作模式 → 根据模式生成剧本
                    if intervention and "selected_mode" in intervention:
                        selected = input_data.get("selected_logline", {})
                        mode = intervention["selected_mode"]
                        sid = input_data.get("session_id", "")
            
                        # 如果 selected_logline 是索引(数字),转换为对应的 logline 对象
                        if isinstance(selected, int):
                            logline_options = input_data.get("logline_options", [])
                            if 0 <= selected < len(logline_options):
                                logline_data = logline_options[selected]
                            else:
                                logline_data = {}
                        else:
                            logline_data = selected if isinstance(selected, dict) else {}
            
                        # 保存进度状态
                        self._save_progress(sid, "mode_selection", {
                            "selected_mode": mode
                        })
            
                        if mode == "micro":
                            return await self._phase_micro_script_gen(input_data, logline_data)
                        else:
                            return await self._phase_script_gen(input_data, logline_data)
            
                    # (B2) 用户选择了一个 Logline → 显示模式选择
                    if intervention and "selected_logline" in intervention:
                        selected = intervention["selected_logline"]
                        sid = input_data.get("session_id", "")
            
                        # 如果 selected_logline 是索引(数字),转换为对应的 logline 对象
                        if isinstance(selected, int):
                            logline_options = input_data.get("logline_options", [])
                            if 0 <= selected < len(logline_options):
                                logline_data = logline_options[selected]
                            else:
                                logline_data = {}
                        else:
                            logline_data = selected if isinstance(selected, dict) else {}
            
                        # 保存进度状态
                        self._save_progress(sid, "logline_confirmed", {
                            "selected_logline": logline_data
                        })
            
                        return {
                            "payload": {
                                "phase": "mode_selection",
                                "selected_logline": logline_data,
                                "session_id": sid,
                            },
                            "requires_intervention": True,
                            "openclaw_hint": "用户已选择情节,需要选择拍摄模式(电影模式4幕/微电影模式1幕)。请展示给用户并等待用户选择。",
                        }
            
                    # 首次运行:自动判断是直接提取 logline 还是进入扩写
                    auto_mode = input_data.get("auto_mode", False)
            
                    # 代理(自动)模式:跳过 Logline 选择,直接生成剧本
                    if auto_mode:
                        return await self._phase_direct(input_data)
            
                    # 自动判断:创意足够则提取 logline,否则自动扩写生成 3 个选项
                    return await self._phase_logline_check(input_data)
            
                # ─── Phase 1A: 生成 3 个 Logline 方案 ───
            
                async def _phase_logline_gen(self, input_data: Dict) -> Dict:
                    idea = input_data.get("idea", "")
                    sid = input_data.get("session_id", "")
                    llm_model = input_data.get("llm_model", "qwen3.5-plus")
                    web_search = input_data.get("web_search", False)
                    is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
            
                    def run():
                        from tool.llm_client import LLM
                        llm = LLM()
                        self._report_progress("剧本生成", "正在生成 Logline 方案...", 5)
                        prompt = (LOGLINE_GENERATE_PROMPT_ZH if is_zh else LOGLINE_GENERATE_PROMPT_EN).format(idea=idea)
            
                        for attempt in range(3):
                            self._check_cancel()
                            raw = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid, web_search=web_search)
                            logger.info(f"[ScriptWriter] Logline gen attempt {attempt + 1}, raw output ({len(raw)} chars): {raw[:500]}")
                            options = self._extract_json_array_from_text(raw)
                            if options and len(options) > 0:
                                # 确保每个选项包含必要字段
                                required = {"logline", "who", "goal", "conflict", "twist", "theme"}
                                valid = [o for o in options if isinstance(o, dict) and required.issubset(o.keys())]
                                if valid:
                                    self._report_progress("剧本生成", "Logline 方案已生成", 20)
                                    return valid[:3]
            
                            logger.warning(f"[ScriptWriter] Logline gen attempt {attempt + 1}: parse failed")
                            if is_zh:
                                prompt = (
                                    "上次输出格式不正确,请严格按要求重新输出。"
                                    "必须输出纯JSON数组,不要用```包裹,不要有任何多余文字。\n\n"
                                    + (LOGLINE_GENERATE_PROMPT_ZH).format(idea=idea)
                                )
                            else:
                                prompt = (
                                    "Previous output format was incorrect. Please try again. "
                                    "Output ONLY a raw JSON array, no code blocks, no extra text.\n\n"
                                    + (LOGLINE_GENERATE_PROMPT_EN).format(idea=idea)
                                )
            
                        raise Exception("Logline 生成失败:多次尝试均无法解析输出")
            
                    loop = asyncio.get_running_loop()
                    options = await loop.run_in_executor(None, run)
            
                    # 保存进度状态
                    self._save_progress(sid, "logline_generation", {
                        "logline_options": options
                    })
            
                    return {
                        "payload": {
                            "phase": "logline_selection",
                            "logline_options": options,
                            "session_id": sid,
                        },
                        "requires_intervention": True,
                        "openclaw_hint": "生成了多个情节候选(logline),需要用户选择。请展示给用户并等待用户选择。",
                    }
            
                # ─── Phase 1B: 检测输入是否可提取 Logline ───
            
                async def _phase_logline_check(self, input_data: Dict) -> Dict:
                    idea = input_data.get("idea", "")
                    sid = input_data.get("session_id", "")
                    llm_model = input_data.get("llm_model", "qwen3.5-plus")
                    web_search = input_data.get("web_search", False)
                    is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
            
                    def run():
                        from tool.llm_client import LLM
                        llm = LLM()
                        self._report_progress("剧本生成", "分析创意文本...", 5)
                        check_prompt = (LOGLINE_CHECK_PROMPT_ZH if is_zh else LOGLINE_CHECK_PROMPT_EN).format(idea=idea)
                        answer = self._cancellable_query(llm, check_prompt, model=llm_model, task_id=sid, web_search=web_search).strip()
            
                        if answer.lower().startswith("yes"):
                            self._report_progress("剧本生成", "提取 Logline...", 10)
                            extract_prompt = (LOGLINE_EXTRACT_PROMPT_ZH if is_zh else LOGLINE_EXTRACT_PROMPT_EN).format(idea=idea)
                            raw = self._cancellable_query(llm, extract_prompt, model=llm_model, task_id=sid, web_search=web_search)
                            logger.info(f"[ScriptWriter] Logline extract raw ({len(raw)} chars): {raw[:500]}")
                            logline_data = self._extract_json_from_text(raw)
                            if logline_data and isinstance(logline_data, dict) and 'logline' in logline_data:
                                self._report_progress("剧本生成", "Logline 提取完成", 20)
                                return {"phase": "logline_confirm", "logline_summary": logline_data}
            
                        return {"phase": "suggest_expand"}
            
                    loop = asyncio.get_running_loop()
                    result = await loop.run_in_executor(None, run)
                    result["session_id"] = sid
            
                    # 如果创意不够,自动进入扩写阶段,生成 3 个 logline 供选择
                    if result.get("phase") == "suggest_expand":
                        return await self._phase_logline_gen(input_data)
            
                    # 保存进度状态
                    phase = result.get("phase", "logline_check")
                    self._save_progress(sid, "logline_check", {
                        "idea_analyzed": phase == "logline_confirm",
                        "logline_summary": result.get("logline_summary")
                    })
            
                    return {
                        "payload": result,
                        "requires_intervention": True,
                        "openclaw_hint": "已提取情节概要,需要用户确认。请展示给用户并等待用户确认。",
                    }
            
                # ─── Phase 2: 根据选定 Logline 生成完整剧本 ───
            
                async def _phase_script_gen(self, input_data: Dict, logline_data: Dict) -> Dict:
                    idea = input_data.get("idea", "")
                    sid = input_data.get("session_id", "")
                    style = input_data.get("style", "anime")
                    llm_model = input_data.get("llm_model", "qwen3.5-plus")
                    web_search = input_data.get("web_search", False)
                    is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
            
                    # 用 Logline 六要素构建丰富的故事概要
                    if is_zh:
                        draft = (
                            f"故事核心 Logline:{logline_data.get('logline', '')}\n"
                            f"主角:{logline_data.get('who', '')}\n"
                            f"目标:{logline_data.get('goal', '')}\n"
                            f"核心障碍:{logline_data.get('conflict', '')}\n"
                            f"反转:{logline_data.get('twist', '')}\n"
                            f"主题:{logline_data.get('theme', '')}\n\n"
                            f"原始灵感:{idea}"
                        )
                    else:
                        draft = (
                            f"Story Logline: {logline_data.get('logline', '')}\n"
                            f"Protagonist: {logline_data.get('who', '')}\n"
                            f"Goal: {logline_data.get('goal', '')}\n"
                            f"Core Conflict: {logline_data.get('conflict', '')}\n"
                            f"Twist: {logline_data.get('twist', '')}\n"
                            f"Theme: {logline_data.get('theme', '')}\n\n"
                            f"Original idea: {idea}"
                        )
            
                    def run():
                        from config import settings as app_settings
                        from tool.llm_client import LLM
            
                        os.makedirs(app_settings.TEMP_DIR, exist_ok=True)
                        os.makedirs(os.path.join(app_settings.RESULT_DIR, 'script'), exist_ok=True)
            
                        llm = LLM()
            
                        # 逐幕生成节拍表 + 分场大纲 + 结构化提取
                        json_data = self._generate_script_incremental(
                            llm, draft, style, llm_model, sid, is_zh, web_search=web_search, pct_start=20
                        )
            
                        # 补充元数据
                        json_data.setdefault("project_id", f"proj_{sid}")
                        json_data.setdefault("version", 1)
                        json_data["created_at"] = datetime.now(timezone.utc).isoformat()
                        json_data["metadata"] = {
                            "generation_model": llm_model,
                            "generation_prompt": idea,
                        }
                        json_data["session_id"] = sid
                        json_data["logline_data"] = logline_data
            
                        self._save_result(json_data, sid, is_zh)
                        # 保存进度状态
                        self._save_progress(sid, "script_generation", {
                            "title": json_data.get("title")
                        })
                        self._report_progress("剧本生成", "完成", 100)
                        return json_data
            
                    loop = asyncio.get_running_loop()
                    script_json = await loop.run_in_executor(None, run)
            
                    # 保存进度状态
                    self._save_progress(sid, "script_generation", {
                        "title": script_json.get("title")
                    })
            
                    return {
                        "payload": script_json,
                        "requires_intervention": False,
                        "stage_completed": True,  # 明确标记阶段完成
                        "openclaw_hint": "剧本生成完成。",
                    }
            
                # ─── Phase 2-Micro: 微电影模式 - 根据选定 Logline 生成紧凑单幕剧本 ───
            
                async def _phase_micro_script_gen(self, input_data: Dict, logline_data: Dict) -> Dict:
                    idea = input_data.get("idea", "")
                    sid = input_data.get("session_id", "")
                    style = input_data.get("style", "anime")
                    llm_model = input_data.get("llm_model", "qwen3.5-plus")
                    web_search = input_data.get("web_search", False)
                    is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
            
                    # 用 Logline 六要素构建故事概要
                    if is_zh:
                        draft = (
                            f"故事核心 Logline:{logline_data.get('logline', '')}\n"
                            f"主角:{logline_data.get('who', '')}\n"
                            f"目标:{logline_data.get('goal', '')}\n"
                            f"核心障碍:{logline_data.get('conflict', '')}\n"
                            f"反转:{logline_data.get('twist', '')}\n"
                            f"主题:{logline_data.get('theme', '')}\n\n"
                            f"原始灵感:{idea}"
                        )
                    else:
                        draft = (
                            f"Story Logline: {logline_data.get('logline', '')}\n"
                            f"Protagonist: {logline_data.get('who', '')}\n"
                            f"Goal: {logline_data.get('goal', '')}\n"
                            f"Core Conflict: {logline_data.get('conflict', '')}\n"
                            f"Twist: {logline_data.get('twist', '')}\n"
                            f"Theme: {logline_data.get('theme', '')}\n\n"
                            f"Original idea: {idea}"
                        )
            
                    def run():
                        from config import settings as app_settings
                        from tool.llm_client import LLM
            
                        os.makedirs(app_settings.TEMP_DIR, exist_ok=True)
                        os.makedirs(os.path.join(app_settings.RESULT_DIR, 'script'), exist_ok=True)
            
                        llm = LLM()
            
                        # 微电影模式: 单幕生成
                        json_data = self._generate_micro_script_incremental(
                            llm, draft, style, llm_model, sid, is_zh, web_search=web_search, pct_start=20
                        )
            
                        # 补充元数据
                        json_data.setdefault("project_id", f"proj_{sid}")
                        json_data.setdefault("version", 1)
                        json_data["created_at"] = datetime.now(timezone.utc).isoformat()
                        json_data["metadata"] = {
                            "generation_model": llm_model,
                            "generation_prompt": idea,
                            "mode": "micro",
                        }
                        json_data["session_id"] = sid
                        json_data["logline_data"] = logline_data
            
                        self._save_result(json_data, sid, is_zh)
                        # 保存进度状态
                        self._save_progress(sid, "script_generation", {
                            "title": json_data.get("title")
                        })
                        self._report_progress("剧本生成", "完成", 100)
                        return json_data
            
                    loop = asyncio.get_running_loop()
                    script_json = await loop.run_in_executor(None, run)
            
                    # 保存进度状态
                    self._save_progress(sid, "script_generation", {
                        "title": script_json.get("title")
                    })
            
                    return {
                        "payload": script_json,
                        "requires_intervention": False,
                        "stage_completed": True,  # 明确标记阶段完成
                        "openclaw_hint": "剧本生成完成。",
                    }
            
                # ─── 代理模式(自动生成 Logline 并选择第一个) ───
            
                async def _phase_direct(self, input_data: Dict) -> Dict:
                    """代理模式:生成 3 个 Logline → 自动选择第一个 → 生成完整剧本"""
                    idea = input_data.get("idea", "")
                    sid = input_data.get("session_id", "")
                    style = input_data.get("style", "anime")
                    llm_model = input_data.get("llm_model", "qwen3.5-plus")
                    web_search = input_data.get("web_search", False)
                    is_zh = any('\u4e00' <= c <= '\u9fff' for c in idea)
            
                    def run():
                        from config import settings as app_settings
                        from tool.llm_client import LLM
            
                        os.makedirs(app_settings.TEMP_DIR, exist_ok=True)
                        os.makedirs(os.path.join(app_settings.RESULT_DIR, 'script'), exist_ok=True)
            
                        llm = LLM()
            
                        # Step 1: 生成 Logline 方案
                        self._report_progress("剧本生成", "正在生成 Logline 方案...", 5)
                        prompt = (LOGLINE_GENERATE_PROMPT_ZH if is_zh else LOGLINE_GENERATE_PROMPT_EN).format(idea=idea)
            
                        logline_data = None
                        for attempt in range(3):
                            self._check_cancel()
                            raw = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid, web_search=web_search)
                            logger.info(f"[ScriptWriter][auto] Logline gen attempt {attempt + 1}, raw ({len(raw)} chars): {raw[:500]}")
                            options = self._extract_json_array_from_text(raw)
                            if options and len(options) > 0:
                                required = {"logline", "who", "goal", "conflict", "twist", "theme"}
                                valid = [o for o in options if isinstance(o, dict) and required.issubset(o.keys())]
                                if valid:
                                    logline_data = valid[0]  # 自动选择第一个
                                    logger.info(f"[ScriptWriter][auto] Auto-selected logline: {logline_data.get('logline', '')[:80]}")
                                    break
            
                            logger.warning(f"[ScriptWriter][auto] Logline gen attempt {attempt + 1}: parse failed")
                            if is_zh:
                                prompt = (
                                    "上次输出格式不正确,请严格按要求重新输出。"
                                    "必须输出纯JSON数组,不要用```包裹,不要有任何多余文字。\n\n"
                                    + LOGLINE_GENERATE_PROMPT_ZH.format(idea=idea)
                                )
                            else:
                                prompt = (
                                    "Previous output format was incorrect. Please try again. "
                                    "Output ONLY a raw JSON array, no code blocks, no extra text.\n\n"
                                    + LOGLINE_GENERATE_PROMPT_EN.format(idea=idea)
                                )
            
                        if logline_data is None:
                            raise Exception("Logline 生成失败:多次尝试均无法解析输出")
            
                        self._report_progress("剧本生成", "已自动选择 Logline,开始创作情节...", 15)
            
                        # Step 2: 用 Logline 六要素构建故事概要
                        if is_zh:
                            draft = (
                                f"故事核心 Logline:{logline_data.get('logline', '')}\n"
                                f"主角:{logline_data.get('who', '')}\n"
                                f"目标:{logline_data.get('goal', '')}\n"
                                f"核心障碍:{logline_data.get('conflict', '')}\n"
                                f"反转:{logline_data.get('twist', '')}\n"
                                f"主题:{logline_data.get('theme', '')}\n\n"
                                f"原始灵感:{idea}"
                            )
                        else:
                            draft = (
                                f"Story Logline: {logline_data.get('logline', '')}\n"
                                f"Protagonist: {logline_data.get('who', '')}\n"
                                f"Goal: {logline_data.get('goal', '')}\n"
                                f"Core Conflict: {logline_data.get('conflict', '')}\n"
                                f"Twist: {logline_data.get('twist', '')}\n"
                                f"Theme: {logline_data.get('theme', '')}\n\n"
                                f"Original idea: {idea}"
                            )
            
                        # Step 3: 逐幕生成节拍表 + 分场大纲 + 结构化提取
                        json_data = self._generate_script_incremental(
                            llm, draft, style, llm_model, sid, is_zh, web_search=web_search, pct_start=20
                        )
            
                        # 补充元数据
                        json_data.setdefault("project_id", f"proj_{sid}")
                        json_data.setdefault("version", 1)
                        json_data["created_at"] = datetime.now(timezone.utc).isoformat()
                        json_data["metadata"] = {
                            "generation_model": llm_model,
                            "generation_prompt": idea,
                        }
                        json_data["session_id"] = sid
                        json_data["logline_data"] = logline_data
            
                        self._save_result(json_data, sid, is_zh)
                        # 保存进度状态
                        self._save_progress(sid, "script_generation", {
                            "title": json_data.get("title")
                        })
                        self._report_progress("剧本生成", "完成", 100)
                        return json_data
            
                    loop = asyncio.get_running_loop()
                    script_json = await loop.run_in_executor(None, run)
            
                    # 保存进度状态
                    self._save_progress(sid, "script_generation", {
                        "title": script_json.get("title")
                    })
            
                    return {
                        "payload": script_json,
                        "requires_intervention": False,
                        "stage_completed": True,  # 明确标记阶段完成
                        "openclaw_hint": "剧本生成完成。",
                    }
            
                # ─── 公共: 逐幕生成节拍表 + 分场大纲 + 结构化提取 ───
            
                def _generate_script_incremental(self, llm, draft: str, style: str,
                                                  llm_model: str, sid: str, is_zh: bool,
                                                  web_search: bool = False,
                                                  pct_start: int = 20) -> dict:
                    """生成 Save the Cat! 节拍表 → 逐幕生成分场大纲 + 结构化提取,返回合并的 JSON"""
            
                    # Step 1: 生成四幕节拍表
                    self._report_progress("剧本生成", "生成节拍表...", pct_start)
                    prompt = (BEAT_SHEET_PROMPT_ZH if is_zh else BEAT_SHEET_PROMPT_EN).format(draft=draft, style=style)
                    beat_sheet = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid, web_search=web_search)
                    logger.info(f"[ScriptWriter] Beat sheet generated ({len(beat_sheet)} chars)")
            
                    # 发送节拍表到前端
                    self._report_progress("剧本生成", "节拍表生成完成", pct_start + 5,
                                          data={"beat_sheet": beat_sheet})
            
                    # Step 2: 提取顶层元数据 (title, logline, genre, synopsis, mood)
                    self._report_progress("剧本生成", "提取故事元信息...", pct_start + 7)
                    meta_prompt = (META_EXTRACT_PROMPT_ZH if is_zh else META_EXTRACT_PROMPT_EN).format(beat_sheet=beat_sheet)
                    meta_raw = self._cancellable_query(llm, meta_prompt, model=llm_model, task_id=sid, web_search=web_search)
                    meta_data = self._extract_json_from_text(meta_raw) or {}
                    logger.info(f"[ScriptWriter] Meta extracted: {list(meta_data.keys())}")
            
                    # Step 3: 逐幕生成分场大纲 + 结构化提取
                    act_names = ACT_NAMES_ZH if is_zh else ACT_NAMES_EN
                    all_characters = []  # 累积角色
                    all_settings = []    # 累积场景
                    all_scenes = []      # 累积分场
                    seen_char_names = set()
                    seen_setting_names = set()
                    scene_start = 1
            
                    for act_num in range(1, 5):
                        self._check_cancel()
                        act_name = act_names[act_num]
            
                        # --- 生成分场大纲 ---
                        pct_outline = pct_start + 8 + (act_num - 1) * 15  # 28, 43, 58, 73
                        progress_msg = (f"生成第{act_num}幕分场大纲({act_name})..."
                                        if is_zh else f"Generating Act {act_num} outline ({act_name})...")
                        self._report_progress("剧本生成", progress_msg, pct_outline)
            
                        outline_prompt = (STEP_OUTLINE_PROMPT_ZH if is_zh else STEP_OUTLINE_PROMPT_EN).format(
                            beat_sheet=beat_sheet,
                            act_number=act_num,
                            act_name=act_name,
                            scene_start=scene_start,
                            style=style,
                        )
                        outline = self._cancellable_query(llm, outline_prompt, model=llm_model, task_id=sid, web_search=web_search)
                        logger.info(f"[ScriptWriter] Act {act_num} outline generated ({len(outline)} chars)")
            
                        # --- 结构化提取本幕 JSON ---
                        pct_extract = pct_outline + 8
                        progress_msg = (f"提取第{act_num}幕结构化数据..."
                                        if is_zh else f"Extracting Act {act_num} structured data...")
                        self._report_progress("剧本生成", progress_msg, pct_extract)
            
                        act_json = self._extract_act_json(
                            llm, outline, act_num, scene_start, style, llm_model, sid, is_zh, web_search
                        )
            
                        # 累积角色(去重)
                        for c in act_json.get("characters", []):
                            if c.get("name") and c["name"] not in seen_char_names:
                                if not c.get("character_id"):
                                    c["character_id"] = self._gen_id("char")
                                all_characters.append(c)
                                seen_char_names.add(c["name"])
            
                        # 累积场景(去重)
                        for s in act_json.get("settings", []):
                            if s.get("name") and s["name"] not in seen_setting_names:
                                if not s.get("setting_id"):
                                    s["setting_id"] = self._gen_id("set")
                                all_settings.append(s)
                                seen_setting_names.add(s["name"])
            
                        # 累积分场
                        act_scenes = act_json.get("scenes", [])
                        all_scenes.extend(act_scenes)
            
                        # 计算下一幕场次起始
                        if act_scenes:
                            scene_start = max(s.get("scene_number", scene_start) for s in act_scenes) + 1
            
                        # 发送本幕结果到前端
                        act_label = f"第{act_num}幕 - {act_name}" if is_zh else f"Act {act_num} - {act_name}"
                        self._report_progress("剧本生成",
                                              f"{act_label} 完成" if is_zh else f"{act_label} done",
                                              pct_extract + 2,
                                              data={"act_complete": {
                                                  "act": act_num,
                                                  "act_name": act_name,
                                                  "characters": act_json.get("characters", []),
                                                  "settings": act_json.get("settings", []),
                                                  "scenes": act_scenes,
                                              }})
            
                    # Step 4: 合并最终 JSON
                    # 重新编号 scene_number
                    for i, sc in enumerate(all_scenes):
                        sc["scene_number"] = i + 1
            
                    json_data = {
                        "title": meta_data.get("title", ""),
                        "logline": meta_data.get("logline", ""),
                        "genre": meta_data.get("genre", []),
                        "target_duration": 1800,
                        "synopsis": meta_data.get("synopsis", ""),
                        "characters": all_characters,
                        "settings": all_settings,
                        "scenes": all_scenes,
                        "overall_style": style,
                        "mood": meta_data.get("mood", ""),
                    }
            
                    # Step 5: 合并相似场景和角色
                    self._report_progress("剧本生成",
                                          "审查并合并相似场景..." if is_zh else "Consolidating similar settings...",
                                          96)
                    json_data = self._consolidate_script(llm, json_data, llm_model, sid, is_zh, web_search=web_search)
            
                    return json_data
            
                # ─── 微电影模式: 单幕紧凑生成 ───
            
                def _generate_micro_script_incremental(self, llm, draft: str, style: str,
                                                        llm_model: str, sid: str, is_zh: bool,
                                                        web_search: bool = False,
                                                        pct_start: int = 20) -> dict:
                    """微电影模式:生成紧凑单幕剧情概要 → 分场大纲 → 结构化提取,返回合并的 JSON"""
            
                    # Step 1: 生成单幕剧情概要
                    self._report_progress("剧本生成", "生成微电影剧情概要...", pct_start)
                    prompt = (MICRO_BEAT_SHEET_PROMPT_ZH if is_zh else MICRO_BEAT_SHEET_PROMPT_EN).format(
                        draft=draft, style=style
                    )
                    beat_sheet = self._cancellable_query(llm, prompt, model=llm_model, task_id=sid, web_search=web_search)
                    logger.info(f"[ScriptWriter][micro] Beat sheet generated ({len(beat_sheet)} chars)")
            
                    # 发送概要到前端
                    self._report_progress("剧本生成", "剧情概要生成完成", pct_start + 10,
                                          data={"beat_sheet": beat_sheet})
            
                    # Step 2: 提取顶层元数据
                    self._report_progress("剧本生成", "提取故事元信息...", pct_start + 15)
                    meta_prompt = (MICRO_META_EXTRACT_PROMPT_ZH if is_zh else MICRO_META_EXTRACT_PROMPT_EN).format(
                        beat_sheet=beat_sheet
                    )
                    meta_raw = self._cancellable_query(llm, meta_prompt, model=llm_model, task_id=sid, web_search=web_search)
                    meta_data = self._extract_json_from_text(meta_raw) or {}
                    logger.info(f"[ScriptWriter][micro] Meta extracted: {list(meta_data.keys())}")
            
                    # Step 3: 生成分场大纲
                    self._check_cancel()
                    self._report_progress("剧本生成",
                                          "生成微电影分场大纲..." if is_zh else "Generating micro-film step outline...",
                                          pct_start + 25)
            
                    outline_prompt = (MICRO_STEP_OUTLINE_PROMPT_ZH if is_zh else MICRO_STEP_OUTLINE_PROMPT_EN).format(
                        beat_sheet=beat_sheet, style=style
                    )
                    outline = self._cancellable_query(llm, outline_prompt, model=llm_model, task_id=sid, web_search=web_search)
                    logger.info(f"[ScriptWriter][micro] Outline generated ({len(outline)} chars)")
            
                    # Step 4: 结构化 JSON 提取
                    self._report_progress("剧本生成",
                                          "提取结构化数据..." if is_zh else "Extracting structured data...",
                                          p
          • storyboard_agent.py 17.4 KB
            # -*- 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,
                    }
            
          • video_agent.py 26.7 KB
            # -*- coding: utf-8 -*-
            """
            阶段5: 视频生成智能体
            分镜参考图 → 各分镜视频片段
            - 视频提示词使用阶段3的原始分镜剧情描述(plot),而非视觉描述或首帧图像提示词
            - 参考图使用用户在阶段4选择的版本,而非第一版
            - 支持逐项实时预览、重新生成、多版本管理
            """
            
            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
            
            logger = logging.getLogger(__name__)
            
            
            class VideoDirectorAgent(AgentInterface):
                """视频生成:分镜参考图(阶段4用户选择) + 分镜剧情描述(阶段3 plot) → 视频片段"""
            
                def __init__(self):
                    super().__init__(name="VideoDirector")
            
                # ─── 版本管理 ───
            
                @staticmethod
                def _video_base(sid: str) -> str:
                    return os.path.join('code/result/video', str(sid))
            
                def _list_versions(self, sid: str, shot_id: str) -> List[str]:
                    """列出某个分镜视频的所有历史版本"""
                    video_dir = self._video_base(sid)
                    pattern = os.path.join(video_dir, f"{shot_id}*.mp4")
                    files = [f for f in sorted(glob.glob(pattern), key=os.path.getmtime)
                             if not f.endswith('_final.mp4')]
                    return files
            
                def _next_version_path(self, sid: str, shot_id: str) -> str:
                    """获取下一个版本路径"""
                    video_dir = self._video_base(sid)
                    os.makedirs(video_dir, exist_ok=True)
            
                    existing = self._list_versions(sid, shot_id)
                    if not existing:
                        return os.path.join(video_dir, f"{shot_id}.mp4")
            
                    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(video_dir, f"{shot_id}_v{max_v + 1}.mp4")
            
                # ─── 视频生成 ───
            
                def _generate_one(self, sid: str, shot_id: str, prompt: str,
                                  img_path: str, video_model: str,
                                  duration: int = 5, sound: str = "",
                                  shot_type: str = "multi") -> tuple:
                    """生成单个分镜视频,返回 (shot_id, path_or_None)"""
                    # 取消时直接跳过,不抛异常,以保留已生成的部分结果
                    if self.cancellation_check and self.cancellation_check():
                        logger.info(f"VideoDirectorAgent: {shot_id} 跳过(用户取消)")
                        return shot_id, None
            
                    if not os.path.exists(img_path):
                        logger.warning(f"Image missing for {shot_id}: {img_path}")
                        return shot_id, None
            
                    save_path = self._next_version_path(sid, shot_id)
                    try:
                        from tool.video_client import VideoClient
                        client = VideoClient()
                        client.generate_video(
                            prompt=prompt,
                            image_path=img_path,
                            save_path=save_path,
                            model=video_model,
                            duration=duration,
                            sound=sound,
                            shot_type=shot_type,
                        )
                        return shot_id, save_path
                    except Exception as e:
                        logger.error(f"Video gen failed for {shot_id}: {e}")
                        if os.path.exists(save_path):
                            try:
                                os.remove(save_path)
                            except Exception:
                                pass
                    return shot_id, None
            
                # ─── 排序 ───
            
                @staticmethod
                def _sort_shot_keys(keys: list) -> list:
                    """对 shot_id 排序: shot_001_01, shot_001_02, shot_002_01, ..."""
                    def sort_key(k):
                        nums = re.findall(r'(\d+)', k)
                        return tuple(int(n) for n in nums) if nums else (0,)
                    return sorted(keys, key=sort_key)
            
                # ─── 预览 / Payload 构建 ───
            
                @staticmethod
                def _shot_display_name(shot_id: str) -> str:
                    """shot_001_02 → 场景1-镜头2"""
                    nums = re.findall(r'(\d+)', shot_id)
                    if len(nums) >= 2:
                        return f"场景{int(nums[0])}-镜头{int(nums[1])}"
                    elif len(nums) == 1:
                        return f"场景{int(nums[0])}"
                    return shot_id
            
                def _build_preview(self, sid: str, shot_keys: list, s2i: dict) -> list:
                    """构建视频片段预览列表"""
                    preview = []
                    for idx, shot_id in enumerate(shot_keys, 1):
                        versions = self._list_versions(sid, shot_id)
                        entry = s2i.get(shot_id, {})
                        desc = entry.get('plot', '') or entry.get('video_prompt', '') or entry.get('prompt', '')
                        preview.append({
                            "id": shot_id,
                            "name": self._shot_display_name(shot_id),
                            "index": idx,
                            "description": desc,
                            "duration": entry.get('duration', 5),
                            "selected": versions[-1] if versions else "",
                            "versions": versions,
                            "status": "done" if versions else "pending",
                        })
                    return preview
            
                def _build_payload(self, sid: str, shot_keys: list, s2i: dict, clip_descriptions: dict = None) -> dict:
                    """构建最终 payload
            
                    Args:
                        clip_descriptions: 用户修改的提示词,优先使用
                    """
                    clips = []
                    for idx, shot_id in enumerate(shot_keys, 1):
                        versions = self._list_versions(sid, shot_id)
                        entry = s2i.get(shot_id, {})
                        # 优先使用用户修改的描述,否则用 scene2image 中的原始描述
                        if clip_descriptions and shot_id in clip_descriptions:
                            desc = clip_descriptions[shot_id]
                        else:
                            desc = entry.get('plot', '') or entry.get('video_prompt', '') or entry.get('prompt', '')
                        clips.append({
                            "id": shot_id,
                            "name": self._shot_display_name(shot_id),
                            "index": idx,
                            "description": desc,
                            "duration": entry.get('duration', 5),
                            "selected": versions[-1] if versions else "",
                            "versions": versions,
                            "status": "done" if versions else "failed",
                        })
                    return {
                        "payload": {
                            "session_id": sid,
                            "clips": clips,
                        },
                        "stage_completed": True,
                    }
            
                # ─── 视频提示词前缀/后缀配置 ───
            
                # 尝试从模板文件加载前缀/后缀,失败则使用默认值
                _VIDEO_PROMPT_PREFIX = None
                _VIDEO_PROMPT_SUFFIX = None
            
                @classmethod
                def _load_video_enhance_prompt(cls) -> tuple:
                    """加载视频提示词优化模板"""
                    if cls._VIDEO_PROMPT_PREFIX is not None:
                        return cls._VIDEO_PROMPT_PREFIX, cls._VIDEO_PROMPT_SUFFIX
            
                    try:
                        from prompts.loader import PROMPTS_DIR
                        import os
                        enhance_file = os.path.join(PROMPTS_DIR, 'video', 'enhance.txt')
                        if os.path.exists(enhance_file):
                            with open(enhance_file, 'r', encoding='utf-8') as f:
                                content = f.read()
            
                            prefix = ""
                            suffix = ""
                            current_section = None
            
                            for line in content.split('\n'):
                                line = line.strip()
                                if not line or line.startswith('#'):
                                    continue
                                if line == '[prefix]':
                                    current_section = 'prefix'
                                elif line == '[suffix]':
                                    current_section = 'suffix'
                                elif line.startswith('[') and line.endswith(']'):
                                    # 遇到新的 section(如 [style_keywords]),停止解析
                                    current_section = None
                                elif current_section == 'prefix':
                                    prefix += line + " "
                                elif current_section == 'suffix':
                                    suffix += " " + line
            
                            cls._VIDEO_PROMPT_PREFIX = prefix.strip()
                            cls._VIDEO_PROMPT_SUFFIX = suffix.strip()
                            logger.info(f"Loaded video enhance prompt from file: prefix={len(cls._VIDEO_PROMPT_PREFIX)} chars, suffix={len(cls._VIDEO_PROMPT_SUFFIX)} chars")
                            return cls._VIDEO_PROMPT_PREFIX, cls._VIDEO_PROMPT_SUFFIX
                    except Exception as e:
                        logger.warning(f"Failed to load video enhance prompt: {e}")
            
                    # 默认值
                    cls._VIDEO_PROMPT_PREFIX = (
                        "high quality, detailed, cinematic footage, smooth motion, natural movement, "
                    )
                    cls._VIDEO_PROMPT_SUFFIX = (
                        ", realistic, no blur, no distortion, professional lighting, film grain"
                    )
                    return cls._VIDEO_PROMPT_PREFIX, cls._VIDEO_PROMPT_SUFFIX
            
                # 视频API字符限制(可灵2500,万象也类似)
                MAX_PROMPT_LENGTH = 2500
            
                # ─── 风格关键词映射 ───
                # 根据项目风格添加对应的视觉描述词
            
                STYLE_VIDEO_KEYWORDS = {
                    # 动漫/动画风格
                    "anime": "anime style, animated, cel-shaded, vibrant colors, manga aesthetic, ",
                    "cartoon": "cartoon style, animated, colorful, fun, children's book illustration, ",
                    # 写实风格
                    "realistic": "photorealistic, realistic, natural lighting, detailed textures, cinema photography, ",
                    "photorealistic": "photorealistic, realistic, natural lighting, detailed textures, cinema photography, ",
                    # 3D 迪士尼风格
                    "3d-disney": "3D animation, Disney style, pixar, CGI, smooth textures, computer generated, ",
                    "3d": "3D animation, CGI, computer generated, smooth textures, digital cinema, ",
                    # 油画风格
                    "oil-painting": "oil painting style, impasto, classical art, painterly, rich brushstrokes, ",
                    "watercolor": "watercolor style, delicate, soft colors, artistic, flowing, ",
                    # 漫画风格
                    "comic-book": "comic book style, vibrant, bold outlines, pop art, graphic novel, ",
                    # 赛博朋克
                    "cyberpunk": "cyberpunk, neon lights, futuristic, dark atmosphere, sci-fi, ",
                    # 中国风
                    "chinese-ink": "Chinese ink painting style, traditional, minimalist, brush strokes, oriental art, ",
                    "ink": "Chinese ink painting style, traditional, minimalist, brush strokes, oriental art, ",
                }
            
                def _get_style_keywords(self, sid: str) -> str:
                    """获取项目风格对应的视频关键词"""
                    try:
                        from config import settings
                        result_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')
                        if os.path.exists(result_file):
                            with open(result_file, 'r', encoding='utf-8') as f:
                                data = json.load(f)
                            session_data = data.get(str(sid), {})
                            style = session_data.get('overall_style', '').lower().strip()
                            if style and style in self.STYLE_VIDEO_KEYWORDS:
                                logger.info(f"Using style keywords for video: {style}")
                                return self.STYLE_VIDEO_KEYWORDS[style]
                    except Exception as e:
                        logger.debug(f"Could not get style for video prompt: {e}")
                    return ""
            
                # ─── 辅助:获取分镜的视频提示词和参考图路径 ───
            
                def _enhance_video_prompt(self, base_prompt: str, sid: str = None) -> str:
                    """
                    增强视频提示词:添加前缀后缀优化生成效果
                    不截断,发送完整提示词给API
                    """
                    if not base_prompt:
                        return base_prompt
            
                    prefix, suffix = self._load_video_enhance_prompt()
                    base_lower = base_prompt.lower().strip()
            
                    # 获取风格关键词
                    style_keywords = ""
                    if sid:
                        style_keywords = self._get_style_keywords(sid)
            
                    # 获取对话语言要求
                    dialog_language = ""
                    if sid:
                        from config import settings
                        import os
                        script_file = os.path.join(settings.RESULT_DIR, 'script', f'script_{sid}.json')
                        if os.path.exists(script_file):
                            import json
                            with open(script_file, 'r', encoding='utf-8') as f:
                                data = json.load(f)
                                script_data = data.get(str(sid), {})
                                # title 可能在 script_json 子对象中
                                title = script_data.get('title') or script_data.get('script_json', {}).get('title', '')
                                # 判断语言:检查标题是否包含中文字符
                                is_zh = any('\u4e00' <= c <= '\u9fff' for c in title)
                                if is_zh:
                                    dialog_language = "注意:人物对话必须使用中文,不要使用英文。"
                                else:
                                    dialog_language = "Note: Character dialogues must be in English."
            
                    # 构建增强后的提示词
                    # 检查是否已包含前缀关键词
                    has_prefix = any(kw in base_lower for kw in ["high quality", "cinematic", "smooth motion", "anime", "photorealistic"])
                    # 检查是否已包含后缀关键词
                    has_suffix = any(kw in base_lower for kw in ["film grain", "realistic", "professional lighting", "masterpiece"])
            
                    enhanced = base_prompt.strip()
            
                    # 添加对话语言要求
                    if dialog_language:
                        enhanced = enhanced + " " + dialog_language
            
                    # 添加风格关键词(前缀之前)
                    if style_keywords:
                        enhanced = style_keywords + enhanced
            
                    if prefix and not has_prefix:
                        enhanced = prefix + enhanced
            
                    if suffix and not has_suffix:
                        enhanced = enhanced + suffix
            
                    logger.info(f"Video prompt enhanced: {len(base_prompt)} -> {len(enhanced)} chars")
                    # 不截断,发送完整提示词
                    return enhanced
            
                def _get_shot_prompt(self, entry: dict, enhance: bool = True, sid: str = None, shot_id: str = None, clip_descriptions: dict = None) -> str:
                    """获取视频提示词:优先用用户修改的提示词,其次用 plot(剧情描述),兼容旧数据回退到 video_prompt"""
                    # 优先使用用户修改的提示词
                    if not shot_id:
                        shot_id = entry.get('shot_id') or entry.get('id', '')
                    if clip_descriptions and shot_id and clip_descriptions.get(shot_id):
                        base_prompt = clip_descriptions[shot_id]
                    else:
                        base_prompt = entry.get('plot', '') or entry.get('video_prompt', '') or entry.get('prompt', '')
                    if enhance:
                        return self._enhance_video_prompt(base_prompt, sid)
                    return base_prompt
            
                def _get_shot_image(self, sid: str, shot_id: str, entry: dict,
                                    selected_images: dict) -> str:
                    """获取参考图路径:优先用前端传入的用户选择,再用 scene2image 的 local_path,
                    最后回退到扫描磁盘最新版本"""
                    # 1. 前端传入的用户选择(stage 4 确认时携带)
                    if selected_images.get(shot_id):
                        path = selected_images[shot_id]
                        if os.path.exists(path):
                            return path
                        logger.warning(f"selected_images path missing for {shot_id}: {path}")
            
                    # 2. scene2image 中的 local_path
                    local_path = entry.get('local_path', '')
                    if local_path and os.path.exists(local_path):
                        return local_path
            
                    # 3. 回退:扫描磁盘,使用最新版本
                    from core.agents.reference_agent import ReferenceGeneratorAgent
                    versions = ReferenceGeneratorAgent._list_versions_static(sid, shot_id)
                    if versions:
                        logger.info(f"Fallback to latest version for {shot_id}: {versions[-1]}")
                        return versions[-1]
            
                    # 4. 最终回退:默认路径
                    return os.path.join('code/result/image', str(sid), 'Scenes', f"{shot_id}.jpg")
            
                # ─── 核心流程 ───
            
                async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
                    from config import settings
            
                    sid = input_data["session_id"]
                    video_model = input_data.get("video_model", "") or settings.VIDEO_MODEL
                    # 根据 enable_concurrency 决定并发数
                    enable_concurrency = input_data.get("enable_concurrency", True)
                    from config_model import get_max_concurrency
                    concurrency = get_max_concurrency(video_model, enable_concurrency)
                    selected_images = input_data.get("selected_images", {})
                    # 优先使用 input_data 中已有的 clips(包含用户修改的 description)
                    existing_clips = input_data.get("clips", [])
                    clip_descriptions = {c['id']: c['description'] for c in existing_clips if c.get('id') and c.get('description')}
                    video_sound = input_data.get("video_sound", "on")
                    video_shot_type = input_data.get("video_shot_type", "multi")
                    sound_param = "" if video_sound == "off" else video_sound
            
                    logger.info(f"VideoDirectorAgent: sid={sid}, video_model={video_model}, sound={video_sound}, shot_type={video_shot_type}")
            
                    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)
                    story_data = results[str(sid)]
            
                    s2i = story_data.get('scene2image', {})
            
                    # 从 storyboard 获取时长数据(用户修改已同步到 clips 中)
                    storyboard = story_data.get('storyboard', {})
                    storyboard_shots_list = storyboard.get('shots', [])
                    shot_duration_map = {s.get('shot_id'): s.get('duration', 10) for s in storyboard_shots_list if s.get('shot_id')}
            
                    # 同步 scene2image 中的时长
                    for shot_id in s2i:
                        if shot_id in shot_duration_map:
                            s2i[shot_id]['duration'] = shot_duration_map[shot_id]
            
                    logger.info(f"VideoDirectorAgent: duration sync from storyboard: {list(shot_duration_map.keys())}")
            
                    # 兼容 shot_xxx_xx 和旧 Scene_x 格式
                    shot_keys = self._sort_shot_keys(list(s2i.keys()))
                    total = len(shot_keys)
            
                    logger.info(f"VideoDirectorAgent: total shots in scene2image = {total}, shot_keys = {shot_keys}")
            
                    if not shot_keys:
                        raise Exception("未找到场景图数据(scene2image),请先完成阶段4")
            
                    # ═══ 介入:重新生成指定片段 ═══
                    if intervention:
                        regen_clips = intervention.get("regenerate_clips", [])
            
                        if regen_clips:
                            self._report_progress("视频生成", "重新生成中...", 10)
            
                            def regen_run():
                                regen_total = len(regen_clips)
                                done = 0
                                with ThreadPoolExecutor(max_workers=concurrency) as executor:
                                    futs = {}
                                    for shot_id in regen_clips:
                                        entry = s2i.get(shot_id, {})
                                        prompt = self._get_shot_prompt(entry, sid=sid, shot_id=shot_id, clip_descriptions=clip_descriptions)
                                        img_path = self._get_shot_image(sid, shot_id, entry, selected_images)
                                        shot_duration = entry.get('duration', 5)
                                        fut = executor.submit(
                                            self._generate_one, sid, shot_id, prompt,
                                            img_path, video_model, shot_duration,
                                            sound_param, video_shot_type
                                        )
                                        futs[fut] = shot_id
                                    for fut in as_completed(futs):
                                        shot_id_done = futs[fut]
                                        try:
                                            _, result_path = fut.result()
                                        except Exception as e:
                                            logger.error(f"Regen future error for {shot_id_done}: {e}")
                                            result_path = None
                                        done += 1
                                        pct = 10 + int(85 * done / max(regen_total, 1))
                                        if result_path:
                                            versions = self._list_versions(sid, shot_id_done)
                                            self._report_progress("视频生成", f"完成: {shot_id_done}", pct, data={
                                                "asset_complete": {
                                                    "type": "clips", "id": shot_id_done,
                                                    "status": "done",
                                                    "selected": result_path,
                                                    "versions": versions,
                                                }
                                            })
                                        else:
                                            self._report_progress("视频生成", f"失败: {shot_id_done}", pct, data={
                                                "asset_complete": {
                                                    "type": "clips", "id": shot_id_done,
                                                    "status": "failed",
                                                    "selected": "", "versions": [],
                                                }
                                            })
                                        # 检查取消:停止等待剩余任务
                                        if self.cancellation_check and self.cancellation_check():
                                            logger.info("VideoDirectorAgent: 用户取消重新生成,停止等待剩余任务")
                                            for f in futs:
                                                if not f.done():
                                                    f.cancel()
                                            break
            
                            loop = asyncio.get_running_loop()
                            await loop.run_in_executor(None, regen_run)
            
                        self._report_progress("视频生成", "完成", 100)
                        return self._build_payload(sid, shot_keys, s2i, clip_descriptions)
            
                    # ═══ 正常流程:全量生成 ═══
                    self._report_progress("视频生成", "加载场景数据...", 5)
            
                    # 发送预览列表
                    preview = self._build_preview(sid, shot_keys, s2i)
                    self._report_progress("视频生成", "加载视频列表", 8, data={"assets_preview": {"clips": preview}})
            
                    def run():
                        # 筛选需要生成的(跳过已有的)
                        tasks = []
                        for shot_id in shot_keys:
                            existing = self._list_versions(sid, shot_id)
                            if existing:
                                continue
                            entry = s2i.get(shot_id, {})
                            prompt = self._get_shot_prompt(entry, sid=sid, shot_id=shot_id, clip_descriptions=clip_descriptions)
                            img_path = self._get_shot_image(sid, shot_id, entry, selected_images)
                            shot_duration = entry.get('duration', 5)
                            tasks.append((shot_id, prompt, img_path, shot_duration))
            
                        if not tasks:
                            self._report_progress("视频生成", "所有视频已存在", 95)
                            return
            
                        gen_total = len(tasks)
                        done = 0
            
                        cancelled = False
                        with ThreadPoolExecutor(max_workers=concurrency) as executor:
                            futs = {}
                            for shot_id, prompt, img_path, shot_duration in tasks:
                                fut = executor.submit(
                                    self._generate_one, sid, shot_id, prompt,
                                    img_path, video_model, shot_duration,
                                    sound_param, video_shot_type,
                                )
                                futs[fut] = shot_id
                            for fut in as_completed(futs):
                                shot_id_done = futs[fut]
                                try:
                                    _, result_path = fut.result()
                                except Exception as e:
                                    logger.error(f"Video future error for {shot_id_done}: {e}")
                                    result_path = None
                                done += 1
                                pct = 10 + int(85 * done / max(gen_total, 1))
                                if result_path:
                                    versions = self._list_versions(sid, shot_id_done)
                                    self._report_progress("视频生成", f"完成: {shot_id_done}", pct, data={
                                        "asset_complete": {
                                            "type": "clips", "id": shot_id_done,
                                            "status": "done",
                                            "selected": result_path,
                                            "versions": versions,
                                        }
                                    })
                                else:
                                    self._report_progress("视频生成", f"失败: {shot_id_done}", pct, data={
                                        "asset_complete": {
                                            "type": "clips", "id": shot_id_done,
                                            "status": "failed",
                                            "selected": "", "versions": [],
                                        }
                                    })
                                # 检查取消:停止等待剩余任务
                                if self.cancellation_check and self.cancellation_check():
                                    logger.info("VideoDirectorAgent: 用户取消,停止等待剩余任务")
                                    for f in futs:
                                        if not f.done():
                                            f.cancel()
                                    cancelled = True
                                    break
            
                        if cancelled:
                            self._report_progress("视频生成", "已取消(保留已完成片段)", 96)
                        else:
                            self._report_progress("视频生成", "保存结果...", 96)
            
                        # 写回结果文件
                        with open(result_file, 'r', encoding='utf-8') as f:
                            res = json.load(f)
                        i2v_data = {}
                        for shot_id in shot_keys:
                            versions = self._list_versions(sid, shot_id)
                            entry = s2i.get(shot_id, {})
                            if versions:
                                i2v_data[shot_id] = {
                                    "video_prompt": self._get_shot_prompt(entry, sid=sid, shot_id=shot_id, clip_descriptions=clip_descriptions),
                                    "input_path": self._get_shot_image(sid, shot_id, entry, selected_images),
                                    "output_path": versions[-1],
                                    "duration": entry.get('duration', 10),
                                    "status": "done",
                                }
                        res[str(sid)]['image2video'] = i2v_data
                        with open(result_file, 'w', encoding='utf-8') as f:
                            json.dump(res, f, indent=4, ensure_ascii=False)
            
                    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("VideoDirectorAgent: 用户取消,返回已完成的部分结果")
                            self._report_progress("视频生成", "已取消(保留已完成片段)", 100)
                            return self._build_payload(sid, shot_keys, s2i, clip_descriptions)
                        raise
            
                    self._report_progress("视频生成", "完成", 100)
                    return self._build_payload(sid, shot_keys, s2i, clip_descriptions)
          • __init__.py 541 B
            # -*- 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",
            ]
            
        • orchestrator.py 22.6 KB
          # -*- coding: utf-8 -*-
          """
          核心编排器 / 工作流引擎
          管理六阶段状态机,协调各智能体执行,支持用户在任意阶段介入
          """
          
          import json
          import logging
          import os
          import threading
          import time
          from datetime import datetime
          from enum import Enum
          from typing import Any, Callable, Dict, List, Optional
          
          from core.agents import (
              ScriptWriterAgent,
              CharacterDesignerAgent,
              StoryboardAgent,
              ReferenceGeneratorAgent,
              VideoDirectorAgent,
              VideoEditorAgent,
          )
          
          logger = logging.getLogger(__name__)
          
          
          class WorkflowStage(str, Enum):
              """工作流阶段"""
              INIT = "init"
              SCRIPT_GENERATION = "script_generation"
              CHARACTER_DESIGN = "character_design"
              STORYBOARD = "storyboard"
              REFERENCE_GENERATION = "reference_generation"
              VIDEO_GENERATION = "video_generation"
              POST_PRODUCTION = "post_production"
              COMPLETED = "session_completed"
          
          
          STAGE_ORDER = [
              WorkflowStage.SCRIPT_GENERATION,
              WorkflowStage.CHARACTER_DESIGN,
              WorkflowStage.STORYBOARD,
              WorkflowStage.REFERENCE_GENERATION,
              WorkflowStage.VIDEO_GENERATION,
              WorkflowStage.POST_PRODUCTION,
          ]
          
          
          class WorkflowState:
              """工作流状态"""
          
              # 状态说明:
              # - idle: 新建会话,还没有任何数据,也没有在运行
              # - running: 会话正在运行
              # - waiting_in_stage: 会话在某一阶段内等待用户介入(如选择角色、选择图片等)
              # - stage_completed: 会话完成了某一阶段,等待用户确定开始下一阶段
              # - session_completed: 会话全部完成
              # - stopped: 用户手动停止
              # - error: 执行中遇到错误
          
              def __init__(self, session_id: str):
                  self.session_id = session_id
                  self.current_stage: WorkflowStage = WorkflowStage.INIT
                  self.status: str = "idle"
                  self.stages_completed: List[str] = []  # 已完成的阶段列表
                  self.artifacts: Dict[str, Any] = {}
                  self.error: Optional[str] = None
                  self.started_at: Optional[datetime] = None
                  self.updated_at: datetime = datetime.now()
                  self.meta: Dict[str, Any] = {}
          
              def to_dict(self) -> Dict:
                  return {
                      "session_id": self.session_id,
                      "current_stage": self.current_stage.value,
                      "status": self.status,
                      "error": self.error,
                      "stages_completed": self.stages_completed,
                      "artifacts": self.artifacts,
                      "meta": self.meta,
                      "updated_at": self.updated_at,
                  }
          
          
          class WorkflowEngine:
              """工作流引擎 - 管理六阶段状态机"""
          
              def __init__(self):
                  self.agents = {
                      WorkflowStage.SCRIPT_GENERATION: ScriptWriterAgent(),
                      WorkflowStage.CHARACTER_DESIGN: CharacterDesignerAgent(),
                      WorkflowStage.STORYBOARD: StoryboardAgent(),
                      WorkflowStage.REFERENCE_GENERATION: ReferenceGeneratorAgent(),
                      WorkflowStage.VIDEO_GENERATION: VideoDirectorAgent(),
                      WorkflowStage.POST_PRODUCTION: VideoEditorAgent(),
                  }
                  self.sessions: Dict[str, WorkflowState] = {}
                  self._stop_events: Dict[str, threading.Event] = {}
                  self._session_dir = os.path.join(
                      os.path.dirname(os.path.abspath(__file__)), '..', 'code', 'data', 'sessions'
                  )
                  os.makedirs(self._session_dir, exist_ok=True)
                  self._load_sessions_from_disk()
          
              def get_or_create_state(self, session_id: str) -> WorkflowState:
                  if session_id not in self.sessions:
                      self.sessions[session_id] = WorkflowState(session_id=session_id)
                  if session_id not in self._stop_events:
                      self._stop_events[session_id] = threading.Event()
                  return self.sessions[session_id]
          
              def get_state(self, session_id: str) -> Optional[WorkflowState]:
                  # 先从内存中获取
                  if session_id in self.sessions:
                      return self.sessions[session_id]
          
                  # 内存中没有,从磁盘加载
                  path = os.path.join(self._session_dir, f"{session_id}.json")
                  if os.path.exists(path):
                      try:
                          with open(path, 'r', encoding='utf-8') as f:
                              data = json.load(f)
          
                          # 从磁盘数据恢复 WorkflowState
                          state = WorkflowState(session_id=session_id)
                          state.status = data.get('status', 'idle')
                          stage_str = data.get('current_stage')
                          state.current_stage = WorkflowStage(stage_str) if stage_str else WorkflowStage.INIT
                          state.stages_completed = data.get('stages_completed', [])
                          state.artifacts = data.get('artifacts', {})
                          state.meta = data.get('meta', {})
                          state.error = data.get('error')
                          state.updated_at = data.get('updated_at', 0)
          
                          # 缓存到内存
                          self.sessions[session_id] = state
                          return state
                      except json.JSONDecodeError as e:
                          logger.warning(f"Session file {session_id} is corrupted, ignoring: {e}")
                      except Exception as e:
                          logger.warning(f"Failed to load session {session_id} from disk: {e}")
          
                  return None
          
              def get_stop_event(self, session_id: str) -> threading.Event:
                  if session_id not in self._stop_events:
                      self._stop_events[session_id] = threading.Event()
                  return self._stop_events[session_id]
          
              def stop_session(self, session_id: str):
                  self.get_stop_event(session_id).set()
                  state = self.get_state(session_id)
                  if state and state.status == "running":
                      state.status = "stopped"
                      state.error = None  # 清除错误,因为是主动停止
                      state.updated_at = datetime.now()
                      self.save_session_to_disk(session_id)
                  logger.info(f"Session {session_id} stop signal sent")
          
              def reset_stop_event(self, session_id: str):
                  if session_id in self._stop_events:
                      self._stop_events[session_id].clear()
          
              def _get_next_stage(self, current: WorkflowStage) -> Optional[WorkflowStage]:
                  try:
                      idx = STAGE_ORDER.index(current)
                      if idx + 1 < len(STAGE_ORDER):
                          return STAGE_ORDER[idx + 1]
                  except ValueError:
                      pass
                  return None
          
              async def execute_stage(self,
                                      state: WorkflowState,
                                      stage: WorkflowStage,
                                      input_data: Any,
                                      cancellation_check: Optional[Callable] = None,
                                      progress_callback: Optional[Callable] = None,
                                      intervention: Optional[Dict] = None) -> Dict:
                  import time
          
                  agent = self.agents[stage]
          
                  # 合并会话级停止信号与请求级取消检查
                  session_stop = self.get_stop_event(state.session_id)
                  def combined_cancel_check():
                      return session_stop.is_set() or (cancellation_check and cancellation_check())
          
                  agent.set_cancellation_check(combined_cancel_check)
          
                  # 包装 progress_callback,定期保存状态到 sessions json
                  last_save_time = {"time": 0}
                  SAVE_INTERVAL = 10  # 每10秒保存一次
          
                  def wrapped_progress_callback(phase: str, step: str, percent: float, data: dict = None):
                      # 调用原始 callback
                      if progress_callback:
                          progress_callback(phase, step, percent, data)
          
                      # 如果有 asset_complete 数据,说明有新生成的图片/视频,立即保存到磁盘
                      if data and data.get("asset_complete"):
                          self.save_session_to_disk(state.session_id)
                          last_save_time["time"] = time.time()
                          return
          
                      # 如果有 progress 回调(纯文字阶段),也定期保存
                      # 这样前端可以实时看到文字生成进度
                      current_time = time.time()
                      if current_time - last_save_time["time"] >= SAVE_INTERVAL:
                          last_save_time["time"] = current_time
                          self.save_session_to_disk(state.session_id)
          
                  if progress_callback:
                      agent.set_progress_callback(wrapped_progress_callback)
          
                  state.current_stage = stage
                  state.status = "running"
                  state.updated_at = datetime.now()
          
                  try:
                      result = await agent.process(input_data, intervention=intervention)
                      state.artifacts[stage.value] = result.get("payload")
          
                      # 第三阶段修改分镜时:同步更新第四、第五阶段的相关内容
                      if stage.value == "storyboard" and intervention and "modified_storyboard" in intervention:
                          modified_shots = intervention["modified_storyboard"]
                          if isinstance(modified_shots, list):
                              shot_durations = {s.get('shot_id'): s.get('duration', 10)
                                              for s in modified_shots if s.get('shot_id')}
                              shot_visual_prompts = {s.get('shot_id'): s.get('visual_prompt', '')
                                                   for s in modified_shots if s.get('shot_id')}
                              shot_plots = {s.get('shot_id'): s.get('plot', '')
                                          for s in modified_shots if s.get('shot_id')}
          
                              # 1. 同步 duration 到第五阶段 clips
                              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]
                                      if shot_id in shot_plots:
                                          clip['description'] = shot_plots[shot_id]
          
                              # 2. 同步 visual_prompt 到第四阶段 scenes (description)
                              ref_art = state.artifacts.get('reference_generation', {})
                              if isinstance(ref_art, dict) and 'scenes' in ref_art:
                                  for scene in ref_art['scenes']:
                                      shot_id = scene.get('id')
                                      if shot_id in shot_visual_prompts:
                                          scene['description'] = shot_visual_prompts[shot_id]
          
                              # 3. 新增分镜:同步到第四、第五阶段
                              existing_shot_ids = {clip.get('id') for clip in video_art.get('clips', [])} if isinstance(video_art, dict) else set()
                              existing_scene_ids = {scene.get('id') for scene in ref_art.get('scenes', [])} if isinstance(ref_art, dict) else set()
          
                              for shot in modified_shots:
                                  shot_id = shot.get('shot_id')
                                  if not shot_id:
                                      continue
                                  # 新增到第五阶段
                                  if shot_id not in existing_shot_ids:
                                      if isinstance(video_art, dict):
                                          video_art.setdefault('clips', []).append({
                                              'id': shot_id,
                                              'name': f"镜头{shot_id.split('_')[-1]}",
                                              'description': shot.get('plot', ''),
                                              'duration': shot.get('duration', 10),
                                              'selected': '',
                                              'versions': [],
                                              'status': 'pending'
                                          })
                                  # 新增到第四阶段
                                  if shot_id not in existing_scene_ids:
                                      if isinstance(ref_art, dict):
                                          ref_art.setdefault('scenes', []).append({
                                              'id': shot_id,
                                              'name': f"场景{shot_id.split('_')[-2]}",
                                              'description': shot.get('visual_prompt', ''),
                                              'selected': '',
                                              'versions': [],
                                              'status': 'pending'
                                          })
          
                              logger.info(f"Synced stage 3 modifications: {len(shot_durations)} durations, {len(shot_visual_prompts)} prompts")
          
                      # 调试日志
                      logger.info(f"[execute_stage] stage={stage.value}, intervention={intervention is not None}, requires_intervention={result.get('requires_intervention')}, stage_completed={result.get('stage_completed')}")
          
                      # 状态转换逻辑:
                      # - stage_completed=True: 阶段已完成,等待用户确认进入下一阶段
                      # - requires_intervention=True: 阶段内需要用户介入(如选择图片等)
                      # - 其他(running):阶段正在执行中
                      if result.get("stage_completed"):
                          # 阶段真正完成,标记到已完成的列表
                          if stage.value not in state.stages_completed:
                              state.stages_completed.append(stage.value)
                          # 如果是最后一个阶段,设置为 session_completed
                          if stage == WorkflowStage.POST_PRODUCTION:
                              state.status = "session_completed"
                          else:
                              state.status = "stage_completed"
                      elif result.get("requires_intervention"):
                          # 阶段内需要用户介入,等待用户选择
                          state.status = "waiting_in_stage"
                      else:
                          # 阶段正在执行中(中间步骤),保持 running
                          state.status = "running"
          
                      state.updated_at = datetime.now()
                      # 立即保存状态到磁盘,确保前端能获取到最新状态
                      self.save_session_to_disk(state.session_id)
                      return result
          
                  except Exception as e:
                      state.status = "error"
                      state.error = str(e)
                      state.updated_at = datetime.now()
                      raise
          
              async def handle_intervention(self,
                                            session_id: str,
                                            stage: str,
                                            modifications: Dict[str, Any]) -> Dict:
                  state = self.sessions[session_id]
                  stage_enum = WorkflowStage(stage)
                  current_artifact = state.artifacts.get(stage, {})
          
                  input_data = current_artifact if isinstance(current_artifact, dict) else {}
                  input_data.update(modifications)
          
                  return await self.execute_stage(state, stage_enum, input_data, intervention=modifications)
          
              async def continue_workflow(self, session_id: str) -> Dict:
                  state = self.sessions[session_id]
                  logger.info(f"[continue_workflow] session={session_id}, current_stage={state.current_stage}, status={state.status}")
          
                  # 检查当前阶段是否已完成
                  current_stage_str = state.current_stage.value if hasattr(state.current_stage, 'value') else str(state.current_stage)
          
                  # 如果当前状态是 running,说明阶段还在执行中,不能继续
                  if state.status == "running":
                      return {
                          "status": "waiting",
                          "openclaw": f"当前阶段({current_stage_str})还在执行中,请等待完成后再调用 /continue。",
                          "message": f"当前阶段({current_stage_str})还在执行中,请等待完成后再调用 /continue。",
                          "current_status": state.status,
                      }
          
                  # 状态转换逻辑:
                  # - waiting_in_stage 或 stage_completed: 用户确认后直接进入下一阶段
                  # 注意:只有当阶段真正完成(waiting_in_stage 或 stage_completed)时才允许继续
          
                  if state.status == "waiting_in_stage" or state.status == "stage_completed":
                      # 用户确认后标记阶段完成
                      if current_stage_str not in state.stages_completed:
                          state.stages_completed.append(current_stage_str)
          
                      # 直接进入下一阶段
                      state.status = "running"
                      next_stage = self._get_next_stage(state.current_stage)
          
                      if not next_stage:
                          state.status = "session_completed"
                          self.save_session_to_disk(state.session_id)
                          return {"status": "session_completed"}
          
                      self.save_session_to_disk(state.session_id)
                      return {"status": "ready", "next_stage": next_stage.value}
          
                  # 其他状态(如 idle, stopped, error, session_completed)不允许继续
                  return {
                      "status": "error",
                      "openclaw": f"当前状态 {state.status} 不允许继续,请检查会话状态。",
                      "message": f"当前状态不允许继续",
                      "current_status": state.status,
                  }
          
              # ──────────── 会话持久化 ────────────
          
              def save_session_to_disk(self, session_id: str, meta: Dict = None):
                  """保存 / 更新会话到磁盘(原子写入)"""
                  import tempfile
                  import shutil
          
                  path = os.path.join(self._session_dir, f"{session_id}.json")
                  data: Dict[str, Any] = {}
                  if os.path.exists(path):
                      try:
                          with open(path, 'r', encoding='utf-8') as f:
                              data = json.load(f)
                      except (json.JSONDecodeError, Exception):
                          # 文件损坏,忽略旧数据
                          pass
                  data["session_id"] = session_id
                  if meta:
                      for k, v in meta.items():
                          data[k] = v
                  if "created_at" not in data:
                      data["created_at"] = time.time()
                  data["updated_at"] = time.time()
                  state = self.sessions.get(session_id)
                  if state:
                      data["current_stage"] = state.current_stage.value
                      data["status"] = state.status
                      data["stages_completed"] = state.stages_completed
                      data["artifacts"] = state.artifacts
                      data["error"] = state.error
                      # datetime 对象需要转换为时间戳
                      data["updated_at"] = state.updated_at.timestamp() if isinstance(state.updated_at, datetime) else state.updated_at
                      # 保存元数据(包含模型配置)
                      if state.meta:
                          for k, v in state.meta.items():
                              if v is not None:
                                  data[k] = v
          
                  # 原子写入:先写临时文件,再重命名
                  dir_path = os.path.dirname(path)
                  fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix='.json')
                  try:
                      with os.fdopen(fd, 'w', encoding='utf-8') as f:
                          json.dump(data, f, ensure_ascii=False, indent=2)
                      shutil.move(tmp_path, path)
                  except Exception:
                      # 写入失败,删除临时文件
                      if os.path.exists(tmp_path):
                          os.remove(tmp_path)
                      raise
          
              def _load_sessions_from_disk(self):
                  """启动时从磁盘加载所有已保存的会话"""
                  if not os.path.exists(self._session_dir):
                      return
                  for filename in os.listdir(self._session_dir):
                      if not filename.endswith('.json'):
                          continue
                      try:
                          fpath = os.path.join(self._session_dir, filename)
                          with open(fpath, 'r', encoding='utf-8') as f:
                              data = json.load(f)
                          sid = data["session_id"]
                          state = WorkflowState(sid)
                          try:
                              state.current_stage = WorkflowStage(data.get("current_stage", "init"))
                          except ValueError:
                              state.current_stage = WorkflowStage.INIT
                          # 旧版本兼容:状态名称转换
                          old_status = data.get("status", "idle")
                          if old_status == "waiting_intervention":
                              state.status = "waiting_in_stage"
                          elif old_status == "completed":
                              state.status = "session_completed"
                          else:
                              state.status = old_status
          
                          state.stages_completed = data.get("stages_completed", [])
                          state.artifacts = data.get("artifacts", {})
                          state.error = data.get("error")
                          state.updated_at = data.get("updated_at", 0)
                          state.meta = {k: data[k] for k in
                                        ("idea", "style", "llm_model", "image_t2i_model",
                                         "image_it2i_model", "video_model")
                                        if k in data}
                          self.sessions[sid] = state
                      except json.JSONDecodeError:
                          logger.warning(f"Skipping corrupted session file: {filename}")
                      except Exception as e:
                          logger.warning(f"Failed to load session {filename}: {e}")
          
              def delete_session(self, session_id: str) -> bool:
                  """删除指定会话(内存 + 磁盘 + 结果文件)"""
                  from config import settings
          
                  # 从内存中移除
                  self.sessions.pop(session_id, None)
                  self._stop_events.pop(session_id, None)
          
                  # 1. 删除会话元数据文件
                  path = os.path.join(self._session_dir, f"{session_id}.json")
                  if os.path.exists(path):
                      os.remove(path)
          
                  # 2. 删除结果文件(剧本、图片、视频)
                  result_base = settings.RESULT_DIR
          
                  # 删除剧本文件
                  script_file = os.path.join(result_base, 'script', f'script_{session_id}.json')
                  if os.path.exists(script_file):
                      os.remove(script_file)
          
                  # 删除图片目录
                  image_dir = os.path.join(result_base, 'image', session_id)
                  if os.path.exists(image_dir):
                      import shutil
                      shutil.rmtree(image_dir)
          
                  # 删除视频目录
                  video_dir = os.path.join(result_base, 'video', session_id)
                  if os.path.exists(video_dir):
                      import shutil
                      shutil.rmtree(video_dir)
          
                  logger.info(f"Session and results deleted: {session_id}")
                  return True
          
              def list_saved_sessions(self) -> List[Dict]:
                  """列出所有已保存的会话概要"""
                  sessions: List[Dict] = []
                  if not os.path.exists(self._session_dir):
                      return sessions
                  for filename in os.listdir(self._session_dir):
                      if not filename.endswith('.json'):
                          continue
                      try:
                          fpath = os.path.join(self._session_dir, filename)
                          with open(fpath, 'r', encoding='utf-8') as f:
                              data = json.load(f)
                          sessions.append({
                              "id": data["session_id"],
                              "idea": data.get("idea", ""),
                              "style": data.get("style", ""),
                              "date": data.get("updated_at", 0),
                              "stages": data.get("stages_completed", []),
                          })
                      except Exception:
                          continue
                  sessions.sort(key=lambda x: x.get("date", 0), reverse=True)
                  return sessions
          
        • __init__.py 127 B
          # -*- coding: utf-8 -*-
          from .orchestrator import WorkflowEngine, WorkflowStage
          
          __all__ = ["WorkflowEngine", "WorkflowStage"]
          
      • docs
        • api.md 9.9 KB
          # MovieAssistant API 文档
          
          ## 概述
          
          MovieAssistant 是一个 AI 视频生成系统,提供 REST API 供外部调用。API 与前端共享同一个历史数据库,支持在 API 和前端之间无缝切换。
          
          **基础 URL**: `http://localhost:8000`
          
          ---
          
          ## 认证
          
          当前版本无需认证,所有接口均可公开访问。
          
          ---
          
          ## 可用阶段
          
          | 阶段 ID | 名称 | 说明 |
          |---------|------|------|
          | `script_generation` | 剧本生成 | 将灵感转化为结构化剧本 |
          | `character_design` | 角色/场景设计 | 生成角色设计图和场景背景 |
          | `storyboard` | 分镜设计 | 设计镜头语言和分镜脚本 |
          | `reference_generation` | 参考图生成 | 生成高精度参考图 |
          | `video_generation` | 视频生成 | 将参考图/分镜图生成视频 |
          | `post_production` | 后期剪辑 | 拼接视频片段为最终成片 |
          
          ---
          
          ## API 接口列表
          
          ### 1. 创建项目
          
          创建一个新的视频生成项目。
          
          **接口**: `POST /api/project/start`
          
          **请求体**:
          ```json
          {
            "idea": "故事线描述",
            "style": "anime",
            "llm_model": "qwen3.5-plus",
            "vlm_model": "qwen-vl-plus",
            "image_t2i_model": "doubao-seedream-5-0",
            "image_it2i_model": "doubao-seedream-5-0",
            "video_model": "wan2.6-i2v-flash",
            "enable_concurrency": true
          }
          ```
          
          **参数说明**:
          | 参数 | 类型 | 必填 | 说明 | 默认值 |
          |------|------|------|------|--------|
          | idea | string | 是 | 故事线描述 | - |
          | style | string | 否 | 视频风格 | anime |
          | llm_model | string | 否 | LLM 模型 | qwen3.5-plus |
          | vlm_model | string | 否 | VLM 评估模型 | qwen-vl-plus |
          | image_t2i_model | string | 否 | 文生图模型 | doubao-seedream-5-0 |
          | image_it2i_model | string | 否 | 图生图模型 | doubao-seedream-5-0 |
          | video_model | string | 否 | 视频模型 | wan2.6-i2v-flash |
          | enable_concurrency | bool | 否 | 开启并发生成(可同时生成多张图片/视频) | true |
          
          **响应示例**:
          ```json
          {
            "session_id": "1773208355389",
            "status": "running",
            "params": {
              "idea": "故事线描述",
              "style": "anime",
              "llm_model": "gemini-3-flash-preview"
            }
          }
          ```
          
          ---
          
          ### 2. 执行阶段
          
          执行指定的生成阶段。
          
          **接口**: `POST /api/project/{session_id}/execute/{stage}`
          
          **路径参数**:
          - `session_id`: 项目会话 ID
          - `stage`: 阶段 ID(见上表)
          
          **请求体**:
          ```json
          {
            "style": "anime"
          }
          ```
          
          > 请求体参数与创建项目相同(可选),会覆盖项目中已有的对应参数。
          
          **响应**: SSE 流式返回,包含以下事件类型:
          - `progress`: 进度更新
          - `stage_complete`: 阶段完成
          - `error`: 执行错误
          
          **示例 - 进度事件**:
          ```json
          {
            "type": "progress",
            "message": "剧本生成: 正在生成...",
            "phase": "剧本生成",
            "step_desc": "正在生成...",
            "percent": 50
          }
          ```
          
          **示例 - 阶段完成事件**:
          ```json
          {
            "type": "stage_complete",
            "stage": "script_generation",
            "status": "stage_completed",
            "requires_intervention": false
          }
          ```
          
          ---
          
          ### 3. 获取项目状态
          
          获取项目的当前状态。
          
          **接口**: `GET /api/project/{session_id}/status`
          
          **响应示例**:
          ```json
          {
            "session_id": "1773208355389",
            "current_stage": "script_generation",
            "status": "running",
            "error": null,
            "stages_completed": [],
            "artifacts": {},
            "meta": {
              "idea": "故事线描述",
              "style": "anime"
            },
            "updated_at": 1773208355389
          }
          ```
          
          ---
          
          ### 4. 获取阶段产物
          
          获取指定阶段的产物数据。
          
          **接口**: `GET /api/project/{session_id}/artifact/{stage}`
          
          **响应示例** (剧本生成阶段):
          ```json
          {
            "stage": "script_generation",
            "artifact": {
              "title": "影弑",
              "logline": "...",
              "characters": [...],
              "settings": [...],
              "scenes": [...]
            }
          }
          ```
          
          ---
          
          ### 5. 更新阶段产物
          
          更新指定阶段的产物数据(如用户修改提示词、选择版本)。
          
          **接口**: `PATCH /api/project/{session_id}/artifact/{stage}`
          
          请求体格式**因阶段而异**:
          
          #### storyboard — 修改分镜(时长/剧情/视觉提示词)
          ```json
          {
            "shots": [
              {"shot_id": "shot_001_01", "duration": 5, "plot": "新剧情描述", "visual_prompt": "新视觉提示词"}
            ]
          }
          ```
          
          #### reference_generation — 修改视觉提示词
          ```json
          {
            "shots": [
              {"shot_id": "shot_001_01", "visual_prompt": "新提示词"}
            ]
          }
          ```
          
          #### reference_generation — 选择参考图版本
          ```json
          {
            "shot_001_01": "code/result/image/xxx/shot_001_01_v2.jpg"
          }
          ```
          
          #### video_generation — 修改片段描述/时长
          ```json
          {
            "shot_001_01": {"description": "新描述", "duration": 5}
          }
          ```
          
          #### video_generation — 选择视频版本
          ```json
          {
            "shot_001_01": "code/result/video/xxx/shot_001_01_v2.mp4"
          }
          ```
          
          **响应**: `{"status": "ok"}`
          
          ---
          
          ### 6. 干预阶段
          
          对已完成的阶段进行修改并重新执行(重新生成部分产物)。
          
          **接口**: `POST /api/project/{session_id}/intervene`
          
          **请求体**:
          ```json
          {
            "stage": "reference_generation",
            "modifications": {
              "regenerate_scenes": ["shot_001_01", "shot_001_02"]
            }
          }
          ```
          
          - `stage`:要干预的阶段
          - `modifications`:修改内容,目前支持 `regenerate_scenes`(要重新生成的镜头 ID 列表)
          
          **响应**: SSE 流式返回,包含以下事件类型:
          - `progress`: 进度更新
          - `stage_complete`: 阶段完成
          - `error`: 执行错误
          
          ---
          
          ### 7. 确认并继续
          
          确认当前阶段的修改,进入下一阶段。
          
          **接口**: `POST /api/project/{session_id}/continue`
          
          **响应示例**:
          ```json
          {
            "status": "ready",
            "next_stage": "character_design"
          }
          ```
          
          ---
          
          ### 8. 停止执行
          
          停止当前正在执行的阶段。
          
          **接口**: `POST /api/project/{session_id}/stop`
          
          **响应示例**:
          ```json
          {
            "status": "stopped"
          }
          ```
          
          ---
          
          ### 9. 获取会话列表
          
          获取所有历史项目列表。
          
          **接口**: `GET /api/sessions`
          
          **响应示例**:
          ```json
          {
            "sessions": [
              {
                "id": "1773208355389",
                "idea": "故事线",
                "style": "anime",
                "date": 1773208355389,
                "stages": ["script_generation", "character_design"]
              }
            ]
          }
          ```
          
          ---
          
          ### 10. 获取阶段列表
          
          获取所有可用阶段列表。
          
          **接口**: `GET /api/stages`
          
          **响应示例**:
          ```json
          {
            "stages": [
              {"id": "script_generation", "name": "剧本生成", "order": 1, "description": "将灵感转化为结构化剧本"},
              {"id": "character_design", "name": "角色/场景设计", "order": 2},
              {"id": "storyboard", "name": "分镜设计", "order": 3},
              {"id": "reference_generation", "name": "参考图生成", "order": 4},
              {"id": "video_generation", "name": "视频生成", "order": 5},
              {"id": "post_production", "name": "后期剪辑", "order": 6}
            ]
          }
          ```
          
          ---
          
          ## 调用示例
          
          ### 完整流程示例
          
          ```bash
          # 1. 创建项目
          SESSION_ID=$(curl -s -X POST http://localhost:8000/api/project/start \
            -H "Content-Type: application/json" \
            -d '{
              "idea": "失忆女刺客刺杀目标时恢复记忆,与爱人联手复仇师兄",
              "style": "anime"
            }' | jq -r '.session_id')
          
          echo "Session ID: $SESSION_ID"
          
          # 2. 执行第一阶段(剧本生成)- 监听 SSE
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/script_generation" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          
          # 3. 获取剧本产物
          curl -s "http://localhost:8000/api/project/${SESSION_ID}/artifact/script_generation"
          
          # 4. 确认并继续到下一阶段
          curl -s -X POST "http://localhost:8000/api/project/${SESSION_ID}/continue"
          
          # 5. 执行第二阶段(角色设计)
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/character_design" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          
          # 6. 确认并继续
          curl -s -X POST "http://localhost:8000/api/project/${SESSION_ID}/continue"
          
          # 7. 执行第三阶段(分镜设计)
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/storyboard" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          
          # 8. 确认并继续
          curl -s -X POST "http://localhost:8000/api/project/${SESSION_ID}/continue"
          
          # 9. 执行第四阶段(参考图生成)
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/reference_generation" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          
          # 10. 确认并继续
          curl -s -X POST "http://localhost:8000/api/project/${SESSION_ID}/continue"
          
          # 11. 执行第五阶段(视频生成)
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/video_generation" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          
          # 12. 确认并继续
          curl -s -X POST "http://localhost:8000/api/project/${SESSION_ID}/continue"
          
          # 13. 执行第六阶段(后期剪辑)
          curl -X POST "http://localhost:8000/api/project/${SESSION_ID}/execute/post_production" \
            -H "Content-Type: application/json" \
            -d '{"style": "anime"}'
          ```
          
          ### 使用 jq 简化
          
          ```bash
          # 创建项目并提取 session_id
          SESSION_ID=$(curl -s -X POST http://localhost:8000/api/project/start \
            -H "Content-Type: application/json" \
            -d '{"idea": "故事线", "style": "anime"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['session_id'])")
          
          # 查看项目状态
          curl -s "http://localhost:8000/api/project/${SESSION_ID}/status" | python3 -m json.tool
          ```
          
          ---
          
          ## 错误处理
          
          ### HTTP 状态码
          
          | 状态码 | 说明 |
          |--------|------|
          | 200 | 请求成功 |
          | 400 | 请求参数错误 |
          | 404 | 资源不存在 |
          | 500 | 服务器内部错误 |
          
          ### 错误响应格式
          
          ```json
          {
            "detail": "错误描述"
          }
          ```
          
          ### 常见错误
          
          | 错误 | 说明 |
          |------|------|
          | Session not found | 指定的 session_id 不存在 |
          | Artifact for stage 'xxx' not found | 指定阶段的产物不存在 |
          | 阶段执行失败 | 阶段执行过程中发生错误 |
          
          ---
          
          ## 前端与 API 共享
          
          API 和前端共享同一个 session 存储:
          - Session 文件位置: `backend/code/data/sessions/{session_id}.json`
          - 产物位置: `backend/code/result/`
          
          这意味着:
          1. API 创建的项目可以在前端查看和继续
          2. 前端创建的项目可以继续使用 API 操作
          3. 两种方式可以随时切换
          
        • session_format.md 6.3 KB
          # Session 数据格式
          
          Session 数据存储在 `code/data/sessions/{session_id}.json`,包含完整的项目会话信息。
          
          ## 根级字段
          
          | 字段 | 类型 | 说明 |
          |------|------|------|
          | `session_id` | string | 会话唯一 ID |
          | `idea` | string | 用户原始创意/故事想法 |
          | `style` | string | 视觉风格,如 `realistic`、`anime` |
          | `video_ratio` | string | 视频比例,如 `16:9`、`9:16` |
          | `expand_idea` | bool | 是否扩展创意 |
          | `llm_model` | string | LLM 模型名称 |
          | `vlm_model` | string | VLM 模型名称 |
          | `image_t2i_model` | string | 文生图模型 |
          | `image_it2i_model` | string | 图生图模型 |
          | `video_model` | string | 视频生成模型 |
          | `enable_concurrency` | string/bool | 是否启用并发 |
          | `web_search` | bool | 是否启用网络搜索 |
          | `created_at` | float | 创建时间戳 |
          | `updated_at` | float | 更新时间戳 |
          | `current_stage` | string | 当前阶段 |
          | `status` | string | 会话状态 |
          | `stages_completed` | string[] | 已完成的阶段列表 |
          | `error` | null / string | 错误信息 |
          | `artifacts` | object | 各阶段产物(见下文) |
          
          ## Status 状态值
          
          | 状态 | 说明 |
          |------|------|
          | `idle` | 初始状态 |
          | `running` | 运行中 |
          | `waiting` | 等待用户确认 |
          | `stage_completed` | 阶段完成 |
          | `session_completed` | 会话完成 |
          
          ## Stages Completed 阶段列表
          
          按顺序完成的所有阶段:
          ```
          script_generation → character_design → storyboard → reference_generation → video_generation → post_production
          ```
          
          ---
          
          ## Artifacts 各阶段产物
          
          ### 1. script_generation
          
          ```json
          {
            "session_id": "...",
            "title": "守",
            "logline": "一句话概括故事",
            "genre": ["奇幻", "温情"],
            "target_duration": 180,
            "synopsis": "详细故事梗概",
            "characters": [
              {
                "name": "角色名",
                "character_id": "char_xxx",
                "description": "角色描述",
                "personality": ["特质1", "特质2"],
                "motivation": "角色动机",
                "arc_description": "角色弧线描述",
                "role": "主角/配角/反派",
                "age": "年龄",
                "species": "物种(人类/狗等)"
              }
            ],
            "settings": [
              {
                "name": "场景名",
                "setting_id": "set_xxx",
                "description": "场景描述"
              }
            ],
            "scenes": [
              {
                "scene_number": 1,
                "location": "场景位置",
                "characters": ["角色名"],
                "plot": "场景剧情描述"
              }
            ],
            "overall_style": "realistic",
            "mood": "整体情绪基调",
            "project_id": "proj_xxx",
            "version": 1,
            "metadata": {
              "generation_model": "模型名",
              "generation_prompt": "原始生成提示词",
              "mode": "movie/micro"
            },
            "logline_data": {
              "logline": "完整logline",
              "who": "主角是谁",
              "goal": "主角目标",
              "conflict": "核心冲突",
              "twist": "反转点",
              "theme": "主题"
            }
          }
          ```
          
          ### 2. character_design
          
          ```json
          {
            "session_id": "...",
            "characters": [
              {
                "id": "char_xxx",
                "name": "角色名",
                "description": "角色外观描述",
                "selected": "选中的角色图路径",
                "versions": ["所有版本路径"]
              }
            ],
            "settings": [
              {
                "id": "set_xxx",
                "name": "场景名",
                "description": "场景描述",
                "selected": "选中的场景图路径",
                "versions": ["所有版本路径"]
              }
            ]
          }
          ```
          
          ### 3. storyboard
          
          ```json
          {
            "session_id": "...",
            "shots": [
              {
                "shot_number": 1,
                "duration": 5,
                "characters": ["角色名"],
                "location": "场景位置",
                "plot": "镜头剧情描述",
                "visual_prompt": "视觉生成提示词",
                "shot_id": "shot_001_01",
                "scene_number": 1,
                "act": 1
              }
            ],
            "user_modified": true,
            "new_shot_ids": []
          }
          ```
          
          > **注意**:storyboard 结构是 `{shots: [...]}`,**没有** `payload` 包装层。
          
          ### 4. reference_generation
          
          ```json
          {
            "session_id": "...",
            "scenes": [
              {
                "id": "shot_001_01",
                "name": "场景1-镜头1",
                "index": 1,
                "description": "视觉生成提示词(由 storyboard.visual_prompt 同步过来)",
                "selected": "用户选中的参考图路径",
                "versions": ["所有版本路径"],
                "status": "done/pending/failed"
              }
            ],
            "shots": [
              {
                "shot_id": "shot_001_01",
                "video_prompt": "视频生成提示词"
              }
            ]
          }
          ```
          
          > **注意**:
          > - `scenes[].id` = `storyboard.shots[].shot_id`,用于跨阶段关联
          > - `shots[]` 是给视频生成用的提示词,格式为 `{shot_id, video_prompt}`
          
          ### 5. video_generation
          
          ```json
          {
            "session_id": "...",
            "clips": [
              {
                "id": "shot_001_01",
                "name": "场景1-镜头1",
                "index": 1,
                "description": "视频片段描述(由 storyboard.plot 同步)",
                "duration": 5,
                "selected": "用户选中的视频路径",
                "versions": ["所有版本路径"],
                "status": "done/pending/failed"
              }
            ]
          }
          ```
          
          > **注意**:`clips[].id` = `storyboard.shots[].shot_id`
          
          ### 6. post_production
          
          ```json
          {
            "session_id": "...",
            "final_video": "code/result/video/xxx_final.mp4"
          }
          ```
          
          ---
          
          ## 跨阶段数据同步关系
          
          ```
          storyboard (修改 plot/visual_prompt/duration)
              ↓
          video_generation.clips (description/duration)
              ↑ (修改 description/duration)
              ↑
          video_generation (修改 clips.description/clips.duration)
              ↓ (修改后同步回 storyboard)
          storyboard (修改后同步回 storyboard.shots.plot)
          
          reference_generation (修改 scenes.description)
              ↓
          storyboard.shots.visual_prompt (由 reference_generation 同步)
              ↑ (修改后同步)
              ↑
          reference_generation (修改 scenes.description)
          ```
          
          ---
          
          ## PATCH /artifact/{stage} 请求格式
          
          ### storyboard
          ```json
          {
            "shots": [
              {"shot_id": "shot_001_01", "duration": 5, "plot": "新描述", "visual_prompt": "新提示词"}
            ]
          }
          ```
          
          ### reference_generation(修改视觉提示词)
          ```json
          {
            "shots": [
              {"shot_id": "shot_001_01", "visual_prompt": "新提示词"}
            ]
          }
          ```
          
          ### reference_generation(选择图片版本)
          ```json
          {
            "shot_001_01": "code/result/image/xxx/shot_001_01_v2.jpg"
          }
          ```
          
          ### video_generation(修改片段描述/时长)
          ```json
          {
            "shot_001_01": {"description": "新描述", "duration": 5}
          }
          ```
          
          ### video_generation(选择视频版本)
          ```json
          {
            "shot_001_01": "code/result/video/xxx/shot_001_01_v2.mp4"
          }
          ```
          
      • prompts
        • character
          • character_styles.txt 9.5 KB
            # Character Reference Sheet Prompt Templates
            # 角色参考图提示词模板(按风格分类)
            # 共用 4 视图结构,风格特定视觉描述
            # 使用方式:根据 {style} 变量选择对应的模板
            
            # ============================================================
            # 共用 4 视图结构定义 (所有风格通用)
            # ============================================================
            # Four-view structure template (shared across all styles):
            # 1) front-facing portrait (head and shoulders)
            # 2) front-facing full body (standing in natural pose)
            # 3) side-view full body (standing in natural pose)
            # 4) back-view full body (standing in natural pose)
            # ALL views show identical character with perfectly consistent appearance, outfit, and lighting
            
            # ============================================================
            # 共用面部要求 (所有风格通用)
            # ============================================================
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin texture.
            
            # ============================================================
            # realistic - 写实摄影风格
            # ============================================================
            [realistic]
            Photorealistic character reference sheet, actual photograph style, pure white background.
            Four views of the same real person arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (standing in natural resting pose),
            3) side-view full body (natural resting pose),
            4) back-view full body (natural resting pose).
            ALL views show identical character with perfectly consistent appearance, outfit, and lighting.
            Portrait shows head to waist; others show complete body head to feet.
            Ultra high quality photography, 8k resolution, sharp focus, 85mm lens, f/2.8, soft studio lighting, even illumination.
            Realistic skin texture with subtle imperfections, fabric has natural folds and highlights.
            NO text, NO labels, NO annotations, NO captions, NO watermarks on image.
            NOT an illustration, NOT a drawing, NOT anime style.
            Character: {name} — {desc}
            
            # ============================================================
            # anime - 动漫风格
            # ============================================================
            [anime]
            Anime character reference sheet, clean vector art style, white background.
            Four views of the same anime character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (standing in natural pose),
            3) side-view full body (natural pose),
            4) back-view full body (natural pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, outfit, hair, and facial features.
            Portrait shows head to waist; others show complete body head to feet.
            Clean anime linework, vibrant colors, cel-shaded rendering style.
            High quality anime illustration, masterpiece, crisp details.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO watermarks on image.
            Character: {name} — {desc}
            
            # ============================================================
            # comic-book - 漫画风格
            # ============================================================
            [comic-book]
            Comic book character reference sheet, comic panel art style, white background.
            Four views of the same comic character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (heroic pose),
            3) side-view full body (action pose),
            4) back-view full body (standing pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, costume, and signature features.
            Portrait shows head to waist; others show complete body head to feet.
            Bold ink lines, comic shading (hatching/cross-hatching), dynamic comic book art style.
            High quality comic illustration, vibrant ink colors, dramatic lighting.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO speech bubbles on image.
            Character: {name} — {desc}
            
            # ============================================================
            # 3d-disney - 迪士尼3D风格
            # ============================================================
            [3d-disney]
            3D Disney-style character reference sheet, CGI animated movie style, pure white background.
            Four views of the same 3D animated character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (friendly pose),
            3) side-view full body (character profile),
            4) back-view full body (standing pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, costume, and features.
            Portrait shows head to waist; others show complete body head to feet.
            Disney Pixar CGI quality, toon shader rendering, smooth 3D geometry, clean textures.
            High quality 3D render, soft shadows, cinematic lighting, feature film quality.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO watermarks on image.
            Character: {name} — {desc}
            
            # ============================================================
            # watercolor - 水彩风格
            # ============================================================
            [watercolor]
            Watercolor character reference sheet, painterly art style, white background.
            Four views of the same character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (elegant pose),
            3) side-view full body (graceful pose),
            4) back-view full body (standing pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, outfit, and features.
            Portrait shows head to waist; others show complete body head to feet.
            Soft watercolor painting texture, flowing color washes, delicate brush strokes, translucent layers.
            High quality watercolor illustration, artistic masterpiece, organic color blending.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO watermarks on image.
            Character: {name} — {desc}
            
            # ============================================================
            # oil-painting - 油画风格
            # ============================================================
            [oil-painting]
            Oil painting character reference sheet, classical fine art style, white background.
            Four views of the same character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (noble pose),
            3) side-view full body (aristocratic pose),
            4) back-view full body (standing pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, attire, and features.
            Portrait shows head to waist; others show complete body head to feet.
            Rich oil paint texture, impasto brushwork, classical portraiture technique, deep colors.
            High quality oil painting, museum-quality masterpiece, dramatic chiaroscuro lighting.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO watermarks on image.
            Character: {name} — {desc}
            
            # ============================================================
            # cyberpunk - 赛博朋克风格
            # ============================================================
            [cyberpunk]
            Cyberpunk character reference sheet, futuristic sci-fi style, white background.
            Four views of the same cybernetic character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders, showing cybernetics),
            2) front-facing full body (tech-enhanced pose),
            3) side-view full body (cyberpunk stance),
            4) back-view full body (with tech gear visible).
            ALL views show IDENTICAL character with perfectly consistent appearance, cybernetic augmentations, and outfit.
            Portrait shows head to waist; others show complete body head to feet.
            Neon lighting, chrome/metallic surfaces, holographic elements, RGB accents.
            High quality cyberpunk illustration, sci-fi concept art, futuristic tech aesthetic.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO HUD elements on image.
            Character: {name} — {desc}
            
            # ============================================================
            # chinese-ink - 中国水墨风格
            # ============================================================
            [chinese-ink]
            Chinese ink painting (水墨画) character reference sheet, traditional East Asian art style, white background.
            Four views of the same character arranged in a single row from left to right:
            1) front-facing portrait (head and shoulders),
            2) front-facing full body (classical pose),
            3) side-view full body (traditional stance),
            4) back-view full body (standing pose).
            ALL views show IDENTICAL character with perfectly consistent appearance, clothing, and features.
            Portrait shows head to waist; others show complete body head to feet.
            Ink wash (墨色) rendering, Gongbi/Boneless brush technique, subtle ink gradients, rice paper texture.
            High quality Chinese ink painting, traditional scroll art aesthetics, elegant brushwork.
            DO NOT describe or specify eye color. Leave eye color unspecified.
            Smooth, clear face, no blemishes, no spots, perfect skin.
            NO text, NO labels, NO annotations, NO captions, NO seals/stamps on image.
            Character: {name} — {desc}
            
          • character_zh.txt 647 B
            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}
            
          • eval_character_zh.txt 1.8 KB
            你是一个专业的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 返回空数组 []。
            
          • eval_select_best_zh.txt 1.5 KB
            你是一个专业的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": "选择这张的原因"
            }}
        • logline
          • check_en.txt 190 B
            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.
            
          • check_zh.txt 268 B
            请判断以下文本是否包含足够的叙事要素,能够从中总结出一个完整故事的 Logline。
            (完整 Logline 需涵盖:主角、目标、核心障碍、反转和主题)
            
            文本:{idea}
            
            只回答 Yes 或 No,不要添加任何其他内容。
            
          • extract_zh.txt 413 B
            你是资深制片人。请从以下文本中总结提取 Logline 及故事五要素。
            Logline 请使用"如果…会怎样"的句式。
            
            文本:{idea}
            
            请严格按如下 JSON 格式输出(直接输出纯JSON,不要用```包裹,不要添加任何其他文字):
            {{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}
            
          • generate_en.txt 508 B
            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"}}]
            
          • generate_zh.txt 671 B
            你是资深制片人。请将以下灵感扩展为 3 个不同的 Logline(故事大纲)。
            要求:
            - 明确主角(Who)、目标(Goal)、核心障碍(Conflict)和反转(Twist)
            - 确定故事的 Theme(潜在主题)
            - Logline 按照"如果…会怎样"的句式描述
            - 3 个 Logline 应风格各异、各有侧重
            
            输入内容:{idea}
            
            请严格按如下 JSON 数组格式输出(直接输出纯JSON,不要用```包裹,不要添加任何其他文字):
            [{{"logline":"如果...会怎样","who":"主角描述","goal":"目标","conflict":"核心障碍","twist":"反转","theme":"潜在主题"}}]
            输出恰好 3 个元素的 JSON 数组。
            
        • reference
          • eval_first_frame_zh.txt 2.4 KB
            你是一个专业的AI图像质量评估师。请根据以下信息评估生成的视频首帧参考图。
            
            分镜剧情:
            {plot}
            
            分镜视觉描述:
            {visual_prompt}
            
            角色描述:
            {character_description}
            
            场景描述:
            {setting_description}
            
            请严格按照以下标准评估并给出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分
            - 出现文字/水印:-1分
            
            【最终评分规则】
            - 8分及以上:可接受(is_acceptable: true)
            - 7分及以下:需要重新生成(is_acceptable: false)
            
            重要:如果发现问题需要重新生成,请同时提供一个优化后的提示词,用于改善图片质量。优化提示词时:
            - **不要改变角色的外貌描述**(如发型、服装、面孔等)
            - 可以让光线、构图、氛围、姿态等描述更清晰具体
            - 保持与角色外观相关的描述不变
            
            请输出纯JSON格式(不要用```包裹):
            {{
              "score": 1-10,
              "issues": ["问题1", "问题2", ...],
              "is_acceptable": true/false,
              "suggestion": "修改建议(如果有问题)",
              "suggested_prompt": "优化后的提示词(如果需要重新生成),如果不需重新生成则为空字符串"
            }}
            
            如果图片可接受,issues 返回空数组 [],suggested_prompt 为空字符串。
            
          • eval_select_best_zh.txt 1.7 KB
            你是一个专业的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} 的整数,表示你选择的图片编号。
          • first_frame_en.txt 624 B
            You are a professional AI image prompt engineer.
            I need to generate a **first-frame reference image** for a {duration}-second video clip.
            
            Shot plot description:
            {plot}
            
            Shot visual description:
            {visual_prompt}
            
            Requirements:
            1. Output a pure English image generation prompt for an AI image model
            2. Describe the **first frame** (static image) of the video, capturing the starting moment
            3. Must include: character appearance/pose/expression, environment, lighting/color tone, composition/shot type
            4. For {duration}s video: {duration_hint}
            5. 80-150 English words
            6. Output ONLY the prompt text, no explanations or markers
            
          • first_frame_zh.txt 600 B
            你是一位专业的AI绘画提示词工程师。
            我需要为一段{duration}秒的视频生成**首帧参考图**。
            
            分镜剧情描述:
            {plot}
            
            分镜视觉描述:
            {visual_prompt}
            
            要求:
            1. 输出一段纯英文的图像生成提示词(image prompt),用于AI绘图模型
            2. 描述的是视频**第一帧**的静态画面,要体现动作的起始瞬间
            3. 必须包含:角色外貌/姿态/表情、场景环境、光线/色调、构图/景别
            4. {duration}秒视频:{duration_hint}
            5. 字数控制在80-150个英文单词
            6. 只输出提示词文本,不要任何解释或标记
            
        • script
          • beat_sheet_en.txt 817 B
            You are a Hollywood screenwriting mentor. Break down the following storyline into a 4-act structure using the Save the Cat! Beat Sheet.
            
            Must include these four key beats:
            Act 1 - Inciting Incident: Establish the world and protagonist's status quo, then a disruptive event
            Act 2 - Break into Two: Protagonist embarks on journey, faces challenges, B-story unfolds
            Act 3 - Dark Night of the Soul: Protagonist suffers the biggest blow, hits rock bottom
            Act 4 - Finale: Protagonist gains epiphany, final confrontation, story resolves
            
            Ensure: tight logic, escalating conflict, clear turning points in each act.
            
            Storyline:
            {draft}
            
            Story style: {style}
            
            Output the 4-act beat sheet directly. Start each act with '[Act X - Name]' heading. Detail the plot points, character development and key turning points for each act.
            
          • beat_sheet_zh.txt 863 B
            你是好莱坞编剧导师。请根据以下故事线,使用 Save the Cat! 节拍表将故事拆解为四幕结构。
            
            必须包含以下四个关键节拍(起承转合):
            第一幕 - 激励事件(Inciting Incident):建立世界观和主角现状,发生打破平衡的事件
            第二幕 - 进入新世界(Break into Two):主角踏上旅程,面对挑战和考验,副线展开
            第三幕 - 灵魂黑夜(Dark Night of the Soul):主角遭受最大打击,陷入低谷
            第四幕 - 高潮决战(Finale):主角获得顿悟,最终决战,故事收束
            
            请确保:逻辑严密、冲突逐步升级、每一幕有清晰的转折点。
            
            故事线:
            {draft}
            
            故事风格:{style}
            
            请直接输出四幕节拍表,每一幕用"【第X幕 - 名称】"标记开始,详细描述该幕的情节要点、角色发展和关键转折。
            
          • micro_beat_sheet_en.txt 500 B
            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.
            
          • micro_beat_sheet_zh.txt 490 B
            你是微电影编剧专家。请根据以下故事线,将故事压缩为一个紧凑的单幕剧情概要。
            
            要求:
            - 叙事节奏快,情节紧凑精炼,没有拖沓的铺垫
            - 全部内容在一幕内完成,不分幕
            - 保留核心冲突和情感转折,去掉多余叙事
            - 场景数量控制在 3-6 场
            - 适合 1-3 分钟的微电影
            
            故事线:
            {draft}
            
            故事风格:{style}
            
            请直接输出紧凑的剧情概要,描述场景发展、核心动作和情感转折。
            
          • micro_step_outline_zh.txt 1.2 KB
            你是微电影编剧专家。请根据以下故事线,将故事压缩为一个紧凑的单幕剧情概要。
            
            {draft}
            
            故事风格:{style}
            
            请将其转化为详细的分场大纲。
            
            格式要求(每场一段):
            [场次编号]. [地点(室内/室外)] - [日/夜]
            [核心动作]:详细描述该场戏发生了什么,包含完整的对话、动作和表情描写。
            [情感转变]:描述主角在本场戏开始到结束的情绪变化(+/-)。
            [出场角色]:列出本场出现的所有角色。
            
            要求:
            - 每个角色都要有详细的外貌描写(发型、眼睛颜色、体型、服装颜色和款式等视觉特征)
            - **重要:外貌描写必须是静态的、贯穿全剧保持一致的特征,不要随剧情发展而变化**
              - 例如:不要写"他穿着破碎的衣服"这种随情节变化的描写
              - 应该写:"他身穿蓝色衬衫,黑色长裤"这种固定的服装描述
            - 每个场景都要有详细的环境、布局、色彩与氛围描写
            - 分场数量根据情节长度和节奏自行决定,确保叙事节奏合理
            - 对话用双引号标注,对话内容真实生动
            - 场次编号从 1 开始递增
            
            请直接输出分场大纲。
            
          • step_outline_en.txt 1.2 KB
            You are a scene director. Here is the complete 4-act beat sheet:
            
            {beat_sheet}
            
            Convert Act {act_number} ({act_name}) into a detailed step outline.
            
            Format for each scene:
            [Scene number]. [Location (Indoor/Outdoor)] - [Day/Night]
            [Core Action]: Detailed description of what happens, including dialogue, actions and expressions.
            [Emotional Shift]: Describe the protagonist's emotional change from start to end (+/-).
            [Characters Present]: List all characters appearing in this scene.
            
            Requirements:
            - Each character needs detailed physical descriptions (hair, eyes, build, clothing details)
            - **IMPORTANT: Physical descriptions must be STATIC and consistent throughout the entire story - do NOT change with plot development**
              - For example: do NOT write "wearing torn clothes" which changes with the plot
              - Instead write: "wearing a blue shirt and black pants" which is a fixed clothing description
            - Each scene needs detailed environment, layout, color and atmosphere descriptions
            - Number of scenes based on plot length and pacing
            - Dialogue marked with double quotes, natural and vivid
            - Scene numbers start from {scene_start} and increment
            - Story style: {style}
            
            Output the step outline directly, without act headings or extra notes.
            
          • step_outline_zh.txt 1.2 KB
            你是分场导演。以下是完整的四幕节拍表:
            
            {beat_sheet}
            
            请将第{act_number}幕({act_name})转化为详细的分场大纲。
            
            格式要求(每场一段):
            [场次编号]. [地点(室内/室外)] - [日/夜]
            [核心动作]:详细描述该场戏发生了什么,包含完整的对话、动作和表情描写。
            [情感转变]:描述主角在本场戏开始到结束的情绪变化(+/-)。
            [出场角色]:列出本场出现的所有角色。
            
            要求:
            - 每个角色都要有详细的外貌描写(发型、眼睛颜色、体型、服装颜色和款式等视觉特征)
            - **重要:外貌描写必须是静态的、贯穿全剧保持一致的特征,不要随剧情发展而变化**
              - 例如:不要写"他穿着破碎的衣服"这种随情节变化的描写
              - 应该写:"他身穿蓝色衬衫,黑色长裤"这种固定的服装描述
            - 每个场景都要有详细的环境、布局、色彩与氛围描写
            - 分场数量根据情节长度和节奏自行决定,确保叙事节奏合理
            - 对话用双引号标注,对话内容真实生动
            - 场次编号从 {scene_start} 开始递增
            - 故事风格:{style}
            
            请直接输出分场大纲,不要添加幕次标题或额外说明。
            
          • validate_characters_zh.txt 1.1 KB
            你是专业的剧本分析师和AI绘画提示词工程师。请审查以下角色列表,识别需要修改或删除的角色。
            
            角色列表:
            {characters_json}
            
            审查标准:
            1. 群体角色(如"邻居们"、"群众"、"乘客们"、"学生们")应该删除,改为在场景描述中用一句话表示(如"车厢里坐满了乘客")
            2. 角色描述必须包含足够的视觉细节用于AI图像生成:
               - 外貌:发型、眼睛颜色、面部特征、体型
               - 服装:颜色、款式、材质配饰:帽子
               - 、眼镜、珠宝等
            3. 避免抽象比喻(如"看起来很凶"),要具体描述
            
            请输出纯JSON(不要用```包裹),格式如下:
            {{
              "characters_to_remove": ["需要删除的角色名1", "角色名2", ...],
              "characters_to_fix": [
                {{
                  "name": "角色名",
                  "issue": "问题描述",
                  "fix_suggestion": "修改建议"
                }}
              ]
            }}
            
            如果没有需要删除的角色,characters_to_remove 返回空数组 []。
            如果没有需要修改的角色,characters_to_fix 返回空数组 []。
            只输出纯JSON,不要添加任何其他文字。
            
          • validate_settings_zh.txt 859 B
            你是专业的剧本分析师和AI绘画提示词工程师。请审查以下场景列表,识别需要修改的场景。
            
            场景列表:
            {settings_json}
            
            审查标准:
            1. 场景描述必须包含足够的视觉细节用于AI图像生成:
               - 空间布局:室内/室外、主要物体位置
               - 光线:自然光/人工光、光源方向、亮度
               - 色彩:主色调、氛围
               - 构图:景别、视角
            2. 场景应该是具体的、可视觉化的地点
            3. 避免过于笼统的描述(如"某个地方")
            
            请输出纯JSON(不要用```包裹),格式如下:
            {{
              "settings_to_fix": [
                {{
                  "name": "场景名",
                  "issue": "问题描述",
                  "fix_suggestion": "修改建议"
                }}
              ]
            }}
            
            如果没有需要修改的场景,settings_to_fix 返回空数组 []。
            只输出纯JSON,不要添加任何其他文字。
            
        • setting
          • eval_select_best_zh.txt 1.4 KB
            你是一个专业的AI图像质量评估师。请从以下{num_images}张场景设计图中选择最好的一张。
            
            场景名称:{setting_name}
            场景描述:{setting_description}
            
            图片列表:
            {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": "选择这张的原因"
            }}
          • eval_setting_zh.txt 1.6 KB
            你是一个专业的AI图像质量评估师。请根据以下信息评估生成的场景参考图。
            
            场景描述:
            {setting_description}
            
            请严格按照以下标准评估并给出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分
            
            【最终评分规则】
            - 8分及以上:可接受(is_acceptable: true)
            - 7分及以下:需要重新生成(is_acceptable: false)
            
            请输出纯JSON格式(不要用```包裹):
            {{
              "score": 1-10,
              "issues": ["问题1", "问题2", ...],
              "is_acceptable": true/false,
              "suggestion": "修改建议(如果有问题)"
            }}
            
            如果图片可接受,issues 返回空数组 []。
            
          • setting_styles.txt 5.2 KB
            # Setting Scene Prompt Templates
            # 场景背景图提示词模板(按风格分类)
            # 使用方式:根据 {style} 变量选择对应的模板
            
            # ============================================================
            # realistic - 写实摄影风格
            # ============================================================
            [realistic]
            Photorealistic landscape photography, actual photograph style.
            Wide establishing shot, eye-level view, panoramic perspective.
            Natural lighting, golden hour or blue hour atmosphere, realistic textures.
            No characters, no people, no animals in frame.
            Cinematic composition, movie scene quality, 8K resolution.
            High quality photography, sharp focus, wide angle lens, f/8, professional camera.
            True-to-life environment, detailed textures, atmospheric depth.
            NO text, NO labels, NO watermarks on image.
            NOT an illustration, NOT a drawing, NOT anime style.
            Scene: {name} — {desc}
            
            # ============================================================
            # anime - 动漫风格
            # ============================================================
            [anime]
            Anime style landscape illustration, clean vector art aesthetic.
            Wide establishing shot, eye-level view, panoramic perspective.
            Vibrant colors, cel-shaded rendering, anime background art style.
            No characters, no people, no animals in frame.
            Cinematic composition, high quality anime illustration, masterpiece.
            Crisp details, atmospheric background, anime movie quality.
            NO text, NO labels, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # comic-book - 漫画风格
            # ============================================================
            [comic-book]
            Comic book style landscape, comic panel art aesthetic.
            Wide establishing shot, eye-level view, dynamic perspective.
            Bold ink lines, comic shading (hatching/cross-hatching), vibrant colors.
            No characters, no people, no animals in frame.
            Comic book art style, dramatic lighting, comic panel composition.
            High quality comic illustration, vibrant ink colors, graphic novel aesthetic.
            NO text, NO speech bubbles, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # 3d-disney - 迪士尼3D风格
            # ============================================================
            [3d-disney]
            3D CGI animated movie style landscape, Disney Pixar aesthetic.
            Wide establishing shot, eye-level view, smooth 3D geometry.
            Toon shader rendering, soft shadows, cinematic lighting, feature film quality.
            No characters, no people, no animals in frame.
            Disney Pixar CGI quality, clean 3D environment, volumetric lighting.
            High quality 3D render, animated movie background, toon shading.
            NO text, NO labels, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # watercolor - 水彩风格
            # ============================================================
            [watercolor]
            Watercolor painting style landscape, painterly art aesthetic.
            Wide establishing shot, eye-level view, soft perspective.
            Flowing color washes, delicate brush strokes, translucent layers.
            No characters, no people, no animals in frame.
            Watercolor art style, organic color blending, artistic masterpiece.
            Soft texture, artistic render, traditional painting aesthetic.
            NO text, NO labels, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # oil-painting - 油画风格
            # ============================================================
            [oil-painting]
            Oil painting style landscape, classical fine art aesthetic.
            Wide establishing shot, eye-level view, classical composition.
            Rich oil paint texture, impasto brushwork, classical portraiture technique.
            No characters, no people, no animals in frame.
            Oil painting art style, dramatic chiaroscuro lighting, museum-quality masterpiece.
            Deep colors, detailed brushwork, traditional canvas texture.
            NO text, NO labels, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # cyberpunk - 赛博朋克风格
            # ============================================================
            [cyberpunk]
            Cyberpunk style landscape, futuristic sci-fi aesthetic.
            Wide establishing shot, eye-level view, dystopian perspective.
            Neon lighting, chrome/metallic surfaces, holographic elements, RGB accents.
            No characters, no people, no animals in frame.
            Cyberpunk cityscape, high-tech environment, volumetric fog.
            High quality cyberpunk illustration, sci-fi concept art, futuristic aesthetic.
            NO text, NO HUD elements, NO watermarks on image.
            Scene: {name} — {desc}
            
            # ============================================================
            # chinese-ink - 中国水墨风格
            # ============================================================
            [chinese-ink]
            Chinese ink painting (水墨画) style landscape, traditional East Asian art aesthetic.
            Wide establishing shot, eye-level view, traditional Chinese composition.
            Ink wash (墨色) rendering, Gongbi/Boneless brush technique, subtle ink gradients.
            No characters, no people, no animals in frame.
            Chinese landscape painting style, rice paper texture, traditional scroll art.
            High quality ink painting, elegant brushwork, classical Chinese aesthetics.
            NO text, NO seals/stamps, NO watermarks on image.
            Scene: {name} — {desc}
          • setting_zh.txt 212 B
            Panoramic landscape, eye-level wide shot, {style} style,
            no characters, no people, no animals,
            cinematic composition, establishing shot,
            high quality, masterpiece, Movie picture quality.
            Scene: {name} — {desc}
            
        • storyboard
          • continue_zh.txt 1.6 KB
            你是一位专业的影视编剧。根据已有的剧情内容,续写接下来的故事情节。
            
            已有剧情信息:
            - 标题:{title}
            - 风格:{style}
            - 角色:{characters}
            - 场景位置:{settings}
            
            当前分镜列表:
            {existing_shots}
            
            请根据以上信息,续写1个新场景(包含多个分镜,总时长40-60秒)。
            
            要求:
            1. 新场景要承接上文剧情自然发展
            2. 分镜时长为 5、10 或 15 秒
            3. 包含4-8个分镜,总时长控制在40-60秒
            4. 每个分镜描述一个独立的画面段落
            5. plot 字段必须按时间顺序描述:角色动作、表情、对话(用引号标注)、背景音效
            6. visual_prompt 字段是适合AI图像生成的视觉描述,包含角色站位、姿态、表情、环境光线,色调、构图
            7. 保持{style}风格
            8. 用角色全名,不要用代词
            9. location 字段必须从上面给出的"场景位置"中选择一个,名称必须完全一致,不能有任何修改或添加
            
            请直接输出JSON数组(不要用```包裹),每个元素格式如下:
            [{{"shot_number":1,"duration":5,"characters":["角色名"],"location":"场景位置","plot":"按时间顺序描述: 角色动作、表情、对话、背景音效,50-100字","visual_prompt":"AI图像生成用的视觉描述,含站位、姿态、表情,光线、构图,80-120字"}}]
            
            注意:
            - duration只能是5、10、15三个值之一
            - 新场景的 scene_number 应该延续上一个场景的编号
            - 新场景应该延续当前幕(act)的内容,或者开启新一幕(如果故事需要)
            - 新场景中出现的人物、场景位置必须与上面给出的完全一致,禁止添加额外的人物或场景位置
            
          • shot_zh.txt 1.2 KB
            你是一位专业的影视分镜师。根据以下场景信息,将该场景拆分为若干分镜(shot)。
            
            要求:
            1. 每个分镜时长为 5、10 或 15 秒(根据内容复杂度选择)
            2. 每个分镜描述一个独立的画面段落(一个动作/一句对话/一个反应)
            3. plot 字段必须按时间顺序描述:角色动作、表情、对话(用引号标注)、背景音效
            4. visual_prompt 字段是适合AI图像生成的视觉描述,包含角色站位、姿态、表情、环境光线、色调、构图
            5. 保持{style}风格
            6. 用角色全名,不要用代词
            
            场景编号:{scene_number}
            场景位置:{location}
            出场角色:{characters}
            场景剧情:{plot}
            
            角色外貌参考:
            {char_descriptions}
            
            场景环境参考:
            {setting_description}
            
            请直接输出JSON数组(不要用```包裹),每个元素格式如下:
            [{{"shot_number":1,"duration":5,"characters":["角色名"],"location":"场景位置","plot":"按时间顺序描述: 角色动作、表情、对话、背景音效,50-100字","visual_prompt":"AI图像生成用的视觉描述,含站位、姿态、表情、光线、构图,80-120字"}}]
            
            注意:duration只能是5、10、15三个值之一。
            
        • video
          • enhance.txt 1.5 KB
            # 视频生成提示词优化模板
            # 在基础提示词前后添加这些内容来提升生成质量
            # 使用方法:从 prompts/loader import load_prompt('video', 'enhance', 'zh')
            
            [prefix]
            high quality, 4K, detailed, cinematic footage, film style, smooth motion, natural movement, professional camera work,
            
            [suffix]
            , realistic, high definition, no blur, no distortion, no artifacts, perfect composition, professional lighting, film grain, masterpiece
            
            # 风格关键词(会自动从会话中读取项目风格并添加对应关键词)
            [style_keywords]
            anime: anime style, animated, cel-shaded, vibrant colors, manga aesthetic,
            cartoon: cartoon style, animated, colorful, fun, children's book illustration,
            realistic: photorealistic, realistic, natural lighting, detailed textures, cinema photography,
            photorealistic: photorealistic, realistic, natural lighting, detailed textures, cinema photography,
            3d-disney: 3D animation, Disney style, pixar, CGI, smooth textures, computer generated,
            3d: 3D animation, CGI, computer generated, smooth textures, digital cinema,
            oil-painting: oil painting style, impasto, classical art, painterly, rich brushstrokes,
            watercolor: watercolor style, delicate, soft colors, artistic, flowing,
            comic-book: comic book style, vibrant, bold outlines, pop art, graphic novel,
            cyberpunk: cyberpunk, neon lights, futuristic, dark atmosphere, sci-fi,
            chinese-ink: Chinese ink painting style, traditional, minimalist, brush strokes, oriental art,
            ink: Chinese ink painting style, traditional, minimalist, brush strokes, oriental art,
            
        • loader.py 4.8 KB
          # -*- coding: utf-8 -*-
          """
          统一提示词加载器
          从 prompts/ 目录加载提示词模板文件
          """
          
          import os
          from typing import Optional
          
          # 获取 prompts 目录的绝对路径 (loader.py is in backend/prompts/, prompts are in backend/prompts/)
          PROMPTS_DIR = os.path.dirname(os.path.abspath(__file__))
          
          
          def load_prompt(category: str, name: str, lang: str = 'zh') -> str:
              """
              加载提示词文件
          
              Args:
                  category: 提示词分类 (script, character, setting, storyboard, reference, video, logline)
                  name: 提示词文件名 (不含扩展名)
                  lang: 语言版本 ('zh' 或 'en')
          
              Returns:
                  提示词内容字符串
          
              Example:
                  prompt = load_prompt('logline', 'generate', 'zh')
              """
              # 尝试加载语言版本
              file_path = os.path.join(PROMPTS_DIR, category, f"{name}_{lang}.txt")
              if os.path.exists(file_path):
                  with open(file_path, 'r', encoding='utf-8') as f:
                      return f.read().strip()
          
              # 回退到中文版本
              file_path = os.path.join(PROMPTS_DIR, category, f"{name}.txt")
              if os.path.exists(file_path):
                  with open(file_path, 'r', encoding='utf-8') as f:
                      return f.read().strip()
          
              raise FileNotFoundError(f"Prompt not found: {category}/{name}_{lang}.txt or {category}/{name}.txt")
          
          
          def load_prompt_with_fallback(category: str, name: str, lang: str = 'zh', fallback_lang: str = 'zh') -> str:
              """
              加载提示词,如果指定语言不存在则回退
          
              Args:
                  category: 提示词分类
                  name: 提示词文件名
                  lang: 首选语言
                  fallback_lang: 回退语言
              """
              # 先尝试首选语言
              file_path = os.path.join(PROMPTS_DIR, category, f"{name}_{lang}.txt")
              if os.path.exists(file_path):
                  with open(file_path, 'r', encoding='utf-8') as f:
                      return f.read().strip()
          
              # 回退到指定语言
              if fallback_lang != lang:
                  file_path = os.path.join(PROMPTS_DIR, category, f"{name}_{fallback_lang}.txt")
                  if os.path.exists(file_path):
                      with open(file_path, 'r', encoding='utf-8') as f:
                          return f.read().strip()
          
              raise FileNotFoundError(f"Prompt not found: {category}/{name}_{lang}.txt")
          
          
          def format_prompt(template: str, **kwargs) -> str:
              """
              格式化提示词模板
          
              Args:
                  template: 提示词模板字符串
                  **kwargs: 格式化参数
          
              Returns:
                  格式化后的提示词
          
              Example:
                  prompt = format_prompt("Hello {name}, you are {age} years old", name="John", age=30)
              """
              return template.format(**kwargs)
          
          
          # 风格提示词缓存
          _STYLE_PROMPTS_CACHE = {}
          
          
          def load_style_prompt(category: str, style: str) -> str:
              """
              加载指定风格的提示词
          
              Args:
                  category: 提示词分类 (character, setting)
                  style: 风格名称 (realistic, anime, comic-book, 3d-disney, watercolor, oil-painting, cyberpunk, chinese-ink)
          
              Returns:
                  风格提示词模板
          
              Example:
                  prompt = load_style_prompt('character', 'realistic')
              """
              # 先检查缓存
              cache_key = f"{category}:{style}"
              if cache_key in _STYLE_PROMPTS_CACHE:
                  return _STYLE_PROMPTS_CACHE[cache_key]
          
              # 加载风格提示词文件
              file_path = os.path.join(PROMPTS_DIR, category, f"{style}_styles.txt")
              if not os.path.exists(file_path):
                  # 回退到通用的风格文件 (character_styles.txt 或 setting_styles.txt)
                  fallback_file = "character_styles.txt" if category == "character" else "setting_styles.txt"
                  file_path = os.path.join(PROMPTS_DIR, category, fallback_file)
          
              if not os.path.exists(file_path):
                  fallback_file = "character_styles.txt" if category == "character" else "setting_styles.txt"
                  raise FileNotFoundError(f"Style prompt file not found: {category}/{fallback_file}")
          
              with open(file_path, 'r', encoding='utf-8') as f:
                  content = f.read()
          
              # 解析风格块
              current_style = None
              style_templates = {}
              for line in content.split('\n'):
                  line = line.strip()
                  # Skip empty lines and comment lines
                  if not line or line.startswith('#'):
                      continue
                  if line.startswith('[') and line.endswith(']'):
                      current_style = line[1:-1]
                      style_templates[current_style] = []
                  elif current_style and line:
                      style_templates[current_style].append(line)
          
              # 转换为字符串
              for s in style_templates:
                  style_templates[s] = '\n'.join(style_templates[s])
          
              # 缓存所有风格
              _STYLE_PROMPTS_CACHE.update(style_templates)
          
              # 返回指定风格的提示词
              if style in style_templates:
                  return style_templates[style]
          
              # 回退到 anime 或第一个可用风格
              if 'anime' in style_templates:
                  return style_templates['anime']
          
              # 返回第一个可用的风格
              return list(style_templates.values())[0] if style_templates else ""
          
      • tool
        • image_client.py 9.1 KB
          import os
          import time
          import uuid
          import logging
          from typing import List, Optional
          from config import Config
          
          try:
              from tool.image_dashscope import DashScopeClient
              from tool.image_jimeng import JiMengClient
              from tool.image_seedream import SeedreamClient
              from tool.image_gpt import ImageGPT
              from tool.image_processor import ImageProcessor
          except ImportError:
              from image_dashscope import DashScopeClient
              from image_jimeng import JiMengClient
              from image_seedream import SeedreamClient
              from image_gpt import ImageGPT
              from image_processor import ImageProcessor
          
          class ImageClient:
              def __init__(self,
                           dashscope_api_key: Optional[str] = None,
                           dashscope_base_url: Optional[str] = None,
                           jimeng_base_url: Optional[str] = None,
                           jimeng_access_key: Optional[str] = None,
                           jimeng_secret_key: Optional[str] = None,
                           gpt_api_key: Optional[str] = None,
                           gpt_base_url: Optional[str] = None,
                           gpt_official_api_key: Optional[str] = None,
                           local_proxy: Optional[str] = None,
                           ark_api_key: Optional[str] = None,
                           ark_base_url: Optional[str] = None):
                  """
                  Unified Image Generation Client
                  Routes requests to DashScope, JiMeng, Seedream, or GPT based on model name.
                  """
                  # Initialize DashScope Client
                  self.dashscope_client = DashScopeClient(
                      api_key=dashscope_api_key,
                      base_url=dashscope_base_url
                  )
          
                  # Initialize JiMeng Client
                  self.jimeng_client = JiMengClient(
                      base_url=jimeng_base_url,
                      access_key=jimeng_access_key,
                      secret_key=jimeng_secret_key
                  )
          
                  # Initialize Seedream Client
                  self.seedream_client = SeedreamClient(
                      api_key=ark_api_key,
                      base_url=ark_base_url
                  )
          
                  # Initialize GPT Image Client
                  self.gpt_client = ImageGPT(
                      api_key=gpt_api_key,
                      base_url=gpt_base_url,
                      official_api_key=gpt_official_api_key or '',
                      local_proxy=local_proxy or ''
                  )
          
                  # Initialize Image Processor for downloads
                  self.image_processor = ImageProcessor()
          
                  # Default save directory
                  self.base_save_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "code", "result", "image_client")
          
              def generate_image(self, 
                                 prompt: str, 
                                 image_paths: Optional[List[str]] = None, 
                                 model: str = "wan2.6-t2i",
                                 save_dir: Optional[str] = None,
                                 session_id: Optional[str] = None,
                                 size: Optional[str] = "1920*1080") -> List[str]:
                  """
                  Generate images based on prompt and optional reference images.
                  
                  Args:
                      prompt: Text prompt for generation.
                      image_paths: List of local file paths or URLs for reference images.
                      model: Model name to determine which provider to use.
                      save_dir: Custom directory to save downloaded images.
                      session_id: Session ID for organizing saved files (especially for JiMeng)
                      size: Desired size of the generated image, e.g., "1024*1024" or "1920*1080".
                      
                  Returns:
                      List of absolute file paths of the generated images.
                  """
                  if not model:
                      model = "wan2.6-t2i"
          
                  if Config.PRINT_MODEL_INPUT:
                      print("---- IMAGE GENERATION REQUEST ----")
                      print(f"Prompt: {prompt}")
                      if image_paths:
                          print(f"Refs: {len(image_paths)}")
                          for p in image_paths:
                              if str(p).startswith("data:"):
                                  print(f" - [Base64图片]")
                              else:
                                  print(f" - {p}")
                      print(f"Model: {model}")
                      if session_id:
                          print(f"Session ID: {session_id}")
                      print("-" * 30)
                      
                  # Determine backend provider
                  is_jimeng = "jimeng" in model.lower()
                  is_seedream = "seedream" in model.lower()
                  is_sora = "sora" in model.lower() or "gpt" in model.lower()
                  
                  # Prepare save directory
                  if not save_dir:
                      if session_id:
                          save_dir = os.path.join(self.base_save_dir, session_id)
                      else:
                          save_dir = self.base_save_dir
                  os.makedirs(save_dir, exist_ok=True)
                  
                  generated_local_paths = []
          
                  if is_jimeng:
                      # --- JiMeng Logic ---
                      try:
                          # JiMengClient handles local paths and typically saves results to code/result internally
                          # or we rely on its return value.
                          # JiMengClient.generate_image returns list of local paths (saved from base64)
                          logging.info(f"ImageClient requesting JiMeng: {model}")
                          paths = self.jimeng_client.generate_image(
                              prompt=prompt,
                              image_paths=image_paths if image_paths else [],
                              model=model,
                              session_id=session_id,
                              size=size
                          )
                          
                          # If JiMengClient saves to a default location, we might want to move them or just return them.
                          # The provided JiMengClient saves to 'backend/code/result'.
                          # We simply return those paths.
                          generated_local_paths.extend(paths)
                          
                      except Exception as e:
                          logging.error(f"JiMeng generation failed: {e}")
          
                  elif is_seedream:
                      # --- Seedream Logic ---
                      try:
                          logging.info(f"ImageClient requesting Seedream: {model}")
          
                          paths = self.seedream_client.generate_image(
                              prompt=prompt,
                              model=model,
                              session_id=session_id or "default",
                              size=size or "2048*2048",
                              image_paths=image_paths
                          )
          
                          if paths:
                              generated_local_paths.extend(paths)
          
                      except Exception as e:
                          logging.error(f"Seedream generation failed: {e}")
          
                  elif is_sora:
                      # --- GPT/Sora Logic ---
                      try:
                          logging.info(f"ImageClient requesting GPT/Sora: {model}")
                          if image_paths:
                              logging.warning("Sora/GPT model only supports Text-to-Image. Ignoring reference images.")
                          
                          # OpenAI uses 'x' separator, e.g. 1024x1024
                          # Attempt to map size if needed or just replace '*'
                          gpt_size = size.replace('*', 'x') if size else "1024x1024"
          
                          path = self.gpt_client.generate_image(
                              prompt=prompt,
                              size=gpt_size,
                              model=model,
                              save_dir=save_dir
                          )
                          
                          if path and os.path.exists(path):
                              generated_local_paths.append(path)
                          else:
                              logging.error(f"GPT/Sora returned invalid path or download failed: {path}")
          
                      except Exception as e:
                          logging.error(f"GPT/Sora generation failed: {e}")
          
                  else:
                      # --- DashScope Logic ---
                      try:
                          logging.info(f"ImageClient requesting DashScope: {model}")
          
                          if image_paths and len(image_paths) > 0:
                              # Pre-process image paths for DashScope
                              # Convert local paths to file:// URIs if they aren't already URLs
                              # DashScope SDK (via MultiModalConversation) handles file://
                              formatted_urls = []
                              for p in image_paths:
                                  if p.startswith("http") or p.startswith("file://"):
                                      formatted_urls.append(p)
                                  else:
                                      abs_path = os.path.abspath(p)
                                      formatted_urls.append(f"file://{abs_path}")
                              
                              paths = self.dashscope_client.edit_image(
                                  prompt=prompt,
                                  image_urls=formatted_urls,
                                  model=model,
                                  size=size,
                                  session_id=session_id,
                                  save_dir=save_dir
                              )
                          else:
                              # Text to Image
                              # Assuming default size 1024*1024 or similar
                              paths = self.dashscope_client.generate_image(
                                  prompt=prompt,
                                  model=model,
                                  size=size,
                                  session_id=session_id,
                                  save_dir=save_dir
                              )
                          
                          if paths:
                              generated_local_paths.extend(paths)
                                      
                      except Exception as e:
                          logging.error(f"DashScope generation failed: {e}")
          
                  return generated_local_paths
          
        • image_dashscope.py 7 KB
          import os
          import json
          import logging
          import time
          import uuid
          import dashscope
          from dashscope import MultiModalConversation
          from dashscope.aigc.image_generation import ImageGeneration
          try:
              from tool.image_processor import ImageProcessor
          except ImportError:
              from image_processor import ImageProcessor
          
          class DashScopeClient:
              def __init__(self, api_key=None, base_url=None):
                  self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
                  # 默认使用中国(北京)地域 API,如果环境变量或参数未设置则使用默认地址
                  self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL")
                  dashscope.api_key = self.api_key
                  dashscope.base_http_api_url = self.base_url
                  self.image_processor = ImageProcessor()
          
              def generate_image(self, prompt, model="wan2.6-t2i", size="1024*1024", n=1, session_id=None, save_dir=None):
                  """
                  Text to Image generation using DashScope
                  """
                  try:
                      messages = [{"role": "user", "content": [{"text": prompt}]}]
                      response = ImageGeneration.call(
                          model=model,
                          api_key=self.api_key,
                          messages=messages,
                          n=n,
                          size=size,
                          watermark=False,
                      )
          
                      if response.status_code == 200:
                          results = []
                          try:
                              # 标准 ImageGeneration 返回结果解析
                              if response.output and response.output.choices:
                                  for item in response.output.choices:
                                      if 'message' in item and 'content' in item['message']:
                                          results.append(item['message']['content'][0]['image'])
                          except Exception as e:
                              logging.error(f"Failed to parse ImageGeneration outputs: {e}")
                          
                          # Check if we should download
                          if save_dir:
                              os.makedirs(save_dir, exist_ok=True)
                              local_files = []
                              for i, url in enumerate(results):
                                  file_name = f"ds_{session_id if session_id else 'nosess'}_{int(time.time())}_{i}_{uuid.uuid4().hex[:6]}.png"
                                  file_path = os.path.join(save_dir, file_name)
                                  if self.image_processor.download_image(url, file_path):
                                      local_files.append(file_path)
                              return local_files
                          
                          return results
                      else:
                          logging.error(f"Image generation failed: {response.code}, {response.message}, status={response.status_code}")
                          return []
                  except Exception as e:
                      logging.error(f"Error in generate_image (DashScope): {e}")
                      return []
          
              def edit_image(self, prompt, image_urls, model="wan2.6-image", size="1920*1080", n=1, session_id=None, save_dir=None):
                  """
                  Image editing/compositing using DashScope ImageGeneration
                  """
                  # Prepare content
                  content_list = []
                  for img_url in image_urls:
                      content_list.append({"image": img_url})
                  content_list.append({"text": prompt})
          
                  messages = [
                      {
                          "role": "user",
                          "content": content_list
                      }
                  ]
          
                  try:
                      # Use ImageGeneration.call with messages, same as generate_image
                      response = ImageGeneration.call(
                          model=model,
                          api_key=self.api_key,
                          messages=messages,
                          n=n,
                          size=size,
                          watermark=False,
                      )
          
                      if response.status_code == 200:
                          results = []
                          try:
                              # 标准 ImageGeneration 返回结果解析
                              if response.output and response.output.choices:
                                  for item in response.output.choices:
                                      # 简化解析逻辑以处理多张图片的返回结构
                                      if isinstance(item, dict):
                                          if 'image' in item: # 部分新模型直接返回 {'image': 'url', 'finish_reason': ...}
                                               results.append(item['image'])
                                          elif 'url' in item:
                                               results.append(item['url'])
                                          elif 'message' in item and 'content' in item['message']: # 兼容 Message 结构
                                              content = item['message']['content']
                                              if isinstance(content, list):
                                                  for c in content:
                                                      if isinstance(c, dict) and 'image' in c:
                                                          results.append(c['image'])
                          except Exception as e:
                              logging.error(f"Failed to parse ImageGeneration outputs: {e}")
          
                          # Check if we should download
                          if save_dir:
                              os.makedirs(save_dir, exist_ok=True)
                              local_files = []
                              for i, url in enumerate(results):
                                  file_name = f"ds_{session_id if session_id else 'nosess'}_{int(time.time())}_{i}_{uuid.uuid4().hex[:6]}.png"
                                  file_path = os.path.join(save_dir, file_name)
                                  if self.image_processor.download_image(url, file_path):
                                      local_files.append(file_path)
                              return local_files
          
                          return results
                      else:
                          logging.error(f"Image edit failed: {response.code}, {response.message}, status={response.status_code}")
                          return []
                  except Exception as e:
                      logging.error(f"Error in edit_image: {e}")
                      return []
          
          
          if __name__ == "__main__":
              import sys
              import tempfile
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              print("=== DashScope 图片生成可用性测试 ===")
              api_key = Config.DASHSCOPE_API_KEY
              base_url = Config.DASHSCOPE_BASE_URL
              if not api_key:
                  print("✗ DASHSCOPE_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              print(f"  Base URL: {base_url}")
              client = DashScopeClient(api_key=api_key, base_url=base_url)
              prompt = "一只橘猫躺在阳光下的窗台上,水彩画风格"
              print(f"Prompt: {prompt}")
              with tempfile.TemporaryDirectory() as tmp:
                  t0 = time.time()
                  try:
                      paths = client.generate_image(
                          prompt=prompt, model="wan2.6-t2i",
                          size="1024*1024", save_dir=tmp,
                      )
                      elapsed = time.time() - t0
                      if paths:
                          print(f"✓ 生成 {len(paths)} 张图片 ({elapsed:.1f}s): {paths}")
                      else:
                          print(f"✗ 返回空列表 ({elapsed:.1f}s)")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
                      sys.exit(1)
          
        • image_gpt.py 10.2 KB
          import os
          import time
          import uuid
          import base64
          import httpx
          from openai import OpenAI
          try:
              from tool.image_processor import ImageProcessor
          except ImportError:
              from image_processor import ImageProcessor
          
          
          class ImageGPT:
              """
              OpenAI 图片生成客户端
              支持模型:
                  - sora_image    → Images API(通过代理/中转服务器)
                  - gpt-image-1.5 → Responses API(直连官方,走本地代理)
              """
              def __init__(self, base_url="", api_key="", timeout=300,
                           official_api_key="", local_proxy=""):
                  # 普通客户端(可连接代理/中转服务器,用于 sora_image 等)
                  if base_url:
                      self.client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
                  else:
                      self.client = OpenAI(api_key=api_key, timeout=timeout)
                  self.max_attempts = 10
                  self.image_processor = ImageProcessor()
          
                  # 官方直连客户端(仅 gpt-image-1.5 等需要直连官方 API 的模型使用)
                  self._official_client = None
                  self._official_api_key = official_api_key
                  self._local_proxy = local_proxy
                  self._timeout = timeout
          
              def _get_official_client(self):
                  """懒加载官方 OpenAI 客户端,通过本地代理访问"""
                  if self._official_client is None:
                      if not self._official_api_key:
                          raise ValueError("使用 gpt-image-1.5 需要设置 OPENAI_OFFICIAL_API_KEY(官方 OpenAI API Key)")
                      kwargs = {"timeout": self._timeout}
                      if self._local_proxy:
                          kwargs["http_client"] = httpx.Client(
                              proxy=self._local_proxy,
                              timeout=self._timeout,
                          )
                      self._official_client = OpenAI(api_key=self._official_api_key, **kwargs)
                  return self._official_client
          
              def generate_image(self, prompt, size="1024x1024", quality="standard", model=None,
                                 save_dir=None, image_urls=None):
                  """Generate a single image, download it, and return the local file path.
          
                  Args:
                      prompt: 图片描述提示词
                      size: 图片尺寸
                      quality: 图片质量
                      model: 模型名称 (sora_image / gpt-image-1.5)
                      save_dir: 保存目录(不传则返回 URL 或 base64)
                      image_urls: 参考图片 URL 列表(仅 gpt-image-1.5 支持)
                  """
                  if model is None:
                      model = "sora_image"
          
                  # gpt-image-1.5 走官方 Responses API
                  if model == "gpt-image-1.5":
                      return self._generate_image_official(prompt, size=size, quality=quality,
                                                            save_dir=save_dir, image_urls=image_urls)
          
                  # 其他模型走普通 Images API
                  return self._generate_image_legacy(prompt, size=size, quality=quality,
                                                      model=model, save_dir=save_dir)
          
              def _generate_image_official(self, prompt, size="1024x1024", quality="standard",
                                            save_dir=None, image_urls=None):
                  """通过官方 Responses API 生成图片 (gpt-image-1.5),走本地代理"""
                  client = self._get_official_client()
          
                  # 构建 input content
                  content = [{"type": "input_text", "text": prompt}]
                  if image_urls:
                      for url in image_urls:
                          content.append({"type": "input_image", "image_url": url})
          
                  attempts = 0
                  last_error = None
                  while attempts < self.max_attempts:
                      try:
                          response = client.responses.create(
                              model="gpt-image-1.5",
                              input=[{"role": "user", "content": content}],
                              tools=[{"type": "image_generation", "size": size, "quality": quality}],
                          )
          
                          # 从 output 中提取 image_generation_call 结果
                          image_data = [
                              output.result for output in response.output
                              if output.type == "image_generation_call"
                          ]
          
                          if image_data:
                              b64 = image_data[0]
                              if save_dir:
                                  os.makedirs(save_dir, exist_ok=True)
                                  file_name = f"gptimg_{int(time.time())}_{uuid.uuid4().hex[:6]}.png"
                                  file_path = os.path.join(save_dir, file_name)
                                  with open(file_path, "wb") as f:
                                      f.write(base64.b64decode(b64))
                                  return file_path
                              else:
                                  return b64  # 返回 base64 字符串
                          else:
                              text_output = " ".join(
                                  getattr(o, "text", "") for o in response.output if hasattr(o, "text")
                              ).strip()
                              print(f"gpt-image-1.5: 未返回图片。模型回复: {text_output[:200]}")
                      except Exception as e:
                          last_error = e
                          print(f"gpt-image-1.5 Error: {e}. Retrying in 10s.")
                      time.sleep(10)
                      attempts += 1
          
                  raise Exception(f"gpt-image-1.5: 达到最大重试次数。Last error: {last_error}")
          
              def _generate_image_legacy(self, prompt, size="1024x1024", quality="standard",
                                          model="sora_image", save_dir=None):
                  """通过 Images API 生成图片 (sora_image 等)"""
                  # Fallback chain: user's choice -> sora_image
                  models_to_try = [model, "sora_image"]
                  models_to_try = list(dict.fromkeys(models_to_try))  # Remove duplicates
          
                  attempts = 0
                  last_error = None
                  while attempts < self.max_attempts:
                      for m in models_to_try:
                          try:
                              response = self.client.images.generate(
                                  model=m,
                                  prompt=prompt,
                                  size=size,
                                  quality=quality,
                                  n=1,
                              )
                              if response and response.data and response.data[0].url:
                                  url = response.data[0].url
                                  if save_dir:
                                      os.makedirs(save_dir, exist_ok=True)
                                      file_name = f"sora_{int(time.time())}_{uuid.uuid4().hex[:6]}.png"
                                      file_path = os.path.join(save_dir, file_name)
                                      if self.image_processor.download_image(url, file_path):
                                          return file_path
                                      else:
                                          print(f"Failed to save image from {url}")
                                  else:
                                      return url
                          except Exception as e:
                              last_error = e
                              msg = str(e)
                              # Model not found or no distributor: try next model
                              if "model_not_found" in msg or "无可用渠道" in msg or "distributor" in msg:
                                  continue
                              # Other errors: wait before retry
                              print(f"Image generation error: {e}. Retrying in 10 seconds.")
                              time.sleep(10)
                              break  # Break inner loop to retry all models
                      attempts += 1
                  raise Exception(f"Max attempts reached, failed to generate image. Last error: {last_error}")
          
              def generate_images(self, prompt, count=4, size="1024x1024", quality="standard", model=None):
                  """Generate multiple image URLs by calling Images API 'count' times."""
                  urls = []
                  for _ in range(count):
                      url = self.generate_image(prompt=prompt, size=size, quality=quality, model=model)
                      urls.append(url)
                  return urls
          
          
          if __name__ == "__main__":
              import sys
              import tempfile
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              print("=== GPT 图片生成可用性测试 ===")
              api_key = Config.OPENAI_API_KEY
              base_url = Config.OPENAI_BASE_URL
              official_key = Config.OPENAI_OFFICIAL_API_KEY
              local_proxy = Config.LOCAL_PROXY
          
              if not api_key and not official_key:
                  print("✗ OPENAI_API_KEY 和 OPENAI_OFFICIAL_API_KEY 均未设置,跳过")
                  sys.exit(1)
          
              print(f"  API Key: {api_key[:6]}***{api_key[-4:] if api_key else '(未设置)'}")
              print(f"  Base URL: {base_url}")
              print(f"  Official Key: {official_key[:6]}***{official_key[-4:] if official_key else '(未设置)'}")
              print(f"  Local Proxy: {local_proxy or '(未设置)'}")
          
              client = ImageGPT(api_key=api_key, base_url=base_url,
                                official_api_key=official_key, local_proxy=local_proxy)
          
              # 1. sora_image 图片生成(仅尝试 1 次)
              if api_key:
                  img_prompt = "一只橘猫躺在阳光下的窗台上"
                  print(f"\n[1/2 sora_image] Prompt: {img_prompt}")
                  client.max_attempts = 1
                  t0 = time.time()
                  try:
                      with tempfile.TemporaryDirectory() as tmp:
                          path = client.generate_image(prompt=img_prompt, size="1024x1024", model="sora_image", save_dir=tmp)
                          elapsed = time.time() - t0
                          print(f"✓ 生成成功 ({elapsed:.1f}s): {path}")
                  except Exception as e:
                      elapsed = time.time() - t0
                      print(f"✗ 图片生成失败 ({elapsed:.1f}s): {e}\n  (该代理可能不支持 sora_image 模型)")
              else:
                  print("\n[1/2 sora_image] 跳过(OPENAI_API_KEY 未设置)")
          
              # 2. gpt-image-1.5 图片生成(官方 API + 本地代理)
              if official_key:
                  img_prompt = "A cute orange cat lying on a sunny windowsill, watercolor style"
                  print(f"\n[2/2 gpt-image-1.5] Prompt: {img_prompt}")
                  client.max_attempts = 1
                  t0 = time.time()
                  try:
                      with tempfile.TemporaryDirectory() as tmp:
                          path = client.generate_image(prompt=img_prompt, size="1024x1024",
                                                        model="gpt-image-1.5", save_dir=tmp)
                          elapsed = time.time() - t0
                          print(f"✓ 生成成功 ({elapsed:.1f}s): {path}")
                  except Exception as e:
                      elapsed = time.time() - t0
                      print(f"✗ gpt-image-1.5 失败 ({elapsed:.1f}s): {e}")
              else:
                  print("\n[2/2 gpt-image-1.5] 跳过(OPENAI_OFFICIAL_API_KEY 未设置)")
          
        • image_jimeng.py 15.8 KB
          """
          即梦 Ti2V (Text+Image to Video) API 客户端
          火山引擎视觉智能服务 - jimeng_ti2v_v30_pro 模型
          """
          
          import os
          import time
          import base64
          import json
          import logging
          import io
          from typing import Optional, Dict, Any
          import requests
          from PIL import Image
          
          
          class JiMengClient:
              """
              即梦图生视频客户端(火山引擎)
              支持基于文本+图片生成视频的功能
              """
          
              # API操作类型
              SUBMIT_ACTION = "CVSync2AsyncSubmitTask"  # 提交异步任务
              RESULT_ACTION = "CVSync2AsyncGetResult"    # 获取异步任务结果
              API_VERSION = "2022-08-31"                 # API版本号
          
              def __init__(
                  self,
                  base_url: Optional[str] = None,
                  access_key: Optional[str] = None,
                  secret_key: Optional[str] = None,
                  timeout: int = 120,
                  poll_interval: int = 2,
                  max_polls: int = 60,
              ) -> None:
                  """
                  初始化即梦客户端
          
                  Args:
                      timeout: HTTP请求超时时间(秒)
                      poll_interval: 轮询间隔(秒)
                      max_polls: 最大轮询次数
                  """
                  self.base_url = base_url or os.getenv("VOLC_BASE_URL")
                  self.access_key = access_key or os.getenv("VOLC_ACCESS_KEY")
                  self.secret_key = secret_key or os.getenv("VOLC_SECRET_KEY")
                  self.timeout = timeout
                  self.poll_interval = poll_interval
                  self.max_polls = max_polls
                  self.region = "cn-north-1"  # 火山引擎区域
                  self.service = "cv"          # 服务名称:计算机视觉
          
                  if not self.access_key or not self.secret_key:
                      logging.warning(
                          "JiMengClient missing access_key/secret_key. Set VOLC_ACCESS_KEY and VOLC_SECRET_KEY."
                      )
          
              def _headers(self, method: str, path: str, query: str, body: str) -> Dict[str, str]:
                  """
                  生成带火山引擎签名v4的请求头
                  
                  Args:
                      method: HTTP方法(如 POST)
                      path: 请求路径
                      query: 查询字符串
                      body: 请求体
                      
                  Returns:
                      包含认证信息的请求头字典
                  """
                  import hashlib
                  import hmac
                  from datetime import datetime, timezone
                  
                  if not self.access_key or not self.secret_key:
                      return {"Content-Type": "application/json"}
                      
                  # 生成时间戳
                  now = datetime.now(timezone.utc)
                  timestamp = now.strftime('%Y%m%dT%H%M%SZ')
                  date_stamp = now.strftime('%Y%m%d')
                  
                  # 计算请求体的SHA256哈希
                  payload_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
                  
                  # 构建规范请求(Canonical Request)
                  canonical_uri = path
                  canonical_querystring = query
                  host = self.base_url.split("//")[1]
                  signed_headers = 'content-type;host;x-content-sha256;x-date'
                  canonical_headers = (
                      f'content-type:application/json\n'
                      f'host:{host}\n'
                      f'x-content-sha256:{payload_hash}\n'
                      f'x-date:{timestamp}\n'
                  )
                  
                  canonical_request = (
                      f'{method}\n{canonical_uri}\n{canonical_querystring}\n'
                      f'{canonical_headers}\n{signed_headers}\n{payload_hash}'
                  )
                  
                  # 构建待签名字符串(String to Sign)
                  algorithm = 'HMAC-SHA256'
                  credential_scope = f'{date_stamp}/{self.region}/{self.service}/request'
                  string_to_sign = (
                      f'{algorithm}\n{timestamp}\n{credential_scope}\n'
                      f'{hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()}'
                  )
                  
                  # 生成签名密钥(Signing Key)
                  def sign(key, msg):
                      return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
                  
                  k_date = sign(self.secret_key.encode('utf-8'), date_stamp)
                  k_region = sign(k_date, self.region)
                  k_service = sign(k_region, self.service)
                  k_signing = sign(k_service, 'request')
                  
                  # 计算签名
                  signature = hmac.new(k_signing, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
                  
                  # 构建Authorization请求头
                  authorization_header = (
                      f'{algorithm} Credential={self.access_key}/{credential_scope}, '
                      f'SignedHeaders={signed_headers}, Signature={signature}'
                  )
                  
                  return {
                      'Content-Type': 'application/json',
                      'X-Date': timestamp,
                      'X-Content-Sha256': payload_hash,
                      'Authorization': authorization_header
                  }
          
              def _submit_url(self) -> str:
                  """构建提交任务的URL"""
                  return f"{self.base_url}?Action={self.SUBMIT_ACTION}&Version={self.API_VERSION}"
          
              def _result_url(self) -> str:
                  """构建查询结果的URL"""
                  return f"{self.base_url}?Action={self.RESULT_ACTION}&Version={self.API_VERSION}"
          
              @staticmethod
              def _encode_image_to_base64(image_path: str, quality: int = 80) -> str:
                  """
                  将本地图片压缩并编码为base64字符串(仅转换格式和压缩质量,不调整尺寸)
                  
                  Args:
                      image_path: 图片文件路径
                      quality: JPEG压缩质量
                      
                  Returns:
                      base64编码的图片字符串
                  """
                  try:
                      with Image.open(image_path) as img:
                          # 转换为RGB(去除PNG的Alpha通道,兼容JPEG)
                          if img.mode in ("RGBA", "P"):
                              img = img.convert("RGB")
                              
                          # 保存为JPEG字节流
                          buffer = io.BytesIO()
                          img.save(buffer, format="JPEG", quality=quality)
                          return base64.b64encode(buffer.getvalue()).decode("utf-8")
                  except Exception as e:
                      logging.warning(f"Image compression failed for {image_path}, falling back to raw read: {e}")
                      # 降级方案:直接读取原文件
                      with open(image_path, "rb") as f:
                          return base64.b64encode(f.read()).decode("utf-8")
                  
              @staticmethod
              def download_video(video_url: str, save_path: str, timeout: int = 60) -> None:
                  """
                  从URL下载视频文件到本地
                  
                  Args:
                      video_url: 视频文件URL
                      save_path: 本地保存路径
                      timeout: 下载超时时间(秒)
                  """
                  with requests.get(video_url, stream=True, timeout=timeout) as r:
                      r.raise_for_status()
                      os.makedirs(os.path.dirname(save_path), exist_ok=True)
                      with open(save_path, "wb") as f:
                          for chunk in r.iter_content(chunk_size=8192):
                              if chunk:
                                  f.write(chunk)
                  
              def poll_task(self, model: str, task_id: str) -> Dict[str, Any]:
                  """
                  轮询任务直到完成或失败
                  
                  Args:
                      model: 模型key
                      task_id: 任务ID
                      
                  Returns:
                      任务结果数据字典,包含 video_url 等信息
                      
                  Raises:
                      RuntimeError: 当API Key未设置或任务失败时
                      TimeoutError: 当超过最大轮询次数时
                  """
                  if not self.access_key or not self.secret_key:
                      raise RuntimeError("VOLC_ACCESS_KEY and VOLC_SECRET_KEY not set; cannot call JiMeng Ti2V API.")
          
                  for attempt in range(self.max_polls):
                      payload = {"req_key": model, "task_id": task_id}
                      body = json.dumps(payload)
                      query = f"Action={self.RESULT_ACTION}&Version={self.API_VERSION}"
                      headers = self._headers("POST", "/", query, body)
                      
                      # 查询任务状态
                      response = requests.post(
                          self._result_url(),
                          data=body,
                          headers=headers,
                          timeout=self.timeout,
                      )
                      response.raise_for_status()
                      data = response.json()
                      
                      # 检查返回码
                      if data.get("code") != 10000:
                          raise RuntimeError(f"GetResult failed: {data}")
          
                      status = data.get("data", {}).get("status")
                      
                      # 任务完成
                      if status == "done":
                          return data["data"]
                      
                      # 任务进行中,继续等待
                      if status in {"in_queue", "generating"}:
                          time.sleep(self.poll_interval)
                          continue
                      
                      # 任务失败或过期
                      if status in {"not_found", "expired"}:
                          raise RuntimeError(f"Task {task_id} {status}")
          
                  raise TimeoutError(f"Polling timeout for task {task_id}")
          
              def generate_video(
                  self,
                  prompt: Optional[str],
                  image_path: Optional[str],
                  seed: int = -1,
                  frames: int = 121,
                  aspect_ratio: str = "16:9",
              ) -> str:
                  """
                  提交视频生成任务
                  
                  Args:
                      prompt: 文本提示词(最多800字符)
                      image_path: 输入图片的本地路径
                      seed: 随机种子,-1表示随机
                      frames: 生成视频的帧数,默认121帧
                      aspect_ratio: 视频宽高比,默认 "16:9"
                      
                  Returns:
                      task_id: 任务ID,用于后续查询结果
                      
                  Raises:
                      RuntimeError: 当API Key未设置或任务提交失败时
                  """
                  if not self.access_key or not self.secret_key:
                      raise RuntimeError("VOLC_ACCESS_KEY and VOLC_SECRET_KEY not set; cannot call JiMeng Ti2V API.")
          
                  # 构建请求载荷
                  payload: Dict[str, Any] = {
                      "req_key": self.req_key,
                      "seed": seed,
                      "frames": frames,
                      "aspect_ratio": aspect_ratio,
                  }
          
                  # 添加文本提示词
                  if prompt:
                      payload["prompt"] = prompt
          
                  # 添加base64编码的图片数据
                  if image_path:
                      payload["binary_data_base64"] = [self._encode_image_to_base64(image_path)]
          
                  body = json.dumps(payload)
                  query = f"Action={self.SUBMIT_ACTION}&Version={self.API_VERSION}"
                  headers = self._headers("POST", "/", query, body)
                  
                  # 发送请求
                  response = requests.post(
                      self._submit_url(),
                      data=body,
                      headers=headers,
                      timeout=self.timeout,
                  )
                  response.raise_for_status()
                  data = response.json()
                  
                  # 检查返回码
                  if data.get("code") != 10000:
                      raise RuntimeError(f"Submit failed: {data}")
                      
                  task_id = data["data"]["task_id"]
                  return task_id
          
              def _save_base64_images(self, b64_list: list, session_id: str) -> list:
                  """保存Base64图片到本地"""
                  image_urls = []
                  # 定位到 backend/code/result
                  # 当前文件在 backend/tool/jimeng_api.py
                  base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                  
                  # 直接使用 session_id 构建路径
                  result_dir = os.path.join(base_dir, "code", "result", "image", str(session_id))
                  os.makedirs(result_dir, exist_ok=True)
          
                  for idx, b64_str in enumerate(b64_list):
                      file_name = f"jimeng_{int(time.time())}_{idx}.png"
                      file_path = os.path.join(result_dir, file_name)
                      
                      try:
                          with open(file_path, "wb") as f:
                              f.write(base64.b64decode(b64_str))
                          image_urls.append(file_path)
                      except Exception as e:
                          logging.error(f"Failed to save base64 image: {e}")
                  
                  return image_urls
          
              def generate_image(self,
                                 prompt: str,
                                 session_id: str,
                                 image_paths: list = [],
                                 model: str = "jimeng_t2i_v40",
                                 size: str = "1920*1080",
                                 **kwargs) -> list:
                  """
                  生成图片
                  
                  Args:
                      prompt: 提示词
                      session_id: 任务或会话ID,用于构建存储路径
                      image_paths: 参考图路径或URL列表 (最多10张)
                      model: 模型 req_key
                      size: 生成图片的分辨率 "1024*1024" or "1920*1080".
                      **kwargs: 其他生成参数 (size, width, height, scale, force_single, min_ratio, max_ratio)
                      
                  Returns:
                      生成的图片路径或URL列表
                  """
                  if not self.access_key or not self.secret_key:
                      raise RuntimeError("VOLC_ACCESS_KEY and VOLC_SECRET_KEY not set.")
                  
                  # 处理图片分辨率,长宽比
                  width = int(size.split("*")[0]) if size else None
                  height = int(size.split("*")[1]) if size else None
                  size = width * height if width and height else None
          
                  # 1. 构造请求参数
                  payload = {
                      "req_key": model,
                      "prompt": prompt,
                      "size": size,
                      "width": width,
                      "height": height,
                  }
                  
                  # 处理可选参数
                  valid_keys = ["width", "height", "scale", "force_single", "min_ratio", "max_ratio"]
                  for key in valid_keys:
                      if key in kwargs:
                          payload[key] = kwargs[key]
                          
                  # 处理参考图
                  if image_paths:
                      img_urls = []
                      img_b64s = []
                      for p in image_paths:
                          if p.startswith("http") or p.startswith("https"):
                              img_urls.append(p)
                          elif os.path.exists(p):
                              img_b64s.append(self._encode_image_to_base64(p))
                      
                      if img_urls:
                          payload["image_urls"] = img_urls
                      if img_b64s:
                          payload["binary_data_base64"] = img_b64s
          
                  body = json.dumps(payload)
                  query = f"Action={self.SUBMIT_ACTION}&Version={self.API_VERSION}"
                  headers = self._headers("POST", "/", query, body)
                  
                  # 2. 提交任务
                  response = requests.post(
                      self._submit_url(),
                      data=body,
                      headers=headers,
                      timeout=self.timeout,
                  )
                  response.raise_for_status()
                  response = response.json()
                  if response.get("code") != 10000:
                      raise RuntimeError(f"Jimeng Generate Failed: {response}")
                  data = response.get("data", {})
                      
                  # 3. 处理异步任务
                  task_id = data.get("task_id", None)
                  if not task_id:
                       raise RuntimeError(f"No task_id or binary data returned: {data}")
                  logging.info(f"Jimeng image task submitted: {task_id}, waiting for result...")
                  # 复用 poll_task
                  result = self.poll_task(model=model, task_id=task_id)
                  return self._save_base64_images(result.get("binary_data_base64", []), session_id=session_id)
              
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config  # 加载 .env
          
              print("=== 即梦 (JiMeng) 可用性测试 ===")
              ak = os.getenv("VOLC_ACCESS_KEY", "")
              sk = os.getenv("VOLC_SECRET_KEY", "")
              base_url = os.getenv("VOLC_BASE_URL", "")
              if not ak or not sk:
                  print("✗ VOLC_ACCESS_KEY / VOLC_SECRET_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  Access Key: {ak[:6]}***{ak[-4:]}")
              if base_url:
                  print(f"  Base URL: {base_url}")
              client = JiMengClient(access_key=ak, secret_key=sk, base_url=base_url)
              prompt = "一只橘猫躺在阳光下的窗台上,水彩画风格"
              print(f"\n[图片] Prompt: {prompt}")
              t0 = time.time()
              try:
                  paths = client.generate_image(
                      prompt=prompt, session_id="test_avail",
                      model="jimeng_t2i_v40", size="1024*1024",
                  )
                  elapsed = time.time() - t0
                  if paths:
                      print(f"✓ 生成 {len(paths)} 张图片 ({elapsed:.1f}s): {paths}")
                  else:
                      print(f"✗ 返回空列表 ({elapsed:.1f}s)")
              except Exception as e:
                  print(f"✗ 图片生成失败: {e}")
              
        • image_processor.py 15.5 KB
          import os
          import requests
          import numpy as np
          from pathlib import Path
          from datetime import datetime, timedelta
          from PIL import Image
          import logging
          
          
          class ImageProcessor:
              """
              图片处理和上传集合类
              支持:图片处理、分割、拼接,以及上传到阿里云OSS
              """
              
              # 阿里云DashScope上传配置
              UPLOAD_API_URL = "https://dashscope.aliyuncs.com/api/v1/uploads"
              
              def __init__(self,
                           image_path='',
                           api_key: str = os.environ.get("AIGC_CLAW_API_KEY", ""),
                           model_name: str = "wan2.6-i2v-flash"):
                  """
                  初始化图片处理器
                  
                  Args:
                      image_path: 图片文件路径(可选,用于处理已有图片)
                      api_key: DashScope API Key(用于上传,可从环境变量 DASHSCOPE_API_KEY 读取)
                      model_name: 模型名称,默认使用 wan2.6-i2v-flash
                  """
                  # 图片处理部分
                  if image_path != '':
                      self.image_path = image_path
                      self.image = Image.open(image_path)
                      self.image_np = np.array(self.image)
                      self.width, self.height = self.image_np.shape[1], self.image_np.shape[0]
                  else:
                      self.image_path = None
                      self.image = None
                      self.image_np = None
                      self.width = None
                      self.height = None
                  
                  # 上传功能部分
                  self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
                  self.model_name = model_name
          
              @staticmethod
              def check_column_white(column_pixels):
                  """检查列是否几乎全白"""
                  is_almost_white = np.logical_or(column_pixels == 254, column_pixels == 255)
                  white_pixels_ratio = np.mean(np.all(is_almost_white, axis=-1))
                  return white_pixels_ratio >= 0.98  # 至少98%的像素为白色
          
              def find_white_section(self, start, end):
                  """查找指定范围内的白色区间"""
                  white_sections = []
                  in_white_section = False
                  start_index = 0
          
                  for col in range(start, end):
                      column_pixels = self.image_np[:, col, :]
                      if self.check_column_white(column_pixels):
                          if not in_white_section:
                              start_index = col
                              in_white_section = True
                      else:
                          if in_white_section:
                              white_sections.append((start_index, col))
                              in_white_section = False
          
                  if in_white_section:
                      white_sections.append((start_index, end))
          
                  return white_sections
          
              def split_image(self):
                  """将图片从中间分割为左右两部分"""
                  start_col = self.width * 2 // 5
                  end_col = self.width * 3 // 5
                  white_sections = self.find_white_section(start_col, end_col)
          
                  if white_sections:
                      middle_section = white_sections[len(white_sections) // 2]
                      mid_col = (middle_section[0] + middle_section[1]) // 2
                  else:
                      raise ValueError("No suitable white column found within the specified range")
          
                  left_box = (0, 0, mid_col, self.height)
                  right_box = (mid_col, 0, self.width, self.height)
                  left_image = self.image.crop(left_box)
                  right_image = self.image.crop(right_box)
          
                  save_dir, filename = os.path.split(self.image_path)
                  base, extension = os.path.splitext(filename)
          
                  left_image_path = os.path.join(save_dir, base + '_front' + extension)
                  right_image_path = os.path.join(save_dir, base + '_back' + extension)
                  left_image.save(left_image_path)
                  right_image.save(right_image_path)
          
                  return left_image_path, right_image_path
              
              def stitch_images(self, image_paths, output_path):
                  """拼接多张图片"""
                  if not image_paths:
                      raise ValueError("No image paths provided")
                  sample_image = Image.open(image_paths[0])
                  single_width, single_height = sample_image.size
                  num_images = len(image_paths)
                  total_desired_width = single_width
                  total_current_width = single_width * num_images
                  total_width_to_cut = max(0, total_current_width - total_desired_width)
                  width_to_cut_per_image = total_width_to_cut // num_images
                  stitched_image = Image.new('RGB', (total_desired_width, single_height), "white")
                  current_x = 0
                  
                  for path in image_paths:
                      image = Image.open(path)
                      if width_to_cut_per_image > 0:
                          left_margin = width_to_cut_per_image // 2
                          right_margin = image.width - width_to_cut_per_image + left_margin
                          image = image.crop((left_margin, 0, right_margin, image.height))
                      stitched_image.paste(image, (current_x, 0))
                      current_x += image.width
                  
                  output_dir = os.path.dirname(output_path)
                  if not os.path.exists(output_dir):
                      os.makedirs(output_dir)
                  stitched_image.save(output_path)
                  return output_path
              
              def download_image(self, image_url, save_path, max_retries=3):
                  """
                  下载图片,带有重试机制和SSL错误处理
                  
                  Args:
                      image_url: 图片URL
                      save_path: 本地保存路径
                      max_retries: 最大重试次数
                  """
                  import time
                  import urllib3
                  
                  urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
                  
                  for attempt in range(max_retries):
                      try:
                          response = requests.get(
                              image_url, 
                              timeout=(10, 30),
                              stream=True,
                              verify=True,
                              headers={
                                  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
                              }
                          )
                          
                          if response.status_code == 200:
                              with open(save_path, 'wb') as file:
                                  for chunk in response.iter_content(chunk_size=8192):
                                      if chunk:
                                          file.write(chunk)
                              print(f"✓ 图片下载成功: {save_path}")
                              return True
                          else:
                              print(f"下载失败,状态码: {response.status_code}")
                              
                      except requests.exceptions.SSLError as e:
                          print(f"SSL错误 (尝试 {attempt + 1}/{max_retries}): {str(e)[:100]}")
                          if attempt < max_retries - 1:
                              wait_time = (attempt + 1) * 2
                              print(f"等待 {wait_time} 秒后重试...")
                              time.sleep(wait_time)
                          else:
                              print("尝试禁用SSL验证重新下载...")
                              try:
                                  response = requests.get(
                                      image_url, 
                                      timeout=(10, 30),
                                      stream=True,
                                      verify=False,
                                      headers={
                                          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
                                      }
                                  )
                                  if response.status_code == 200:
                                      with open(save_path, 'wb') as file:
                                          for chunk in response.iter_content(chunk_size=8192):
                                              if chunk:
                                                  file.write(chunk)
                                      print(f"✓ 图片下载成功(已禁用SSL验证): {save_path}")
                                      return True
                              except Exception as fallback_error:
                                  print(f"禁用SSL验证后仍然失败: {fallback_error}")
                                  raise
                                  
                      except requests.exceptions.Timeout as e:
                          print(f"超时错误 (尝试 {attempt + 1}/{max_retries}): {e}")
                          if attempt < max_retries - 1:
                              time.sleep((attempt + 1) * 2)
                          else:
                              raise
                              
                      except Exception as e:
                          print(f"下载错误 (尝试 {attempt + 1}/{max_retries}): {e}")
                          if attempt < max_retries - 1:
                              time.sleep((attempt + 1) * 2)
                          else:
                              raise
                  
                  return False
          
              def resize_image(self, image_path):
                  """调整图片大小(添加顶部空白)"""
                  original_image = Image.open(image_path)
                  width, height = original_image.size
                  top_blank_height = height // 2
                  final_height = height + top_blank_height
                  final_width = int(final_height * 5 / 3)
                  new_image = Image.new("RGB", (final_width, final_height), color="white")
                  left = (final_width - width) // 2
                  top = top_blank_height
                  new_image.paste(original_image, (left, top))
                  new_image.save(image_path)
                  return image_path
          
              def has_black_borders(self, image_path, threshold=10, black_limit=20):
                  """检查图片是否有黑色边框"""
                  img = Image.open(image_path)
                  pixels = img.load()
                  width, height = img.size
                  
                  def is_black_pixel(pixel):
                      return all(x <= black_limit for x in pixel)
                  
                  # 检查顶部和底部边框
                  for y in range(threshold):
                      if all(is_black_pixel(pixels[x, y]) for x in range(width)):
                          return True
                      if all(is_black_pixel(pixels[x, height - 1 - y]) for x in range(width)):
                          return True
                  
                  # 检查左右边框
                  for x in range(threshold):
                      if all(is_black_pixel(pixels[x, y]) for y in range(height)):
                          return True
                      if all(is_black_pixel(pixels[width - 1 - x, y]) for y in range(height)):
                          return True
                  
                  return False
          
              # ===== 图片上传功能 =====
              
              def get_upload_policy(self):
                  """
                  获取文件上传凭证
                  
                  Returns:
                      policy_data: 包含上传所需凭证的字典
                      
                  Raises:
                      Exception: 获取上传凭证失败时
                  """
                  if not self.api_key:
                      raise RuntimeError("DASHSCOPE_API_KEY 未设置,无法使用图片上传服务")
                  
                  headers = {
                      "Authorization": f"Bearer {self.api_key}",
                      "Content-Type": "application/json"
                  }
                  params = {
                      "action": "getPolicy",
                      "model": self.model_name
                  }
                  
                  response = requests.get(self.UPLOAD_API_URL, headers=headers, params=params)
                  if response.status_code != 200:
                      raise Exception(f"Failed to get upload policy: {response.text}")
                  
                  return response.json()['data']
              
              def upload_file_to_oss(self, policy_data: dict, file_path: str) -> str:
                  """
                  将文件上传到临时存储OSS
                  
                  Args:
                      policy_data: 上传凭证数据
                      file_path: 本地文件路径
                      
                  Returns:
                      oss_url: OSS URL (格式: oss://...)
                      
                  Raises:
                      Exception: 上传失败时
                  """
                  file_name = Path(file_path).name
                  # Sanitize filename for upload to avoid issues with spaces/characters
                  safe_file_name = "".join([c if c.isalnum() or c in ('-','_','.') else '_' for c in file_name])
                  
                  key = f"{policy_data['upload_dir']}/{safe_file_name}"
                  
                  with open(file_path, 'rb') as file:
                      files = {
                          'OSSAccessKeyId': (None, policy_data['oss_access_key_id']),
                          'Signature': (None, policy_data['signature']),
                          'policy': (None, policy_data['policy']),
                          'x-oss-object-acl': (None, policy_data['x_oss_object_acl']),
                          'x-oss-forbid-overwrite': (None, policy_data['x_oss_forbid_overwrite']),
                          'key': (None, key),
                          'success_action_status': (None, '200'),
                          'file': (safe_file_name, file)
                      }
                      
                      response = requests.post(policy_data['upload_host'], files=files)
                      if response.status_code != 200:
                          raise Exception(f"Failed to upload file: {response.text}")
                  
                  # Construct OSS URL correctly: oss://<bucket>/<key>
                  # Extract bucket from upload_host (e.g., https://dashscope-instant.oss-cn-beijing.aliyuncs.com)
                  upload_host = policy_data['upload_host']
                  bucket_name = ""
                  if '://' in upload_host:
                      domain = upload_host.split('://')[1]
                      bucket_name = domain.split('.')[0]
                  
                  if bucket_name:
                      return f"oss://{bucket_name}/{key}"
                  else:
                      # Fallback if parsing fails (though unlikely for standard OSS hosts)
                      # If the original code's assumption that key was self-sufficient was somehow valid, logic is here.
                      # But normally, oss://<key> is wrong if key doesn't have bucket.
                      return f"oss://{key}"
              
              def upload(self, file_path: str) -> str:
                  """
                  上传文件到阿里云OSS并获取URL(统一接口方法)
                  
                  Args:
                      file_path: 本地文件路径
                      
                  Returns:
                      oss_url: OSS URL,可在48小时内使用
                      
                  Raises:
                      FileNotFoundError: 文件不存在时
                      RuntimeError: API Key未设置时
                      Exception: 上传失败时
                  """
                  # 检查文件是否存在
                  if not os.path.exists(file_path):
                      raise FileNotFoundError(f"文件不存在: {file_path}")
                  
                  if not self.api_key:
                      raise RuntimeError("DASHSCOPE_API_KEY 未设置,无法使用图片上传服务")
                  
                  # 1. 获取上传凭证(注意:上传凭证接口有限流)
                  policy_data = self.get_upload_policy()
                  
                  # 2. 上传文件到OSS
                  oss_url = self.upload_file_to_oss(policy_data, file_path)
                  
                  # 3. 计算过期时间
                  expire_time = datetime.now() + timedelta(hours=48)
                  
                  logging.info(f"文件上传成功: {file_path}")
                  logging.info(f"  OSS URL: {oss_url}")
                  logging.info(f"  过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (48小时)")
                  
                  return oss_url
          
              def collage_images(self, image_paths, output_path):
                  """
                  拼图功能:将多张图片水平拼接
                  Args:
                      image_paths: 图片路径列表
                      output_path: 输出文件路径
                  """
                  if not image_paths:
                      return None
                  
                  images = []
                  for p in image_paths:
                      try:
                          img = Image.open(p)
                          images.append(img)
                      except Exception as e:
                          logging.error(f"Cannot open image {p}: {e}")
                  
                  if not images:
                      return None
          
                  # 统一高度,按第一张图片的高度调整其他图片
                  base_height = images[0].height
                  resized_images = []
                  for img in images:
                      if img.height != base_height:
                          ratio = base_height / img.height
                          new_width = int(img.width * ratio)
                          resized_images.append(img.resize((new_width, base_height)))
                      else:
                          resized_images.append(img)
                  
                  total_width = sum(img.width for img in resized_images)
                  new_im = Image.new('RGB', (total_width, base_height))
                  
                  x_offset = 0
                  for img in resized_images:
                      new_im.paste(img, (x_offset, 0))
                      x_offset += img.width
                      
                  new_im.save(output_path)
                  return output_path
          
          
        • image_seedream.py 9.9 KB
          """
          Seedream 图像生成 API 客户端
          字节跳动 ARK - doubao-seedream-5-0-260128 模型
          """
          
          import os
          import time
          import logging
          from typing import Optional, List, Dict
          from openai import OpenAI
          
          # 模型名称映射表(旧名称 -> 新名称)
          MODEL_NAME_MAP: Dict[str, str] = {
              # doubao-seedream-5-0 系列
              "doubao-seedream-5-0": "doubao-seedream-5-0-260128",
              # doubao-seedream-4-5 系列
              "doubao-seedream-4-5": "doubao-seedream-4-5-251128",
              # doubao-seedream-4-0 系列
              "doubao-seedream-4-0": "doubao-seedream-4-0-250828",
          }
          
          
          def normalize_model_name(model: str) -> str:
              """
              规范化模型名称
          
              Args:
                  model: 传入的模型名称
          
              Returns:
                  规范化后的模型名称
              """
              return MODEL_NAME_MAP.get(model, model)
          
          
          class SeedreamClient:
              """
              Seedream 图像生成客户端(字节跳动 ARK)
              支持文生图功能
              """
          
              def __init__(
                  self,
                  api_key: Optional[str] = None,
                  base_url: Optional[str] = None,
                  timeout: int = 120,
              ) -> None:
                  """
                  初始化 Seedream 客户端
          
                  Args:
                      api_key: ARK API Key
                      base_url: ARK API 基础 URL
                      timeout: HTTP请求超时时间(秒)
                  """
                  self.api_key = api_key or os.getenv("ARK_API_KEY")
                  self.base_url = base_url or "https://ark.cn-beijing.volces.com/api/v3"
                  self.timeout = timeout
          
                  if not self.api_key:
                      logging.warning(
                          "SeedreamClient missing api_key. Set ARK_API_KEY."
                      )
          
                  self.client = OpenAI(
                      base_url=self.base_url,
                      api_key=self.api_key,
                      timeout=timeout,
                  )
          
              def generate_image(
                  self,
                  prompt: str,
                  session_id: str,
                  model: str = "doubao-seedream-4-5-251128",
                  size: str = "1920*1080",
                  image_paths: Optional[List[str]] = None,
                  **kwargs
              ) -> List[str]:
                  """
                  生成图片
          
                  Args:
                      prompt: 提示词
                      session_id: 任务或会话ID,用于构建存储路径
                      model: 模型名称
                      size: 生成图片的分辨率,如 "1920*1080", "1024*1024"
                      image_paths: 参考图路径或URL列表 (图生图)
                      **kwargs: 其他生成参数
          
                  Returns:
                      生成的图片路径列表
                  """
                  if not self.api_key:
                      raise RuntimeError("ARK_API_KEY not set.")
          
                  # 规范化模型名称(旧名称 -> 新名称)
                  model = normalize_model_name(model)
          
                  # 处理分辨率 (Seedream 要求至少 3686400 像素)
                  # 常用 2K/4K 分辨率
                  size_map = {
                      # 16:9
                      "1920*1080": (1920, 1080),
                      "2048*1080": (2048, 1080),  # 2K 电影
                      "2560*1440": (2560, 1440),  # 2K QHD
                      "3840*2160": (3840, 2160),  # 4K UHD
                      "4096*2160": (4096, 2160),  # 4K 电影
                      # 9:16
                      "1080*1920": (1080, 1920),
                      "1080*2048": (1080, 2048),
                      "1440*2560": (1440, 2560),
                      "2160*3840": (2160, 3840),
                      "2160*4096": (2160, 4096),
                      # 1:1
                      "1024*1024": (1024, 1024),
                      "2048*2048": (2048, 2048),  # 2K 正方
                      # 4:3
                      "1920*1440": (1920, 1440),
                      "2560*1920": (2560, 1920),
                      # 3:4
                      "1440*1920": (1440, 1920),
                      "1920*2560": (1920, 2560),
                  }
          
                  width, height = 1920, 1080  # 默认
                  min_pixels = 3686400
          
                  if size:
                      parts = size.split("*")
                      if len(parts) == 2:
                          w, h = int(parts[0]), int(parts[1])
                          width, height = w, h
          
                  # 确保满足最小像素要求
                  if width * height < min_pixels:
                      # 查找相同宽高比的常用分辨率
                      aspect_ratio = width / height
                      for (w, h) in size_map.values():
                          if abs(w / h - aspect_ratio) < 0.01 and w * h >= min_pixels:
                              width, height = w, h
                              break
                      else:
                          # 没有找到合适的,按比例放大
                          scale = (min_pixels / (width * height)) ** 0.5
                          width = int(width * scale)
                          height = int(height * scale)
                          width = width if width % 2 == 0 else width + 1
                          height = height if height % 2 == 0 else height + 1
          
                  # 构建 extra_body
                  extra_body = {
                      "watermark": False,
                      "sequential_image_generation": "disabled",
                  }
          
                  # 添加其他参数
                  if "seed" in kwargs:
                      extra_body["seed"] = kwargs["seed"]
                  if "quality" in kwargs:
                      extra_body["quality"] = kwargs["quality"]
                  if "style" in kwargs:
                      extra_body["style"] = kwargs["style"]
          
                  # 处理参考图 (图生图)
                  image_urls = []
                  if image_paths and len(image_paths) > 0:
                      # 处理参考图:支持 URL 和本地文件
                      ref_images = []
                      for p in image_paths:
                          if p.startswith("http"):
                              ref_images.append(p)
                          elif os.path.exists(p):
                              # 转换为 base64 URL
                              import base64
                              with open(p, "rb") as f:
                                  img_data = base64.b64encode(f.read()).decode("utf-8")
                              ext = os.path.splitext(p)[1].lower()
                              mime = "image/png" if ext == ".png" else "image/jpeg"
                              ref_images.append(f"data:{mime};base64,{img_data}")
                      extra_body["image"] = ref_images
          
                  # 调用 API
                  if image_paths and len(image_paths) > 0:
                      # 图生图 - image 放在 extra_body 中
                      response = self.client.images.generate(
                          model=model,
                          prompt=prompt,
                          size=f"{width}x{height}",
                          response_format="url",
                          extra_body=extra_body,
                      )
                  else:
                      # 文生图
                      response = self.client.images.generate(
                          model=model,
                          prompt=prompt,
                          size=f"{width}x{height}",
                          response_format="url",
                          extra_body=extra_body,
                      )
          
                  # 下载图片到本地
                  generated_paths = []
                  if response.data:
                      for idx, img_data in enumerate(response.data):
                          if img_data.url:
                              local_path = self._download_image(
                                  img_data.url, session_id, idx
                              )
                              if local_path:
                                  generated_paths.append(local_path)
          
                  return generated_paths
          
              def _download_image(self, url: str, session_id: str, idx: int) -> Optional[str]:
                  """从URL下载图片到本地"""
                  import requests
          
                  # 构建存储路径
                  base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
                  result_dir = os.path.join(base_dir, "code", "result", "image", str(session_id))
                  os.makedirs(result_dir, exist_ok=True)
          
                  file_name = f"seedream_{int(time.time())}_{idx}.png"
                  file_path = os.path.join(result_dir, file_name)
          
                  try:
                      response = requests.get(url, timeout=self.timeout)
                      response.raise_for_status()
                      with open(file_path, "wb") as f:
                          f.write(response.content)
                      return file_path
                  except Exception as e:
                      logging.error(f"Failed to download image from {url}: {e}")
                      return None
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config  # 加载 .env
          
              print("=== Seedream 可用性测试 ===")
              api_key = os.getenv("ARK_API_KEY", "")
              base_url = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
              if not api_key:
                  print("✗ ARK_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              print(f"  Base URL: {base_url}")
          
              client = SeedreamClient(api_key=api_key, base_url=base_url)
          
              # === 测试1: 文生图 ===
              prompt = "星际穿越,黑洞,黑洞里冲出一辆支离破碎的复古列车,视觉冲击力,电影大片,末日既视感"
              print(f"\n[测试1: 文生图] Prompt: {prompt}")
              t0 = time.time()
              try:
                  paths = client.generate_image(
                      prompt=prompt,
                      session_id="test_avail",
                      model="doubao-seedream-5-0-260128",
                      size="1920*1080",
                  )
                  elapsed = time.time() - t0
                  if paths:
                      print(f"✓ 生成 {len(paths)} 张图片 ({elapsed:.1f}s): {paths}")
                  else:
                      print(f"✗ 返回空列表 ({elapsed:.1f}s)")
              except Exception as e:
                  print(f"✗ 图片生成失败: {e}")
          
              # === 测试2: 图生图 ===
              # 需要一张已有的参考图路径
              ref_image_path = "code/result/image/test_avail/test_input.png"
              if os.path.exists(ref_image_path):
                  prompt_i2i = "将这只猫变成赛博朋克风格"
                  print(f"\n[测试2: 图生图] Prompt: {prompt_i2i}")
                  print(f"  参考图: {ref_image_path}")
                  t0 = time.time()
                  try:
                      paths = client.generate_image(
                          prompt=prompt_i2i,
                          session_id="test_avail",
                          model="doubao-seedream-5-0-260128",
                          size="1920*1080",
                          image_paths=[ref_image_path],
                      )
                      elapsed = time.time() - t0
                      if paths:
                          print(f"✓ 生成 {len(paths)} 张图片 ({elapsed:.1f}s): {paths}")
                      else:
                          print(f"✗ 返回空列表 ({elapsed:.1f}s)")
                  except Exception as e:
                      print(f"✗ 图生图失败: {e}")
              else:
                  print(f"\n[测试2: 图生图] ✗ 参考图不存在: {ref_image_path}")
                  print("  跳过图生图测试,请先运行文生图测试生成参考图")
          
        • llm_client.py 5.7 KB
          import os
          import logging
          
          try:
              from tool.llm_gpt import GPT
              from tool.llm_gemini import Gemini
              from tool.llm_deepseek import DeepSeek
              from tool.llm_dashscope import QwenLLM
              from tool.relay_client import RelayClient
          except ImportError:
              from llm_gpt import GPT
              from llm_gemini import Gemini
              from llm_deepseek import DeepSeek
              from llm_dashscope import QwenLLM
              from relay_client import RelayClient
          
          from config import Config
          
          logger = logging.getLogger(__name__)
          
          class LLM:
              def __init__(self, gemini_base_url="", gemini_api_key="", gpt_base_url="", gpt_api_key="", deepseek_base_url="", deepseek_api_key="", dashscope_api_key=""):
                  self.gemini_base_url = gemini_base_url or os.getenv("GOOGLE_GEMINI_BASE_URL", "")
                  self.gemini_api_key = gemini_api_key or os.getenv("GEMINI_API_KEY", "")
                  self.gpt_base_url = gpt_base_url or os.getenv("OPENAI_BASE_URL", "")
                  self.gpt_api_key = gpt_api_key or os.getenv("OPENAI_API_KEY", "")
                  self.deepseek_base_url = deepseek_base_url or os.getenv("DEEPSEEK_BASE_URL", "")
                  self.deepseek_api_key = deepseek_api_key or os.getenv("DEEPSEEK_API_KEY", "")
                  self.dashscope_api_key = dashscope_api_key or os.getenv("DASHSCOPE_API_KEY", "")
          
                  # 初始化中转站客户端(如果配置了)
                  self._relay_client = None
                  relay_key = os.getenv("RELAY_API_KEY", "")
                  relay_url = os.getenv("RELAY_BASE_URL", "")
                  if relay_key and relay_url:
                      try:
                          self._relay_client = RelayClient(api_key=relay_key, base_url=relay_url)
                          logger.info("Relay client 初始化成功")
                      except Exception as e:
                          logger.warning(f"Relay client 初始化失败: {e}")
          
              def full_to_half(self, text):
                  if not isinstance(text, str):
                      return text
                  
                  translation_table = {0x3000: 0x0020}
                  for i in range(65281, 65375):
                      translation_table[i] = i - 65248
                      
                  return text.translate(translation_table)
          
              def query(self, prompt, image_urls=[], model="gemini-3-flash-preview", safe_content=True, task_id=None, web_search=False):
                  """
                  Query the LLM with a prompt and optional image URLs.
                  Selects the backend (GPT or Gemini) based on the model name.
          
                  :param web_search: Enable web search for supported providers
                  """
                  if safe_content:
                      prompt = self.full_to_half(prompt)
          
                  if not model:
                      model = "gemini-3-flash-preview"
                      
                  if Config.PRINT_MODEL_INPUT:
                      print("---- LLM QUERY REQUEST ----")
                      print(f"Model: {model}")
                      if task_id:
                          print(f"Task ID: {task_id}")
                      if image_urls:
                          print(f"Images: {len(image_urls)}")
                          for u in image_urls:
                              print(f"  - {u}")
                      print(f"Prompt: {prompt[:200]}{'...' if len(prompt) > 200 else ''}")
                      print("-" * 30)
                      
                  result = ""
                  model_lower = model.lower()
          
                  # 判断是否通过中转站调用(优先级:config_model.json 中 provider=relay)
                  is_relay_model = self._is_relay_model(model_lower)
          
                  if is_relay_model and self._relay_client:
                      # 通过中转站统一调用
                      result = self._relay_client.chat(prompt, model=model, image_urls=image_urls, web_search=web_search)
                  elif model_lower.startswith("gemini"):
                      # Gemini client handles its own credentials internally in the current implementation,
                      # but we pass args for consistency/future compatibility.
                      # Note: Gemini doesn't have built-in web search parameter, user needs to use Function Calling
                      client = Gemini(base_url=self.gemini_base_url, api_key=self.gemini_api_key)
                      result = client.query(prompt, image_urls=image_urls, model=model)
                  elif model_lower.startswith("deepseek"):
                      client = DeepSeek(base_url=self.deepseek_base_url, api_key=self.deepseek_api_key)
                      result = client.query(prompt, image_urls=image_urls, model=model, web_search=web_search)
                  elif "qwen" in model_lower:
                      # Qwen models via DashScope Generation API (text-only mode)
                      client = QwenLLM(api_key=self.dashscope_api_key)
                      result = client.query(prompt, image_urls=image_urls, model=model, web_search=web_search)
                  else:
                      # OpenAI 系列: gpt-4, gpt-4o, gpt-5, gpt-5.1, o3 等
                      client = GPT(base_url=self.gpt_base_url, api_key=self.gpt_api_key)
                      result = client.query(prompt, image_urls=image_urls, model=model, web_search=web_search)
          
                  if safe_content:
                      result = self.full_to_half(result)
                  
                  # Remove empty lines
                  return '\n'.join([line for line in result.split('\n') if line.strip() != ''])
          
              def _is_relay_model(self, model_lower: str) -> bool:
                  """
                  判断模型是否应通过中转站调用
                  优先读取 config_model.json 中的 provider 字段,
                  如果未配置或中转站不可用,则回退到直连。
                  """
                  if not self._relay_client:
                      return False
          
                  # 尝试从 config_model.json 读取 provider
                  try:
                      import json
                      config_path = os.path.join(
                          os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                          "config_model.json"
                      )
                      if os.path.exists(config_path):
                          with open(config_path, "r", encoding="utf-8") as f:
                              config = json.load(f)
                          model_info = config.get("models", {}).get(model_lower)
                          if model_info and model_info.get("provider") == "relay":
                              return True
                  except Exception:
                      pass
          
                  return False
          
        • llm_dashscope.py 4.5 KB
          # -*- coding: utf-8 -*-
          """
          Qwen LLM API 客户端(DashScope Generation API)
          支持 qwen3.5-plus, qwen3.5-max 等模型
          """
          
          import os
          import time
          import logging
          from typing import Optional
          
          logger = logging.getLogger(__name__)
          
          try:
              import dashscope
              from dashscope import Generation
          except ImportError:
              dashscope = None
              Generation = None
          
          
          class QwenLLM:
              """
              Qwen LLM 客户端,使用 DashScope Generation API
              支持纯文本生成(可作为 LLM 使用)
              """
              def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
                  """
                  :param api_key: DashScope API Key
                  :param base_url: DashScope API Base URL (可选)
                  """
                  self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
                  self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/api/v1")
          
                  if not self.api_key:
                      print("Warning: DASHSCOPE_API_KEY is not set.")
          
                  if dashscope:
                      dashscope.api_key = self.api_key
          
                  self.max_attempts = 3
          
              def query(self, prompt: str, image_urls: list = None, model: str = "qwen3.5-vl", web_search: bool = False):
                  """
                  Query Qwen model for text generation.
                  Note: This is for text-only LLM use. For image+text, use VLM client.
          
                  :param prompt: Text prompt
                  :param image_urls: Ignored in this LLM implementation (use VLM for multimodal)
                  :param model: Model name (e.g., qwen3.5-vl, qwen3.5-plus, qwen3.5-max)
                  :param web_search: If True, adds enable_search: True to API call
                  """
                  if dashscope is None:
                      raise RuntimeError("dashscope package not installed. Run: pip install dashscope")
          
                  if not model:
                      model = "qwen3.5-vl"
          
                  # Map common model names to DashScope API names
                  model_mapping = {
                      "qwen3.5-plus": "qwen-plus",
                      "qwen3.5-max": "qwen-max",
                      "qwen2.5-plus": "qwen-plus",
                      "qwen2.5-max": "qwen-max",
                  }
                  api_model = model_mapping.get(model.lower(), model)
          
                  messages = [{"role": "system", "content": "You are a helpful assistant."}]
                  messages.append({"role": "user", "content": prompt})
          
                  attempts = 0
                  while attempts < self.max_attempts:
                      try:
                          # Build request parameters
                          request_params = {
                              "model": api_model,
                              "messages": messages,
                              "result_format": "message",
                              "stream": False
                          }
                          # Add web search if enabled
                          if web_search:
                              request_params["enable_search"] = True
          
                          response = Generation.call(**request_params)
          
                          if response.status_code == 200:
                              if response.output.choices and response.output.choices[0].message.content:
                                  return response.output.choices[0].message.content
                              else:
                                  print("Received an empty response from Qwen. Retrying.")
                                  time.sleep(2)
                          else:
                              error_msg = f"Qwen API error: {response.code} - {response.message}"
                              logger.error(error_msg)
                              raise RuntimeError(error_msg)
          
                      except Exception as e:
                          logger.error(f"Error occurred with Qwen: {e}. Retrying.")
                          time.sleep(5)
          
                      attempts += 1
          
                  raise Exception("Max attempts reached, failed to get a response from Qwen.")
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的模型列表
              MODELS = ["qwen3.5-plus", "qwen3.5-max"]
          
              print("=== Qwen LLM (DashScope) 可用性测试 ===")
              api_key = os.getenv("DASHSCOPE_API_KEY", "")
              if not api_key:
                  print("✗ DASHSCOPE_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
          
              client = QwenLLM(api_key=api_key)
              prompt = "用一句话介绍你自己。"
              print(f"  Prompt: {prompt}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      resp = client.query(prompt, model=model)
                      elapsed = time.time() - t0
                      print(f"✓ 响应 ({elapsed:.1f}s): {resp.strip()[:200]}")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
          
        • llm_deepseek.py 3.3 KB
          import os
          import time
          from openai import OpenAI
          
          class DeepSeek:
              """
              deepseek-chat: DeepSeek-V3.2 非思考模式
              deepseek-reasoner: DeepSeek-V3.2 思考模式
              """
              def __init__(self, base_url="", api_key=""):
                  self.base_url = base_url or os.getenv("DEEPSEEK_BASE_URL") or "https://api.deepseek.com/v1"
                  self.api_key = api_key or os.getenv("DEEPSEEK_API_KEY")
                  
                  if not self.api_key:
                      print("Warning: DEEPSEEK_API_KEY is not set.")
          
                  self.client = OpenAI(
                      api_key=self.api_key, 
                      base_url=self.base_url
                  )
                  self.max_attempts = 3
          
              def query(self, prompt, image_urls=[], model="deepseek-chat", web_search=False):
                  """
                  Query DeepSeek model.
          
                  :param web_search: If True, adds enable_web_search: True to API call
                  """
                  if not model:
                      model = "deepseek-chat"
          
                  messages = [{"role": "system", "content": "You are a helpful assistant."}]
                  messages.append({"role": "user", "content": prompt})
          
                  attempts = 0
                  while attempts < self.max_attempts:
                      try:
                          # Build request parameters
                          request_params = {
                              "model": model,
                              "messages": messages,
                              "stream": False
                          }
                          # Add web search if enabled
                          if web_search:
                              request_params["enable_web_search"] = True
          
                          response = self.client.chat.completions.create(**request_params)
                          
                          # DeepSeek might return reasoning_content for reasoner models, 
                          # but standard content is what we return conform to other interfaces.
                          if response.choices and response.choices[0].message.content:
                              return response.choices[0].message.content
                          else:
                              print("Received an empty response from DeepSeek. Retrying.")
                              time.sleep(2)
                      except Exception as e:
                          print(f"Error occurred with DeepSeek: {e}. Retrying.")
                          time.sleep(5)
                      attempts += 1
                          
                  raise Exception("Max attempts reached, failed to get a response from DeepSeek.")
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的模型列表
              MODELS = ["deepseek-chat", "deepseek-reasoner"]
          
              print("=== DeepSeek 可用性测试 ===")
              api_key = os.getenv("DEEPSEEK_API_KEY", "")
              base_url = os.getenv("DEEPSEEK_BASE_URL", "")
              if not api_key:
                  print("✗ DEEPSEEK_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              if base_url:
                  print(f"  Base URL: {base_url}")
          
              client = DeepSeek(api_key=api_key, base_url=base_url)
              prompt = "用一句话介绍你自己。"
              print(f"  Prompt: {prompt}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      resp = client.query(prompt, model=model)
                      elapsed = time.time() - t0
                      print(f"✓ 响应 ({elapsed:.1f}s): {resp.strip()[:200]}")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
          
        • llm_gemini.py 4.2 KB
          # -*- coding: utf-8 -*-
          """
          Google Gemini LLM 客户端 (OpenAI 兼容格式)
          支持 gemini-2.5-flash, gemini-2.5-pro 等模型
          
          可用模型:
              - gemini-2.5-flash (性价比高)
              - gemini-2.5-flash-preview
              - gemini-2.5-pro (效果最好)
              - gemini-2.5-pro-preview
              - gemini-2.0-flash
          """
          
          import os
          import time
          from openai import OpenAI
          from typing import List
          
          class Gemini:
              """
              Gemini LLM 客户端,使用 OpenAI 兼容格式调用
              """
              def __init__(self, base_url: str = "", api_key: str = ""):
                  """
                  初始化 Gemini 客户端
                  :param base_url: OpenAI 兼容的 Base URL
                  :param api_key: Gemini API Key
                  """
                  # 确保 base_url 以 /v1 结尾
                  default_url = "http://35.164.11.19:3887/v1"
                  self.base_url = base_url or os.getenv("GOOGLE_GEMINI_BASE_URL", default_url)
                  if self.base_url and not self.base_url.endswith("/v1"):
                      self.base_url = self.base_url.rstrip("/") + "/v1"
                  self.api_key = api_key or os.getenv("GEMINI_API_KEY", "")
                  self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
                  self.max_attempts = 10
                  self.max_tokens = 20000
          
              def query(self, prompt: str, image_urls: List[str] = [], model: str = "gemini-2.5-flash") -> str:
                  """
                  调用 Gemini LLM
                  :param prompt: 文本提示
                  :param image_urls: 图片 URL 列表(可选,用于多模态模型)
                  :param model: 模型名
                  :return: 生成的文本
                  """
                  if not model:
                      model = "gemini-2.5-flash"
          
                  # 构建消息格式
                  content: list = [{"type": "text", "text": prompt}]
          
                  # 添加图片 (如果有多模态模型支持)
                  if image_urls:
                      for img_url in image_urls:
                          if img_url.startswith("http"):
                              content.append({
                                  "type": "image_url",
                                  "image_url": {"url": img_url}
                              })
          
                  messages = [{"role": "user", "content": content}]
          
                  attempts = 0
                  while attempts < self.max_attempts:
                      try:
                          # 直接使用模型名(代理服务会处理格式转换)
                          response = self.client.chat.completions.create(
                              model=model,
                              messages=messages,
                              max_tokens=self.max_tokens,
                              temperature=0.7
                          )
          
                          # 检查响应类型
                          if isinstance(response, str):
                              print(f"Gemini 返回字符串响应: {response}")
                              raise Exception(f"API 返回错误: {response}")
          
                          if response.choices and len(response.choices) > 0:
                              return response.choices[0].message.content
          
                      except Exception as e:
                          print(f"Gemini 请求错误: {e}")
                          attempts += 1
                          if attempts < self.max_attempts:
                              time.sleep(10)
          
                  raise Exception("Gemini: 达到最大重试次数,仍未获得有效响应。")
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的模型列表
              MODELS = ["gemini-2.5-flash", "gemini-2.0-flash"]
          
              print("=== Gemini LLM 可用性测试 ===")
              api_key = Config.GEMINI_API_KEY
              base_url = Config.GOOGLE_GEMINI_BASE_URL
              if not api_key:
                  print("✗ GEMINI_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***")
              # 实际使用的 URL(会自动添加 /v1)
              actual_url = base_url if base_url and base_url.endswith("/v1") else (base_url + "/v1" if base_url else "http://35.164.11.19:3887/v1")
              print(f"  Base URL: {actual_url}")
              client = Gemini(api_key=api_key, base_url=base_url)
              prompt = "用一句话介绍你自己。"
              print(f"  Prompt: {prompt}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      resp = client.query(prompt, model=model)
                      elapsed = time.time() - t0
                      print(f"✓ 响应 ({elapsed:.1f}s): {resp.strip()[:200]}")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
          
        • llm_gpt.py 3.4 KB
          
          import os
          import time
          from openai import OpenAI
          
          
          class GPT:
              """
              OpenAI 文本生成客户端
              可选模型:gpt-4o 等
              """
              def __init__(self, base_url="", api_key="", timeout=300):
                  if base_url:
                      self.client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
                  else:
                      self.client = OpenAI(api_key=api_key, timeout=timeout)
                  self.max_attempts = 10
                  self.max_tokens = 8192
          
              def query(self, prompt, image_urls=[], model="", web_search=False):
                  self.model = model
                  if self.model == "":
                      self.model = "gpt-5"
          
                  # Switch to search model if web_search is enabled
                  # OpenAI uses gpt-4o-search-preview for web search
                  if web_search and not self.model.endswith("-search"):
                      search_model_map = {
                          "gpt-4o": "gpt-4o-search-preview",
                          "gpt-4": "gpt-4-search-preview",
                          "gpt-5": "gpt-5-search",
                      }
                      self.model = search_model_map.get(self.model, self.model + "-search")
          
                  messages = [{"role": "system", "content": "You are a helpful assistant."}]
                  content = [{"type": "text", "text": prompt}]
                  if image_urls:
                      content.extend([{"type": "image_url", "image_url": {"url": url}} for url in image_urls])
                  messages.append({"role": "user", "content": content})
          
                  attempts = 0
                  while attempts < self.max_attempts:
                      try:
                          # Build request parameters
                          request_params = {
                              "model": self.model,
                              "messages": messages,
                              "max_tokens": self.max_tokens
                          }
                          # Add search tool if web_search is enabled
                          if web_search:
                              request_params["search_tool"] = "auto"
          
                          response = self.client.chat.completions.create(**request_params)
                          if response.choices[0].message.content.strip():
                              return response.choices[0].message.content
                          else:
                              print("Received an empty response. Retrying in 10 seconds.")
                      except Exception as e:
                          print(messages)
                          print(f"Error occurred: {e}. Retrying in 10 seconds.")
                          time.sleep(10)
                          attempts += 1
          
                  raise Exception("Max attempts reached, failed to get a response from OpenAI.") 
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的模型列表
              MODELS = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-5"]
          
              print("=== GPT 文本生成可用性测试 ===")
              api_key = Config.OPENAI_API_KEY
              base_url = Config.OPENAI_BASE_URL
              if not api_key:
                  print("✗ OPENAI_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              print(f"  Base URL: {base_url}")
              client = GPT(api_key=api_key, base_url=base_url)
          
              prompt = "用一句话介绍你自己。"
              print(f"  Prompt: {prompt}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      resp = client.query(prompt, model=model)
                      elapsed = time.time() - t0
                      print(f"✓ 响应 ({elapsed:.1f}s): {resp.strip()[:200]}")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
          
        • relay_client.py 20.1 KB
          # -*- coding: utf-8 -*-
          """
          统一中转站 (Relay) 客户端
          通过第三方中转站(如青云 API)统一调用各种模型
          使用 OpenAI 兼容格式,一个 API Key + Base URL 调用所有模型
          
          支持的模型类型:
            - LLM 文本生成(chat.completions)
            - VLM 视觉理解(chat.completions + 图片)
            - 图片生成(images.generate 或 chat.completions)
            - 视频生成(异步任务:创建 → 轮询 → 获取结果)
            - Embedding(embeddings)
          
          参考:青云聚合 API (https://api.qingyuntop.top)
          """
          
          import os
          import time
          import json
          import base64
          import logging
          import requests
          from typing import List, Optional, Dict, Any
          from openai import OpenAI
          
          logger = logging.getLogger(__name__)
          
          
          class RelayClient:
              """
              统一中转站客户端
              所有模型通过同一个 OpenAI 兼容 API 调用
              """
          
              def __init__(
                  self,
                  api_key: Optional[str] = None,
                  base_url: Optional[str] = None,
                  timeout: int = 300,
              ):
                  self.api_key = api_key or os.getenv("RELAY_API_KEY", "")
                  self.base_url = base_url or os.getenv("RELAY_BASE_URL", "")
                  self.provider_name = os.getenv("RELAY_PROVIDER_NAME", "relay")
                  self.timeout = timeout
          
                  if not self.api_key:
                      raise ValueError(
                          "RELAY_API_KEY 未设置。请在 .env 中配置 RELAY_API_KEY 或传入 api_key 参数。\n"
                          "示例: RELAY_API_KEY=sk-xxx RELAY_BASE_URL=https://api.qingyuntop.top/v1"
                      )
                  if not self.base_url:
                      raise ValueError(
                          "RELAY_BASE_URL 未设置。请在 .env 中配置 RELAY_BASE_URL 或传入 base_url 参数。\n"
                          "示例: RELAY_BASE_URL=https://api.qingyuntop.top/v1"
                      )
          
                  # 确保 base_url 以 /v1 结尾
                  if not self.base_url.endswith("/v1"):
                      self.base_url = self.base_url.rstrip("/") + "/v1"
          
                  self.client = OpenAI(
                      api_key=self.api_key,
                      base_url=self.base_url,
                      timeout=timeout,
                  )
                  self._base_url_no_v1 = self.base_url.rstrip("/v1").rstrip("/v")
          
                  logger.info(f"RelayClient 初始化: provider={self.provider_name}, base_url={self.base_url}")
          
              # ==================== LLM 文本生成 ====================
          
              def chat(
                  self,
                  prompt: str,
                  model: str = "qwen3.5-plus",
                  image_urls: Optional[List[str]] = None,
                  max_tokens: int = 8192,
                  temperature: float = 0.7,
                  system_prompt: str = "You are a helpful assistant.",
                  web_search: bool = False,
              ) -> str:
                  """
                  LLM 文本生成(OpenAI chat.completions 格式)
          
                  Args:
                      prompt: 用户提示词
                      model: 模型名称
                      image_urls: 图片 URL 列表(多模态模型)
                      max_tokens: 最大生成 token 数
                      temperature: 温度参数
                      system_prompt: 系统提示词
                      web_search: 是否启用联网搜索
          
                  Returns:
                      生成的文本内容
                  """
                  messages = [{"role": "system", "content": system_prompt}]
          
                  # 构建用户消息
                  content: list = [{"type": "text", "text": prompt}]
                  if image_urls:
                      for url in image_urls:
                          content.append({"type": "image_url", "image_url": {"url": url}})
          
                  messages.append({"role": "user", "content": content})
          
                  max_attempts = 5
                  for attempt in range(max_attempts):
                      try:
                          request_params = {
                              "model": model,
                              "messages": messages,
                              "max_tokens": max_tokens,
                              "temperature": temperature,
                          }
                          if web_search:
                              request_params["search_tool"] = "auto"
          
                          response = self.client.chat.completions.create(**request_params)
          
                          if response.choices and response.choices[0].message.content:
                              return response.choices[0].message.content.strip()
                          else:
                              logger.warning(f"[{self.provider_name}] 空响应,重试 {attempt + 1}/{max_attempts}")
                      except Exception as e:
                          logger.error(f"[{self.provider_name}] chat 错误 (model={model}): {e}")
                          if attempt < max_attempts - 1:
                              time.sleep(5 * (attempt + 1))
          
                  raise Exception(f"[{self.provider_name}] 达到最大重试次数,模型 {model} 调用失败")
          
              # ==================== VLM 视觉理解 ====================
          
              def vlm_chat(
                  self,
                  prompt: str,
                  image_paths: Optional[List[str]] = None,
                  model: str = "gemini-3-flash-preview",
                  max_tokens: int = 4096,
              ) -> str:
                  """
                  VLM 视觉语言模型调用(图片理解)
          
                  Args:
                      prompt: 文本提示
                      image_paths: 图片路径列表(支持本地路径、URL、data: URI)
                      model: 视觉模型名称
          
                  Returns:
                      模型回复文本
                  """
                  content: list = [{"type": "text", "text": prompt}]
          
                  if image_paths:
                      for path in image_paths:
                          if path.startswith("data:"):
                              content.append({"type": "image_url", "image_url": {"url": path}})
                          elif path.startswith("http"):
                              content.append({"type": "image_url", "image_url": {"url": path}})
                          else:
                              # 本地文件转 base64
                              with open(path, "rb") as f:
                                  img_data = base64.b64encode(f.read()).decode("utf-8")
                              ext = os.path.splitext(path)[1].lower()
                              mime = "image/png" if ext == ".png" else "image/jpeg"
                              content.append({
                                  "type": "image_url",
                                  "image_url": {"url": f"data:{mime};base64,{img_data}"}
                              })
          
                  messages = [{"role": "user", "content": content}]
          
                  max_attempts = 5
                  for attempt in range(max_attempts):
                      try:
                          response = self.client.chat.completions.create(
                              model=model,
                              messages=messages,
                              max_tokens=max_tokens,
                          )
                          if response.choices and response.choices[0].message.content:
                              return response.choices[0].message.content.strip()
                      except Exception as e:
                          logger.error(f"[{self.provider_name}] vlm_chat 错误: {e}")
                          if attempt < max_attempts - 1:
                              time.sleep(5 * (attempt + 1))
          
                  raise Exception(f"[{self.provider_name}] VLM 调用失败,模型 {model}")
          
              # ==================== 图片生成 ====================
          
              def generate_image(
                  self,
                  prompt: str,
                  model: str = "gemini-3-pro-image-preview",
                  size: str = "1024x1024",
                  save_dir: Optional[str] = None,
                  image_urls: Optional[List[str]] = None,
              ) -> str:
                  """
                  图片生成(通过中转站)
          
                  支持两种格式:
                    1. images.generate 格式(如 sora_image)
                    2. chat.completions 格式(如 gemini-3-pro-image-preview,通过 chat 返回 base64 图片)
          
                  Args:
                      prompt: 图片描述
                      model: 图片模型名
                      size: 图片尺寸
                      save_dir: 保存目录(不传则返回 URL/base64)
                      image_urls: 参考图片 URL
          
                  Returns:
                      本地文件路径、URL 或 base64 字符串
                  """
                  # 判断是否使用 chat 格式(gemini/grok 系列图片模型)
                  chat_image_models = [
                      "gemini-3-pro-image-preview",
                      "gemini-2.5-pro-image",
                      "gemini-2.5-flash-image",
                      "grok-imagine-image-pro",
                  ]
                  use_chat_format = any(m in model for m in chat_image_models)
          
                  if use_chat_format:
                      return self._generate_image_chat(prompt, model, size, save_dir, image_urls)
                  else:
                      return self._generate_image_api(prompt, model, size, save_dir)
          
              def _generate_image_chat(
                  self,
                  prompt: str,
                  model: str,
                  size: str = "1024x1024",
                  save_dir: Optional[str] = None,
                  image_urls: Optional[List[str]] = None,
              ) -> str:
                  """通过 chat.completions 格式生成图片(Gemini/Grok 系列)"""
                  content: list = [{"type": "text", "text": prompt}]
                  if image_urls:
                      for url in image_urls:
                          content.append({"type": "image_url", "image_url": {"url": url}})
          
                  messages = [{"role": "user", "content": content}]
          
                  max_attempts = 5
                  for attempt in range(max_attempts):
                      try:
                          response = self.client.chat.completions.create(
                              model=model,
                              messages=messages,
                          )
          
                          if not response.choices:
                              continue
          
                          choice = response.choices[0]
                          msg = choice.message
          
                          # 检查是否有 inline_data(base64 图片)
                          if hasattr(msg, 'content') and isinstance(msg.content, list):
                              for part in msg.content:
                                  if isinstance(part, dict) and part.get("type") == "image_url":
                                      img_url = part["image_url"]["url"]
                                      return self._save_or_return_image(img_url, save_dir)
          
                          # 文本响应中可能包含 base64
                          text = msg.content if isinstance(msg.content, str) else ""
                          if text and ";base64," in text:
                              # 从文本中提取 base64
                              import re
                              match = re.search(r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)', text)
                              if match:
                                  b64_data = match.group(1)
                                  return self._save_b64_image(b64_data, save_dir)
          
                          # 直接返回文本(可能包含 URL)
                          if text:
                              # 尝试提取 URL
                              url_match = re.search(r'https?://[^\s)]+\.(png|jpg|jpeg|webp)', text)
                              if url_match:
                                  return self._download_and_save(url_match.group(0), save_dir)
                              return text
          
                      except Exception as e:
                          logger.error(f"[{self.provider_name}] 图片生成(chat) 错误: {e}")
                          if attempt < max_attempts - 1:
                              time.sleep(5)
          
                  raise Exception(f"[{self.provider_name}] 图片生成失败,模型 {model}")
          
              def _generate_image_api(
                  self,
                  prompt: str,
                  model: str,
                  size: str = "1024x1024",
                  save_dir: Optional[str] = None,
              ) -> str:
                  """通过 images.generate 格式生成图片"""
                  max_attempts = 5
                  for attempt in range(max_attempts):
                      try:
                          response = self.client.images.generate(
                              model=model,
                              prompt=prompt,
                              size=size,
                              n=1,
                          )
                          if response.data and response.data[0]:
                              img = response.data[0]
                              if img.url:
                                  return self._download_and_save(img.url, save_dir)
                              elif hasattr(img, 'b64_json') and img.b64_json:
                                  return self._save_b64_image(img.b64_json, save_dir)
                      except Exception as e:
                          logger.error(f"[{self.provider_name}] 图片生成(api) 错误: {e}")
                          if attempt < max_attempts - 1:
                              time.sleep(5)
          
                  raise Exception(f"[{self.provider_name}] 图片生成失败,模型 {model}")
          
              # ==================== 视频生成(异步任务) ====================
          
              def generate_video(
                  self,
                  prompt: str,
                  model: str = "sora-2-all",
                  image_url: Optional[str] = None,
                  save_path: Optional[str] = None,
                  poll_interval: int = 10,
                  max_wait: int = 600,
                  **kwargs,
              ) -> str:
                  """
                  视频生成(异步任务模式)
                  创建任务 → 轮询状态 → 下载结果
          
                  支持模型:
                    - sora-2-all, sora-2-pro-all (Sora 格式)
                    - veo_3_1-fast-4K, veo_3_1-components-4K (Veo 格式)
                    - grok-video-3-10s (Grok 格式)
                    - doubao-seedance-* (豆包格式)
          
                  Args:
                      prompt: 视频描述
                      model: 视频模型名
                      image_url: 首帧图片 URL(图生视频)
                      save_path: 保存路径
                      poll_interval: 轮询间隔(秒)
                      max_wait: 最大等待时间(秒)
                      **kwargs: 额外参数(duration, ratio 等)
          
                  Returns:
                      视频本地路径或 URL
                  """
                  # 1. 创建任务
                  task_id = self._create_video_task(prompt, model, image_url, **kwargs)
                  logger.info(f"[{self.provider_name}] 视频任务创建: task_id={task_id}, model={model}")
          
                  # 2. 轮询等待
                  result = self._poll_task(task_id, poll_interval, max_wait)
          
                  # 3. 提取视频 URL
                  video_url = self._extract_video_url(result, model)
          
                  # 4. 下载保存
                  if save_path and video_url:
                      return self._download_video(video_url, save_path)
          
                  return video_url
          
              def _create_video_task(
                  self,
                  prompt: str,
                  model: str,
                  image_url: Optional[str] = None,
                  **kwargs,
              ) -> str:
                  """创建视频生成任务"""
                  body: Dict[str, Any] = {
                      "model": model,
                      "prompt": prompt,
                  }
                  if image_url:
                      body["image_url"] = image_url
                  body.update(kwargs)
          
                  # 使用 raw HTTP 请求(视频 API 通常是自定义格式)
                  url = f"{self._base_url_no_v1}/v1/video/generations"
                  headers = {
                      "Content-Type": "application/json",
                      "Authorization": f"Bearer {self.api_key}",
                  }
          
                  resp = requests.post(url, json=body, headers=headers, timeout=self.timeout)
                  data = resp.json()
          
                  if resp.status_code >= 400:
                      raise RuntimeError(f"视频任务创建失败: {data}")
          
                  # 兼容多种响应格式
                  task_id = (
                      data.get("id")
                      or data.get("task_id")
                      or data.get("data", {}).get("task_id")
                      or data.get("data", {}).get("id")
                  )
                  if not task_id:
                      raise RuntimeError(f"无法提取 task_id: {data}")
          
                  return str(task_id)
          
              def _poll_task(self, task_id: str, interval: int = 10, max_wait: int = 600) -> dict:
                  """轮询异步任务状态"""
                  url = f"{self._base_url_no_v1}/v1/video/generations/{task_id}"
                  headers = {"Authorization": f"Bearer {self.api_key}"}
          
                  elapsed = 0
                  while elapsed < max_wait:
                      resp = requests.get(url, headers=headers, timeout=30)
                      data = resp.json()
          
                      status = (
                          data.get("status")
                          or data.get("data", {}).get("status")
                          or "unknown"
                      ).lower()
          
                      if status in ("completed", "succeed", "succeeded", "success"):
                          return data
                      elif status in ("failed", "error"):
                          raise RuntimeError(f"视频任务失败: {data}")
          
                      logger.info(f"[{self.provider_name}] 视频任务 {task_id}: {status} ({elapsed}s)")
                      time.sleep(interval)
                      elapsed += interval
          
                  raise TimeoutError(f"视频任务超时 ({max_wait}s): task_id={task_id}")
          
              def _extract_video_url(self, result: dict, model: str) -> str:
                  """从任务结果中提取视频 URL"""
                  # 多种响应格式兼容
                  candidates = [
                      result.get("data", {}).get("video_url"),
                      result.get("data", {}).get("url"),
                      result.get("output", {}).get("video_url"),
                      result.get("video_url"),
                  ]
          
                  # 嵌套在 results 数组中
                  results_list = result.get("data", {}).get("results", [])
                  if results_list and isinstance(results_list, list):
                      for item in results_list:
                          if isinstance(item, dict):
                              url = item.get("url") or item.get("video_url")
                              if url:
                                  candidates.append(url)
          
                  for url in candidates:
                      if url and isinstance(url, str) and url.startswith("http"):
                          return url
          
                  raise RuntimeError(f"无法提取视频 URL: {json.dumps(result, ensure_ascii=False)[:500]}")
          
              def _download_video(self, url: str, save_path: str) -> str:
                  """下载视频到本地"""
                  os.makedirs(os.path.dirname(save_path), exist_ok=True)
                  resp = requests.get(url, stream=True, timeout=300)
                  resp.raise_for_status()
                  with open(save_path, "wb") as f:
                      for chunk in resp.iter_content(chunk_size=8192):
                          if chunk:
                              f.write(chunk)
                  logger.info(f"[{self.provider_name}] 视频已保存: {save_path}")
                  return save_path
          
              # ==================== Embedding ====================
          
              def embed(self, text: str, model: str = "gemini-embedding-2-preview") -> List[float]:
                  """
                  文本 Embedding
          
                  Args:
                      text: 输入文本
                      model: Embedding 模型名
          
                  Returns:
                      向量列表
                  """
                  response = self.client.embeddings.create(
                      model=model,
                      input=text,
                  )
                  return response.data[0].embedding
          
              # ==================== 工具方法 ====================
          
              def _save_or_return_image(self, url_or_data: str, save_dir: Optional[str]) -> str:
                  if url_or_data.startswith("data:"):
                      # base64 data URI
                      _, b64_data = url_or_data.split(",", 1)
                      return self._save_b64_image(b64_data, save_dir)
                  elif url_or_data.startswith("http"):
                      return self._download_and_save(url_or_data, save_dir)
                  else:
                      return url_or_data
          
              def _save_b64_image(self, b64_data: str, save_dir: Optional[str]) -> str:
                  if save_dir:
                      os.makedirs(save_dir, exist_ok=True)
                      file_name = f"relay_{int(time.time())}_{id(b64_data) % 10000}.png"
                      file_path = os.path.join(save_dir, file_name)
                      with open(file_path, "wb") as f:
                          f.write(base64.b64decode(b64_data))
                      return file_path
                  return b64_data
          
              def _download_and_save(self, url: str, save_dir: Optional[str]) -> str:
                  if not save_dir:
                      return url
                  os.makedirs(save_dir, exist_ok=True)
                  ext = os.path.splitext(url.split("?")[0])[1] or ".png"
                  file_name = f"relay_{int(time.time())}_{hash(url) % 10000}{ext}"
                  file_path = os.path.join(save_dir, file_name)
                  resp = requests.get(url, timeout=120)
                  resp.raise_for_status()
                  with open(file_path, "wb") as f:
                      f.write(resp.content)
                  return file_path
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              print("=== Relay Client (统一中转站) 可用性测试 ===")
              api_key = Config.RELAY_API_KEY
              base_url = Config.RELAY_BASE_URL
              provider = Config.RELAY_PROVIDER_NAME
          
              if not api_key or not base_url:
                  print("✗ RELAY_API_KEY 或 RELAY_BASE_URL 未设置,跳过")
                  print("  请在 .env 中配置:")
                  print("  RELAY_API_KEY=your_relay_api_key")
                  print("  RELAY_BASE_URL=https://api.qingyuntop.top")
                  sys.exit(1)
          
              print(f"  Provider: {provider}")
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              print(f"  Base URL: {base_url}")
          
              client = RelayClient(api_key=api_key, base_url=base_url)
          
              # 测试 1: LLM
              print("\n--- 测试 LLM (qwen3.5-plus) ---")
              try:
                  resp = client.chat("用一句话介绍你自己。", model="qwen3.5-plus")
                  print(f"✓ LLM 响应: {resp[:200]}")
              except Exception as e:
                  print(f"✗ LLM 失败: {e}")
          
              # 测试 2: Embedding
              print("\n--- 测试 Embedding (gemini-embedding-2-preview) ---")
              try:
                  vec = client.embed("测试文本", model="gemini-embedding-2-preview")
                  print(f"✓ Embedding 维度: {len(vec)}, 前5: {vec[:5]}")
              except Exception as e:
                  print(f"✗ Embedding 失败: {e}")
          
        • video_client.py 5.7 KB
          """
          统一视频生成客户端
          根据 model 名称自动路由到对应后端:
            - wan*      → WanVideoClient (DashScope VideoSynthesis)
            - jimeng*   → JiMengClient (火山引擎即梦)
            - kling*    → KlingVideoClient (可灵 AI)
          """
          
          import os
          import logging
          from typing import Optional
          from config import Config
          
          try:
              from tool.video_wan import WanVideoClient
              from tool.image_jimeng import JiMengClient
              from tool.video_kling import KlingVideoClient
          except ImportError:
              from video_wan import WanVideoClient
              from image_jimeng import JiMengClient
              from video_kling import KlingVideoClient
          
          logger = logging.getLogger(__name__)
          
          
          class VideoClient:
              """
              统一视频生成客户端
              参照 ImageClient 模式,按模型名路由到不同后端
              """
          
              def __init__(
                  self,
                  dashscope_api_key: Optional[str] = None,
                  dashscope_base_url: Optional[str] = None,
                  jimeng_base_url: Optional[str] = None,
                  jimeng_access_key: Optional[str] = None,
                  jimeng_secret_key: Optional[str] = None,
                  kling_access_key: Optional[str] = None,
                  kling_secret_key: Optional[str] = None,
                  kling_base_url: Optional[str] = None,
              ):
                  # 万象客户端
                  self.wan_client = WanVideoClient(
                      api_key=dashscope_api_key,
                      base_url=dashscope_base_url,
                  )
          
                  # 即梦客户端(图片+视频共用 HMAC 鉴权)
                  self.jimeng_client = JiMengClient(
                      base_url=jimeng_base_url,
                      access_key=jimeng_access_key,
                      secret_key=jimeng_secret_key,
                  )
          
                  # 可灵客户端
                  self.kling_client = KlingVideoClient(
                      access_key=kling_access_key,
                      secret_key=kling_secret_key,
                      base_url=kling_base_url,
                  )
          
              def generate_video(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str = "wan2.6-i2v-flash",
                  duration: int = 5,
                  shot_type: str = "multi",
                  sound: str = "",
              ) -> str:
                  """
                  生成视频
          
                  Args:
                      prompt: 视频描述提示词
                      image_path: 输入图片本地路径
                      save_path: 输出视频保存路径
                      model: 模型名,决定使用哪个后端
                      duration: 视频时长(秒)
                      shot_type: 镜头类型 "single" / "multi"
          
                  Returns:
                      video_url: 远端视频 URL(万象)或 task_id(即梦)
          
                  Raises:
                      FileNotFoundError: 输入图片不存在
                      RuntimeError: 生成或下载失败
                  """
                  if not model:
                      model = "wan2.6-i2v-flash"
          
                  if Config.PRINT_MODEL_INPUT:
                      print("---- VIDEO GENERATION REQUEST ----")
                      print(f"Prompt: {prompt}")
                      if image_path and str(image_path).startswith("data:"):
                          print(f"Image: [Base64图片]")
                      else:
                          print(f"Image: {image_path}")
                      print(f"Model: {model}")
                      print(f"Duration: {duration}s")
                      print(f"Shot Type: {shot_type}")
                      print(f"Save: {save_path}")
                      print("-" * 30)
          
                  model_lower = model.lower()
          
                  if "jimeng" in model_lower:
                      return self._generate_jimeng(prompt, image_path, save_path, model)
                  elif "kling" in model_lower:
                      return self._generate_kling(prompt, image_path, save_path, model, duration, sound)
                  else:
                      return self._generate_wan(prompt, image_path, save_path, model, duration, shot_type)
          
              def _generate_wan(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str,
                  duration: int,
                  shot_type: str,
              ) -> str:
                  """通过万象模型生成视频"""
                  logger.info(f"VideoClient: 路由至万象 model={model}")
                  return self.wan_client.generate_video(
                      prompt=prompt,
                      image_path=image_path,
                      save_path=save_path,
                      model=model,
                      duration=duration,
                      shot_type=shot_type,
                  )
          
              def _generate_jimeng(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str,
              ) -> str:
                  """通过即梦模型生成视频"""
                  logger.info(f"VideoClient: 路由至即梦 model={model}")
                  task_id = self.jimeng_client.generate_video(
                      prompt=prompt,
                      image_path=image_path,
                  )
          
                  # 轮询获取结果
                  result = self.jimeng_client.poll_task(model=model, task_id=task_id)
          
                  # 即梦返回的视频数据可能是 URL 或 base64
                  video_url = result.get("video_url", "")
                  if video_url:
                      import requests
                      os.makedirs(os.path.dirname(save_path), exist_ok=True)
                      resp = requests.get(video_url, stream=True, timeout=120)
                      resp.raise_for_status()
                      with open(save_path, 'wb') as f:
                          for chunk in resp.iter_content(chunk_size=8192):
                              if chunk:
                                  f.write(chunk)
                      return video_url
          
                  raise RuntimeError(f"即梦视频生成未返回有效结果: {result}")
          
              def _generate_kling(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str,
                  duration: int = 5,
                  sound: str = "",
              ) -> str:
                  """通过可灵模型生成视频"""
                  logger.info(f"VideoClient: 路由至可灵 model={model}")
                  return self.kling_client.generate_video(
                      prompt=prompt,
                      image_path=image_path,
                      save_path=save_path,
                      model=model,
                      duration=duration,
                      sound=sound,
                  )
          
        • video_kling.py 14.7 KB
          """
          可灵(Kling AI)视频生成客户端
          基于可灵 API 的图生视频功能 (image2video)
          支持模型: kling-v3, kling-v2-6, kling-v2-5-turbo
          """
          
          import os
          import io
          import ssl
          import time
          import base64
          import logging
          from typing import Optional
          
          import jwt
          import requests
          from requests.adapters import HTTPAdapter
          from urllib3.util.retry import Retry
          from PIL import Image
          
          logger = logging.getLogger(__name__)
          
          # 可灵 API 基础地址
          KLING_BASE_URL = "https://api-beijing.klingai.com"
          
          
          class _TLSAdapter(HTTPAdapter):
              """强制 TLS 1.2 的 HTTPS 适配器,兼容老版本 LibreSSL"""
          
              def init_poolmanager(self, *args, **kwargs):
                  ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
                  ctx.minimum_version = ssl.TLSVersion.TLSv1_2
                  ctx.maximum_version = ssl.TLSVersion.TLSv1_2
                  ctx.load_default_certs()
                  kwargs["ssl_context"] = ctx
                  return super().init_poolmanager(*args, **kwargs)
          
          
          def _build_session(max_retries: int = 3) -> requests.Session:
              """创建带 TLS 适配器和自动重试的 requests Session"""
              session = requests.Session()
              retry = Retry(
                  total=max_retries,
                  backoff_factor=1,
                  status_forcelist=[502, 503, 504],
                  allowed_methods=["GET", "POST"],
              )
              adapter = _TLSAdapter(max_retries=retry)
              session.mount("https://", adapter)
              return session
          
          
          class KlingVideoClient:
              """
              可灵 AI 图生视频客户端
              使用 JWT (HMAC-SHA256) 鉴权,调用 /v1/videos/image2video 接口
              """
          
              def __init__(
                  self,
                  access_key: Optional[str] = None,
                  secret_key: Optional[str] = None,
                  base_url: Optional[str] = None,
                  token_ttl: int = 1800,
                  poll_interval: int = 5,
                  max_polls: int = 120,
              ) -> None:
                  """
                  Args:
                      access_key: 可灵 API Access Key
                      secret_key: 可灵 API Secret Key
                      base_url:   可灵 API 基础 URL (默认北京节点)
                      token_ttl:  JWT 有效期(秒),默认 30 分钟
                      poll_interval: 轮询间隔(秒)
                      max_polls:  最大轮询次数
                  """
                  self.access_key = access_key or os.getenv("KLING_ACCESS_KEY", "")
                  self.secret_key = secret_key or os.getenv("KLING_SECRET_KEY", "")
                  self.base_url = (base_url or os.getenv("KLING_BASE_URL", "")).rstrip("/") or KLING_BASE_URL
                  self.token_ttl = token_ttl
                  self.poll_interval = poll_interval
                  self.max_polls = max_polls
          
                  if not self.access_key or not self.secret_key:
                      logger.warning(
                          "KlingVideoClient: KLING_ACCESS_KEY / KLING_SECRET_KEY 未设置,请检查配置"
                      )
          
                  # 使用强制 TLS 1.2 + 自动重试的 Session
                  self._session = _build_session()
          
              # ─── JWT 鉴权 ───
          
              def _generate_token(self) -> str:
                  """
                  使用 Access Key / Secret Key 生成 JWT Token
                  算法: HS256
                  Payload:
                    - iss: Access Key
                    - iat: 签发时间
                    - exp: 过期时间
                    - nbf: 生效时间
                  """
                  now = int(time.time())
                  payload = {
                      "iss": self.access_key,
                      "iat": now,
                      "exp": now + self.token_ttl,
                      "nbf": now - 5,  # 允许 5 秒时钟偏差
                  }
                  token = jwt.encode(payload, self.secret_key, algorithm="HS256")
                  return token
          
              def _auth_headers(self) -> dict:
                  """构建带 JWT 鉴权的请求头"""
                  token = self._generate_token()
                  return {
                      "Content-Type": "application/json",
                      "Authorization": f"Bearer {token}",
                  }
          
              # ─── 图片处理 ───
          
              @staticmethod
              def _encode_image(image_path: str, quality: int = 85) -> str:
                  """
                  将本地图片编码为 Base64 字符串
                  可灵要求:不添加 data:image/xxx;base64, 前缀,直接传 Base64 字符串
                  图片大小 ≤ 10MB,宽高 ≥ 300px,宽高比 1:2.5 ~ 2.5:1
                  """
                  try:
                      with Image.open(image_path) as img:
                          if img.mode in ("RGBA", "P"):
                              img = img.convert("RGB")
                          buf = io.BytesIO()
                          img.save(buf, format="JPEG", quality=quality)
                          return base64.b64encode(buf.getvalue()).decode("utf-8")
                  except Exception as e:
                      logger.warning(f"图片压缩失败 ({image_path}),使用原始文件: {e}")
                      with open(image_path, "rb") as f:
                          return base64.b64encode(f.read()).decode("utf-8")
          
              # ─── 创建任务 ───
          
              def _submit_task(
                  self,
                  image_path: str,
                  prompt: str = "",
                  negative_prompt: str = "",
                  model_name: str = "kling-v3",
                  mode: str = "std",
                  duration: str = "5",
                  cfg_scale: float = 0.5,
                  sound: str = "",
              ) -> str:
                  """
                  提交图生视频任务
          
                  Args:
                      image_path: 本地图片路径
                      prompt: 正向提示词(≤2500字符)
                      negative_prompt: 负向提示词(≤2500字符)
                      model_name: 可灵模型名 (kling-v3 / kling-v2-6 / kling-v2-5-turbo)
                      mode: 生成模式 std (标准) / pro (高品质)
                      duration: 视频时长,v3: "3"~"15", v2: "5"或"10"
                      cfg_scale: 自由度 [0,1],越大越贴合提示词
                      sound: 是否生成声音 "on"/"off"
          
                  Returns:
                      task_id: 任务 ID
                  """
                  if not os.path.exists(image_path):
                      raise FileNotFoundError(f"输入图片不存在: {image_path}")
          
                  # 根据模型系列确定 duration 范围
                  model_lower = model_name.lower()
                  is_v3 = "v3" in model_lower or "video-o1" in model_lower
                  is_v26 = any(tag in model_lower for tag in ("v2-6", "v2.6"))
          
                  if is_v3:
                      # v3 系列支持 3~15s
                      clamped = str(min(max(int(duration), 3), 15))
                  else:
                      # v2 系列仅支持 5 或 10
                      clamped = str(min(max(int(duration), 5), 10))
          
                  image_b64 = self._encode_image(image_path)
          
                  body = {
                      "model_name": model_name,
                      "image": image_b64,
                      "mode": mode,
                      "duration": clamped,
                  }
          
                  # sound 参数处理
                  # v3 / v2-6: 默认开启声音,除非显式 sound="off"
                  # v2-6 的 sound=on 必须搭配 pro 模式
                  # kling-v2-5-turbo 不支持 sound
                  if is_v3 or is_v26:
                      if sound == "off":
                          body["sound"] = "off"
                      else:
                          body["sound"] = "on"
                          # v2-6 的 sound=on 必须搭配 pro 模式; v3 无此限制
                          if is_v26 and mode != "pro":
                              mode = "pro"
                              body["mode"] = mode
                              logger.info("KlingVideoClient: v2-6 sound=on 需要 pro 模式,已自动切换")
                  elif sound == "on":
                      logger.warning(f"KlingVideoClient: 模型 {model_name} 不支持 sound 参数,已忽略")
          
                  if prompt:
                      body["prompt"] = prompt
                  if negative_prompt:
                      body["negative_prompt"] = negative_prompt
          
                  url = f"{self.base_url}/v1/videos/image2video"
                  headers = self._auth_headers()
          
                  logger.info(f"KlingVideoClient: 提交任务 model={model_name}, mode={mode}, duration={clamped}s, sound={body.get('sound', 'off')}")
          
                  resp = self._session.post(url, json=body, headers=headers, timeout=120)
                  if not resp.ok:
                      try:
                          err_body = resp.json()
                      except Exception:
                          err_body = resp.text
                      logger.error(f"KlingVideoClient: HTTP {resp.status_code}, 响应: {err_body}")
                      resp.raise_for_status()
                  data = resp.json()
          
                  if data.get("code") != 0:
                      raise RuntimeError(
                          f"可灵 API 错误: code={data.get('code')}, message={data.get('message')}"
                      )
          
                  task_id = data["data"]["task_id"]
                  logger.info(f"KlingVideoClient: 任务已提交 task_id={task_id}")
                  return task_id
          
              # ─── 查询任务 ───
          
              def _query_task(self, task_id: str) -> dict:
                  """
                  查询单个任务状态
          
                  Returns:
                      API 响应中的 data 字段
                  """
                  url = f"{self.base_url}/v1/videos/image2video/{task_id}"
                  headers = self._auth_headers()
          
                  resp = self._session.get(url, headers=headers, timeout=30)
                  resp.raise_for_status()
                  data = resp.json()
          
                  if data.get("code") != 0:
                      raise RuntimeError(
                          f"可灵查询 API 错误: code={data.get('code')}, message={data.get('message')}"
                      )
          
                  return data["data"]
          
              # ─── 轮询等待 ───
          
              def _poll_until_done(self, task_id: str) -> dict:
                  """
                  轮询任务直到完成或失败
          
                  Returns:
                      任务结果数据
          
                  Raises:
                      RuntimeError: 任务失败
                      TimeoutError: 超过最大轮询次数
                  """
                  for attempt in range(self.max_polls):
                      result = self._query_task(task_id)
                      status = result.get("task_status", "")
          
                      if status == "succeed":
                          logger.info(f"KlingVideoClient: 任务完成 task_id={task_id}")
                          return result
                      elif status == "failed":
                          msg = result.get("task_status_msg", "未知错误")
                          raise RuntimeError(f"可灵视频生成失败: {msg} (task_id={task_id})")
                      else:
                          # submitted / processing
                          logger.debug(
                              f"KlingVideoClient: 任务进行中 task_id={task_id}, "
                              f"status={status}, attempt={attempt + 1}/{self.max_polls}"
                          )
                          time.sleep(self.poll_interval)
          
                  raise TimeoutError(f"可灵视频生成超时 (task_id={task_id}, 已等待 {self.max_polls * self.poll_interval}s)")
          
              # ─── 下载视频 ───
          
              @staticmethod
              def _download_video(video_url: str, save_path: str) -> None:
                  """从 URL 下载视频到本地"""
                  save_dir = os.path.dirname(save_path)
                  if save_dir:
                      os.makedirs(save_dir, exist_ok=True)
                  # 下载也用 TLS 安全 Session
                  dl_session = _build_session(max_retries=2)
                  resp = dl_session.get(video_url, stream=True, timeout=120)
                  resp.raise_for_status()
                  with open(save_path, "wb") as f:
                      for chunk in resp.iter_content(chunk_size=8192):
                          if chunk:
                              f.write(chunk)
                  logger.info(f"KlingVideoClient: 视频已保存: {save_path}")
          
              # ─── 主入口 ───
          
              def generate_video(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str = "kling-v3",
                  duration: int = 5,
                  mode: str = "std",
                  cfg_scale: float = 0.5,
                  negative_prompt: str = "",
                  sound: str = "",
              ) -> str:
                  """
                  图生视频完整流程:提交任务 → 轮询等待 → 下载视频
          
                  Args:
                      prompt: 视频描述提示词
                      image_path: 输入图片本地路径
                      save_path: 输出视频保存路径
                      model: 可灵模型名 (kling-v3 / kling-v2-6 / kling-v2-5-turbo)
                      duration: 视频时长(秒),v3: 3~15, v2: 5或10
                      mode: 生成模式 "std" (标准) 或 "pro" (高品质)
                      cfg_scale: 自由度 [0,1]
                      negative_prompt: 负向提示词
                      sound: 是否生成声音 "on"/"off"
          
                  Returns:
                      video_url: 远端视频 URL
                  """
                  # 1. 提交任务
                  task_id = self._submit_task(
                      image_path=image_path,
                      prompt=prompt,
                      negative_prompt=negative_prompt,
                      model_name=model,
                      mode=mode,
                      duration=str(duration),
                      cfg_scale=cfg_scale,
                      sound=sound,
                  )
          
                  # 2. 轮询等待
                  result = self._poll_until_done(task_id)
          
                  # 3. 提取视频 URL
                  videos = result.get("task_result", {}).get("videos", [])
                  if not videos:
                      raise RuntimeError(f"可灵任务成功但未返回视频数据 (task_id={task_id})")
          
                  video_url = videos[0].get("url", "")
                  if not video_url:
                      raise RuntimeError(f"可灵任务成功但视频 URL 为空 (task_id={task_id})")
          
                  # 4. 下载到本地
                  self._download_video(video_url, save_path)
          
                  return video_url
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
          
              # ── 测试参数(按需修改) ──
              IMAGE_PATH = "code/result/image/test_avail/test_input.png"
              OUTPUT_PATH = "code/result/video/test_avail/kling_test_output.mp4"
              PROMPT = ""
              MODEL = "kling-v3"         # kling-v3 / kling-v2-6 / kling-v2-5-turbo
              DURATION = 5               # v3: 3~15, v2: 5 或 10
              MODE = "std"               # std 或 pro
              SOUND = ""                 # "" = 自动开启, "on", "off"
          
              print("=== 可灵 (Kling) 图生视频测试 ===")
              ak = Config.KLING_ACCESS_KEY
              sk = Config.KLING_SECRET_KEY
              base_url = Config.KLING_BASE_URL
              if not ak or not sk:
                  print("✗ KLING_ACCESS_KEY / KLING_SECRET_KEY 未设置,请检查 .env 配置")
                  sys.exit(1)
          
              if not os.path.exists(IMAGE_PATH):
                  print(f"✗ 输入图片不存在: {IMAGE_PATH}")
                  sys.exit(1)
          
              print(f"  Access Key : {ak[:6]}***{ak[-4:]}")
              print(f"  Base URL   : {base_url}")
              print(f"  输入图片   : {IMAGE_PATH}")
              print(f"  输出路径   : {OUTPUT_PATH}")
              print(f"  模型       : {MODEL}")
              print(f"  时长       : {DURATION}s")
              print(f"  模式       : {MODE}")
              print(f"  声音       : {SOUND or '自动'}")
              if PROMPT:
                  print(f"  提示词     : {PROMPT[:80]}")
              print("-" * 40)
          
              try:
                  client = KlingVideoClient(access_key=ak, secret_key=sk, base_url=base_url)
                  print("✓ 客户端初始化成功")
          
                  start = time.time()
                  video_url = client.generate_video(
                      prompt=PROMPT,
                      image_path=IMAGE_PATH,
                      save_path=OUTPUT_PATH,
                      model=MODEL,
                      duration=DURATION,
                      mode=MODE,
                      sound=SOUND,
                  )
                  elapsed = time.time() - start
          
                  print(f"✓ 视频生成完成!耗时 {elapsed:.1f}s")
                  print(f"  远端 URL : {video_url}")
                  print(f"  本地文件 : {os.path.abspath(OUTPUT_PATH)}")
                  print(f"  文件大小 : {os.path.getsize(OUTPUT_PATH) / 1024 / 1024:.2f} MB")
              except Exception as e:
                  print(f"✗ 失败: {e}")
                  sys.exit(1)
          
        • video_wan.py 4 KB
          """
          通义万象(Wan)视频生成客户端
          基于 DashScope SDK (dashscope.VideoSynthesis)
          支持 wan2.6-i2v-flash 等模型的图生视频功能
          """
          
          import os
          import logging
          from typing import Optional
          from http import HTTPStatus
          
          import dashscope
          from dashscope import VideoSynthesis
          import requests
          
          logger = logging.getLogger(__name__)
          
          
          class WanVideoClient:
              """
              阿里云通义万象视频生成客户端
              使用 dashscope SDK 的 VideoSynthesis 接口
              """
          
              def __init__(
                  self,
                  api_key: Optional[str] = None,
                  base_url: Optional[str] = None,
              ) -> None:
                  self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
                  self.base_url = base_url or os.getenv("DASHSCOPE_BASE_URL")
          
                  if self.api_key:
                      dashscope.api_key = self.api_key
                  if self.base_url:
                      dashscope.base_http_api_url = self.base_url
          
              def generate_video(
                  self,
                  prompt: str,
                  image_path: str,
                  save_path: str,
                  model: str = "wan2.6-i2v-flash",
                  duration: int = 10,
                  shot_type: str = "multi",
              ) -> str:
                  """
                  图生视频:提交任务 → 等待完成 → 下载到本地
          
                  Args:
                      prompt: 视频描述提示词
                      image_path: 输入图片本地路径
                      save_path: 输出视频保存路径
                      model: 万象视频模型名
                      duration: 视频时长(秒),5-10
                      shot_type: 镜头类型,"single" 或 "multi"
          
                  Returns:
                      video_url: 远端视频 URL
          
                  Raises:
                      FileNotFoundError: 输入图片不存在
                      RuntimeError: API 调用或下载失败
                  """
                  if not os.path.exists(image_path):
                      raise FileNotFoundError(f"输入图片不存在: {image_path}")
          
                  abs_img = os.path.abspath(image_path)
                  img_url = f"file://{abs_img}"
          
                  logger.info(f"WanVideoClient: model={model}, prompt={prompt[:60]}...")
          
                  rsp = VideoSynthesis.call(
                      api_key=self.api_key,
                      model=model,
                      prompt=prompt,
                      img_url=img_url,
                      duration=duration,
                      shot_type=shot_type,
                  )
          
                  if rsp.status_code != HTTPStatus.OK:
                      raise RuntimeError(
                          f"万象视频 API 错误: status={rsp.status_code}, "
                          f"code={rsp.code}, message={rsp.message}"
                      )
          
                  video_url = rsp.output.video_url
                  # 检查是否返回了有效的视频URL
                  if not video_url:
                      raise RuntimeError(f"万象视频 API 返回空URL,可能生成失败: code={rsp.code}, message={rsp.message}")
          
                  logger.info(f"WanVideoClient: 视频生成成功: {video_url}")
          
                  # 确保输出目录存在
                  os.makedirs(os.path.dirname(save_path), exist_ok=True)
          
                  # 下载视频
                  resp = requests.get(video_url, stream=True, timeout=120)
                  if resp.status_code != 200:
                      raise RuntimeError(f"视频下载失败: HTTP {resp.status_code}")
          
                  with open(save_path, 'wb') as f:
                      for chunk in resp.iter_content(chunk_size=8192):
                          if chunk:
                              f.write(chunk)
          
                  logger.info(f"WanVideoClient: 视频已保存: {save_path}")
                  return video_url
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              print("=== 万象 (Wan) 视频客户端可用性测试 ===")
              api_key = Config.DASHSCOPE_API_KEY
              base_url = Config.DASHSCOPE_BASE_URL
              if not api_key:
                  print("✗ DASHSCOPE_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              print(f"  Base URL: {base_url}")
              try:
                  client = WanVideoClient(api_key=api_key, base_url=base_url)
                  print("✓ 客户端初始化成功")
                  print("  (视频生成需要图片输入且耗时数分钟,仅验证初始化)")
              except Exception as e:
                  print(f"✗ 初始化失败: {e}")
                  sys.exit(1)
          
        • vlm_client.py 5.4 KB
          import os
          from typing import List, Optional
          from config import Config
          
          try:
              from tool.vlm_dashscope import QwenVLClient
              from tool.vlm_gemini import GeminiVLClient
              from tool.relay_client import RelayClient
          except ImportError:
              from vlm_dashscope import QwenVLClient
              from vlm_gemini import GeminiVLClient
              from relay_client import RelayClient
          
          class VLM:
              def __init__(self,
                           dashscope_api_key: Optional[str] = None,
                           dashscope_base_url: Optional[str] = None,
                           gemini_api_key: Optional[str] = None,
                           gemini_base_url: Optional[str] = None,
                           local_proxy: Optional[str] = None):
                  """
                  Unified VLM (Vision Language Model) Client
                  Routes requests to DashScope (QwenVL), Gemini, or Relay based on model name.
                  """
                  # Initialize DashScope Client
                  self.dashscope_client = QwenVLClient(
                      api_key=dashscope_api_key,
                      base_url=dashscope_base_url
                  )
                  # Initialize Gemini Client
                  self.gemini_client = GeminiVLClient(
                      api_key=gemini_api_key,
                      base_url=gemini_base_url
                  )
          
                  # Initialize Relay Client (中转站)
                  self._relay_client = None
                  relay_key = os.getenv("RELAY_API_KEY", "")
                  relay_url = os.getenv("RELAY_BASE_URL", "")
                  if relay_key and relay_url:
                      try:
                          self._relay_client = RelayClient(api_key=relay_key, base_url=relay_url)
                      except Exception:
                          pass
          
              def query(self,
                       prompt: str,
                       image_paths: Optional[List[str]] = None,
                       model: str = "qwen3.5-plus",
                       session_id: Optional[str] = None) -> str:
                  if Config.PRINT_MODEL_INPUT:
                      print("---- VLM REQUEST ----")
                      print(f"Prompt: {prompt}")
                      if image_paths:
                          print(f"Images: {len(image_paths)}")
                          for p in image_paths:
                              if p.startswith("data:"):
                                  print(f" - [Base64图片]")
                              else:
                                  print(f" - {p}")
                      print(f"Model: {model}")
                      if session_id:
                          print(f"Session ID: {session_id}")
                      print("-" * 30)
          
                  # Determine backend provider
                  model_lower = model.lower()
                  is_relay = self._is_relay_model(model_lower)
          
                  if is_relay and self._relay_client:
                      # 通过中转站调用 VLM
                      return self._relay_client.vlm_chat(
                          prompt=prompt, image_paths=image_paths, model=model
                      )
                  elif "gemini" in model_lower:
                      # 处理图片路径
                      processed_images = []
                      for p in image_paths or []:
                          if p.startswith("data:") or p.startswith("http") or p.startswith("file://"):
                              processed_images.append(p)
                          else:
                              processed_images.append(p)  # 传递原始路径,内部会处理
                      return self.gemini_client.chat(text=prompt, images=processed_images, model=model)
                  else:
                      # Qwen (DashScope) - 需要将 base64 保存为临时文件
                      file_urls = []
                      import tempfile
                      import base64 as b64
          
                      for p in image_paths or []:
                          if p.startswith("data:"):
                              # Base64 数据 URL,需要解码并保存为临时文件
                              try:
                                  # 解析 data URL: data:image/png;base64,xxxxx
                                  header, b64_data = p.split(",", 1)
                                  mime_type = header.split(";")[0].replace("data:", "")
                                  image_data = b64.b64decode(b64_data)
          
                                  # 创建临时文件
                                  suffix = f".{mime_type.split('/')[-1]}" if '/' in mime_type else ".png"
                                  with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
                                      tmp.write(image_data)
                                      temp_path = tmp.name
          
                                  abs_path = os.path.abspath(temp_path)
                                  file_urls.append(f"file://{abs_path}")
                              except Exception as e:
                                  print(f"Error processing base64 image: {e}")
                                  raise ValueError(f"无法解析 base64 图片: {e}")
                          elif p.startswith("http") or p.startswith("file://"):
                              file_urls.append(p)
                          else:
                              abs_path = os.path.abspath(p)
                              file_urls.append(f"file://{abs_path}")
                      return self.dashscope_client.chat(text=prompt, images=file_urls, model=model, stream=False)
          
              def _is_relay_model(self, model_lower: str) -> bool:
                  """判断模型是否应通过中转站调用"""
                  if not self._relay_client:
                      return False
                  try:
                      import json
                      config_path = os.path.join(
                          os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                          "config_model.json"
                      )
                      if os.path.exists(config_path):
                          with open(config_path, "r", encoding="utf-8") as f:
                              config = json.load(f)
                          model_info = config.get("models", {}).get(model_lower)
                          if model_info and model_info.get("provider") == "relay":
                              return True
                  except Exception:
                      pass
                  return False
        • vlm_dashscope.py 4.1 KB
          # -*- coding: utf-8 -*-
          """
          Qwen3.5-VL 多模态大模型 API 客户端(DashScope 多模态接口专用)
          只支持 Qwen3.5-VL 及兼容 DashScope 多模态对话接口
          参考官方文档:https://help.aliyun.com/zh/model-studio/qwen-api-reference
          """
          
          import os
          
          import dashscope
          from dashscope import MultiModalConversation
          import logging
          
          logger = logging.getLogger(__name__)
          from typing import Any, Dict, List, Optional
          
          class QwenVLClient:
              def __init__(self,
                           api_key: Optional[str] = None, 
                           base_url: Optional[str] = None):
                  """
                  Qwen3.5-VL 多模态客户端
                  :param api_key: DashScope/Qwen3.5 API Key
                  :param model: 模型名(如 qwen3.5-plus/qwen3.5-max 等)
                  """
                  self.api_key = api_key or os.getenv("DASHSCOPE_API_KEY")
          
              def chat(self, text: str, images: List[str], model: str, stream: bool = False, parameters: Optional[Dict] = None, **kwargs) -> Any:
                  """
                  使用阿里云 dashscope SDK 进行多模态对话(文本+图片),风格与 image_dashscope.py 一致。
                  :param text: 文本内容
                  :param images: 图片路径列表(支持本地路径或URL,内部会转换为file://绝对路径)
                  :param model: 模型名(支持qwen3.5-plus, qwen3-vl-plus)
                  :param stream: 是否流式输出(暂不支持流式)
                  :param parameters: 其他API参数
                  :return: API响应内容 dict
                  """
                  dashscope.api_key = self.api_key
                  # 只支持非流式
                  try:
                      messages = [{"role": "user", "content": [
                          {"text": text},
                          *({"image": p} for p in images)
                      ]}]
                      response = MultiModalConversation.call(
                          model=model,
                          messages=messages,
                          api_key=self.api_key,
                          enable_thinking=False,
                          **(parameters or {})
                      )
                      if hasattr(response, 'status_code') and response.status_code == 200:
                          # qwen3.5-plus 的返回格式为 { choices: [ { message: { content: [...] } } ] }
                          resp = response.output.choices[0].message.content[0]
                          if resp.get('text'):
                              return resp['text']
                          return resp
                      else:
                          raise RuntimeError(f"DashScope QwenVLClient failed: {getattr(response, 'message', response)}")
                  except Exception as e:
                      raise RuntimeError(f"DashScope QwenVLClient error: {e}")
          
          
          if __name__ == "__main__":
              import sys
              import time
              import json
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的 VLM 模型列表
              MODELS = ["qwen3.5-plus", "qwen-vl-plus", "qwen3.5-max"]
          
              print("=== Qwen VL (DashScope) 多模态可用性测试 ===")
              api_key = getattr(Config, "DASHSCOPE_API_KEY", None) or os.getenv("DASHSCOPE_API_KEY", "")
              if not api_key:
                  print("✗ DASHSCOPE_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***{api_key[-4:]}")
              client = QwenVLClient(api_key=api_key)
          
              # 测试图片
              img_path = ''
              abs_img_path = os.path.abspath(img_path)
              if not os.path.exists(img_path):
                  img_path = "backend/code/result/image/test_avail/test_input.png"
                  abs_img_path = os.path.abspath(img_path)
                  if not os.path.exists(img_path):
                      print("✗ 测试图片不存在,跳过")
                      sys.exit(0)
          
              text = "请描述这张图片的内容"
              print(f"\n[多模态] Prompt: {text}")
              print(f"  图片: {img_path}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      result = client.chat(text=text, images=[img_path], model=model, stream=False)
                      elapsed = time.time() - t0
                      if result:
                          print(f"✓ 返回结果 ({elapsed:.1f}s): {str(result)[:200]}")
                      else:
                          print(f"✗ 返回空结果 ({elapsed:.1f}s)")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
        • vlm_gemini.py 5.7 KB
          # -*- coding: utf-8 -*-
          """
          Google Gemini 多模态大模型 API 客户端 (OpenAI 兼容格式)
          支持 gemini-2.5-flash-image, gemini-2.5-pro 等视觉模型
          
          可用模型:
              - gemini-2.5-flash-image (性价比最高)
              - gemini-2.5-pro (效果最好)
              - gemini-3-pro-preview
              - gemini-3-pro-image-preview
          """
          
          import os
          import time
          import base64
          from openai import OpenAI
          from typing import Dict, List, Optional
          
          class GeminiVLClient:
              """
              Gemini VLM 客户端,使用 OpenAI 兼容格式调用
              """
              def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None):
                  """
                  Gemini 多模态客户端
                  :param api_key: Gemini API Key
                  :param base_url: 自定义 Base URL(可选,用于代理)
                  """
                  self.api_key = api_key or os.getenv("GEMINI_API_KEY")
                  default_url = "http://35.164.11.19:3887/v1"
                  self.base_url = base_url or os.getenv("GOOGLE_GEMINI_BASE_URL", default_url)
                  if self.base_url and not self.base_url.endswith("/v1"):
                      self.base_url = self.base_url.rstrip("/") + "/v1"
                  self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
                  self.max_attempts = 10
                  self.max_tokens = 20000
          
              def _encode_image(self, image_path: str) -> str:
                  """将本地图片编码为 base64"""
                  abs_path = os.path.abspath(image_path)
                  with open(abs_path, "rb") as f:
                      return base64.b64encode(f.read()).decode("utf-8")
          
              def _get_mime_type(self, image_path: str) -> str:
                  """根据文件扩展名获取 MIME 类型"""
                  ext = os.path.splitext(image_path)[1].lower()
                  mime_types = {
                      ".jpg": "image/jpeg",
                      ".jpeg": "image/jpeg",
                      ".png": "image/png",
                      ".webp": "image/webp",
                      ".gif": "image/gif"
                  }
                  return mime_types.get(ext, "image/jpeg")
          
              def chat(self, text: str, images: List[str], model: str = "gemini-2.5-flash-image",
                       parameters: Optional[Dict] = None) -> str:
                  """
                  使用 Gemini 进行多模态对话(文本+图片)
                  :param text: 文本内容
                  :param images: 图片路径列表(支持本地路径或URL)
                  :param model: 模型名(如 gemini-2.5-flash-image, gemini-2.5-pro)
                  :param parameters: 其他API参数
                  :return: API响应内容
                  """
                  # 构建消息格式
                  content: list = [{"type": "text", "text": text}]
          
                  # 处理图片
                  if images:
                      for img_path in images:
                          if img_path.startswith("data:"):
                              # Base64 数据 URL,直接使用
                              content.append({
                                  "type": "image_url",
                                  "image_url": {"url": img_path}
                              })
                          elif img_path.startswith("http"):
                              # URL 图片
                              content.append({
                                  "type": "image_url",
                                  "image_url": {"url": img_path}
                              })
                          else:
                              # 本地图片 - 转为 base64
                              mime_type = self._get_mime_type(img_path)
                              base64_data = self._encode_image(img_path)
                              data_url = f"data:{mime_type};base64,{base64_data}"
                              content.append({
                                  "type": "image_url",
                                  "image_url": {"url": data_url}
                              })
          
                  messages = [{"role": "user", "content": content}]
          
                  attempts = 0
                  while attempts < self.max_attempts:
                      try:
                          # 直接使用模型名
                          response = self.client.chat.completions.create(
                              model=model,
                              messages=messages,
                              max_tokens=self.max_tokens,
                              temperature=parameters.get("temperature", 0.7) if parameters else 0.7
                          )
          
                          if response.choices and len(response.choices) > 0:
                              return response.choices[0].message.content
          
                      except Exception as e:
                          print(f"GeminiVL 请求错误: {e}")
                          attempts += 1
                          if attempts < self.max_attempts:
                              time.sleep(10)
          
                  raise Exception("GeminiVL: 达到最大重试次数,仍未获得有效响应。")
          
          
          if __name__ == "__main__":
              import sys
              sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
              from config import Config
          
              # 支持的 VLM 模型列表
              MODELS = ["gemini-2.5-flash-image", "gemini-2.0-flash"]
          
              print("=== Gemini VL 多模态可用性测试 ===")
              api_key = Config.GEMINI_API_KEY
              if not api_key:
                  print("✗ GEMINI_API_KEY 未设置,跳过")
                  sys.exit(1)
              print(f"  API Key: {api_key[:6]}***")
              client = GeminiVLClient(api_key=api_key)
          
              # 测试图片(使用示例图片)
              img_path = ""
              if not os.path.exists(img_path):
                  print(f"✗ 测试图片不存在: {img_path}")
                  img_path = "backend/code/result/image/test_avail/test_input.png"
                  if not os.path.exists(img_path):
                      print("✗ 跳过图片测试(无测试图片)")
                      sys.exit(0)
          
              text = "请描述这张图片的内容"
              print(f"\n[多模态] Prompt: {text}")
              print(f"  图片: {img_path}")
          
              for model in MODELS:
                  print(f"\n--- 测试模型: {model} ---")
                  t0 = time.time()
                  try:
                      result = client.chat(text=text, images=[img_path], model=model)
                      elapsed = time.time() - t0
                      if result:
                          print(f"✓ 返回结果 ({elapsed:.1f}s): {result[:200]}")
                      else:
                          print(f"✗ 返回空结果 ({elapsed:.1f}s)")
                  except Exception as e:
                      print(f"✗ 失败: {e}")
          
      • .gitignore 71 B · in bundle
      • api_server.py 43.7 KB
        # -*- 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.setLevel(logging.INFO)   # 只显示 INFO 及以上,避免 HTTP DEBUG 日志刷屏
            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()
        logger = logging.getLogger("api_server")
        # =================================
        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 初始生成,不接受用户修改
        
            按阶段分类处理:
            - 第二阶段(character_design): 修改 characters[]/settings[] 的 description
            - 第三阶段(storyboard): 修改 shots[] 的 duration/plot/visual_prompt
            - 第四阶段(reference_generation): 修改 scenes[] 的 description(视觉提示词)
            - 第五阶段(video_generation): 修改 clips[] 的 duration/description
            """
            state = workflow_engine.get_state(session_id)
            if not state:
                raise HTTPException(404, "Session not found")
            body = await request.json()
        
            # ══════════════════════════════════════════════════════════════
            # 第二阶段:角色/背景描述修改
            # 修改 characters[].description 或 settings[].description
            # 不涉及跨阶段同步
            # ══════════════════════════════════════════════════════════════
            if stage == "character_design":
                # 直接更新 body,由后续逻辑合并到 artifact
                pass
        
            # ══════════════════════════════════════════════════════════════
            # 第三阶段:分镜修改
            # 修改 payload.shots[].duration / payload.shots[].plot / payload.shots[].visual_prompt
            # 同步到:video_generation.clips[].duration, video_generation.clips[].description
            # ══════════════════════════════════════════════════════════════
            elif stage == "storyboard" and "shots" in body:
                # 清除 is_new 标记(确认新分镜)
                for shot in body['shots']:
                    if 'is_new' in shot:
                        shot['is_new'] = False
        
                # 同步到 video_generation
                shot_id_to_duration = {s['shot_id']: s.get('duration', 10) for s in body['shots'] if 'shot_id' in s}
                shot_id_to_plot = {s['shot_id']: s.get('plot', '') 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_id_to_duration:
                            clip['duration'] = shot_id_to_duration[shot_id]
                        if shot_id in shot_id_to_plot:
                            clip['description'] = shot_id_to_plot[shot_id]
        
                # 移除 shots,避免覆盖 storyboard artifact
                body = {k: v for k, v in body.items() if k != "shots"}
        
                # 清除 new_shot_ids 标记
                if "new_shot_ids" in body and body.get('new_shot_ids') == []:
                    del body['new_shot_ids']
        
            # ══════════════════════════════════════════════════════════════
            # 第四阶段:参考图提示词修改
            # 修改 scenes[].description(视觉提示词)
            # 同步到:storyboard.shots[].visual_prompt
            # ══════════════════════════════════════════════════════════════
            elif stage == "reference_generation":
                if "shots" in body:
                    # 修改视觉提示词 → 同步到 storyboard
                    # body.shots 是 [{shot_id: "...", visual_prompt: "..."}]
                    shot_id_to_prompt = {s['shot_id']: s.get('visual_prompt', '')
                                         for s in body['shots'] if 'shot_id' in s}
        
                    storyboard_art = state.artifacts.get('storyboard', {})
                    # storyboard 结构: {shots: [...]} (无 payload 包装)
                    if isinstance(storyboard_art, dict):
                        shots = storyboard_art.get('shots', [])
                        for shot in shots:
                            if isinstance(shot, dict):
                                shot_id = shot.get('shot_id')
                                if shot_id in shot_id_to_prompt:
                                    shot['visual_prompt'] = shot_id_to_prompt[shot_id]
        
                    # 同步到 reference_generation.scenes 的 description
                    ref_art = state.artifacts.get('reference_generation', {})
                    if isinstance(ref_art, dict):
                        scenes = ref_art.get('scenes', [])
                        for scene in scenes:
                            if isinstance(scene, dict):
                                scene_id = scene.get('id')
                                if scene_id in shot_id_to_prompt:
                                    scene['description'] = shot_id_to_prompt[scene_id]
        
                    # 移除 shots,避免覆盖 reference_generation artifact
                    body = {k: v for k, v in body.items() if k != "shots"}
        
                # 处理图片版本选择 {sceneId: path}
                ref_art = state.artifacts.get('reference_generation', {})
                if isinstance(ref_art, dict):
                    scenes = ref_art.get('scenes', [])
                    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:
                        for scene in scenes:
                            scene_id = scene.get('id')
                            if scene_id and scene_id in body:
                                scene['selected'] = body[scene_id]
                        body = {}
        
            # ══════════════════════════════════════════════════════════════
            # 第五阶段:视频片段修改
            # 修改 clips[].duration / clips[].description
            # 同步到:storyboard.shots[].duration / storyboard.shots[].plot
            # ══════════════════════════════════════════════════════════════
            elif stage == "video_generation":
                # 收集 clips 的修改
                clip_id_to_duration = {}
                clip_id_to_description = {}
        
                for clip_id, value in body.items():
                    if isinstance(value, dict):
                        if 'duration' in value:
                            clip_id_to_duration[clip_id] = value['duration']
                        if 'description' in value:
                            clip_id_to_description[clip_id] = value['description']
        
                # 同步到 storyboard 和 video_generation.clips
                if clip_id_to_duration or clip_id_to_description:
                    storyboard_art = state.artifacts.get('storyboard', {})
                    # storyboard 结构: {shots: [...]} (无 payload 包装)
                    if isinstance(storyboard_art, dict):
                        shots = storyboard_art.get('shots', [])
                        for shot in shots:
                            if isinstance(shot, dict):
                                shot_id = shot.get('shot_id')
                                if shot_id in clip_id_to_duration:
                                    shot['duration'] = clip_id_to_duration[shot_id]
                                if shot_id in clip_id_to_description:
                                    shot['plot'] = clip_id_to_description[shot_id]
        
                    # 更新 video_generation.clips 的 duration 和 description
                    vid_art = state.artifacts.get('video_generation', {})
                    if isinstance(vid_art, dict):
                        clips = vid_art.get('clips', [])
                        for clip in clips:
                            if isinstance(clip, dict):
                                clip_id = clip.get('id')
                                if clip_id in clip_id_to_duration:
                                    clip['duration'] = clip_id_to_duration[clip_id]
                                if clip_id in clip_id_to_description:
                                    clip['description'] = clip_id_to_description[clip_id]
        
                # 处理视频版本选择 {clipId: path}
                vid_art = state.artifacts.get('video_generation', {})
                if isinstance(vid_art, dict):
                    clips = vid_art.get('clips', [])
                    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:
                        for clip in clips:
                            clip_id = clip.get('id')
                            if clip_id and clip_id in body:
                                clip['selected'] = body[clip_id]
                        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 = {}
            password = body.get("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 = {}
            password = body.get("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)
        
      • config.py 3.8 KB
        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")
        
            # ==========================
            # 第三方中转站(Relay)配置
            # ==========================
            # 统一中转 API,支持 OpenAI 兼容格式,一个 key 调用所有模型
            # 支持的中转站:青云 (qingyun)、Xingsuancode 等
            RELAY_API_KEY = os.getenv("RELAY_API_KEY", "")
            RELAY_BASE_URL = os.getenv("RELAY_BASE_URL", "")
            # 可选:指定中转站名称,用于日志标识
            RELAY_PROVIDER_NAME = os.getenv("RELAY_PROVIDER_NAME", "relay")
        
            # 管理员密码(用于删除历史记录等管理操作)
            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()
        
      • config_model.json 7.8 KB
        {
          "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
            },
        
            "_comment_relay": "=== 中转站 (relay) 模型 — 通过 RELAY_API_KEY + RELAY_BASE_URL 统一调用 ===",
            "_comment_relay_usage": "将任何模型的 provider 改为 relay 即可通过中转站调用",
        
            "sora-2-all": {
              "name": "Sora 2 (中转站)",
              "provider": "relay",
              "type": ["video"],
              "concurrency": 3,
              "price_per_second": 1.0
            },
            "sora-2-pro-all": {
              "name": "Sora 2 Pro (中转站)",
              "provider": "relay",
              "type": ["video"],
              "concurrency": 3,
              "price_per_second": 2.0
            },
            "veo_3_1-fast-4K": {
              "name": "Veo 3.1 Fast 4K (中转站)",
              "provider": "relay",
              "type": ["video"],
              "concurrency": 3,
              "price_per_second": 1.5
            },
            "doubao-seedance-1-5-pro-251215": {
              "name": "豆包 Seedance 1.5 Pro (中转站)",
              "provider": "relay",
              "type": ["video"],
              "concurrency": 3,
              "price_per_second": 0.4
            },
            "grok-video-3-10s": {
              "name": "Grok Video 3 (中转站)",
              "provider": "relay",
              "type": ["video"],
              "concurrency": 3,
              "price_per_second": 0.8
            },
            "grok-imagine-image-pro": {
              "name": "Grok Image Pro (中转站)",
              "provider": "relay",
              "type": ["t2i"],
              "concurrency": 5,
              "price_per_image": 0.5
            },
            "gpt-4o-audio-preview": {
              "name": "GPT-4o Audio (中转站)",
              "provider": "relay",
              "type": ["audio"],
              "concurrency": 5
            },
            "gemini-embedding-2-preview": {
              "name": "Gemini Embedding (中转站)",
              "provider": "relay",
              "type": ["embedding"],
              "concurrency": 20
            }
          },
          "relay_defaults": {
            "_comment": "中转站默认配置,provider=relay 的模型都走此配置",
            "api_key_env": "RELAY_API_KEY",
            "base_url_env": "RELAY_BASE_URL",
            "provider_name_env": "RELAY_PROVIDER_NAME",
            "supported_stations": {
              "qingyun": {
                "base_url": "https://api.qingyuntop.top",
                "note": "青云聚合 API,18+ 模型,OpenAI 兼容格式"
              },
              "xingsuancode": {
                "base_url": "https://cn.xingsuancode.com",
                "note": "星算中转站"
              }
            }
          }
        }
        
      • config_model.py 1.6 KB
        """
        模型配置表 - 从 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
        
      • requirements.txt 361 B
        # MovieAssistant Backend Dependencies
        # Python 3.9+
        
        # Web Framework
        fastapi>=0.100.0
        uvicorn>=0.23.0
        
        # Data Validation
        pydantic>=2.0.0
        
        # Environment Variables
        python-dotenv>=1.0.0
        
        # HTTP Clients
        requests>=2.28.0
        httpx>=0.24.0
        
        # Image Processing
        Pillow>=10.0.0
        numpy>=1.24.0
        
        # AI/ML Clients
        openai>=1.0.0
        dashscope>=1.10.0
        
        # Video Generation
        PyJWT>=2.8.0
        
      • session.py 2.2 KB
        # -*- coding: utf-8 -*-
        import json
        import os
        import time
        
        class SessionManager:
            """Manages chat sessions persistence"""
            def __init__(self, data_dir="code/data"):
                self.data_dir = data_dir
                if not os.path.exists(data_dir):
                    os.makedirs(data_dir)
        
            def _get_file(self, session_id):
                return os.path.join(self.data_dir, f"{session_id}.json")
        
            def list_sessions(self):
                """List all sessions ordered by modification time"""
                sessions = []
                if not os.path.exists(self.data_dir):
                    return sessions
        
                files = [f for f in os.listdir(self.data_dir) if f.endswith('.json')]
                for f in files:
                    try:
                        path = os.path.join(self.data_dir, f)
                        with open(path, 'r', encoding='utf-8') as fs:
                            data = json.load(fs)
                            # title, id, last_updated
                            sessions.append({
                                "id": data.get("id"),
                                "title": data.get("title", "Untitled"),
                                "date": "7days", # Simplification. Real logic would calc date diff
                                "timestamp": os.path.getmtime(path)
                            })
                    except Exception:
                        continue
                
                # Sort by timestamp desc
                sessions.sort(key=lambda x: x['timestamp'], reverse=True)
                return sessions
        
            def get_session(self, session_id):
                """Get full history of a session"""
                path = self._get_file(session_id)
                if os.path.exists(path):
                    try:
                        with open(path, 'r', encoding='utf-8') as f:
                            return json.load(f)
                    except Exception:
                        pass
                return None
        
            def save_session(self, session_id, title, messages, asset_library=None):
                """Save or update session"""
                data = {
                    "id": session_id,
                    "title": title,
                    "last_updated": time.time(),
                    "messages": messages,
                    "asset_library": asset_library or {}
                }
                with open(self._get_file(session_id), 'w', encoding='utf-8') as f:
                    json.dump(data, f, indent=2, ensure_ascii=False)
        
      • __init__.py 69 B
        # code package
        # This file makes the code directory a Python package
        
    • frontend
      • app
        • sandbox
          • page.tsx 245 B · in bundle
        • favicon.ico 25.3 KB · in bundle
        • globals.css 671 B · in bundle
        • layout.tsx 409 B · in bundle
        • page.tsx 573 B · in bundle
      • components
        • Sandbox
          • Sandbox.tsx 27.9 KB · in bundle
        • stages
          • CharacterStage.tsx 16.8 KB · in bundle
          • ImageLightbox.tsx 7 KB · in bundle
          • index.ts 442 B
            export { default as ScriptStage } from './ScriptStage';
            export { default as CharacterStage } from './CharacterStage';
            export { default as StoryboardStage } from './StoryboardStage';
            export { default as ReferenceStage } from './ReferenceStage';
            export { default as VideoStage } from './VideoStage';
            export { default as PostProductionStage } from './PostProductionStage';
            export type { StageState, StageStatus, StageViewProps } from './types';
            
          • PostProductionStage.tsx 2.7 KB · in bundle
          • ReferenceStage.tsx 18.5 KB · in bundle
          • ScriptStage.tsx 33.6 KB · in bundle
          • StageActions.tsx 5.9 KB · in bundle
          • StageProgress.tsx 2.4 KB · in bundle
          • StoryboardStage.tsx 28.9 KB · in bundle
          • types.ts 1.2 KB
            export type StageStatus = 'pending' | 'running' | 'waiting' | 'completed' | 'error';
            
            export interface StageState {
              status: StageStatus;
              progress: number;
              progressMessage: string;
              artifact: any;
              error: string | null;
            }
            
            export interface StageViewProps {
              state: StageState;
              sessionId: string;
              onConfirm: () => void;
              onIntervene: (modifications: Record<string, any>) => void;
              onRegenerate: () => void;
              onUpdateArtifact?: (patch: Record<string, any>) => void;
              onSaveSelections?: (selections: Record<string, any>) => Promise<void>;
              /** 是否显示"确认并继续"按钮(后续阶段已执行过时为 false) */
              showConfirm?: boolean;
              isRunning: boolean;
              /** 是否有待生成的项(阶段2、4、5使用) */
              hasPendingItems?: boolean;
              /** 后续阶段是否已开始(阶段1、3使用) */
              hasNextStageStarted?: boolean;
              /** 视频生成参数(仅 VideoStage 使用) */
              videoSound?: string;
              videoShotType?: string;
              onVideoParamsChange?: (params: { videoSound?: string; videoShotType?: string }) => void;
              /** 参考图阶段的 artifact(仅 VideoStage 使用,用于检查依赖) */
              referenceArtifact?: any;
            }
            
          • utils.ts 4.7 KB
            /**
             * 阶段数据工具函数
             * - 路径转 URL
             * - 剧本/分镜结构化文本解析与重建
             */
            
            /** 将后端本地文件路径转换为浏览器可访问的 URL */
            export function assetUrl(path: string): string {
              if (!path) return '';
              if (path.startsWith('http') || path.startsWith('/') || path.startsWith('blob:') || path.startsWith('data:')) return path;
              return '/' + path;
            }
            
            /* ─── 剧本 / 分镜 结构化文本解析 ─── */
            
            export interface ParsedCharacter {
              name: string;
              description: string;
            }
            
            export interface ParsedSetting {
              name: string;
              description: string;
            }
            
            export interface ParsedScene {
              id: string;
              characters: string[];
              settings: string[];
              description: string;
              raw: string;
            }
            
            export interface ParsedScript {
              characters: ParsedCharacter[];
              settings: ParsedSetting[];
              scenes: ParsedScene[];
              isZh: boolean;
            }
            
            /** 解析结构化剧本/分镜文本 → 角色 + 场景 + 故事线 */
            export function parseScriptText(text: string): ParsedScript {
              if (!text) return { characters: [], settings: [], scenes: [], isZh: false };
            
              const normalized = text.replace(/\\n/g, '\n');
              const isZh = /角色[::]|场景设置[::]|视频片段/.test(normalized);
            
              const charHeader = isZh ? /角色[::]/ : /Characters[::]/;
              const settingHeader = isZh ? /场景设置[::]/ : /Settings[::]/;
              const sceneHeader = isZh ? /视频片段[::]/ : /Scenes[::]/;
            
              function findMatch(src: string, pat: RegExp) {
                const m = src.match(pat);
                return m ? { index: src.indexOf(m[0]), length: m[0].length } : null;
              }
            
              function extractBlock(src: string, startPat: RegExp, endPat: RegExp | null): string {
                const sm = findMatch(src, startPat);
                if (!sm) return '';
                const sp = sm.index + sm.length;
                if (endPat) {
                  const em = findMatch(src.slice(sp), endPat);
                  return em ? src.slice(sp, sp + em.index) : src.slice(sp);
                }
                return src.slice(sp);
              }
            
              const charBlock = extractBlock(normalized, charHeader, settingHeader).trim();
              const settingBlock = extractBlock(normalized, settingHeader, sceneHeader).trim();
              const sceneBlock = extractBlock(normalized, sceneHeader, null).trim();
            
              const characters: ParsedCharacter[] = [];
              for (const line of charBlock.split('\n')) {
                const t = line.trim();
                if (!t) continue;
                const ci = t.search(/[::]/);
                if (ci > 0) {
                  characters.push({
                    name: t.slice(0, ci).trim(),
                    description: t.slice(ci + 1).trim().replace(/\.\s*$/, ''),
                  });
                }
              }
            
              const settings: ParsedSetting[] = [];
              for (const line of settingBlock.split('\n')) {
                const t = line.trim();
                if (!t) continue;
                const ci = t.search(/[::]/);
                if (ci > 0) {
                  settings.push({
                    name: t.slice(0, ci).trim(),
                    description: t.slice(ci + 1).trim().replace(/\.\s*$/, ''),
                  });
                }
              }
            
              const scenes: ParsedScene[] = [];
              const scenePat = isZh
                ? /^视频片段\s*(\d+)\s*[::]\s*(.*)/
                : /^Scene\s+(\d+)\s*[::]\s*(.*)/i;
            
              for (const line of sceneBlock.split('\n')) {
                const m = line.trim().match(scenePat);
                if (m) {
                  const raw = m[2];
                  const cm = raw.match(/\[(?:Characters|角色)\s*[::]\s*([^\]]+)\]/i);
                  const sm2 = raw.match(/\[(?:Settings|场景(?:设置)?)\s*[::]\s*([^\]]+)\]/i);
                  const chars = cm ? cm[1].split(/[,,]/).map(s => s.trim()).filter(Boolean) : [];
                  const sets = sm2 ? sm2[1].split(/[,,]/).map(s => s.trim()).filter(Boolean) : [];
                  const desc = raw.replace(/\[.*?\]/g, '').trim();
                  scenes.push({ id: m[1], characters: chars, settings: sets, description: desc, raw });
                }
              }
            
              return { characters, settings, scenes, isZh };
            }
            
            /** 将解析后的结构重建为标准文本格式 */
            export function reconstructScriptText(parsed: ParsedScript): string {
              const { isZh, characters, settings, scenes } = parsed;
              const charH = isZh ? '角色:' : 'Characters:';
              const settH = isZh ? '场景设置:' : 'Settings:';
              const sceneH = isZh ? '视频片段:' : 'Scenes:';
              const scenePrefix = isZh ? '视频片段' : 'Scene';
              const charLabel = isZh ? '角色' : 'Characters';
              const settLabel = isZh ? '场景' : 'Settings';
            
              const lines: string[] = [];
              lines.push(charH);
              for (const c of characters) {
                lines.push(`${c.name}: ${c.description}`);
              }
              lines.push(settH);
              for (const s of settings) {
                lines.push(`${s.name}: ${s.description}`);
              }
              lines.push(sceneH);
              for (const sc of scenes) {
                const cp = sc.characters.length ? `[${charLabel}: ${sc.characters.join(', ')}]` : '';
                const sp = sc.settings.length ? `[${settLabel}: ${sc.settings.join(', ')}]` : '';
                const parts = [`${scenePrefix} ${sc.id}:`, cp, sp, sc.description].filter(Boolean);
                lines.push(parts.join(' '));
              }
              return lines.join('\n');
            }
            
          • VideoStage.tsx 20.8 KB · in bundle
        • HomePage.tsx 20.8 KB · in bundle
        • TopBar.tsx 13.8 KB · in bundle
        • WorkflowPanel.tsx 45.3 KB · in bundle
      • config
        • examples.ts 1.1 KB
          export const PROMPT_EXAMPLES = [
              {
                  title: "生成科幻短片",
                  description: "怪兽宇宙中的激烈对决...",
                  text: "以哥斯拉大战金刚主题,生成一个科幻风格短片"
              },
              {
                  title: "水墨风格动画",
                  description: "山水之间扁舟一叶...",
                  text: "请帮我制作一个水墨中国风的动画"
              },
              {
                  title: "悬疑侦探故事",
                  description: "雨夜中的神秘黑影...",
                  text: "请生成一个发生在上世纪伦敦的悬疑侦探故事短片"
              },
              {
                  title: "治愈系微电影",
                  description: "午后阳光下的猫咪...",
                  text: "制作一个温馨治愈的日常风格短片,主角是一只橘猫"
              },
              {
                  title: "太空探索纪录片",
                  description: "穿越星云的壮丽旅程...",
                  text: "生成一段关于人类探索火星的伪纪录片风格视频"
              },
              {
                  title: "古风武侠打斗",
                  description: "竹林深处的剑客对决...",
                  text: "制作一段节奏紧凑的古风武侠打斗场面"
              }
          ];
          
        • models.ts 5.6 KB
          /* ─── Provider + Model 分组结构 ─── */
          export interface ModelOption {
              id: string;
              label: string;
              default?: boolean;
          }
          
          export interface ProviderGroup {
              provider: string;
              label: string;
              models: ModelOption[];
          }
          
          /* ─── LLM 模型(按 Provider 分组) ─── */
          export const LLM_PROVIDERS: ProviderGroup[] = [
              {
                  provider: 'qwen',
                  label: 'Qwen (DashScope)',
                  models: [
                      { id: 'qwen3.5-plus', label: 'qwen3.5-plus', default: true },
                      { id: 'qwen3.5-max', label: 'qwen3.5-max' },
                  ],
              },
              {
                  provider: 'deepseek',
                  label: 'DeepSeek',
                  models: [
                      { id: 'deepseek-chat', label: 'deepseek-chat' },
                      { id: 'deepseek-reasoner', label: 'deepseek-reasoner' },
                  ],
              },
              {
                  provider: 'openai',
                  label: 'OpenAI',
                  models: [
                      { id: 'gpt-4o', label: 'gpt-4o' },
                      { id: 'gpt-4', label: 'gpt-4' },
                      { id: 'gpt-5', label: 'gpt-5' },
                      { id: 'gpt-5.1', label: 'gpt-5.1' },
                      { id: 'o3', label: 'o3' },
                  ],
              },
              {
                  provider: 'gemini',
                  label: 'Gemini',
                  models: [
                      { id: 'gemini-3-flash-preview', label: 'gemini-3-flash-preview' },
                      { id: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
                  ],
              },
          ];
          
          /** 扁平 LLM 列表(向后兼容) */
          export const LLM_MODELS: ModelOption[] = LLM_PROVIDERS.flatMap(p => p.models);
          
          /* ─── 文生图 ─── */
          export const T2I_PROVIDERS: ProviderGroup[] = [
              {
                  provider: 'seedream',
                  label: 'Seedream',
                  models: [
                      { id: 'doubao-seedream-5-0-260128', label: 'Seedream 5.0', default: true },
                      { id: 'doubao-seedream-4-5-251128', label: 'Seedream 4.5' },
                      { id: 'doubao-seedream-4-0-250828', label: 'Seedream 4.0' },
                  ],
              },
              {
                  provider: 'jimeng',
                  label: 'JiMeng',
                  models: [
                      { id: 'jimeng_t2i_v40', label: 'jimeng_t2i_v40' },
                  ],
              },
              {
                  provider: 'dashscope',
                  label: 'DashScope',
                  models: [
                      { id: 'wan2.6-t2i', label: 'wan2.6-t2i' },
                  ],
              },
              {
                  provider: 'openai',
                  label: 'OpenAI',
                  models: [
                      { id: 'sora_image', label: 'sora_image' },
                      { id: 'gpt-image-1.5', label: 'gpt-image-1.5' },
                  ],
              },
          ];
          
          export const T2I_MODELS: ModelOption[] = T2I_PROVIDERS.flatMap(p => p.models);
          
          /* ─── 图生图 ─── */
          export const I2I_PROVIDERS: ProviderGroup[] = [
              {
                  provider: 'seedream',
                  label: 'Seedream',
                  models: [
                      { id: 'doubao-seedream-5-0-260128', label: 'Seedream 5.0', default: true },
                      { id: 'doubao-seedream-4-5-251128', label: 'Seedream 4.5' },
                      { id: 'doubao-seedream-4-0-250828', label: 'Seedream 4.0' },
                  ],
              },
              {
                  provider: 'jimeng',
                  label: 'JiMeng',
                  models: [
                      { id: 'jimeng_t2i_v40', label: 'jimeng_t2i_v40' },
                  ],
              },
              {
                  provider: 'dashscope',
                  label: 'DashScope',
                  models: [
                      { id: 'wan2.6-image', label: 'wan2.6-image' },
                  ],
              },
          ];
          
          export const I2I_MODELS: ModelOption[] = I2I_PROVIDERS.flatMap(p => p.models);
          
          /* ─── 视频 ─── */
          export const VIDEO_PROVIDERS: ProviderGroup[] = [
              {
                  provider: 'dashscope',
                  label: 'DashScope',
                  models: [
                      { id: 'wan2.6-i2v-flash', label: 'wan2.6-i2v-flash', default: true },
                  ],
              },
              {
                  provider: 'kling',
                  label: 'Kling',
                  models: [
                      { id: 'kling-v3', label: 'kling-v3' },
                      { id: 'kling-v2-6', label: 'kling-v2-6' },
                      { id: 'kling-v2-5-turbo', label: 'kling-v2-5-turbo' },
                  ],
              },
          ];
          
          export const VIDEO_MODELS: ModelOption[] = VIDEO_PROVIDERS.flatMap(p => p.models);
          
          /* ─── VLM 评估模型 ─── */
          export const VLM_PROVIDERS: ProviderGroup[] = [
              {
                  provider: 'qwen',
                  label: 'Qwen (DashScope)',
                  models: [
                      { id: 'qwen-vl-plus', label: 'qwen-vl-plus', default: true },
                      { id: 'qwen3.5-plus', label: 'qwen3.5-plus' },
                      { id: 'qwen3.5-max', label: 'qwen3.5-max' },
                  ],
              },
              {
                  provider: 'gemini',
                  label: 'Gemini (Google)',
                  models: [
                      { id: 'gemini-2.5-flash-image', label: 'gemini-2.5-flash-image' },
                      { id: 'gemini-2.5-pro-image', label: 'gemini-2.5-pro-image' },
                      { id: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview' },
                      { id: 'gemini-3-pro-image-preview', label: 'gemini-3-pro-image-preview' },
                  ],
              },
          ];
          
          export const VLM_MODELS: ModelOption[] = VLM_PROVIDERS.flatMap(p => p.models);
          
          export const STYLES = [
              { id: 'comic-book', label: 'Comic Book / 漫画' },
              { id: 'anime', label: 'Anime / 动漫' },
              { id: 'realistic', label: 'Realistic / 写实' },
              { id: '3d-disney', label: '3D Disney / 迪士尼' },
              { id: 'watercolor', label: 'Watercolor / 水彩' },
              { id: 'oil-painting', label: 'Oil Painting / 油画' },
              { id: 'cyberpunk', label: 'Cyberpunk / 赛博朋克' },
              { id: 'chinese-ink', label: 'Chinese Ink / 水墨' },
          ];
          
          /* ─── 视频比例 ─── */
          export const VIDEO_RATIOS = [
              { id: '16:9', label: '16:9', ratio: '16:9' },
              { id: '9:16', label: '9:16', ratio: '9:16' },
              { id: '1:1', label: '1:1', ratio: '1:1' },
              { id: '4:3', label: '4:3', ratio: '4:3' },
              { id: '3:4', label: '3:4', ratio: '3:4' },
              { id: '21:9', label: '21:9', ratio: '21:9' },
          ];
          
      • public
        • file.svg 391 B · in bundle
        • globe.svg 1 KB · in bundle
        • logo.jpg 9.6 KB · in bundle
        • next.svg 1.3 KB · in bundle
        • vercel.svg 128 B · in bundle
        • window.svg 385 B · in bundle
      • .gitignore 480 B · in bundle
      • eslint.config.mjs 299 B · in bundle
      • next.config.ts 889 B
        import type { NextConfig } from "next";
        
        const nextConfig: NextConfig = {
          async rewrites() {
            return [
              {
                source: '/code/:path*',
                destination: 'http://127.0.0.1:8000/code/:path*',
              },
              {
                source: '/api/sessions',
                destination: 'http://127.0.0.1:8000/api/sessions',
              },
              {
                source: '/api/sessions/:path*',
                destination: 'http://127.0.0.1:8000/api/sessions/:path*',
              },
              // 工作流 API
              {
                source: '/api/project/:path*',
                destination: 'http://127.0.0.1:8000/api/project/:path*',
              },
              {
                source: '/api/stages',
                destination: 'http://127.0.0.1:8000/api/stages',
              },
              // 临时工作台 API
              {
                source: '/api/sandbox/:path*',
                destination: 'http://127.0.0.1:8000/api/sandbox/:path*',
              },
            ];
          },
        };
        
        export default nextConfig;
        
      • package-lock.json 207 KB
        {
          "name": "aigc-claw",
          "version": "0.1.0",
          "lockfileVersion": 3,
          "requires": true,
          "packages": {
            "": {
              "name": "aigc-claw",
              "version": "0.1.0",
              "dependencies": {
                "clsx": "^2.1.1",
                "lucide-react": "^0.563.0",
                "next": "15.2.4",
                "react": "19.2.3",
                "react-dom": "19.2.3",
                "tailwind-merge": "^3.4.0"
              },
              "devDependencies": {
                "@tailwindcss/postcss": "^4",
                "@types/node": "^20",
                "@types/react": "^19",
                "@types/react-dom": "^19",
                "eslint": "^9",
                "eslint-config-next": "15.2.4",
                "tailwindcss": "^4",
                "typescript": "^5"
              }
            },
            "node_modules/@alloc/quick-lru": {
              "version": "5.2.0",
              "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
              "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">=10"
              },
              "funding": {
                "url": "https://github.com/sponsors/sindresorhus"
              }
            },
            "node_modules/@emnapi/core": {
              "version": "1.9.1",
              "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
              "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
              "dev": true,
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "@emnapi/wasi-threads": "1.2.0",
                "tslib": "^2.4.0"
              }
            },
            "node_modules/@emnapi/runtime": {
              "version": "1.9.1",
              "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
              "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "tslib": "^2.4.0"
              }
            },
            "node_modules/@emnapi/wasi-threads": {
              "version": "1.2.0",
              "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
              "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
              "dev": true,
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "tslib": "^2.4.0"
              }
            },
            "node_modules/@eslint-community/eslint-utils": {
              "version": "4.9.1",
              "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
              "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "eslint-visitor-keys": "^3.4.3"
              },
              "engines": {
                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/eslint"
              },
              "peerDependencies": {
                "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
              }
            },
            "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
              "version": "3.4.3",
              "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
              "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/eslint"
              }
            },
            "node_modules/@eslint-community/regexpp": {
              "version": "4.12.2",
              "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
              "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
              }
            },
            "node_modules/@eslint/config-array": {
              "version": "0.21.2",
              "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
              "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
              "dev": true,
              "license": "Apache-2.0",
              "dependencies": {
                "@eslint/object-schema": "^2.1.7",
                "debug": "^4.3.1",
                "minimatch": "^3.1.5"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              }
            },
            "node_modules/@eslint/config-helpers": {
              "version": "0.4.2",
              "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
              "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
              "dev": true,
              "license": "Apache-2.0",
              "dependencies": {
                "@eslint/core": "^0.17.0"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              }
            },
            "node_modules/@eslint/core": {
              "version": "0.17.0",
              "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
              "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
              "dev": true,
              "license": "Apache-2.0",
              "dependencies": {
                "@types/json-schema": "^7.0.15"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              }
            },
            "node_modules/@eslint/eslintrc": {
              "version": "3.3.5",
              "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
              "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "ajv": "^6.14.0",
                "debug": "^4.3.2",
                "espree": "^10.0.1",
                "globals": "^14.0.0",
                "ignore": "^5.2.0",
                "import-fresh": "^3.2.1",
                "js-yaml": "^4.1.1",
                "minimatch": "^3.1.5",
                "strip-json-comments": "^3.1.1"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "url": "https://opencollective.com/eslint"
              }
            },
            "node_modules/@eslint/js": {
              "version": "9.39.4",
              "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
              "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "url": "https://eslint.org/donate"
              }
            },
            "node_modules/@eslint/object-schema": {
              "version": "2.1.7",
              "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
              "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              }
            },
            "node_modules/@eslint/plugin-kit": {
              "version": "0.4.1",
              "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
              "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
              "dev": true,
              "license": "Apache-2.0",
              "dependencies": {
                "@eslint/core": "^0.17.0",
                "levn": "^0.4.1"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              }
            },
            "node_modules/@humanfs/core": {
              "version": "0.19.1",
              "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
              "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": ">=18.18.0"
              }
            },
            "node_modules/@humanfs/node": {
              "version": "0.16.7",
              "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
              "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
              "dev": true,
              "license": "Apache-2.0",
              "dependencies": {
                "@humanfs/core": "^0.19.1",
                "@humanwhocodes/retry": "^0.4.0"
              },
              "engines": {
                "node": ">=18.18.0"
              }
            },
            "node_modules/@humanwhocodes/module-importer": {
              "version": "1.0.1",
              "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
              "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": ">=12.22"
              },
              "funding": {
                "type": "github",
                "url": "https://github.com/sponsors/nzakas"
              }
            },
            "node_modules/@humanwhocodes/retry": {
              "version": "0.4.3",
              "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
              "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": ">=18.18"
              },
              "funding": {
                "type": "github",
                "url": "https://github.com/sponsors/nzakas"
              }
            },
            "node_modules/@img/sharp-darwin-arm64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
              "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
              "cpu": [
                "arm64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-darwin-arm64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-darwin-x64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
              "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
              "cpu": [
                "x64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-darwin-x64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-libvips-darwin-arm64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
              "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
              "cpu": [
                "arm64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "darwin"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-darwin-x64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
              "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
              "cpu": [
                "x64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "darwin"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linux-arm": {
              "version": "1.0.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
              "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
              "cpu": [
                "arm"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linux-arm64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
              "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
              "cpu": [
                "arm64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linux-s390x": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
              "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
              "cpu": [
                "s390x"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linux-x64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
              "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
              "cpu": [
                "x64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
              "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
              "cpu": [
                "arm64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-libvips-linuxmusl-x64": {
              "version": "1.0.4",
              "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
              "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
              "cpu": [
                "x64"
              ],
              "license": "LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "linux"
              ],
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-linux-arm": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
              "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
              "cpu": [
                "arm"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linux-arm": "1.0.5"
              }
            },
            "node_modules/@img/sharp-linux-arm64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
              "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
              "cpu": [
                "arm64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linux-arm64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-linux-s390x": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
              "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
              "cpu": [
                "s390x"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linux-s390x": "1.0.4"
              }
            },
            "node_modules/@img/sharp-linux-x64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
              "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
              "cpu": [
                "x64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linux-x64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-linuxmusl-arm64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
              "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
              "cpu": [
                "arm64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-linuxmusl-x64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
              "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
              "cpu": [
                "x64"
              ],
              "license": "Apache-2.0",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              },
              "optionalDependencies": {
                "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
              }
            },
            "node_modules/@img/sharp-wasm32": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
              "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
              "cpu": [
                "wasm32"
              ],
              "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
              "optional": true,
              "dependencies": {
                "@emnapi/runtime": "^1.2.0"
              },
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-win32-ia32": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
              "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
              "cpu": [
                "ia32"
              ],
              "license": "Apache-2.0 AND LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@img/sharp-win32-x64": {
              "version": "0.33.5",
              "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
              "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
              "cpu": [
                "x64"
              ],
              "license": "Apache-2.0 AND LGPL-3.0-or-later",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
              },
              "funding": {
                "url": "https://opencollective.com/libvips"
              }
            },
            "node_modules/@jridgewell/gen-mapping": {
              "version": "0.3.13",
              "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
              "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@jridgewell/sourcemap-codec": "^1.5.0",
                "@jridgewell/trace-mapping": "^0.3.24"
              }
            },
            "node_modules/@jridgewell/remapping": {
              "version": "2.3.5",
              "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
              "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@jridgewell/gen-mapping": "^0.3.5",
                "@jridgewell/trace-mapping": "^0.3.24"
              }
            },
            "node_modules/@jridgewell/resolve-uri": {
              "version": "3.1.2",
              "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
              "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">=6.0.0"
              }
            },
            "node_modules/@jridgewell/sourcemap-codec": {
              "version": "1.5.5",
              "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
              "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@jridgewell/trace-mapping": {
              "version": "0.3.31",
              "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
              "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@jridgewell/resolve-uri": "^3.1.0",
                "@jridgewell/sourcemap-codec": "^1.4.14"
              }
            },
            "node_modules/@napi-rs/wasm-runtime": {
              "version": "0.2.12",
              "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
              "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
              "dev": true,
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "@emnapi/core": "^1.4.3",
                "@emnapi/runtime": "^1.4.3",
                "@tybys/wasm-util": "^0.10.0"
              }
            },
            "node_modules/@next/env": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz",
              "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==",
              "license": "MIT"
            },
            "node_modules/@next/eslint-plugin-next": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.4.tgz",
              "integrity": "sha512-O8ScvKtnxkp8kL9TpJTTKnMqlkZnS+QxwoQnJwPGBxjBbzd6OVVPEJ5/pMNrktSyXQD/chEfzfFzYLM6JANOOQ==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "fast-glob": "3.3.1"
              }
            },
            "node_modules/@next/swc-darwin-arm64": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz",
              "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==",
              "cpu": [
                "arm64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-darwin-x64": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz",
              "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==",
              "cpu": [
                "x64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-linux-arm64-gnu": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz",
              "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==",
              "cpu": [
                "arm64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-linux-arm64-musl": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz",
              "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==",
              "cpu": [
                "arm64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-linux-x64-gnu": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz",
              "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==",
              "cpu": [
                "x64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-linux-x64-musl": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz",
              "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==",
              "cpu": [
                "x64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-win32-arm64-msvc": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz",
              "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==",
              "cpu": [
                "arm64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@next/swc-win32-x64-msvc": {
              "version": "15.2.4",
              "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz",
              "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==",
              "cpu": [
                "x64"
              ],
              "license": "MIT",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": ">= 10"
              }
            },
            "node_modules/@nodelib/fs.scandir": {
              "version": "2.1.5",
              "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
              "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@nodelib/fs.stat": "2.0.5",
                "run-parallel": "^1.1.9"
              },
              "engines": {
                "node": ">= 8"
              }
            },
            "node_modules/@nodelib/fs.stat": {
              "version": "2.0.5",
              "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
              "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">= 8"
              }
            },
            "node_modules/@nodelib/fs.walk": {
              "version": "1.2.8",
              "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
              "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@nodelib/fs.scandir": "2.1.5",
                "fastq": "^1.6.0"
              },
              "engines": {
                "node": ">= 8"
              }
            },
            "node_modules/@nolyfill/is-core-module": {
              "version": "1.0.39",
              "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
              "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">=12.4.0"
              }
            },
            "node_modules/@rtsao/scc": {
              "version": "1.1.0",
              "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
              "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@rushstack/eslint-patch": {
              "version": "1.16.1",
              "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz",
              "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@swc/counter": {
              "version": "0.1.3",
              "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
              "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
              "license": "Apache-2.0"
            },
            "node_modules/@swc/helpers": {
              "version": "0.5.15",
              "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
              "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
              "license": "Apache-2.0",
              "dependencies": {
                "tslib": "^2.8.0"
              }
            },
            "node_modules/@tailwindcss/node": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
              "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@jridgewell/remapping": "^2.3.5",
                "enhanced-resolve": "^5.19.0",
                "jiti": "^2.6.1",
                "lightningcss": "1.32.0",
                "magic-string": "^0.30.21",
                "source-map-js": "^1.2.1",
                "tailwindcss": "4.2.2"
              }
            },
            "node_modules/@tailwindcss/oxide": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
              "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">= 20"
              },
              "optionalDependencies": {
                "@tailwindcss/oxide-android-arm64": "4.2.2",
                "@tailwindcss/oxide-darwin-arm64": "4.2.2",
                "@tailwindcss/oxide-darwin-x64": "4.2.2",
                "@tailwindcss/oxide-freebsd-x64": "4.2.2",
                "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
                "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
                "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
                "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
                "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
                "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
                "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
                "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
              }
            },
            "node_modules/@tailwindcss/oxide-android-arm64": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
              "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "android"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-darwin-arm64": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
              "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-darwin-x64": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
              "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-freebsd-x64": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
              "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "freebsd"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
              "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
              "cpu": [
                "arm"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
              "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
              "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
              "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-linux-x64-musl": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
              "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-wasm32-wasi": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
              "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
              "bundleDependencies": [
                "@napi-rs/wasm-runtime",
                "@emnapi/core",
                "@emnapi/runtime",
                "@tybys/wasm-util",
                "@emnapi/wasi-threads",
                "tslib"
              ],
              "cpu": [
                "wasm32"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "@emnapi/core": "^1.8.1",
                "@emnapi/runtime": "^1.8.1",
                "@emnapi/wasi-threads": "^1.1.0",
                "@napi-rs/wasm-runtime": "^1.1.1",
                "@tybys/wasm-util": "^0.10.1",
                "tslib": "^2.8.1"
              },
              "engines": {
                "node": ">=14.0.0"
              }
            },
            "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
              "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
              "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "win32"
              ],
              "engines": {
                "node": ">= 20"
              }
            },
            "node_modules/@tailwindcss/postcss": {
              "version": "4.2.2",
              "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
              "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@alloc/quick-lru": "^5.2.0",
                "@tailwindcss/node": "4.2.2",
                "@tailwindcss/oxide": "4.2.2",
                "postcss": "^8.5.6",
                "tailwindcss": "4.2.2"
              }
            },
            "node_modules/@tybys/wasm-util": {
              "version": "0.10.1",
              "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
              "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
              "dev": true,
              "license": "MIT",
              "optional": true,
              "dependencies": {
                "tslib": "^2.4.0"
              }
            },
            "node_modules/@types/estree": {
              "version": "1.0.8",
              "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
              "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@types/json-schema": {
              "version": "7.0.15",
              "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
              "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@types/json5": {
              "version": "0.0.29",
              "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
              "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
              "dev": true,
              "license": "MIT"
            },
            "node_modules/@types/node": {
              "version": "20.19.37",
              "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
              "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "undici-types": "~6.21.0"
              }
            },
            "node_modules/@types/react": {
              "version": "19.2.14",
              "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
              "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "csstype": "^3.2.2"
              }
            },
            "node_modules/@types/react-dom": {
              "version": "19.2.3",
              "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
              "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
              "dev": true,
              "license": "MIT",
              "peerDependencies": {
                "@types/react": "^19.2.0"
              }
            },
            "node_modules/@typescript-eslint/eslint-plugin": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
              "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@eslint-community/regexpp": "^4.12.2",
                "@typescript-eslint/scope-manager": "8.57.2",
                "@typescript-eslint/type-utils": "8.57.2",
                "@typescript-eslint/utils": "8.57.2",
                "@typescript-eslint/visitor-keys": "8.57.2",
                "ignore": "^7.0.5",
                "natural-compare": "^1.4.0",
                "ts-api-utils": "^2.4.0"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "@typescript-eslint/parser": "^8.57.2",
                "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
              "version": "7.0.5",
              "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
              "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": ">= 4"
              }
            },
            "node_modules/@typescript-eslint/parser": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
              "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/scope-manager": "8.57.2",
                "@typescript-eslint/types": "8.57.2",
                "@typescript-eslint/typescript-estree": "8.57.2",
                "@typescript-eslint/visitor-keys": "8.57.2",
                "debug": "^4.4.3"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/project-service": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
              "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/tsconfig-utils": "^8.57.2",
                "@typescript-eslint/types": "^8.57.2",
                "debug": "^4.4.3"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/scope-manager": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
              "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/types": "8.57.2",
                "@typescript-eslint/visitor-keys": "8.57.2"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              }
            },
            "node_modules/@typescript-eslint/tsconfig-utils": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
              "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/type-utils": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
              "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/types": "8.57.2",
                "@typescript-eslint/typescript-estree": "8.57.2",
                "@typescript-eslint/utils": "8.57.2",
                "debug": "^4.4.3",
                "ts-api-utils": "^2.4.0"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/types": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
              "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              }
            },
            "node_modules/@typescript-eslint/typescript-estree": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
              "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/project-service": "8.57.2",
                "@typescript-eslint/tsconfig-utils": "8.57.2",
                "@typescript-eslint/types": "8.57.2",
                "@typescript-eslint/visitor-keys": "8.57.2",
                "debug": "^4.4.3",
                "minimatch": "^10.2.2",
                "semver": "^7.7.3",
                "tinyglobby": "^0.2.15",
                "ts-api-utils": "^2.4.0"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
              "version": "4.0.4",
              "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
              "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
              "dev": true,
              "license": "MIT",
              "engines": {
                "node": "18 || 20 || >=22"
              }
            },
            "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
              "version": "5.0.5",
              "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
              "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "balanced-match": "^4.0.2"
              },
              "engines": {
                "node": "18 || 20 || >=22"
              }
            },
            "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
              "version": "10.2.4",
              "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
              "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
              "dev": true,
              "license": "BlueOak-1.0.0",
              "dependencies": {
                "brace-expansion": "^5.0.2"
              },
              "engines": {
                "node": "18 || 20 || >=22"
              },
              "funding": {
                "url": "https://github.com/sponsors/isaacs"
              }
            },
            "node_modules/@typescript-eslint/utils": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
              "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@eslint-community/eslint-utils": "^4.9.1",
                "@typescript-eslint/scope-manager": "8.57.2",
                "@typescript-eslint/types": "8.57.2",
                "@typescript-eslint/typescript-estree": "8.57.2"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              },
              "peerDependencies": {
                "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
                "typescript": ">=4.8.4 <6.0.0"
              }
            },
            "node_modules/@typescript-eslint/visitor-keys": {
              "version": "8.57.2",
              "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
              "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
              "dev": true,
              "license": "MIT",
              "dependencies": {
                "@typescript-eslint/types": "8.57.2",
                "eslint-visitor-keys": "^5.0.0"
              },
              "engines": {
                "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
              },
              "funding": {
                "type": "opencollective",
                "url": "https://opencollective.com/typescript-eslint"
              }
            },
            "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
              "version": "5.0.1",
              "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
              "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
              "dev": true,
              "license": "Apache-2.0",
              "engines": {
                "node": "^20.19.0 || ^22.13.0 || >=24"
              },
              "funding": {
                "url": "https://opencollective.com/eslint"
              }
            },
            "node_modules/@unrs/resolver-binding-android-arm-eabi": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
              "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
              "cpu": [
                "arm"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "android"
              ]
            },
            "node_modules/@unrs/resolver-binding-android-arm64": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
              "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "android"
              ]
            },
            "node_modules/@unrs/resolver-binding-darwin-arm64": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
              "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ]
            },
            "node_modules/@unrs/resolver-binding-darwin-x64": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
              "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "darwin"
              ]
            },
            "node_modules/@unrs/resolver-binding-freebsd-x64": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
              "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
              "cpu": [
                "x64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "freebsd"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
              "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
              "cpu": [
                "arm"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
              "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
              "cpu": [
                "arm"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
              "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
              "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
              "cpu": [
                "arm64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
              "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
              "cpu": [
                "ppc64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
              "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
              "cpu": [
                "riscv64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
              "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
              "cpu": [
                "riscv64"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
              "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
              "cpu": [
                "s390x"
              ],
              "dev": true,
              "license": "MIT",
              "optional": true,
              "os": [
                "linux"
              ]
            },
            "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
              "version": "1.11.1",
              "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
              "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm
      • package.json 627 B
        {
          "name": "aigc-claw",
          "version": "0.1.0",
          "private": true,
          "scripts": {
            "dev": "next dev",
            "build": "next build --no-lint",
            "start": "next start",
            "lint": "eslint"
          },
          "dependencies": {
            "clsx": "^2.1.1",
            "lucide-react": "^0.563.0",
            "next": "15.2.4",
            "react": "19.2.3",
            "react-dom": "19.2.3",
            "tailwind-merge": "^3.4.0"
          },
          "devDependencies": {
            "@tailwindcss/postcss": "^4",
            "@types/node": "^20",
            "@types/react": "^19",
            "@types/react-dom": "^19",
            "eslint": "^9",
            "eslint-config-next": "15.2.4",
            "tailwindcss": "^4",
            "typescript": "^5"
          }
        }
        
      • postcss.config.mjs 94 B · in bundle
      • README.md 1.4 KB
        This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
        
        ## Getting Started
        
        First, run the development server:
        
        ```bash
        npm run dev
        # or
        yarn dev
        # or
        pnpm dev
        # or
        bun dev
        ```
        
        Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
        
        You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
        
        This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
        
        ## Learn More
        
        To learn more about Next.js, take a look at the following resources:
        
        - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
        - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
        
        You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
        
        ## Deploy on Vercel
        
        The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
        
        Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
        
      • tsconfig.json 713 B
        {
          "compilerOptions": {
            "target": "ES2017",
            "lib": [
              "dom",
              "dom.iterable",
              "esnext"
            ],
            "allowJs": true,
            "skipLibCheck": true,
            "strict": true,
            "noEmit": true,
            "esModuleInterop": true,
            "module": "esnext",
            "moduleResolution": "bundler",
            "resolveJsonModule": true,
            "isolatedModules": true,
            "jsx": "preserve",
            "incremental": true,
            "plugins": [
              {
                "name": "next"
              }
            ],
            "paths": {
              "@/*": [
                "./*"
              ]
            }
          },
          "include": [
            "next-env.d.ts",
            "**/*.ts",
            "**/*.tsx",
            ".next/types/**/*.ts",
            ".next/dev/types/**/*.ts",
            "**/*.mts"
          ],
          "exclude": [
            "node_modules"
          ]
        }
        
  • references
    • init_project
      • init_all.md 1.1 KB
        # 项目初始化
        
        首次下载项目后,需要配置环境并启动前后端服务。
        
        ## 使用场景
        
        当用户给出以下指令时使用:
        - "初始化项目"
        - "配置项目"
        - "部署项目"
        - "安装项目"
        - "开始项目"
        - "setup project"
        - "deploy"
        
        ## 前置检查
        
        ```bash
        # 检查 Python 版本(需要 3.9+)
        python3 --version
        
        # 检查 Node.js 版本(需要 18+)
        node --version
        
        # 检查 npm 版本
        npm --version
        ```
        
        ## 初始化步骤
        
        ### 步骤1:初始化后端
        
        参考 [init_backend.md](init_backend.md)
        
        ### 步骤2:初始化前端
        
        参考 [init_frontend.md](init_frontend.md)
        
        ### 步骤3:验证服务
        
        ```bash
        # 检查后端运行
        curl http://localhost:8000/api/health
        
        # 检查前端运行
        curl http://localhost:3000
        ```
        
        ## 常见问题
        
        | 问题 | 解决方法 |
        |------|----------|
        | 后端启动失败 | 检查 Python 版本,确保 3.9+,检查 .env 配置 |
        | 前端 build 失败 | 删除 node_modules 和 .next,重新 `npm install` |
        | 端口被占用 | `lsof -ti :8000 | xargs kill` 或 `lsof -ti :3000 | xargs kill` |
        | 依赖安装慢 | 使用国内镜像源 |
      • init_backend.md 1.8 KB
        # 初始化后端
        
        配置并启动 FastAPI 后端服务。
        
        ## 步骤1:进入后端目录
        
        ```bash
        cd aigc-claw/backend
        ```
        
        ## 步骤2:创建虚拟环境(首次)
        
        ```bash
        # 创建虚拟环境
        python3 -m venv venv
        
        # 激活虚拟环境
        source venv/bin/activate
        ```
        
        ## 步骤3:安装依赖
        
        ```bash
        # 激活虚拟环境后执行
        pip install -r requirements.txt
        ```
        
        > **注意**:如果安装慢,可使用国内镜像:
        > ```bash
        > pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
        > ```
        
        ## 步骤4:配置环境变量
        
        ```bash
        # 复制配置示例文件
        cp .env.example .env
        ```
        
        然后编辑 `.env` 文件,填入必要的 API Key:
        
        | 变量 | 说明 | 获取方式 |
        |------|------|----------|
        | `DASHSCOPE_API_KEY` | 阿里云 Dashscope API Key(文生图、文生视频) | [阿里云百炼](https://dashscope.console.aliyun.com/) |
        | `DEEPSEEK_API_KEY` | DeepSeek API Key(LLM) | [DeepSeek](https://platform.deepseek.com/) |
        | `OPENAI_API_KEY` | OpenAI 兼容 API Key | 根据实际部署情况 |
        
        > ⚠️ **重要**:至少需要配置一个 LLM 和一个图片/视频生成 API,否则无法正常使用。
        
        ## 步骤5:启动后端
        
        ```bash
        source venv/bin/activate
        python api_server.py
        ```
        
        ## 步骤6:验证
        
        ```bash
        curl http://localhost:8000/api/health
        ```
        
        返回 `{"status":"ok"}` 表示成功。
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `python: command not found` | Python 未安装 | 安装 Python 3.9+ |
        | `No module named venv` | python3-venv 未安装 | `brew install python3-venv` (macOS) |
        | `ModuleNotFoundError` | 依赖未安装 | `pip install -r requirements.txt` |
        | `KeyError: 'DASHSCOPE_API_KEY'` | .env 未配置 | 编辑 .env 填入 API Key |
        | `Address already in use` | 端口 8000 被占用 | `lsof -ti :8000 | xargs kill` |
      • init_frontend.md 1.3 KB
        # 初始化前端
        
        配置并启动 Next.js 前端服务。
        
        ## 步骤1:进入前端目录
        
        ```bash
        cd aigc-claw/frontend
        ```
        
        ## 步骤2:安装依赖(首次)
        
        ```bash
        npm install
        ```
        
        > **注意**:如果安装慢,可使用国内镜像:
        > ```bash
        > npm config set registry https://registry.npmmirror.com
        > npm install
        > ```
        
        ## 步骤3:配置环境变量(可选)
        
        ```bash
        # 复制配置示例文件
        cp .env.local.example .env.local
        ```
        
        通常无需修改默认配置。
        
        ## 步骤4:Build 并启动
        
        ```bash
        # 首次 build(必须)
        npm run build
        
        # 启动服务
        npm start
        ```
        
        ## 步骤5:验证
        
        ```bash
        curl http://localhost:3000
        ```
        
        返回 HTML 表示成功。
        
        ## 后续启动
        
        首次初始化后,后续启动只需:
        
        ```bash
        cd aigc-claw/frontend
        npm start
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `npm: command not found` | Node.js 未安装 | 安装 Node.js 18+ |
        | `Error: Could not find or load config file` | .next 目录损坏 | `rm -rf .next && npm run build` |
        | 白屏/空白页面 | build 缓存问题 | `rm -rf .next && npm run build` |
        | `Address already in use` | 端口 3000 被占用 | `lsof -ti :3000 | xargs kill` |
        | 依赖安装失败 | 网络问题 | 使用镜像或科学上网 |
    • run_project
      • start_backend.md 1.2 KB
        # 启动后端
        
        启动 FastAPI 后端服务。
        
        ## 检查是否已运行
        
        ```bash
        # 推荐(检查服务响应)
        curl -s http://localhost:8000/api/health && echo "后端运行中" || echo "后端未运行"
        ```
        
        ## 启动命令
        
        ```bash
        cd aigc-claw/backend
        source venv/bin/activate
        python api_server.py
        ```
        
        ## 验证
        
        ```bash
        curl http://localhost:8000/api/health
        ```
        
        ## 启动后等待
        
        ⚠️ **重要**:后端启动需要时间,启动后必须等待 **3 秒** 再调用 API,否则可能收到 404 错误。
        
        ```bash
        sleep 3
        # 然后再调用 API
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `command not found: lsof` | lsof 命令不存在 | 使用备用检查方式 `curl http://localhost:8000/api/health` |
        | `后端未运行` | 服务未启动 | 执行启动命令 |
        | `Address already in use` | 端口被占用 | `lsof -ti :8000 \| xargs kill` 或 `pkill -f api_server.py` |
        | `ModuleNotFoundError` | 虚拟环境未激活 | 先执行 `source venv/bin/activate` |
        | `Connection refused` | 服务未启动或崩溃 | 检查日志 `/tmp/movie-backend.log` |
        
        ## 注意事项
        
        - 后端端口:`8000`
        - 日志位置:`/tmp/movie-backend.log`
        - **必须确保后端运行后才能调用 API**
        
      • start_frontend.md 1.4 KB
        # 启动前端
        
        启动 Next.js 前端服务。
        
        ## 检查是否已运行
        
        ```bash
        # 推荐(检查服务响应)
        curl -s http://localhost:3000 > /dev/null 2>&1 && echo "前端运行中" || echo "前端未运行"
        ```
        
        ## 首次启动(需要 build)
        
        ```bash
        cd aigc-claw/frontend
        npm run build
        npm start
        ```
        
        ## 后续启动
        
        ```bash
        cd aigc-claw/frontend
        npm start
        ```
        
        ## 验证
        
        ```bash
        curl http://localhost:3000
        ```
        
        ## 启动后等待
        
        ⚠️ **重要**:前端启动需要时间,启动后必须等待 **5 秒** 再访问页面,否则可能出现空白页面。
        
        ```bash
        sleep 5
        # 然后再访问 http://localhost:3000
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `command not found: lsof` | lsof 命令不存在 | 使用备用检查方式 `curl http://localhost:3000` |
        | `前端未运行` | 服务未启动 | 执行启动命令 |
        | `Address already in use` | 端口被占用 | `lsof -ti :3000 \| xargs kill` |
        | `npm: command not found` | Node.js 未安装 | 安装 Node.js |
        | `Error: Could not find or load config file` | .next 目录损坏 | 删除 .next 目录后重新 `npm run build` |
        | 白屏/空白页面 | build 缓存问题 | `rm -rf .next && npm run build` |
        
        ## 注意事项
        
        - 前端端口:`3000`
        - **必须确保前端运行后才能给用户 Web 界面链接**
        - 建议使用生产模式 `npm start`,开发模式可用 `npm run dev`
        
    • sandbox
      • generate_image_it2i.md 1.2 KB
        # 图生图 (I2I)
        
        使用图片生成图片(风格转换)。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/sandbox/i2i" \
          -H "Content-Type: application/json" \
          -d '{
            "model": "doubao-seedream-5-0",
            "prompt": "转换为动漫风格",
            "image": "code/result/image/user_upload/photo.png"
          }'
        ```
        
        ## 参数说明
        
        | 参数 | 必填 | 说明 |
        |------|------|------|
        | model | | 模型,默认 doubao-seedream-5-0 |
        | prompt | ✅ | 目标风格描述 |
        | image | ✅ | 源图片路径 |
        
        ## 可用模型
        
        | 模型 | 说明 |
        |------|------|
        | doubao-seedream-5-0 | 默认 |
        | wan2.6-image | |
        
        ## ⚠️ 路径格式
        
        - `image` 必须使用 `code/result/...` 格式的**相对路径**
        - 禁止使用完整 URL 或本地路径
        
        ## 响应
        
        ```json
        {
          "success": true,
          "image_url": "code/result/sandbox/images/xxx.png"
        }
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `"error": "image not found"` | 图片路径错误 | 确认 image 路径格式为 `code/result/...` |
        | `"success": false` | API Key 额度用完或无效 | 检查对应平台的 API Key |
        | 风格转换效果差 | 提示词不明确 | 使用更具体的风格描述 |
      • generate_image_t2i.md 1 KB
        # 文生图 (T2I)
        
        使用文字生成图片。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/sandbox/t2i" \
          -H "Content-Type: application/json" \
          -d '{
            "model": "doubao-seedream-5-0",
            "prompt": "A cute cat sitting on a couch, realistic style"
          }'
        ```
        
        ## 可用模型
        
        | 模型 | 说明 |
        |------|------|
        | doubao-seedream-5-0 | 默认 |
        | wan2.6-t2i | |
        | sora_image | |
        
        ## 响应
        
        ```json
        {
          "success": true,
          "image_url": "code/result/sandbox/images/xxx.png"
        }
        ```
        
        ## 注意事项
        
        1. **提示词建议使用英文**,效果更好
        2. **图片路径格式**:返回的是 `code/result/...` 格式,需要转换为实际文件路径
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `"success": false` | API Key 额度用完或无效 | 检查对应平台的 API Key |
        | `"error": "rate limit"` | 触发限流 | 等待后重试 |
        | 图片生成质量差 | 提示词不够具体 | 使用更具体的英文描述 |
      • generate_video.md 1.7 KB
        # 视频生成
        
        使用图片或文字生成视频片段(15秒以内)。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/sandbox/video" \
          -H "Content-Type: application/json" \
          -d '{
            "model": "wan2.6-i2v-flash",
            "prompt": "一只猫在草地上奔跑",
            "image": "code/result/image/user_upload/cat.png"
          }'
        ```
        
        ## 参数说明
        
        | 参数 | 必填 | 说明 |
        |------|------|------|
        | model | | 模型,默认 wan2.6-i2v-flash |
        | prompt | ✅ | 视频描述 |
        | image | ✅ | 参考图片路径 |
        
        ## 可用模型
        
        | 模型 | 说明 |
        |------|------|
        | wan2.6-i2v-flash | 默认,最快 |
        | wan2.6-i2v | |
        | kling-v3 | |
        | kling-v2-6 | |
        | jimeng_ti2v_v30_pro | |
        
        ## ⚠️ 路径格式
        
        - `image` 必须使用 `code/result/...` 格式的**相对路径**
        - 禁止使用完整 URL 或本地路径
        
        ## 响应
        
        ```json
        {
          "success": true,
          "video_path": "code/result/sandbox/videos/xxx.mp4",
          "record_id": "xxx"
        }
        ```
        
        ## 获取视频文件
        
        ```python
        # 直接从后端目录复制
        backend_path = "/code/result/sandbox/videos/{record_id}.mp4"
        local_path = "~/.openclaw/workspace/temp_imgs/{record_id}.mp4"
        shutil.copy2(backend_path, local_path)
        ```
        
        ## 注意事项
        
        1. 生成的视频较短(15秒以内)
        2. 如果需要生成长视频,请使用完整工作流
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `404 Not Found` | 路径格式错误 | 使用 `code/result/...` 格式 |
        | `"error": "image not found"` | 图片文件不存在 | 检查图片路径是否正确 |
        | `"success": false` | API Key 额度用完或无效 | 检查对应平台的 API Key |
    • send_message
      • feishu.md 2 KB
        # 飞书消息发送
        
        向用户发送图片、视频等媒体文件。
        
        ## 发送消息
        
        ```python
        message(action="send", message="消息内容", target="user_open_id")
        ```
        
        ## 发送图片
        
        ```python
        message(action="send", filePath="~/.openclaw/workspace/temp_imgs/xxx.png", message="图片描述", target="user_open_id")
        ```
        
        ## 发送视频
        
        ```python
        message(action="send", filePath="~/.openclaw/workspace/temp_imgs/xxx.mp4", message="视频描述", target="user_open_id")
        ```
        
        ## 参数说明
        
        | 参数 | 说明 |
        |------|------|
        | action | 固定为 "send" |
        | message | 消息文本内容 |
        | filePath | 本地文件路径(可选) |
        | target | 用户 Open ID |
        
        ## ⚠️ 强制要求
        
        1. **必须直接发送文件**:使用 `filePath` 参数直接发送,禁止只发送路径或 URL
        2. **下载到本地**:生成媒体后必须先下载到 `~/.openclaw/workspace/temp_imgs/` 目录
        
        ## 下载图片示例
        
        ```python
        import requests
        import os
        
        temp_dir = os.path.expanduser("~/.openclaw/workspace/temp_imgs")
        os.makedirs(temp_dir, exist_ok=True)
        
        # 从后端下载图片
        url = "http://localhost:8000/code/result/image/xxx.png"
        local_path = os.path.join(temp_dir, "xxx.png")
        
        resp = requests.get(url)
        with open(local_path, 'wb') as f:
            f.write(resp.content)
        
        # 发送给用户
        message(action="send", filePath=local_path, message="图片描述", target="user_open_id")
        ```
        
        ## ⚠️ 违规警告
        
        - 禁止只告诉用户"图片已保存到 xxx 路径"而不发送
        - 禁止只发送 URL 而不发送文件
        - 违规后果:用户必须主动要求"把图片发给我",这是严重失误!
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | 文件下载失败 | URL 错误或后端未运行 | 检查 URL 是否正确,确认后端已启动 |
        | 文件太小/无效 | 下载的可能是错误页面 | 验证文件大小 > 1KB |
        | 找不到文件 | 路径不存在 | 确认 `~/.openclaw/workspace/temp_imgs/` 目录下有文件 |
        | message 发送失败 | user_open_id 错误 | 确认 target 参数正确 |
      • wechat.md 625 B
        # 微信发送消息
        
        ## 注意事项
        
        微信不支持 Markdown 语法,**不要使用 Markdown 表格**!
        
        如果需要发送表格内容,改用**带缩进的编号列表**格式:
        
        ```markdown
        1. 选项1:说明内容
           - 详情1
           - 详情2
        2. 选项2:说明内容
           - 详情1
           - 详情2
        3. 选项3:说明内容
           - 详情1
           - 详情2
        ```
        
        ## 示例
        
        ❌ 错误(Markdown 表格,微信不显示):
        ```
        | 选项 | 说明 |
        |------|------|
        | A | xxx |
        | B | yyy |
        ```
        
        ✅ 正确(缩进编号列表):
        ```
        1. 选项A:xxx
           - 详情1
           - 详情2
        2. 选项B:yyy
           - 详情1
           - 详情2
        ```
    • workflow
      • create_character.md 2.9 KB
        # 生成角色与场景
        
        执行第二阶段:角色和场景设计。
        
        ---
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/character_design" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ---
        
        ## 停点说明
        
        此阶段有 1 个停点:角色/场景设计完成后需要用户确认。
        
        ## 产物结构
        
        ```json
        {
          "characters": [
            {
              "id": "char_1",
              "name": "角色名",
              "description": "角色描述",
              "visual_prompt": "视觉提示词",
              "selected": "code/result/image/xxx/character_001.png",
              "versions": ["code/result/xxx.png"]
            }
          ],
          "settings": [
            {
              "id": "set_1",
              "name": "场景名",
              "description": "场景描述",
              "visual_prompt": "视觉提示词",
              "selected": "code/result/image/xxx/setting_001.png",
              "versions": [...]
            }
          ]
        }
        ```
        
        ## 实时反馈
        
        在生成过程中,SSE 会发送 `asset_complete` 事件:
        
        ```json
        {
          "type": "progress",
          "data": {
            "asset_complete": {
              "type": "characters|settings",
              "id": "char_1",
              "status": "done",
              "selected": "code/result/image/xxx.png",
              "versions": [...]
            }
          }
        }
        ```
        
        收到此事件后,必须立即下载图片并发送给用户。
        
        ## 停点6:角色/场景设计完成,等待用户确认后继续下一阶段
        
        **必须向用户发送消息**,展示完整的角色和场景设计:
        
        1. **人物图片**:从 `artifact.characters[].selected` 获取每个人物的图片路径
        2. **场景图片**:从 `artifact.settings[].selected` 获取每个场景的图片路径
        3. **人物列表**:包含角色名、描述、视觉提示词
        4. **场景列表**:包含场景名、描述、视觉提示词
        
        **发送消息时必须**:
        - 根据消息渠道参考 [send_message/feishu.md](../send_message/feishu.md) 或 [send_message/wechat.md](../send_message/wechat.md) 发送图片
        - 每张图片需附带简短说明(角色名/场景名)
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=character_design`)
        - 发送完整列表后,询问用户确认
        
        询问内容示例:
        > "角色和场景设计已完成,共生成 X 个人物和 Y 个场景。请确认是否继续进行分镜设计?"
        
        ## 继续下一阶段
        
        用户确认后调用:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/continue"
        ```
        
        ---
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `asset_complete` 状态为 failed | 图片生成失败 | 检查 API Key 配置,记录失败原因 |
        | 图片下载失败 | URL 路径错误 | 确认 path 格式为 `code/result/...` |
        | SSE 连接断开 | 网络超时 | 使用轮询 `/api/project/{session_id}/status` 继续 |
        | 用户不确认 | 用户想修改角色 | 调用 modify_character 重新生成 |
        
      • create_post.md 1.2 KB
        # 后期剪辑
        
        执行第六阶段:将所有视频片段拼接成一个完整视频。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/post_production" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 停点说明
        
        此阶段**无停点**,直接执行完成。
        
        ## 产物结构
        
        ```json
        {
          "final_video": "code/result/video/xxx/final.mp4"
        }
        ```
        
        ## 完成提示
        
        全部阶段完成后,告知用户:
        - 完整视频已生成
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=post_production`)
        - 提供 Web 界面链接供用户查看和下载
        
        ```bash
        curl "http://localhost:8000/api/project/{session_id}/artifact/post_production"
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | 拼接失败 | 部分视频片段缺失或损坏 | 检查 video_generation 阶段的产物 |
        | final_video 为空 | 所有视频片段生成失败 | 回退到 video_generation 阶段重新生成 |
        | 视频时长为 0 | FFmpeg 处理失败 | 检查后端日志 |
      • create_project.md 6.9 KB
        # 创建项目
        
        创建一个新的视频生成项目。
        
        ## 请求与响应
        
        ### 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/start" \
          -H "Content-Type: application/json" \
          -d '{
            "idea": "故事内容",
            "style": "anime",
            "video_ratio": "16:9",
            "llm_model": "qwen3.5-plus",
            "vlm_model": "qwen-vl-plus",
            "image_t2i_model": "doubao-seedream-5-0",
            "image_it2i_model": "doubao-seedream-5-0",
            "video_model": "wan2.6-i2v-flash",
            "enable_concurrency": true,
            "web_search": false
          }'
        ```
        
        ### 响应
        
        ```json
        {
          "session_id": "xxx",
          "status": "idle",
          "current_stage": "init"
        }
        ```
        
        ---
        
        ## 参数说明
        
        | 参数 | 必填 | 说明 | 默认值 |
        |------|------|------|--------|
        | idea | ✅ | 故事创意/灵感 | - |
        | style | ✅ | 视频风格 | realistic |
        | video_ratio | | 视频比例 | 16:9 |
        | llm_model | | 剧本生成模型 | qwen3.5-plus |
        | vlm_model | | VLM 评估模型 | qwen-vl-plus |
        | image_t2i_model | | 文生图模型 | doubao-seedream-5-0 |
        | image_it2i_model | | 图生图模型 | doubao-seedream-5-0 |
        | video_model | | 视频生成模型 | wan2.6-i2v-flash |
        | enable_concurrency | | 开启并发生成 | true |
        | web_search | | 联网搜索 | false |
        
        ### 可用风格
        
        `anime`, `realistic`, `comic-book`, `3d-disney`, `watercolor`, `oil-painting`, `cyberpunk`, `chinese-ink`
        
        ### 可选视频比例
        
        6:9, 9:16, 1:1, 4:3, 3:4
        
        ### 可用模型
        
        | 模块 | 模型 |
        |------|------|
        | LLM | qwen3.5-plus, deepseek-chat, gpt-4o, gemini-2.5-flash |
        | VLM | qwen-vl-plus, gemini-2.5-flash-image |
        | T2I | doubao-seedream-5-0, wan2.6-t2i, jimeng_t2i_v40 |
        | I2I | doubao-seedream-5-0, wan2.6-image |
        | Video | wan2.6-i2v-flash, kling-v3, jimeng_ti2v_v30_pro |
        
        ---
        
        ## 询问用户示例
        
        在创建项目前,请向用户展示以下选项并让用户选择:
        
        表格形式:
        | 配置项 | 选项 | 默认值(推荐) |
        |--------|------|---------------|
        | **视频风格 (style)** | anime, realistic, comic-book, 3d-disney, watercolor, oil-painting, cyberpunk, chinese-ink | realistic |
        | **视频比例 (video_ratio)** | 16:9(横屏), 9:16(竖屏), 1:1(方形), 4:3, 3:4 | 16:9 |
        | **LLM 模型** | qwen3.5-plus, deepseek-chat, gpt-4o, gemini-2.5-flash | qwen3.5-plus |
        | **VLM 模型** | qwen-vl-plus, gemini-2.5-flash-image | qwen-vl-plus |
        | **T2I 模型** | doubao-seedream-5-0, wan2.6-t2i, jimeng_t2i_v40 | doubao-seedream-5-0 |
        | **I2I 模型** | doubao-seedream-5-0, wan2.6-image | doubao-seedream-5-0 |
        | **Video 模型** | wan2.6-i2v-flash, kling-v3, jimeng_ti2v_v30_pro | wan2.6-i2v-flash |
        | **联网搜索** | true, false | false |
        | **并发生成** | true, false | true |
        
        编号列表形式:
        1. 故事创意 (idea): [用户的创意内容]
        2. 视频风格 (style): realistic(默认值)
           - 可选:anime, realistic, comic-book, 3d-disney, watercolor, oil-painting, cyberpunk, chinese-ink
        3. 视频比例 (video_ratio): 16:9(默认值)
           - 可选:16:9, 9:16, 1:1, 4:3, 3:4
        4. LLM 模型: qwen3.5-plus(默认值)
           - 可选:qwen3.5-plus, deepseek-chat, gpt-4o, gemini-2.5-flash
        5. VLM 模型: qwen-vl-plus(默认值)
           - 可选:qwen-vl-plus, gemini-2.5-flash-image
        6. T2I 模型: doubao-seedream-5-0(默认值)
           - 可选:doubao-seedream-5-0, wan2.6-t2i, jimeng_t2i_v40
        7. I2I 模型: doubao-seedream-5-0(默认值)
           - 可选:doubao-seedream-5-0, wan2.6-image
        8. Video 模型: wan2.6-i2v-flash(默认值)
           - 可选:wan2.6-i2v-flash, kling-v3, jimeng_ti2v_v30_pro
        9. 联网搜索: false(默认值)
           - 可选:true, false
        10. 并发生成: true(默认值)
           - 可选:true, false
        
        > **注意**:
        > - **所有参数必须都展示给用户**
        > - 根据用户消息渠道选择格式:
        >   - 飞书:使用 Markdown 表格
        >   - 微信:使用编号列表(微信不支持 Markdown 表格)
        
        ---
        
        ## 停点1:项目配置确认
        
        在调用 API 创建项目之前,必须展示当前配置并询问用户:
        
        ### 展示当前配置
        
        根据用户提供的idea和选择(用户未提及的选项使用默认值),生成配置确认表格:
        
        | 配置项 | 当前值 |
        |--------|--------|
        | 故事创意 (idea) | [用户的创意内容] |
        | 视频风格 (style) | realistic(默认值)或其他用户选择 |
        | 视频比例 (video_ratio) | 16:9(默认值)或其他用户选择 |
        | LLM 模型 | qwen3.5-plus(默认值)或其他用户选择 |
        | VLM 模型 | qwen-vl-plus(默认值)或其他用户选择 |
        | T2I 模型 | doubao-seedream-5-0(默认值)或其他用户选择 |
        | I2I 模型 | doubao-seedream-5-0(默认值)或其他用户选择 |
        | Video 模型 | wan2.6-i2v-flash(默认值)或其他用户选择 |
        | 联网搜索 | false(默认值)|
        | 并发生成 | true(默认值)|
        
        ### 询问用户
        
        > 当前配置如上,请问是否有需要修改的?
        > - 如需修改,请告知具体要修改的项目和新值
        > - 如无需修改,请回复"确认"或"确定"
        
        ### 循环确认
        
        - 如果用户提出修改 → 记录修改项 → 重新展示更新后的配置 → 再次询问确认
        - 直到用户确认无需修改 → 才能调用 API 创建项目
        
        ---
        
        ## 注意事项
        
        1. **必须询问用户**:在创建项目前,一定要询问用户项目的配置,用户没有提及的选项则使用默认值
        2. **检查 API Key**:在创建项目前,必须检查用户选择的模型对应的 API Key 是否已配置
        
        ### API Key 检查步骤
        
        ```bash
        # 1. 读取 .env 文件
        cat aigc-claw/backend/.env
        
        # 2. 根据用户选择的模型检查对应 API Key
        #    - LLM 模型:检查 DASHSCOPE_API_KEY / DEEPSEEK_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY
        #    - 图片模型:检查 ARK_API_KEY / DASHSCOPE_API_KEY / VOLC_ACCESS_KEY/VOLC_SECRET_KEY
        #    - 视频模型:检查 DASHSCOPE_API_KEY / VOLC_ACCESS_KEY/VOLC_SECRET_KEY / KLING_ACCESS_KEY/KLING_SECRET_KEY
        
        # 3. 如果缺少 API Key,提醒用户配置
        ```
        
        ### 缺少 API 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 |
        
        ---
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `500 Internal Server Error` | API Key 缺失或配置错误 | 检查 `backend/.env` 文件 |
        | `404 Not Found` | API 路径错误 | 确认 URL 为 `http://localhost:8000/api/project/start` |
      • create_reference.md 2.4 KB
        # 生成参考图
        
        执行第四阶段:为每个分镜生成参考图。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/reference_generation" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 停点说明
        
        此阶段有 1 个停点:参考图生成完成后需要用户确认。
        
        ## 产物结构
        
        ```json
        {
          "shots": [
            {
              "scene_id": "1",
              "shot_id": "1",
              "selected": "code/result/image/xxx/shot_001.png",
              "versions": [...],
              "visual_prompt": "优化后的视觉提示词"
            }
          ]
        }
        ```
        
        ## 实时反馈
        
        在生成过程中,SSE 会发送 `asset_complete` 事件:
        
        ```json
        {
          "type": "progress",
          "data": {
            "asset_complete": {
              "type": "images",
              "id": "1-1",
              "status": "done",
              "selected": "code/result/image/xxx.png",
              "versions": [...]
            }
          }
        }
        ```
        
        收到此事件后,必须立即下载图片并发送给用户。
        
        ## 停点8:参考图生成完成,等待用户确认后继续下一阶段
        
        **必须向用户发送消息**,展示每个分镜的参考图:
        
        从 `artifact.shots[].selected` 获取每个分镜的参考图路径。
        
        **发送消息时必须**:
        - 参考 [send_message/feishu.md](../send_message/feishu.md) 发送图片给用户
        - 每张参考图需附带分镜编号和简短描述(如"场景1-分镜1:角色A在咖啡馆")
        - 按场景顺序依次发送
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=reference_generation`)
        - 发送完整列表后,询问用户确认
        
        询问内容示例:
        > "参考图生成已完成,共 X 张参考图。请确认是否继续生成视频片段?"
        
        ## 继续下一阶段
        
        用户确认后调用:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/continue"
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `asset_complete` 状态为 failed | 图片生成失败(超时/限流) | 可在请求中设置 `"enable_concurrency": false` 降低并发重试 |
        | 图片下载失败 | URL 路径错误 | 确认 path 格式为 `code/result/...` |
        | SSE 连接断开 | 网络超时 | 使用轮询 `/api/project/{session_id}/status` 继续 |
        | 用户不确认 | 用户想修改参考图 | 调用 modify_reference 重新生成 |
        
      • create_script.md 4.3 KB
        # 生成剧本
        
        执行第一阶段:剧本生成。
        
        ---
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/script_generation" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx", "style": "anime"}'
        ```
        
        ---
        
        ## 停点流程
        
        剧本生成阶段有 4 个停点:
        
        | 停点 | phase 值 | 操作 |
        |------|----------|------|
        | 1 | suggest_expand | 询问用户是否需要对情节进行扩写 |
        | 2 | logline_selection | 让用户从 3 个情节候选中选择 |
        | 3 | mode_selection | 让用户选择电影(4幕)或微电影(1幕) |
        | 4 | script_generation | 等待剧本生成完成 |
        
        > **注意**:如果用户输入足够清晰完整丰富,`suggest_expand` 和 `logline_selection` 可能会直接跳过,直接进入 `mode_selection`。
        
        ## 处理各停点
        
        ### 停点2:建议扩写(suggest_expand)
        
        此停点表示当前输入的情节过于简短,难以生成高质量剧情,系统建议进行创意扩写。
        
        ```bash
        # 获取 artifact 查看扩写建议
        curl "http://localhost:8000/api/project/{session_id}/artifact/script_generation"
        ```
        
        从 `artifact.expand_suggestion` 或 `artifact.suggestion` 获取扩写建议,询问用户选择:
        
        - **选择扩写**:调用 intervene,让系统进行创意扩写
        - **跳过扩写**:直接进入下一停点(logline_selection 或 mode_selection)
        
        ```bash
        # 选择扩写
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "script_generation", "modifications": {"expand_idea": true}}'
        
        # 跳过扩写
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "script_generation", "modifications": {"expand_idea": false}}'
        ```
        
        ### 停点3:选择情节
        
        ```bash
        # 获取 artifact 查看候选
        curl "http://localhost:8000/api/project/{session_id}/artifact/script_generation"
        ```
        
        从 `artifact.logline_options` 获取候选列表,询问用户选择,然后调用 intervene:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "script_generation", "modifications": {"selected_logline": 0}}'
        ```
        
        > **注意**:参数名是 `selected_logline`,不是 `selected_logline_index`!
        
        ### 停点4:选择模式
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "script_generation", "modifications": {"selected_mode": "expand"}}'
        ```
        
        - `expand`: 电影模式(4幕,15-20分钟)
        - `micro`: 微电影模式(1幕,3-5分钟)
        
        ### 停点5:剧本生成完成,等待用户确认后继续下一阶段
        
        **必须向用户发送消息**,展示完整的剧本内容:
        
        - **标题**:`artifact.title`
        - **故事线**:`artifact.logline`
        - **人物列表**:`artifact.characters`(包含人物名称、描述、性格特点)
        - **背景列表**:`artifact.settings`(包含背景名称、描述、氛围)
        - **场景列表**:`artifact.scenes`(包含场景编号、类型、描述、人物、地点)
        
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=script_generation`)
        
        询问用户确认后调用:
        
        ```bash
        # 确认剧本,继续下一阶段
        curl -X POST "http://localhost:8000/api/project/{session_id}/continue"
        ```
        
        ---
        
        ## SSE 事件监听
        
        - `progress`: 实时进度,可能包含 `asset_complete`
        - `stage_complete`: 阶段完成,检查 `data.phase` 确认是否还有下一阶段
        - `error`: 执行出错
        
        ---
        
        ## 响应示例
        
        ```json
        {
          "title": "标题",
          "logline": "核心故事线",
          "characters": [...],
          "settings": [...],
          "scenes": [...],
          "phase": "script_generation"
        }
        ```
        
        ---
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `404 Not Found` | session_id 错误或 API 路径错误 | 确认 session_id 正确 |
        | SSE 无响应 | 后端任务卡住 | 检查日志 `/tmp/movie-backend.log` |
        | 用户不选择 | 用户在停点未回复 | 等待用户选择,不要自行决定 |
        | `"phase": "suggest_expand"` | 系统建议启用创意扩写 | 可自动调用 intervene 启用 |
        
      • create_storyboard.md 2.2 KB
        # 生成分镜
        
        执行第三阶段:分镜设计。
        
        ---
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/storyboard" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ---
        
        ## 停点说明
        
        此阶段有 1 个停点:分镜设计完成后需要用户确认。
        
        ## 产物结构
        
        ```json
        {
          "shots": [
            {
              "scene_id": "1",
              "shot_id": "1",
              "duration": "5",
              "characters": ["角色A"],
              "location": "场景名",
              "description": "分镜描述",
              "visual_prompt": "视觉提示词"
            }
          ]
        }
        ```
        
        ## 停点7:分镜设计完成,等待用户确认后继续下一阶段
        
        **必须向用户发送消息**,展示完整的分镜列表:
        
        从 `artifact.shots` 获取所有分镜数据,用表格形式展示:
        
        | 场景-分镜 | 时长 | 人物 | 地点 | 情节描述 |
        |-----------|------|------|------|----------|
        | 1-1 | 5s | 角色A, 角色B | 咖啡馆 | 两人在咖啡馆交谈 |
        | 1-2 | 3s | 角色A | 咖啡馆 | 角色A望向窗外 |
        
        **发送消息时必须**:
        - 使用文字形式发送表格(参考 [send_message/feishu.md](../send_message/feishu.md))
        - 包含总时长统计
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=storyboard`)
        - 发送完整列表后,询问用户确认
        
        询问内容示例:
        > "分镜设计已完成,共 X 个分镜,总时长约 Y 秒。请确认是否继续生成参考图?"
        
        ## 继续下一阶段
        
        用户确认后调用:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/continue"
        ```
        
        ---
        
        ## 智能续写(可选)
        
        用户可以要求续写分镜:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "storyboard", "modifications": {"continue_story": true}}'
        ```
        
        ---
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | 分镜数量太少 | LLM 生成不完整 | 询问用户是否需要续写 |
        | 用户不确认 | 用户想修改分镜 | 调用 modify_storyboard 修改 |
        | 续写失败 | 剧本内容不足 | 检查剧本阶段产物是否完整 |
        
      • create_video.md 2.5 KB
        # 生成视频片段
        
        执行第五阶段:视频生成。
        
        ## 请求
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/video_generation" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 停点说明
        
        此阶段有 1 个停点:视频生成完成后需要用户确认。
        
        ## 产物结构
        
        ```json
        {
          "clips": [
            {
              "scene_id": "1",
              "shot_id": "1",
              "description": "视频描述",
              "selected": "code/result/video/xxx/clip_001.mp4",
              "versions": [...],
              "status": "done|failed"
            }
          ]
        }
        ```
        
        ## 实时反馈
        
        在生成过程中,SSE 会发送 `asset_complete` 事件:
        
        ```json
        {
          "type": "progress",
          "data": {
            "asset_complete": {
              "type": "clips",
              "id": "1-1",
              "status": "done",
              "selected": "code/result/video/xxx.mp4",
              "versions": [...]
            }
          }
        }
        ```
        
        收到此事件后,必须立即下载视频并发送给用户。
        
        ## 停点9:视频片段生成完成,等待用户确认后继续下一阶段
        
        **必须向用户发送消息**,展示每个视频片段:
        
        从 `artifact.clips[].selected` 获取每个视频片段的路径。
        
        **发送消息时必须**:
        - 参考 [send_message/feishu.md](../send_message/feishu.md) 发送视频给用户
        - 每个视频片段需附带分镜编号和描述(如"场景1-分镜1:角色A走进咖啡馆")
        - 标注视频时长和状态(done/failed)
        - 按场景顺序依次发送
        - **发送前端 URL**(获取本地 IPv4 地址,构造 `http://{local_ip}:3000/?session={session_id}&stage=video_generation`)
        - 发送完整列表后,询问用户确认
        
        询问内容示例:
        > "视频片段生成完成,共 X 个片段,Y 个成功。请确认是否继续进行后期剪辑?"
        
        ## 继续下一阶段
        
        用户确认后调用:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/continue"
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `asset_complete` 状态为 failed | 视频生成失败(超时/限流/不支持的内容) | 可降低并发数重试,或检查视频模型是否支持该内容 |
        | 视频下载失败 | URL 路径错误 | 确认 path 格式为 `code/result/...` |
        | 视频文件太小 | 生成可能失败 | 检查文件大小 > 1KB |
        | SSE 连接断开 | 视频生成时间长 | 使用轮询 `/api/project/{session_id}/status` 继续 |
        | 用户不确认 | 用户想修改视频 | 调用 modify_video 重新生成 |
        
      • modify_character.md 1.2 KB
        # 修改角色/场景提示词
        
        在第二阶段完成后,用户可以修改角色或场景的视觉提示词并重新生成。
        
        ## 修改角色提示词
        
        ```bash
        curl -X PATCH "http://localhost:8000/api/project/{session_id}/artifact/character_design" \
          -H "Content-Type: application/json" \
          -d '{
            "characters": [
              {
                "id": "char_1",
                "visual_prompt": "新的视觉提示词"
              }
            ]
          }'
        ```
        
        ## 重新执行角色/场景设计
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/character_design" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 参数说明
        
        | 参数 | 说明 |
        |------|------|
        | characters[].id | 角色 ID |
        | characters[].visual_prompt | 新的视觉提示词 |
        
        > 修改后需要重新执行阶段才能生效。
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | `404 Not Found` | session_id 错误 | 确认 session_id 正确 |
        | PATCH 成功但无变化 | 需要重新执行阶段 | 调用 execute/character_design 重新生成 |
        | 角色 ID 不存在 | ID 错误 | 从 artifact 中获取正确的角色 ID |
      • modify_reference.md 1.1 KB
        # 修改参考图提示词
        
        在第四阶段完成后,用户可以修改某个分镜的视觉提示词并重新生成参考图。
        
        ## 修改分镜提示词
        
        ```bash
        curl -X PATCH "http://localhost:8000/api/project/{session_id}/artifact/reference_generation" \
          -H "Content-Type: application/json" \
          -d '{
            "shots": [
              {
                "scene_id": "1",
                "shot_id": "1",
                "visual_prompt": "新的视觉提示词"
              }
            ]
          }'
        ```
        
        ## 重新执行参考图生成
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/reference_generation" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | PATCH 成功但无变化 | 需要重新执行阶段 | 调用 execute/reference_generation 重新生成 |
        | scene_id/shot_id 不存在 | ID 错误 | 从 artifact 中获取正确的 ID |
        | 图片生成失败 | 提示词包含不支持的内容 | 修改提示词后重试 |
      • modify_storyboard.md 1.3 KB
        # 修改分镜
        
        在第三阶段完成后,用户可以修改或续写分镜。
        
        ## 修改分镜
        
        ```bash
        curl -X PATCH "http://localhost:8000/api/project/{session_id}/artifact/storyboard" \
          -H "Content-Type: application/json" \
          -d '{
            "shots": [
              {
                "scene_id": "1",
                "shot_id": "1",
                "duration": "8",
                "description": "新的分镜描述",
                "visual_prompt": "新的视觉提示词"
              }
            ]
          }'
        ```
        
        ## 智能续写
        
        根据已有剧情自动生成新场景:
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/intervene" \
          -H "Content-Type: application/json" \
          -d '{"stage": "storyboard", "modifications": {"continue_story": true}}'
        ```
        
        ## 重新执行分镜设计
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/storyboard" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | PATCH 成功但无变化 | 需要重新执行阶段 | 调用 execute/storyboard 重新生成 |
        | scene_id/shot_id 不存在 | ID 错误 | 从 artifact 中获取正确的 ID |
        | 续写失败 | 剧本内容不足 | 检查剧本阶段产物是否完整 |
      • modify_video.md 1.1 KB
        # 修改视频生成提示词
        
        在第五阶段完成后,用户可以修改某个分镜的视频生成提示词并重新生成视频。
        
        ## 修改分镜提示词
        
        ```bash
        curl -X PATCH "http://localhost:8000/api/project/{session_id}/artifact/video_generation" \
          -H "Content-Type: application/json" \
          -d '{
            "clips": [
              {
                "scene_id": "1",
                "shot_id": "1",
                "description": "新的视频描述"
              }
            ]
          }'
        ```
        
        ## 重新执行视频生成
        
        ```bash
        curl -X POST "http://localhost:8000/api/project/{session_id}/execute/video_generation" \
          -H "Content-Type: application/json" \
          -d '{"session_id": "xxx"}'
        ```
        
        ## 常见问题
        
        | 错误 | 原因 | 解决方法 |
        |------|------|----------|
        | `curl: (7) Failed to connect` | 后端未运行 | 启动后端服务 |
        | PATCH 成功但无变化 | 需要重新执行阶段 | 调用 execute/video_generation 重新生成 |
        | scene_id/shot_id 不存在 | ID 错误 | 从 artifact 中获取正确的 ID |
        | 视频生成失败 | 提示词不支持或超时 | 修改提示词或降低并发后重试 |
  • .gitignore 295 B · in bundle
  • CLAUDE.md 3.7 KB
    # 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
  • README.md 9.9 KB
    # 🎬 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
    
  • SKILL.md 12.5 KB
    ---
    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) | 微信发送媒体文件 | 用户通过微信渠道发起对话,并且需要向用户发送图片/视频给用户时 |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related