Repository: chatfire-AI/huobao-drama Branch: master Commit: 43d048179a6e Files: 183 Total size: 32.4 MB Directory structure: gitextract_vm6sptpc/ ├── .dockerignore ├── .gitignore ├── DOCKER_HOST_ACCESS.md ├── Dockerfile ├── LICENSE ├── MIGRATE_README.md ├── README-CN.md ├── README-JA.md ├── README.md ├── api/ │ ├── handlers/ │ │ ├── ai_config.go │ │ ├── asset.go │ │ ├── audio_extraction.go │ │ ├── character_batch.go │ │ ├── character_library.go │ │ ├── character_library_gen.go │ │ ├── drama.go │ │ ├── frame_prompt.go │ │ ├── frame_prompt_query.go │ │ ├── image_generation.go │ │ ├── prop.go │ │ ├── scene.go │ │ ├── script_generation.go │ │ ├── settings.go │ │ ├── storyboard.go │ │ ├── task.go │ │ ├── upload.go │ │ ├── video_generation.go │ │ └── video_merge.go │ ├── middlewares/ │ │ ├── cors.go │ │ ├── logger.go │ │ └── ratelimit.go │ └── routes/ │ └── routes.go ├── application/ │ └── services/ │ ├── ai_service.go │ ├── asset_duration_update.go │ ├── asset_service.go │ ├── audio_extraction_service.go │ ├── character_library_service.go │ ├── data_migration_service.go │ ├── drama_service.go │ ├── frame_prompt_helper.go │ ├── frame_prompt_service.go │ ├── image_generation_service.go │ ├── prompt_i18n.go │ ├── prop_service.go │ ├── resource_transfer_service.go │ ├── script_generation_service.go │ ├── storyboard_composition_service.go │ ├── storyboard_service.go │ ├── storyboard_update_full.go │ ├── task_service.go │ ├── upload_service.go │ ├── video_generation_service.go │ └── video_merge_service.go ├── cmd/ │ └── migrate/ │ └── main.go ├── configs/ │ └── config.example.yaml ├── docker-compose.yml ├── docs/ │ └── DATA_MIGRATION.md ├── domain/ │ └── models/ │ ├── ai_config.go │ ├── asset.go │ ├── character_library.go │ ├── drama.go │ ├── frame_prompt.go │ ├── image_generation.go │ ├── task.go │ ├── timeline.go │ ├── video_generation.go │ └── video_merge.go ├── go.mod ├── go.sum ├── infrastructure/ │ ├── database/ │ │ ├── custom_logger.go │ │ └── database.go │ ├── external/ │ │ └── ffmpeg/ │ │ └── ffmpeg.go │ ├── scheduler/ │ │ └── resource_transfer_scheduler.go │ └── storage/ │ └── local_storage.go ├── main.go ├── migrations/ │ ├── 20260126_add_local_path.sql │ └── init.sql ├── pkg/ │ ├── ai/ │ │ ├── client.go │ │ ├── gemini_client.go │ │ └── openai_client.go │ ├── config/ │ │ └── config.go │ ├── image/ │ │ ├── gemini_image_client.go │ │ ├── image_client.go │ │ ├── openai_image_client.go │ │ └── volcengine_image_client.go │ ├── logger/ │ │ └── logger.go │ ├── response/ │ │ └── response.go │ ├── utils/ │ │ ├── image_utils.go │ │ ├── json_parser.go │ │ └── json_parser_test.go │ └── video/ │ ├── chatfire_client.go │ ├── minimax_client.go │ ├── openai_sora_client.go │ ├── video_client.go │ └── volces_ark_client.go └── web/ ├── .gitignore ├── index.html ├── nginx.conf ├── package.json ├── public/ │ └── ffmpeg/ │ ├── ffmpeg-core.js │ └── ffmpeg-core.wasm ├── src/ │ ├── App.vue │ ├── api/ │ │ ├── ai.ts │ │ ├── asset.ts │ │ ├── audio.ts │ │ ├── character-library.ts │ │ ├── drama.ts │ │ ├── frame.ts │ │ ├── generation.ts │ │ ├── image.ts │ │ ├── prop.ts │ │ ├── settings.ts │ │ ├── task.ts │ │ ├── video.ts │ │ └── videoMerge.ts │ ├── assets/ │ │ └── styles/ │ │ ├── element/ │ │ │ └── index.scss │ │ └── main.css │ ├── components/ │ │ ├── LanguageSwitcher.vue │ │ ├── common/ │ │ │ ├── AIConfigDialog.vue │ │ │ ├── ActionButton.vue │ │ │ ├── AppHeader.vue │ │ │ ├── AppLayout.vue │ │ │ ├── BaseCard.vue │ │ │ ├── CreateDramaDialog.vue │ │ │ ├── EmptyState.vue │ │ │ ├── ImageCropDialog.vue │ │ │ ├── ImagePreview.vue │ │ │ ├── PageHeader.vue │ │ │ ├── ProjectCard.vue │ │ │ ├── StatCard.vue │ │ │ ├── ThemeToggle.vue │ │ │ └── index.ts │ │ └── editor/ │ │ ├── GridImageEditor.vue │ │ ├── StoryboardEditor.vue │ │ └── VideoTimelineEditor.vue │ ├── locales/ │ │ ├── en-US.ts │ │ ├── index.ts │ │ └── zh-CN.ts │ ├── main.ts │ ├── router/ │ │ └── index.ts │ ├── stores/ │ │ └── episode.ts │ ├── types/ │ │ ├── ai.ts │ │ ├── asset.ts │ │ ├── drama.ts │ │ ├── generation.ts │ │ ├── image.ts │ │ ├── prop.ts │ │ ├── timeline.ts │ │ ├── user.ts │ │ └── video.ts │ ├── utils/ │ │ ├── ffmpeg.ts │ │ ├── image.ts │ │ ├── request.ts │ │ └── videoMerger.ts │ └── views/ │ ├── dashboard/ │ │ └── Dashboard.vue │ ├── drama/ │ │ ├── DramaCreate.vue │ │ ├── DramaList.vue │ │ ├── DramaManagement.vue │ │ ├── DramaWorkflow.vue │ │ ├── EpisodeWorkflow.vue │ │ ├── ProfessionalEditor.vue │ │ └── components/ │ │ └── UploadScriptDialog.vue │ ├── editor/ │ │ └── TimelineEditor.vue │ ├── generation/ │ │ ├── ImageGeneration.vue │ │ ├── VideoGeneration.vue │ │ └── components/ │ │ ├── GenerateImageDialog.vue │ │ ├── GenerateVideoDialog.vue │ │ ├── ImageDetailDialog.vue │ │ └── VideoDetailDialog.vue │ ├── script/ │ │ └── ScriptEdit.vue │ ├── settings/ │ │ ├── AIConfig.vue │ │ ├── SystemSettings.vue │ │ └── components/ │ │ └── ConfigList.vue │ ├── storyboard/ │ │ └── StoryboardEdit.vue │ └── workflow/ │ ├── CharacterExtraction.vue │ ├── CharacterImages.vue │ ├── DramaSettings.vue │ ├── SceneImages.vue │ └── StoryboardGeneration.vue ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Git .git .gitignore .gitattributes # IDE .idea .vscode *.swp *.swo *~ # OS .DS_Store Thumbs.db # 构建产物 dist/ build/ *.exe *.dll *.so *.dylib huobao-drama backend # 依赖 node_modules/ **/node_modules/ vendor/ # 数据文件 data/ *.db *.log # 临时文件 tmp/ temp/ # 前端构建缓存 web/.vite/ web/dist/ # 测试 *.test coverage/ # 文档 README.md drama.png *.md # Docker Dockerfile* docker-compose*.yml .dockerignore # CI/CD .github/ .gitlab-ci.yml ================================================ FILE: .gitignore ================================================ # Binaries bin/ drama-generator backend *.exe *.dll *.so *.dylib drama-generator.exe # Test binary *.test # Output of the go coverage tool *.out # Dependency directories vendor/ # Go workspace file go.work # IDE .idea/ .vscode/ *.swp *.swo *~ # Environment .env .env.local # Logs *.log # OS .DS_Store Thumbs.db # Build dist/ build/ # Temporary files tmp/ temp/ # Data (database and uploaded files) data/drama_generator.db data/storage/videos/* !data/storage/videos/.gitkeep # Frontend build output web/dist/ web/node_modules/ web/.vite/ web/.env.local # Config file (use config.example.yaml as template) configs/config.yaml # Docker publish documentation (optional) DOCKER_PUBLISH.md build.sh /data/storage/ /web/package-lock.json .DS_Store ================================================ FILE: DOCKER_HOST_ACCESS.md ================================================ # Docker 容器访问宿主机服务指南 ## 核心配置 Docker 容器内使用 `http://host.docker.internal:端口号` 访问宿主机服务。 ### macOS / Windows 直接使用,无需额外配置。 ### Linux **docker-compose** - 已在 `docker-compose.yml` 配置: ```yaml extra_hosts: - "host.docker.internal:host-gateway" ``` **docker run** - 需添加参数: ```bash docker run --add-host=host.docker.internal:host-gateway ... ``` ## Ollama 配置示例 ### 1. 宿主机启动服务 ```bash # 监听所有接口(重要) export OLLAMA_HOST=0.0.0.0:11434 ollama serve ``` ### 2. 前端 AI 服务配置 | 字段 | 值 | |------|-----| | Base URL | `http://host.docker.internal:11434/v1` | | Provider | `openai` | | Model | `qwen2.5:latest` | | API Key | `ollama` 或留空 | ### 3. 其他服务端口 | 服务 | 默认端口 | Base URL | |------|---------|----------| | Ollama | 11434 | `http://host.docker.internal:11434/v1` | | LM Studio | 1234 | `http://host.docker.internal:1234/v1` | | vLLM | 8000 | `http://host.docker.internal:8000/v1` | ## 验证和故障排查 ### 测试连接 ```bash # 进入容器测试 docker exec -it huobao-drama sh wget -O- http://host.docker.internal:11434/api/tags # 查看容器日志 docker logs huobao-drama -f ``` ### 常见问题 **Connection refused** 1. **宿主机服务未运行** - 检查服务状态 ```bash curl http://localhost:11434/api/tags ``` 2. **服务未监听 0.0.0.0** - Ollama 默认只监听 127.0.0.1 ```bash export OLLAMA_HOST=0.0.0.0:11434 ollama serve ``` 3. **防火墙阻止** - 检查防火墙规则或临时关闭测试 ================================================ FILE: Dockerfile ================================================ # 多阶段构建 Dockerfile for Huobao Drama # ==================== 阶段1: 构建前端 ==================== # 声明构建参数(支持镜像源配置) ARG DOCKER_REGISTRY= ARG NPM_REGISTRY= FROM ${DOCKER_REGISTRY:-}node:20-alpine AS frontend-builder # 重新声明 ARG(FROM 之后 ARG 作用域失效,需要重新声明) ARG NPM_REGISTRY= # 配置 npm 镜像源(条件执行) ENV NPM_REGISTRY=${NPM_REGISTRY:-} RUN if [ -n "$NPM_REGISTRY" ]; then \ npm config set registry "$NPM_REGISTRY" || true; \ fi WORKDIR /app/web # 复制前端依赖文件 COPY web/package*.json ./ # 安装前端依赖(包括 devDependencies,构建需要) RUN npm install # 复制前端源码 COPY web/ ./ # 构建前端 RUN npm run build # ==================== 阶段2: 构建后端 ==================== # 每个阶段前重新声明构建参数 ARG DOCKER_REGISTRY= ARG GO_PROXY= ARG ALPINE_MIRROR= FROM ${DOCKER_REGISTRY:-}golang:1.23-alpine AS backend-builder # 重新声明 ARG(FROM 之后 ARG 作用域失效,需要重新声明) ARG GO_PROXY= ARG ALPINE_MIRROR= # 配置 Alpine 镜像源(条件执行) ENV ALPINE_MIRROR=${ALPINE_MIRROR:-} RUN if [ -n "$ALPINE_MIRROR" ]; then \ sed -i "s@dl-cdn.alpinelinux.org@$ALPINE_MIRROR@g" /etc/apk/repositories 2>/dev/null || true; \ fi # 配置 Go 代理(使用 ENV 持久化到运行时) ENV GOPROXY=${GO_PROXY:-https://goproxy.cn,direct} ENV GO111MODULE=on # 安装必要的构建工具(纯 Go 编译,无需 CGO) RUN apk add --no-cache \ git \ ca-certificates \ tzdata WORKDIR /app # 复制 Go 模块文件 COPY go.mod go.sum ./ # 下载依赖 RUN go mod download # 复制后端源码 COPY . . # 复制前端构建产物 COPY --from=frontend-builder /app/web/dist ./web/dist # 构建后端可执行文件(纯 Go 编译,使用 modernc.org/sqlite) RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o huobao-drama . # 构建迁移脚本可执行文件 RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o migrate cmd/migrate/main.go # ==================== 阶段3: 运行时镜像 ==================== # 每个阶段前重新声明构建参数 ARG DOCKER_REGISTRY= ARG ALPINE_MIRROR= FROM ${DOCKER_REGISTRY:-}alpine:latest # 重新声明 ARG(FROM 之后 ARG 作用域失效,需要重新声明) ARG ALPINE_MIRROR= # 配置 Alpine 镜像源(条件执行) ENV ALPINE_MIRROR=${ALPINE_MIRROR:-} RUN if [ -n "$ALPINE_MIRROR" ]; then \ sed -i "s@dl-cdn.alpinelinux.org@$ALPINE_MIRROR@g" /etc/apk/repositories 2>/dev/null || true; \ fi # 安装运行时依赖 RUN apk add --no-cache \ ca-certificates \ tzdata \ ffmpeg \ wget \ && rm -rf /var/cache/apk/* # 设置时区 ENV TZ=Asia/Shanghai WORKDIR /app # 从构建阶段复制可执行文件 COPY --from=backend-builder /app/huobao-drama . COPY --from=backend-builder /app/migrate . # 复制前端构建产物 COPY --from=frontend-builder /app/web/dist ./web/dist # 复制配置文件模板并创建默认配置 COPY configs/config.example.yaml ./configs/ RUN cp ./configs/config.example.yaml ./configs/config.yaml # 复制数据库迁移文件 COPY migrations ./migrations/ # 创建数据目录(root 用户运行,无需权限设置) RUN mkdir -p /app/data/storage # 暴露端口 EXPOSE 5678 # 健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:5678/health || exit 1 # 启动应用 CMD ["./huobao-drama"] ================================================ FILE: LICENSE ================================================ Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License Copyright (c) 2026 火宝 (Chatfire) 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。 This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 要查看该许可协议,可访问: To view a copy of this license, visit: https://creativecommons.org/licenses/by-nc-sa/4.0/ 或者写信到: Or send a letter to: Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. --- 个人使用许可 (Personal Use License) 您可以自由地: You are free to: - 分享 — 在任何媒介以任何形式复制、发行本作品 Share — copy and redistribute the material in any medium or format - 演绎 — 修改、转换或以本作品为基础进行创作 Adapt — remix, transform, and build upon the material 惟须遵守下列条件: Under the following terms: - 署名 — 您必须给出适当的署名,提供指向本许可协议的链接 Attribution — You must give appropriate credit and provide a link to the license - 非商业性使用 — 您不得将本作品用于商业目的 NonCommercial — You may not use the material for commercial purposes - 相同方式共享 — 如果您再混合、转换或者基于本作品进行创作,您必须基于与原先许可协议相同的许可协议分发您贡献的作品 ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license --- 商业授权 (Commercial License) 如需将本项目用于商业目的,请联系作者获取商业授权: For commercial use, please contact the author for a commercial license: Email: 18550175439@163.com WeChat: dangbao1117 GitHub: https://github.com/chatfire-AI --- 免责声明 (Disclaimer) 本软件按"原样"提供,不提供任何形式的明示或暗示担保。 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. ================================================ FILE: MIGRATE_README.md ================================================ # 数据清洗工具使用说明 ## 使用方法 ### 本地部署 ```bash # 在项目根目录执行 go run cmd/migrate/main.go ``` ### Docker 部署 在 Docker 容器中,迁移脚本已经被编译为可执行文件 `migrate`。 ```bash # 进入容器 docker exec -it huobao-drama sh # 在容器内执行迁移脚本 ./migrate # 执行完成后,退出容器 exit ``` 或者直接执行(不进入容器): ```bash docker exec huobao-drama ./migrate ``` ## 配置要求 脚本会自动读取项目配置文件,确保以下配置正确: - 数据库连接信息(`config/config.yaml` 或环境变量) - 存储目录:`data/storage`(自动创建) ## 输出示例 ``` === 数据清洗工具:迁移 local_path === 开始时间: 2026-01-27 14:30:00 INFO 初始化日志系统... INFO 配置加载成功 INFO 数据库连接成功 INFO 开始数据清洗:迁移 local_path 为空的数据 INFO 存储目录创建成功 root=data/storage INFO 开始迁移 assets 数据... INFO 找到需要迁移的 assets 数量=5 INFO 处理 asset id=1 name=背景图 type=image url=https://... INFO 开始下载文件 url=https://... filepath=data/storage/images/asset_1_1738048200.jpg INFO 文件下载成功 filepath=data/storage/images/asset_1_1738048200.jpg size=245678 INFO 已缓存 URL 映射 url=https://... local_path=images/asset_1_1738048200.jpg INFO asset 迁移成功 asset_id=1 local_path=images/asset_1_1738048200.jpg INFO 开始迁移 character_libraries 数据... INFO 找到需要迁移的 character_libraries 数量=3 INFO 使用缓存的本地路径 url=https://... local_path=characters/charlib_2_1738048201.jpg INFO 开始迁移 image_generations 数据... INFO 找到需要迁移的 image_generations 数量=10 INFO 处理 image_generation id=15 image_type=character image_url=https://... INFO image_generation 迁移成功 imggen_id=15 local_path=characters/imggen_15_1738048205.jpg INFO 数据清洗完成 总耗时=25.5s URL映射缓存数=8 Assets成功=5 Assets失败=0 角色库成功=3 角色库失败=0 角色成功=4 角色失败=0 图片生成成功=10 图片生成失败=0 场景成功=6 场景失败=0 视频成功=2 视频失败=0 === 数据清洗完成 === 结束时间: 2026-01-27 14:30:25 ``` ## 注意事项 1. **运行前确保**: - 数据库可访问 - 有足够的磁盘空间 - 网络连接正常 2. **安全提示**: - 脚本会修改数据库中的 `local_path` 字段 - 建议先在测试环境运行 - 可以多次运行,已处理的数据会自动跳过 3. **性能优化**: - URL 缓存机制避免重复下载 - 下载失败会跳过,不影响其他数据 - 超时时间设置为 60 秒 ## 常见问题 ### Q: 脚本可以重复运行吗? A: 可以。脚本只处理 `local_path` 为空的记录,已处理的数据会自动跳过。 ### Q: 下载失败怎么办? A: 单个文件下载失败会记录错误日志并继续处理其他文件。可以查看日志定位问题后重新运行。 ### Q: 如何查看详细日志? A: 日志会实时输出到控制台,包含每个文件的处理状态和最终统计信息。 ### Q: 存储路径可以修改吗? A: 可以。修改脚本中的 `storageRoot` 变量(默认为 `data/storage`)。 ## 技术支持 如有问题,请查看日志输出或联系开发团队。 ================================================ FILE: README-CN.md ================================================ # 🎬 Huobao Drama - AI 短剧生成平台
**基于 Go + Vue3 的全栈 AI 短剧自动化生产平台** [![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org) [![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) [![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) [功能特性](#功能特性) • [快速开始](#快速开始) • [部署指南](#部署指南) [简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)
--- ## 📖 项目简介 Huobao Drama 是一个基于 AI 的短剧自动化生产平台,实现从剧本生成、角色设计、分镜制作到视频合成的全流程自动化。 火宝短剧商业版地址:[火宝短剧商业版](https://drama.chatfire.site/shortvideo) 火宝小说生成:[火宝小说生成](https://marketing.chatfire.site/huobao-novel/) ### 🎯 核心价值 - **🤖 AI 驱动**:使用大语言模型解析剧本,提取角色、场景和分镜信息 - **🎨 智能创作**:AI 绘图生成角色形象和场景背景 - **📹 视频生成**:基于文生视频和图生视频模型自动生成分镜视频 - **🔄 工作流**:完整的短剧制作工作流,从创意到成片一站式完成 ### 🛠️ 技术架构 采用**DDD 领域驱动设计**,清晰分层: ``` ├── API层 (Gin HTTP) ├── 应用服务层 (Business Logic) ├── 领域层 (Domain Models) └── 基础设施层 (Database, External Services) ``` ### 🎥 作品展示 / Demo Videos 体验 AI 短剧生成效果:
**示例作品 1** **示例作品 2** [点击观看视频 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [点击观看视频 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
--- ## ✨ 功能特性 ### 🎭 角色管理 - ✅ AI 生成角色形象 - ✅ 批量角色生成 - ✅ 角色图片上传和管理 ### 🎬 分镜制作 - ✅ 自动生成分镜脚本 - ✅ 场景描述和镜头设计 - ✅ 分镜图片生成(文生图) - ✅ 帧类型选择(首帧/关键帧/尾帧/分镜板) ### 🎥 视频生成 - ✅ 图生视频自动生成 - ✅ 视频合成和剪辑 - ✅ 转场效果 ### 📦 资源管理 - ✅ 素材库统一管理 - ✅ 本地存储支持 - ✅ 资源导入导出 - ✅ 任务进度追踪 --- ## 🚀 快速开始 ### 📋 环境要求 | 软件 | 版本要求 | 说明 | | ----------- | -------- | -------------------- | | **Go** | 1.23+ | 后端运行环境 | | **Node.js** | 18+ | 前端构建环境 | | **npm** | 9+ | 包管理工具 | | **FFmpeg** | 4.0+ | 视频处理(**必需**) | | **SQLite** | 3.x | 数据库(已内置) | #### 安装 FFmpeg **macOS:** ```bash brew install ffmpeg ``` **Ubuntu/Debian:** ```bash sudo apt update sudo apt install ffmpeg ``` **Windows:** 从 [FFmpeg 官网](https://ffmpeg.org/download.html) 下载并配置环境变量 验证安装: ```bash ffmpeg -version ``` ### ⚙️ 配置文件 复制并编辑配置文件: ```bash cp configs/config.example.yaml configs/config.yaml vim configs/config.yaml ``` 配置文件格式(`configs/config.yaml`): ```yaml app: name: "Huobao Drama API" version: "1.0.0" debug: true # 开发环境设为true,生产环境设为false server: port: 5678 host: "0.0.0.0" cors_origins: - "http://localhost:3012" read_timeout: 600 write_timeout: 600 database: type: "sqlite" path: "./data/drama_generator.db" max_idle: 10 max_open: 100 storage: type: "local" local_path: "./data/storage" base_url: "http://localhost:5678/static" ai: default_text_provider: "openai" default_image_provider: "openai" default_video_provider: "doubao" ``` **重要配置项:** - `app.debug`: 调试模式开关(开发环境建议设为 true) - `server.port`: 服务运行端口 - `server.cors_origins`: 允许跨域访问的前端地址 - `database.path`: SQLite 数据库文件路径 - `storage.local_path`: 本地文件存储路径 - `storage.base_url`: 静态资源访问 URL - `ai.default_*_provider`: AI 服务提供商配置(在 Web 界面中配置具体的 API Key) ### 📥 安装依赖 ```bash # 克隆项目 git clone https://github.com/chatfire-AI/huobao-drama.git cd huobao-drama # 安装Go依赖 go mod download # 安装前端依赖 cd web npm install cd .. ``` ### 🎯 启动项目 #### 方式一:开发模式(推荐) **前后端分离,支持热重载** ```bash # 终端1:启动后端服务 go run main.go # 终端2:启动前端开发服务器 cd web npm run dev ``` - 前端地址: `http://localhost:3012` - 后端 API: `http://localhost:5678/api/v1` - 前端自动代理 API 请求到后端 #### 方式二:单服务模式 **后端同时提供 API 和前端静态文件** ```bash # 1. 构建前端 cd web npm run build cd .. # 2. 启动服务 go run main.go ``` 访问: `http://localhost:5678` ### 🗄️ 数据库初始化 数据库表会在首次启动时自动创建(使用 GORM AutoMigrate),无需手动迁移。 --- ## 📦 部署指南 ### ☁️ 云端一键部署(推荐 3080Ti) 👉 [优云智算,一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai) > ⚠️ **注意**:云端部署方案数据请及时存储到本地 --- ### 🐳 Docker 部署(推荐) #### 方式一:Docker Compose(推荐) #### 🚀 国内网络加速(可选) 如果您在国内网络环境下,Docker 拉取镜像和安装依赖可能较慢。可以通过配置镜像源加速构建过程。 **步骤 1:创建环境变量文件** ```bash cp .env.example .env ``` **步骤 2:编辑 `.env` 文件,取消注释需要的镜像源** ```bash # 启用 Docker Hub 镜像(推荐) DOCKER_REGISTRY=docker.1ms.run/ # 启用 npm 镜像 NPM_REGISTRY=https://registry.npmmirror.com/ # 启用 Go 代理 GO_PROXY=https://goproxy.cn,direct # 启用 Alpine 镜像 ALPINE_MIRROR=mirrors.aliyun.com ``` **步骤 3:使用 docker compose 构建(必须)** ```bash docker compose build ``` > **重要说明**: > > - ⚠️ 必须使用 `docker compose build` 才能自动加载 `.env` 文件中的镜像源配置 > - ❌ 如果使用 `docker build` 命令,需要手动传递 `--build-arg` 参数 > - ✅ 推荐始终使用 `docker compose build` 进行构建 **效果对比**: | 操作 | 不配置镜像源 | 配置镜像源后 | | ------------- | ------------ | ------------ | | 拉取基础镜像 | 5-30 分钟 | 1-5 分钟 | | 安装 npm 依赖 | 可能失败 | 快速成功 | | 下载 Go 依赖 | 5-10 分钟 | 30 秒-1 分钟 | > **注意**:国外用户请勿配置镜像源,使用默认配置即可。 ```bash # 启动服务 docker-compose up -d # 查看日志 docker-compose logs -f # 停止服务 docker-compose down ``` #### 方式二:Docker 命令 > **注意**:Linux 用户需添加 `--add-host=host.docker.internal:host-gateway` 以访问宿主机服务 ```bash # 从 Docker Hub 运行 docker run -d \ --name huobao-drama \ -p 5678:5678 \ -v $(pwd)/data:/app/data \ --restart unless-stopped \ huobao/huobao-drama:latest # 查看日志 docker logs -f huobao-drama ``` **本地构建**(可选): ```bash docker build -t huobao-drama:latest . docker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest ``` **Docker 部署优势:** - ✅ 开箱即用,内置默认配置 - ✅ 环境一致性,避免依赖问题 - ✅ 一键启动,无需安装 Go、Node.js、FFmpeg - ✅ 易于迁移和扩展 - ✅ 自动健康检查和重启 - ✅ 自动处理文件权限,无需手动配置 #### 🔗 访问宿主机服务(Ollama/本地模型) 容器已配置支持访问宿主机服务,直接使用 `http://host.docker.internal:端口号` 即可。 **配置步骤:** 1. **宿主机启动服务(监听所有接口)** ```bash export OLLAMA_HOST=0.0.0.0:11434 && ollama serve ``` 2. **前端 AI 服务配置** - Base URL: `http://host.docker.internal:11434/v1` - Provider: `openai` - Model: `qwen2.5:latest` --- ### 🏭 传统部署方式 #### 1. 编译构建 ```bash # 1. 构建前端 cd web npm run build cd .. # 2. 编译后端 go build -o huobao-drama . ``` 生成文件: - `huobao-drama` - 后端可执行文件 - `web/dist/` - 前端静态文件(已嵌入后端) #### 2. 准备部署文件 需要上传到服务器的文件: ``` huobao-drama # 后端可执行文件 configs/config.yaml # 配置文件 data/ # 数据目录(可选,首次运行自动创建) ``` #### 3. 服务器配置 ```bash # 上传文件到服务器 scp huobao-drama user@server:/opt/huobao-drama/ scp configs/config.yaml user@server:/opt/huobao-drama/configs/ # SSH登录服务器 ssh user@server # 修改配置文件 cd /opt/huobao-drama vim configs/config.yaml # 设置mode为production # 配置域名和存储路径 # 创建数据目录并设置权限(重要!) # 注意:将 YOUR_USER 替换为实际运行服务的用户名(如 www-data、ubuntu、deploy 等) sudo mkdir -p /opt/huobao-drama/data/storage sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # 赋予执行权限 chmod +x huobao-drama # 启动服务 ./huobao-drama ``` #### 4. 使用 systemd 管理服务 创建服务文件 `/etc/systemd/system/huobao-drama.service`: ```ini [Unit] Description=Huobao Drama Service After=network.target [Service] Type=simple User=YOUR_USER WorkingDirectory=/opt/huobao-drama ExecStart=/opt/huobao-drama/huobao-drama Restart=on-failure RestartSec=10 # 环境变量(可选) # Environment="GIN_MODE=release" [Install] WantedBy=multi-user.target ``` 启动服务: ```bash sudo systemctl daemon-reload sudo systemctl enable huobao-drama sudo systemctl start huobao-drama sudo systemctl status huobao-drama ``` **⚠️ 常见问题:SQLite 写权限错误** 如果遇到 `attempt to write a readonly database` 错误: ```bash # 1. 确认当前运行服务的用户 sudo systemctl status huobao-drama | grep "Main PID" ps aux | grep huobao-drama # 2. 修复权限(将 YOUR_USER 替换为实际用户名) sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # 3. 验证权限 ls -la /opt/huobao-drama/data # 应该显示所有者为运行服务的用户 # 4. 重启服务 sudo systemctl restart huobao-drama ``` **原因说明**: - SQLite 需要对数据库文件 **和** 所在目录都有写权限 - 需要在目录中创建临时文件(如 `-wal`、`-journal`) - **关键**:确保 systemd 配置中的 `User` 与数据目录所有者一致 **常用用户名**: - Ubuntu/Debian: `www-data`、`ubuntu` - CentOS/RHEL: `nginx`、`apache` - 自定义部署: `deploy`、`app`、当前登录用户 #### 5. Nginx 反向代理 ```nginx server { listen 80; server_name your-domain.com; location / { proxy_pass http://localhost:5678; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态文件直接访问 location /static/ { alias /opt/huobao-drama/data/storage/; } } ``` --- ## 🎨 技术栈 ### 后端技术 - **语言**: Go 1.23+ - **Web 框架**: Gin 1.9+ - **ORM**: GORM - **数据库**: SQLite - **日志**: Zap - **视频处理**: FFmpeg - **AI 服务**: OpenAI、Gemini、火山等 ### 前端技术 - **框架**: Vue 3.4+ - **语言**: TypeScript 5+ - **构建工具**: Vite 5 - **UI 组件**: Element Plus - **CSS 框架**: TailwindCSS - **状态管理**: Pinia - **路由**: Vue Router 4 ### 开发工具 - **包管理**: Go Modules, npm - **代码规范**: ESLint, Prettier - **版本控制**: Git --- ## 📝 常见问题 ### Q: Docker 容器如何访问宿主机的 Ollama? A: 使用 `http://host.docker.internal:11434/v1` 作为 Base URL。注意两点: 1. 宿主机 Ollama 需监听 `0.0.0.0`:`export OLLAMA_HOST=0.0.0.0:11434 && ollama serve` 2. Linux 用户使用 `docker run` 需添加:`--add-host=host.docker.internal:host-gateway` 详见:[DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md) ### Q: FFmpeg 未安装或找不到? A: 确保 FFmpeg 已安装并在 PATH 环境变量中。运行 `ffmpeg -version` 验证。 ### Q: 前端无法连接后端 API? A: 检查后端是否启动,端口是否正确。开发模式下前端代理配置在 `web/vite.config.ts`。 ### Q: 数据库表未创建? A: GORM 会在首次启动时自动创建表,检查日志确认迁移是否成功。 --- ## 📋 更新日志 / Changelog ### v1.0.5 (2026-02-06) #### 🎨 重大功能 - **🎭 全局风格系统**:引入了项目级别的风格选择支持。用户现在可以在剧本层面定义自定义视觉风格,该风格将自动应用于所有 AI 生成的内容,包括角色、场景和分镜图像,确保整个制作过程中的艺术风格一致性。 - **✂️ 九宫格序列图裁剪**:新增裁剪工具,支持从动作序列图(3x3 网格布局)中提取单个帧,并将其指定为首帧、尾帧或关键帧用于视频生成,为镜头构图和连续性提供更大的灵活性。 #### 🚀 功能增强 - **📐 优化动作序列网格**:改进了九宫格动作序列图的视觉质量和布局,优化了间距、对齐和帧过渡效果。 - **🔧 手动网格拼接**:引入手动网格组合工具,支持 2x2(四宫格)、2x3(六宫格)和 3x3(九宫格)布局,允许用户从单个帧创建自定义动作序列。 - **🗑️ 内容管理**:新增图片和视频的删除功能,实现更好的素材组织和存储管理。 ### v1.0.4 (2026-01-27) #### 🚀 重大更新 - 引入本地存储策略,实现生成内容的本地化缓存管理,有效规避外部资源链接失效风险 - 采用 Base64 编码方案进行参考图像的嵌入式传输 - 修复镜头切换时镜头图片提示词状态未重置问题 - 修复视频添加素材库视频时长显示为0的问题 - 添加场景迁移至章节内 #### 历史数据清洗 - 增加清洗脚本,用于处理历史数据,具体操作请参考 [MIGRATE_README.md](MIGRATE_README.md) ### v1.0.3 (2026-01-16) #### 🚀 重大更新 - SQLite 纯 Go 驱动(`modernc.org/sqlite`),支持 `CGO_ENABLED=0` 跨平台编译 - 优化并发性能(WAL 模式),解决 "database is locked" 错误 - Docker 跨平台支持 `host.docker.internal` 访问宿主机服务 - 精简文档和部署指南 ### v1.0.2 (2026-01-14) #### 🐛 Bug Fixes / 🔧 Improvements - 修复视频生成 API 响应解析问题 - 添加 OpenAI Sora 视频端点配置 - 优化错误处理和日志输出 --- ## 🤝 贡献指南 欢迎提交 Issue 和 Pull Request! 1. Fork 本项目 2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) 3. 提交改动 (`git commit -m 'Add some AmazingFeature'`) 4. 推送到分支 (`git push origin feature/AmazingFeature`) 5. 开启 Pull Request --- ## API 配置站点 2 分钟完成配置:[API 聚合站点](https://api.chatfire.site/models) --- ## 👨‍💻 关于我们 **AI 火宝 - AI 工作室创业中** - 🏠 **位置**: 中国南京 - 🚀 **状态**: 创业中 - 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com) - 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama) > _"让 AI 帮我们做更有创造力的事"_ ### 📢 招聘信息 #### 全栈高级开发工程师(Base 南京) **关于岗位:** 我们是创业团队,致力于打造 AI 驱动的终端应用。如果你热爱技术、追求极致,渴望在 AI 领域有所作为,欢迎加入我们,一起做有意义的事! **岗位要求:** 1. 参与 AI 驱动的终端应用全栈开发,覆盖 Web/移动端前后端系统搭建,主导核心业务模块的设计、开发、测试与上线迭代。 2. 负责核心功能模块开发,包括:前端交互实现(React/Vue.js + TypeScript)、后端服务开发(Python/Node.js + Flask/FastAPI),确保模块高效、稳定、可扩展。 3. 负责 AI 模型服务接口设计与对接,参与 AI 能力落地终端场景的技术方案设计,推动 AI 技术与业务场景的深度融合。 4. 深度践行 vibe coding 开发模式,注重代码质量、开发效率与技术美感,搭建规范的开发流程,推动团队研发模式优化。 **加入我们:** 如果你对 AI 技术充满热情,喜欢挑战和创新,欢迎投递简历至 [18550175439@163.com](mailto:18550175439@163.com) 期待志同道合的你一起探索 AI 的无限可能! ## 项目交流群 ![项目交流群](drama.png) - 提交 [Issue](../../issues) - 发送邮件至项目维护者 ---
**⭐ 如果这个项目对你有帮助,请给一个 Star!** ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left) Made with ❤️ by Huobao Team
================================================ FILE: README-JA.md ================================================ # 🎬 Huobao Drama - AI ショートドラマ制作プラットフォーム
**Go + Vue3 ベースのフルスタック AI ショートドラマ自動化プラットフォーム** [![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org) [![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) [![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) [機能](#機能) • [クイックスタート](#クイックスタート) • [デプロイ](#デプロイ) [简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)
--- ## 📖 概要 Huobao Drama は、脚本生成、キャラクターデザイン、絵コンテ作成から動画合成までの全ワークフローを自動化する AI 駆動のショートドラマ制作プラットフォームです。 火宝短剧商业版地址:[火宝短剧商业版](https://drama.chatfire.site/shortvideo) 火宝小说生成:[火宝小说生成](https://marketing.chatfire.site/huobao-novel/) ### 🎯 主要機能 - **🤖 AI 駆動**: 大規模言語モデルを使用して脚本を解析し、キャラクター、シーン、絵コンテ情報を抽出 - **🎨 インテリジェント創作**: AI によるキャラクターポートレートとシーン背景の生成 - **📹 動画生成**: テキストから動画、画像から動画モデルによる絵コンテ動画の自動生成 - **🔄 完全なワークフロー**: アイデアから完成動画までのエンドツーエンド制作ワークフロー ### 🛠️ 技術アーキテクチャ **DDD(ドメイン駆動設計)** に基づく明確なレイヤー構造: ``` ├── APIレイヤー (Gin HTTP) ├── アプリケーションサービスレイヤー (ビジネスロジック) ├── ドメインレイヤー (ドメインモデル) └── インフラストラクチャレイヤー (データベース、外部サービス) ``` ### 🎥 デモ動画 AI ショートドラマ生成を体験:
**サンプル作品 1** **サンプル作品 2** [動画 1 を見る](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [動画 2 を見る](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
--- ## ✨ 機能 ### 🎭 キャラクター管理 - ✅ AI 生成キャラクターポートレート - ✅ バッチキャラクター生成 - ✅ キャラクター画像のアップロードと管理 ### 🎬 絵コンテ制作 - ✅ 自動絵コンテスクリプト生成 - ✅ シーン説明とショットデザイン - ✅ 絵コンテ画像生成(テキストから画像) - ✅ フレームタイプ選択(先頭フレーム/キーフレーム/末尾フレーム/パネル) ### 🎥 動画生成 - ✅ 画像から動画の自動生成 - ✅ 動画合成と編集 - ✅ トランジション効果 ### 📦 アセット管理 - ✅ 統合アセットライブラリ管理 - ✅ ローカルストレージサポート - ✅ アセットのインポート/エクスポート - ✅ タスク進捗トラッキング --- ## 🚀 クイックスタート ### 📋 前提条件 | ソフトウェア | バージョン | 説明 | | ------------ | ---------- | ------------------------ | | **Go** | 1.23+ | バックエンドランタイム | | **Node.js** | 18+ | フロントエンドビルド環境 | | **npm** | 9+ | パッケージマネージャー | | **FFmpeg** | 4.0+ | 動画処理(**必須**) | | **SQLite** | 3.x | データベース(内蔵) | #### FFmpeg のインストール **macOS:** ```bash brew install ffmpeg ``` **Ubuntu/Debian:** ```bash sudo apt update sudo apt install ffmpeg ``` **Windows:** [FFmpeg 公式サイト](https://ffmpeg.org/download.html)からダウンロードし、環境変数を設定 インストール確認: ```bash ffmpeg -version ``` ### ⚙️ 設定 設定ファイルをコピーして編集: ```bash cp configs/config.example.yaml configs/config.yaml vim configs/config.yaml ``` 設定ファイル形式(`configs/config.yaml`): ```yaml app: name: "Huobao Drama API" version: "1.0.0" debug: true # 開発環境ではtrue、本番環境ではfalseに設定 server: port: 5678 host: "0.0.0.0" cors_origins: - "http://localhost:3012" read_timeout: 600 write_timeout: 600 database: type: "sqlite" path: "./data/drama_generator.db" max_idle: 10 max_open: 100 storage: type: "local" local_path: "./data/storage" base_url: "http://localhost:5678/static" ai: default_text_provider: "openai" default_image_provider: "openai" default_video_provider: "doubao" ``` **主要設定項目:** - `app.debug`: デバッグモードスイッチ(開発環境では true を推奨) - `server.port`: サービスポート - `server.cors_origins`: フロントエンドの許可 CORS オリジン - `database.path`: SQLite データベースファイルパス - `storage.local_path`: ローカルファイルストレージパス - `storage.base_url`: 静的リソースアクセス URL - `ai.default_*_provider`: AI サービスプロバイダー設定(API キーは Web UI で設定) ### 📥 インストール ```bash # プロジェクトをクローン git clone https://github.com/chatfire-AI/huobao-drama.git cd huobao-drama # Go依存関係をインストール go mod download # フロントエンド依存関係をインストール cd web npm install cd .. ``` ### 🎯 プロジェクトの起動 #### 方法 1: 開発モード(推奨) **フロントエンドとバックエンドを分離、ホットリロード対応** ```bash # ターミナル1: バックエンドサービスを起動 go run main.go # ターミナル2: フロントエンド開発サーバーを起動 cd web npm run dev ``` - フロントエンド: `http://localhost:3012` - バックエンド API: `http://localhost:5678/api/v1` - フロントエンドは API リクエストを自動的にバックエンドにプロキシ #### 方法 2: シングルサービスモード **バックエンドが API とフロントエンド静的ファイルの両方を提供** ```bash # 1. フロントエンドをビルド cd web npm run build cd .. # 2. サービスを起動 go run main.go ``` アクセス: `http://localhost:5678` ### 🗄️ データベース初期化 データベーステーブルは初回起動時に自動作成されます(GORM AutoMigrate を使用)。手動マイグレーションは不要です。 --- ## 📦 デプロイ ### ☁️ クラウドワンクリックデプロイ(推奨 3080Ti) 👉 [优云智算,一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai) > ⚠️ **注意**:クラウドデプロイを使用する場合は、データを速やかにローカルストレージに保存してください --- ### 🐳 Docker デプロイ(推奨) #### 方法 1: Docker Compose(推奨) #### 🚀 中国国内ネットワーク高速化(オプション) 中国国内のネットワーク環境では、Docker イメージのプルや依存関係のインストールが遅い場合があります。ミラーソースを設定することでビルドプロセスを高速化できます。 **ステップ 1: 環境変数ファイルを作成** ```bash cp .env.example .env ``` **ステップ 2: `.env` ファイルを編集し、必要なミラーソースのコメントを解除** ```bash # Docker Hub ミラーを有効化(推奨) DOCKER_REGISTRY=docker.1ms.run/ # npm ミラーを有効化 NPM_REGISTRY=https://registry.npmmirror.com/ # Go プロキシを有効化 GO_PROXY=https://goproxy.cn,direct # Alpine ミラーを有効化 ALPINE_MIRROR=mirrors.aliyun.com ``` **ステップ 3: docker compose でビルド(必須)** ```bash docker compose build ``` > **重要な注意事項**: > > - ⚠️ `.env` ファイルのミラーソース設定を自動的に読み込むには `docker compose build` を使用する必要があります > - ❌ `docker build` コマンドを使用する場合は、手動で `--build-arg` パラメータを渡す必要があります > - ✅ 常に `docker compose build` を使用してビルドすることを推奨 **パフォーマンス比較**: | 操作 | ミラー未設定 | ミラー設定後 | | ------------------------ | -------------- | ------------ | | ベースイメージのプル | 5-30 分 | 1-5 分 | | npm 依存関係インストール | 失敗する可能性 | 高速成功 | | Go 依存関係ダウンロード | 5-10 分 | 30 秒-1 分 | > **注意**: 中国国外のユーザーはミラーソースを設定せず、デフォルト設定を使用してください。 ```bash # サービスを起動 docker-compose up -d # ログを表示 docker-compose logs -f # サービスを停止 docker-compose down ``` #### 方法 2: Docker コマンド > **注意**: Linux ユーザーはホストサービスにアクセスするために `--add-host=host.docker.internal:host-gateway` を追加する必要があります ```bash # Docker Hubから実行 docker run -d \ --name huobao-drama \ -p 5678:5678 \ -v $(pwd)/data:/app/data \ --restart unless-stopped \ huobao/huobao-drama:latest # ログを表示 docker logs -f huobao-drama ``` **ローカルビルド**(オプション): ```bash docker build -t huobao-drama:latest . docker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest ``` **Docker デプロイの利点:** - ✅ デフォルト設定ですぐに使用可能 - ✅ 環境の一貫性、依存関係の問題を回避 - ✅ ワンクリック起動、Go、Node.js、FFmpeg のインストール不要 - ✅ 移行とスケーリングが容易 - ✅ 自動ヘルスチェックと再起動 - ✅ ファイル権限の自動処理 #### 🔗 ホストサービスへのアクセス(Ollama/ローカルモデル) コンテナは `http://host.docker.internal:ポート番号` を使用してホストサービスにアクセスするよう設定されています。 **設定手順:** 1. **ホストでサービスを起動(全インターフェースでリッスン)** ```bash export OLLAMA_HOST=0.0.0.0:11434 && ollama serve ``` 2. **フロントエンド AI サービス設定** - Base URL: `http://host.docker.internal:11434/v1` - Provider: `openai` - Model: `qwen2.5:latest` --- ### 🏭 従来のデプロイ方法 #### 1. ビルド ```bash # 1. フロントエンドをビルド cd web npm run build cd .. # 2. バックエンドをコンパイル go build -o huobao-drama . ``` 生成ファイル: - `huobao-drama` - バックエンド実行ファイル - `web/dist/` - フロントエンド静的ファイル(バックエンドに埋め込み) #### 2. デプロイファイルの準備 サーバーにアップロードするファイル: ``` huobao-drama # バックエンド実行ファイル configs/config.yaml # 設定ファイル data/ # データディレクトリ(オプション、初回実行時に自動作成) ``` #### 3. サーバー設定 ```bash # ファイルをサーバーにアップロード scp huobao-drama user@server:/opt/huobao-drama/ scp configs/config.yaml user@server:/opt/huobao-drama/configs/ # サーバーにSSH接続 ssh user@server # 設定ファイルを編集 cd /opt/huobao-drama vim configs/config.yaml # modeをproductionに設定 # ドメインとストレージパスを設定 # データディレクトリを作成し権限を設定(重要!) # 注意: YOUR_USERを実際にサービスを実行するユーザー名に置き換え(例: www-data、ubuntu、deploy) sudo mkdir -p /opt/huobao-drama/data/storage sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # 実行権限を付与 chmod +x huobao-drama # サービスを起動 ./huobao-drama ``` #### 4. systemd でサービス管理 サービスファイル `/etc/systemd/system/huobao-drama.service` を作成: ```ini [Unit] Description=Huobao Drama Service After=network.target [Service] Type=simple User=YOUR_USER WorkingDirectory=/opt/huobao-drama ExecStart=/opt/huobao-drama/huobao-drama Restart=on-failure RestartSec=10 # 環境変数(オプション) # Environment="GIN_MODE=release" [Install] WantedBy=multi-user.target ``` サービスを起動: ```bash sudo systemctl daemon-reload sudo systemctl enable huobao-drama sudo systemctl start huobao-drama sudo systemctl status huobao-drama ``` **⚠️ よくある問題: SQLite 書き込み権限エラー** `attempt to write a readonly database` エラーが発生した場合: ```bash # 1. サービスを実行中のユーザーを確認 sudo systemctl status huobao-drama | grep "Main PID" ps aux | grep huobao-drama # 2. 権限を修正(YOUR_USERを実際のユーザー名に置き換え) sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # 3. 権限を確認 ls -la /opt/huobao-drama/data # サービスを実行するユーザーが所有者として表示されるはず # 4. サービスを再起動 sudo systemctl restart huobao-drama ``` **原因:** - SQLite はデータベースファイル**と**そのディレクトリの両方に書き込み権限が必要 - ディレクトリ内に一時ファイル(例: `-wal`、`-journal`)を作成する必要がある - **重要**: systemd の`User`がデータディレクトリの所有者と一致していることを確認 **一般的なユーザー名:** - Ubuntu/Debian: `www-data`、`ubuntu` - CentOS/RHEL: `nginx`、`apache` - カスタムデプロイ: `deploy`、`app`、現在ログインしているユーザー #### 5. Nginx リバースプロキシ ```nginx server { listen 80; server_name your-domain.com; location / { proxy_pass http://localhost:5678; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静的ファイルへの直接アクセス location /static/ { alias /opt/huobao-drama/data/storage/; } } ``` --- ## 🎨 技術スタック ### バックエンド - **言語**: Go 1.23+ - **Web フレームワーク**: Gin 1.9+ - **ORM**: GORM - **データベース**: SQLite - **ログ**: Zap - **動画処理**: FFmpeg - **AI サービス**: OpenAI、Gemini、Doubao など ### フロントエンド - **フレームワーク**: Vue 3.4+ - **言語**: TypeScript 5+ - **ビルドツール**: Vite 5 - **UI コンポーネント**: Element Plus - **CSS フレームワーク**: TailwindCSS - **状態管理**: Pinia - **ルーター**: Vue Router 4 ### 開発ツール - **パッケージ管理**: Go Modules、npm - **コード規約**: ESLint、Prettier - **バージョン管理**: Git --- ## 📝 よくある質問 ### Q: Docker コンテナからホストの Ollama にアクセスするには? A: Base URL として `http://host.docker.internal:11434/v1` を使用します。注意点: 1. ホストの Ollama は `0.0.0.0` でリッスンする必要があります: `export OLLAMA_HOST=0.0.0.0:11434 && ollama serve` 2. `docker run` を使用する Linux ユーザーは追加が必要: `--add-host=host.docker.internal:host-gateway` 詳細: [DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md) ### Q: FFmpeg がインストールされていない、または見つからない? A: FFmpeg がインストールされ、PATH 環境変数に含まれていることを確認してください。`ffmpeg -version` で確認。 ### Q: フロントエンドがバックエンド API に接続できない? A: バックエンドが実行中で、ポートが正しいか確認してください。開発モードでは、フロントエンドプロキシ設定は `web/vite.config.ts` にあります。 ### Q: データベーステーブルが作成されない? A: GORM は初回起動時にテーブルを自動作成します。ログでマイグレーション成功を確認してください。 --- ## 📋 更新履歴 ### v1.0.5 (2026-02-06) #### 🎨 主要機能 - **🎭 グローバルスタイルシステム**:プロジェクト全体でスタイル選択をサポートする包括的なシステムを導入しました。ユーザーはドラマレベルでカスタムビジュアルスタイルを定義でき、キャラクター、シーン、ストーリーボードを含むすべてのAI生成コンテンツに自動的に適用され、制作全体で一貫した芸術的方向性を確保します。 - **✂️ 9グリッドシーケンス画像クロップ**:アクションシーケンス画像用のクロップツールを追加しました。3x3グリッドレイアウトから個別のフレームを抽出し、ビデオ生成用のファーストフレーム、ラストフレーム、またはキーフレームとして指定できるようになり、ショット構成と連続性においてより大きな柔軟性を提供します。 #### 🚀 機能強化 - **📐 アクションシーケンスグリッドの最適化**:9グリッドアクションシーケンス画像の視覚品質とレイアウトを改善し、間隔、配置、フレーム遷移を最適化しました。 - **🔧 手動グリッド組み立て**:2x2(4グリッド)、2x3(6グリッド)、3x3(9グリッド)レイアウトをサポートする手動グリッド構成ツールを導入し、個別のフレームからカスタムアクションシーケンスを作成できるようになりました。 - **🗑️ コンテンツ管理**:生成された画像とビデオの両方に削除機能を追加し、より良いアセット整理とストレージ管理を実現しました。 ### v1.0.4 (2026-01-27) #### 🚀 主要アップデート - 生成コンテンツのローカルストレージ戦略を導入し、外部リソースリンクの有効期限切れリスクを効果的に軽減 - 参照画像の埋め込み転送用 Base64 エンコーディング方式を実装 - ショット切り替え時にショット画像プロンプト状態がリセットされない問題を修正 - ライブラリ動画追加時に動画の長さが 0 と表示される問題を修正 - シーンのエピソードへの移行機能を追加 #### 履歴データクリーニング - 履歴データ処理用のマイグレーションスクリプトを追加。詳細な手順については [MIGRATE_README.md](MIGRATE_README.md) を参照してください ### v1.0.3 (2026-01-16) #### 🚀 主要アップデート - 純粋な Go SQLite ドライバー(`modernc.org/sqlite`)、`CGO_ENABLED=0` クロスプラットフォームコンパイルをサポート - 並行性能を最適化(WAL モード)、"database is locked" エラーを解決 - ホストサービスへのアクセス用 `host.docker.internal` の Docker クロスプラットフォームサポート - ドキュメントとデプロイガイドの簡素化 ### v1.0.2 (2026-01-14) #### 🐛 バグ修正 / 🔧 改善 - 動画生成 API レスポンスのパース問題を修正 - OpenAI Sora 動画エンドポイント設定を追加 - エラー処理とログ出力を最適化 --- ## 🤝 コントリビューション Issue と Pull Request を歓迎します! 1. このプロジェクトをフォーク 2. フィーチャーブランチを作成 (`git checkout -b feature/AmazingFeature`) 3. 変更をコミット (`git commit -m 'Add some AmazingFeature'`) 4. ブランチにプッシュ (`git push origin feature/AmazingFeature`) 5. Pull Request を作成 --- ## API 設定サイト 2 分で設定完了: [API 集約サイト](https://api.chatfire.site/models) --- ## 👨‍💻 私たちについて **AI 火宝 - AI スタジオ起業中** - 🏠 **所在地**: 中国南京 - 🚀 **ステータス**: 起業中 - 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com) - 💬 **WeChat**: dangbao1117 (個人 WeChat - 技術的な質問には対応しません) - 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama) > _「AI に私たちのより創造的なことを手伝ってもらおう」_ ## コミュニティグループ ![コミュニティグループ](drama.png) - [Issue](../../issues)を提出 - プロジェクトメンテナにメール ---
**⭐ このプロジェクトが役に立ったら、Star をお願いします!** ## Star 履歴 [![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left) Made with ❤️ by Huobao Team
================================================ FILE: README.md ================================================ # 🎬 Huobao Drama - AI Short Drama Production Platform
**Full-stack AI Short Drama Automation Platform Based on Go + Vue3** [![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat&logo=go)](https://golang.org) [![Vue Version](https://img.shields.io/badge/Vue-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org) [![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/) [Features](#features) • [Quick Start](#quick-start) • [Deployment](#deployment) [简体中文](README-CN.md) | [English](README.md) | [日本語](README-JA.md)
--- ## 📖 About Huobao Drama is an AI-powered short drama production platform that automates the entire workflow from script generation, character design, storyboarding to video composition. 火宝短剧商业版地址:[火宝短剧商业版](https://drama.chatfire.site/shortvideo) 火宝小说生成:[火宝小说生成](https://marketing.chatfire.site/huobao-novel/) ### 🎯 Core Features - **🤖 AI-Driven**: Parse scripts using large language models to extract characters, scenes, and storyboards - **🎨 Intelligent Creation**: AI-generated character portraits and scene backgrounds - **📹 Video Generation**: Automatic storyboard video generation using text-to-video and image-to-video models - **🔄 Complete Workflow**: End-to-end production workflow from idea to final video。 ### 🛠️ Technical Architecture Based on **DDD (Domain-Driven Design)** with clear layering: ``` ├── API Layer (Gin HTTP) ├── Application Service Layer (Business Logic) ├── Domain Layer (Domain Models) └── Infrastructure Layer (Database, External Services) ``` ### 🎥 Demo Videos Experience AI short drama generation:
**Sample Work 1** **Sample Work 2** [Watch Video 1](https://ffile.chatfire.site/cf/public/20260114094337396.mp4) | [Watch Video 2](https://ffile.chatfire.site/cf/public/fcede75e8aeafe22031dbf78f86285b8.mp4)
--- ## ✨ Features ### 🎭 Character Management - ✅ AI-generated character portraits - ✅ Batch character generation - ✅ Character image upload and management ### 🎬 Storyboard Production - ✅ Automatic storyboard script generation - ✅ Scene descriptions and shot design - ✅ Storyboard image generation (text-to-image) - ✅ Frame type selection (first frame/key frame/last frame/panel) ### 🎥 Video Generation - ✅ Automatic image-to-video generation - ✅ Video composition and editing - ✅ Transition effects ### 📦 Asset Management - ✅ Unified asset library management - ✅ Local storage support - ✅ Asset import/export - ✅ Task progress tracking --- ## 🚀 Quick Start ### 📋 Prerequisites | Software | Version | Description | | ----------- | ------- | ------------------------------- | | **Go** | 1.23+ | Backend runtime | | **Node.js** | 18+ | Frontend build environment | | **npm** | 9+ | Package manager | | **FFmpeg** | 4.0+ | Video processing (**Required**) | | **SQLite** | 3.x | Database (built-in) | #### Installing FFmpeg **macOS:** ```bash brew install ffmpeg ``` **Ubuntu/Debian:** ```bash sudo apt update sudo apt install ffmpeg ``` **Windows:** Download from [FFmpeg Official Site](https://ffmpeg.org/download.html) and configure environment variables Verify installation: ```bash ffmpeg -version ``` ### ⚙️ Configuration Copy and edit the configuration file: ```bash cp configs/config.example.yaml configs/config.yaml vim configs/config.yaml ``` Configuration file format (`configs/config.yaml`): ```yaml app: name: "Huobao Drama API" version: "1.0.0" debug: true # Set to true for development, false for production server: port: 5678 host: "0.0.0.0" cors_origins: - "http://localhost:3012" read_timeout: 600 write_timeout: 600 database: type: "sqlite" path: "./data/drama_generator.db" max_idle: 10 max_open: 100 storage: type: "local" local_path: "./data/storage" base_url: "http://localhost:5678/static" ai: default_text_provider: "openai" default_image_provider: "openai" default_video_provider: "doubao" ``` **Key Configuration Items:** - `app.debug`: Debug mode switch (recommended true for development) - `server.port`: Service port - `server.cors_origins`: Allowed CORS origins for frontend - `database.path`: SQLite database file path - `storage.local_path`: Local file storage path - `storage.base_url`: Static resource access URL - `ai.default_*_provider`: AI service provider configuration (API keys configured in Web UI) ### 📥 Installation ```bash # Clone the project git clone https://github.com/chatfire-AI/huobao-drama.git cd huobao-drama # Install Go dependencies go mod download # Install frontend dependencies cd web npm install cd .. ``` ### 🎯 Starting the Project #### Method 1: Development Mode (Recommended) **Frontend and backend separation with hot reload** ```bash # Terminal 1: Start backend service go run main.go # Terminal 2: Start frontend dev server cd web npm run dev ``` - Frontend: `http://localhost:3012` - Backend API: `http://localhost:5678/api/v1` - Frontend automatically proxies API requests to backend #### Method 2: Single Service Mode **Backend serves both API and frontend static files** ```bash # 1. Build frontend cd web npm run build cd .. # 2. Start service go run main.go ``` Access: `http://localhost:5678` ### 🗄️ Database Initialization Database tables are automatically created on first startup (using GORM AutoMigrate), no manual migration needed. --- ## 📦 Deployment ### ☁️ Cloud One-Click Deployment (Recommended 3080Ti) 👉 [优云智算,一键部署](https://www.compshare.cn/images/fScvzK95NUk5?referral_code=8hUJOaWz3YzG64FI2OlCiB&ytag=GPU_YY_YX_GitHub_huobaoai) > ⚠️ **Note**: Please save your data to local storage promptly when using cloud deployment --- ### 🐳 Docker Deployment (Recommended) #### Method 1: Docker Compose (Recommended) #### 🚀 China Network Acceleration (Optional) If you are in China, pulling Docker images and installing dependencies may be slow. You can speed up the build process by configuring mirror sources. **Step 1: Create environment variable file** ```bash cp .env.example .env ``` **Step 2: Edit `.env` file and uncomment the mirror sources you need** ```bash # Enable Docker Hub mirror (recommended) DOCKER_REGISTRY=docker.1ms.run/ # Enable npm mirror NPM_REGISTRY=https://registry.npmmirror.com/ # Enable Go proxy GO_PROXY=https://goproxy.cn,direct # Enable Alpine mirror ALPINE_MIRROR=mirrors.aliyun.com ``` **Step 3: Build with docker compose (required)** ```bash docker compose build ``` > **Important Note**: > > - ⚠️ You must use `docker compose build` to automatically load mirror source configurations from the `.env` file > - ❌ If using `docker build` command, you need to manually pass `--build-arg` parameters > - ✅ Always recommended to use `docker compose build` for building **Performance Comparison**: | Operation | Without Mirrors | With Mirrors | | ---------------- | --------------- | ------------ | | Pull base images | 5-30 minutes | 1-5 minutes | | Install npm deps | May fail | Fast success | | Download Go deps | 5-10 minutes | 30s-1 minute | > **Note**: Users outside China should not configure mirror sources, use default settings. ```bash # Start services docker-compose up -d # View logs docker-compose logs -f # Stop services docker-compose down ``` #### Method 2: Docker Command > **Note**: Linux users need to add `--add-host=host.docker.internal:host-gateway` to access host services ```bash # Run from Docker Hub docker run -d \ --name huobao-drama \ -p 5678:5678 \ -v $(pwd)/data:/app/data \ --restart unless-stopped \ huobao/huobao-drama:latest # View logs docker logs -f huobao-drama ``` **Local Build** (optional): ```bash docker build -t huobao-drama:latest . docker run -d --name huobao-drama -p 5678:5678 -v $(pwd)/data:/app/data huobao-drama:latest ``` **Docker Deployment Advantages:** - ✅ Ready to use with default configuration - ✅ Environment consistency, avoiding dependency issues - ✅ One-click start, no need to install Go, Node.js, FFmpeg - ✅ Easy to migrate and scale - ✅ Automatic health checks and restarts - ✅ Automatic file permission handling #### 🔗 Accessing Host Services (Ollama/Local Models) The container is configured to access host services using `http://host.docker.internal:PORT`. **Configuration Steps:** 1. **Start service on host (listen on all interfaces)** ```bash export OLLAMA_HOST=0.0.0.0:11434 && ollama serve ``` 2. **Frontend AI Service Configuration** - Base URL: `http://host.docker.internal:11434/v1` - Provider: `openai` - Model: `qwen2.5:latest` --- ### 🏭 Traditional Deployment #### 1. Build ```bash # 1. Build frontend cd web npm run build cd .. # 2. Compile backend go build -o huobao-drama . ``` Generated files: - `huobao-drama` - Backend executable - `web/dist/` - Frontend static files (embedded in backend) #### 2. Prepare Deployment Files Files to upload to server: ``` huobao-drama # Backend executable configs/config.yaml # Configuration file data/ # Data directory (optional, auto-created on first run) ``` #### 3. Server Configuration ```bash # Upload files to server scp huobao-drama user@server:/opt/huobao-drama/ scp configs/config.yaml user@server:/opt/huobao-drama/configs/ # SSH to server ssh user@server # Modify configuration file cd /opt/huobao-drama vim configs/config.yaml # Set mode to production # Configure domain and storage path # Create data directory and set permissions (Important!) # Note: Replace YOUR_USER with actual user running the service (e.g., www-data, ubuntu, deploy) sudo mkdir -p /opt/huobao-drama/data/storage sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # Grant execute permission chmod +x huobao-drama # Start service ./huobao-drama ``` #### 4. Manage Service with systemd Create service file `/etc/systemd/system/huobao-drama.service`: ```ini [Unit] Description=Huobao Drama Service After=network.target [Service] Type=simple User=YOUR_USER WorkingDirectory=/opt/huobao-drama ExecStart=/opt/huobao-drama/huobao-drama Restart=on-failure RestartSec=10 # Environment variables (optional) # Environment="GIN_MODE=release" [Install] WantedBy=multi-user.target ``` Start service: ```bash sudo systemctl daemon-reload sudo systemctl enable huobao-drama sudo systemctl start huobao-drama sudo systemctl status huobao-drama ``` **⚠️ Common Issue: SQLite Write Permission Error** If you encounter `attempt to write a readonly database` error: ```bash # 1. Check current user running the service sudo systemctl status huobao-drama | grep "Main PID" ps aux | grep huobao-drama # 2. Fix permissions (replace YOUR_USER with actual username) sudo chown -R YOUR_USER:YOUR_USER /opt/huobao-drama/data sudo chmod -R 755 /opt/huobao-drama/data # 3. Verify permissions ls -la /opt/huobao-drama/data # Should show owner as the user running the service # 4. Restart service sudo systemctl restart huobao-drama ``` **Reason:** - SQLite requires write permission on both the database file **and** its directory - Needs to create temporary files in the directory (e.g., `-wal`, `-journal`) - **Key**: Ensure systemd `User` matches data directory owner **Common Usernames:** - Ubuntu/Debian: `www-data`, `ubuntu` - CentOS/RHEL: `nginx`, `apache` - Custom deployment: `deploy`, `app`, current logged-in user #### 5. Nginx Reverse Proxy ```nginx server { listen 80; server_name your-domain.com; location / { proxy_pass http://localhost:5678; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # Direct access to static files location /static/ { alias /opt/huobao-drama/data/storage/; } } ``` --- ## 🎨 Tech Stack ### Backend - **Language**: Go 1.23+ - **Web Framework**: Gin 1.9+ - **ORM**: GORM - **Database**: SQLite - **Logging**: Zap - **Video Processing**: FFmpeg - **AI Services**: OpenAI, Gemini, Doubao, etc. ### Frontend - **Framework**: Vue 3.4+ - **Language**: TypeScript 5+ - **Build Tool**: Vite 5 - **UI Components**: Element Plus - **CSS Framework**: TailwindCSS - **State Management**: Pinia - **Router**: Vue Router 4 ### Development Tools - **Package Management**: Go Modules, npm - **Code Standards**: ESLint, Prettier - **Version Control**: Git --- ## 📝 FAQ ### Q: How can Docker containers access Ollama on the host? A: Use `http://host.docker.internal:11434/v1` as Base URL. Note two things: 1. Host Ollama needs to listen on `0.0.0.0`: `export OLLAMA_HOST=0.0.0.0:11434 && ollama serve` 2. Linux users using `docker run` need to add: `--add-host=host.docker.internal:host-gateway` See: [DOCKER_HOST_ACCESS.md](docs/DOCKER_HOST_ACCESS.md) ### Q: FFmpeg not installed or not found? A: Ensure FFmpeg is installed and in the PATH environment variable. Verify with `ffmpeg -version`. ### Q: Frontend cannot connect to backend API? A: Check if backend is running and port is correct. In development mode, frontend proxy config is in `web/vite.config.ts`. ### Q: Database tables not created? A: GORM automatically creates tables on first startup, check logs to confirm migration success. --- ## 📋 Changelog ### v1.0.5 (2026-02-06) #### 🎨 Major Features - **🎭 Global Style System**: Introduced comprehensive style selection support across the entire project. Users can now define custom visual styles at the drama level, which automatically applies to all AI-generated content including characters, scenes, and storyboards, ensuring consistent artistic direction throughout the production. - **✂️ Nine-Grid Sequence Image Cropping**: Added cropping tool for action sequence images. Users can now extract individual frames from 3x3 grid layouts and designate them as first frames, last frames, or keyframes for video generation, providing greater flexibility in shot composition and continuity. #### 🚀 Enhancements - **📐 Optimized Action Sequence Grid**: Enhanced the visual quality and layout of nine-grid action sequence images with improved spacing, alignment, and frame transitions. - **🔧 Manual Grid Assembly**: Introduced manual grid composition tools supporting 2x2 (four-grid), 2x3 (six-grid), and 3x3 (nine-grid) layouts, allowing users to create custom action sequences from individual frames. - **🗑️ Content Management**: Added delete functionality for both generated images and videos, enabling better asset organization and storage management. ### v1.0.4 (2026-01-27) #### 🚀 Major Updates - Introduced local storage strategy for generated content caching, effectively mitigating external resource link expiration risks - Implemented Base64 encoding for embedded reference image transmission - Fixed issue where shot image prompt state was not reset when switching shots - Fixed issue where video duration displayed as 0 when adding library videos - Added scene migration to episodes #### Historical Data Migration - Added migration script for processing historical data. For detailed instructions, please refer to [MIGRATE_README.md](MIGRATE_README.md) ### v1.0.3 (2026-01-16) #### 🚀 Major Updates - Pure Go SQLite driver (`modernc.org/sqlite`), supports `CGO_ENABLED=0` cross-platform compilation - Optimized concurrency performance (WAL mode), resolved "database is locked" errors - Docker cross-platform support for `host.docker.internal` to access host services - Streamlined documentation and deployment guides ### v1.0.2 (2026-01-14) #### 🐛 Bug Fixes / 🔧 Improvements - Fixed video generation API response parsing issues - Added OpenAI Sora video endpoint configuration - Optimized error handling and logging --- ## 🤝 Contributing Issues and Pull Requests are welcome! 1. Fork this project 2. Create a feature branch (`git checkout -b feature/AmazingFeature`) 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 4. Push to the branch (`git push origin feature/AmazingFeature`) 5. Open a Pull Request --- ## API Configuration Site Configure in 2 minutes: [API Aggregation Site](https://api.chatfire.site/models) --- ## 👨‍💻 About Us **AI Huobao - AI Studio Startup** - 🏠 **Location**: Nanjing, China - 🚀 **Status**: Startup in Progress - 📧 **Email**: [18550175439@163.com](mailto:18550175439@163.com) - 🐙 **GitHub**: [https://github.com/chatfire-AI/huobao-drama](https://github.com/chatfire-AI/huobao-drama) > _"Let AI help us do more creative things"_ ## Community Group ![Community Group](drama.png) - Submit [Issue](../../issues) - Email project maintainers ---
**⭐ If this project helps you, please give it a Star!** ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=chatfire-AI/huobao-drama&type=date&legend=top-left)](https://www.star-history.com/#chatfire-AI/huobao-drama&type=date&legend=top-left) Made with ❤️ by Huobao Team
================================================ FILE: api/handlers/ai_config.go ================================================ package handlers import ( "strconv" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type AIConfigHandler struct { aiService *services.AIService log *logger.Logger } func NewAIConfigHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AIConfigHandler { return &AIConfigHandler{ aiService: services.NewAIService(db, log), log: log, } } func (h *AIConfigHandler) CreateConfig(c *gin.Context) { var req services.CreateAIConfigRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } config, err := h.aiService.CreateConfig(&req) if err != nil { response.InternalError(c, "创建失败") return } response.Created(c, config) } func (h *AIConfigHandler) GetConfig(c *gin.Context) { configID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的配置ID") return } config, err := h.aiService.GetConfig(uint(configID)) if err != nil { if err.Error() == "config not found" { response.NotFound(c, "配置不存在") return } response.InternalError(c, "获取失败") return } response.Success(c, config) } func (h *AIConfigHandler) ListConfigs(c *gin.Context) { serviceType := c.Query("service_type") configs, err := h.aiService.ListConfigs(serviceType) if err != nil { response.InternalError(c, "获取列表失败") return } response.Success(c, configs) } func (h *AIConfigHandler) UpdateConfig(c *gin.Context) { configID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的配置ID") return } var req services.UpdateAIConfigRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } config, err := h.aiService.UpdateConfig(uint(configID), &req) if err != nil { if err.Error() == "config not found" { response.NotFound(c, "配置不存在") return } response.InternalError(c, "更新失败") return } response.Success(c, config) } func (h *AIConfigHandler) DeleteConfig(c *gin.Context) { configID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的配置ID") return } if err := h.aiService.DeleteConfig(uint(configID)); err != nil { if err.Error() == "config not found" { response.NotFound(c, "配置不存在") return } response.InternalError(c, "删除失败") return } response.Success(c, gin.H{"message": "删除成功"}) } func (h *AIConfigHandler) TestConnection(c *gin.Context) { var req services.TestConnectionRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.aiService.TestConnection(&req); err != nil { response.BadRequest(c, "连接测试失败: "+err.Error()) return } response.Success(c, gin.H{"message": "连接测试成功"}) } ================================================ FILE: api/handlers/asset.go ================================================ package handlers import ( "strconv" "strings" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type AssetHandler struct { assetService *services.AssetService log *logger.Logger } func NewAssetHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *AssetHandler { return &AssetHandler{ assetService: services.NewAssetService(db, log), log: log, } } func (h *AssetHandler) CreateAsset(c *gin.Context) { var req services.CreateAssetRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } asset, err := h.assetService.CreateAsset(&req) if err != nil { h.log.Errorw("Failed to create asset", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, asset) } func (h *AssetHandler) UpdateAsset(c *gin.Context) { assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } var req services.UpdateAssetRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } asset, err := h.assetService.UpdateAsset(uint(assetID), &req) if err != nil { h.log.Errorw("Failed to update asset", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, asset) } func (h *AssetHandler) GetAsset(c *gin.Context) { assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } asset, err := h.assetService.GetAsset(uint(assetID)) if err != nil { response.NotFound(c, "素材不存在") return } response.Success(c, asset) } func (h *AssetHandler) ListAssets(c *gin.Context) { var dramaID *string if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { dramaID = &dramaIDStr } var episodeID *uint if episodeIDStr := c.Query("episode_id"); episodeIDStr != "" { if id, err := strconv.ParseUint(episodeIDStr, 10, 32); err == nil { uid := uint(id) episodeID = &uid } } var storyboardID *uint if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { if id, err := strconv.ParseUint(storyboardIDStr, 10, 32); err == nil { uid := uint(id) storyboardID = &uid } } var assetType *models.AssetType if typeStr := c.Query("type"); typeStr != "" { t := models.AssetType(typeStr) assetType = &t } var isFavorite *bool if favoriteStr := c.Query("is_favorite"); favoriteStr != "" { if favoriteStr == "true" { fav := true isFavorite = &fav } else if favoriteStr == "false" { fav := false isFavorite = &fav } } var tagIDs []uint if tagIDsStr := c.Query("tag_ids"); tagIDsStr != "" { for _, idStr := range strings.Split(tagIDsStr, ",") { if id, err := strconv.ParseUint(strings.TrimSpace(idStr), 10, 32); err == nil { tagIDs = append(tagIDs, uint(id)) } } } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } req := &services.ListAssetsRequest{ DramaID: dramaID, EpisodeID: episodeID, StoryboardID: storyboardID, Type: assetType, Category: c.Query("category"), TagIDs: tagIDs, IsFavorite: isFavorite, Search: c.Query("search"), Page: page, PageSize: pageSize, } assets, total, err := h.assetService.ListAssets(req) if err != nil { h.log.Errorw("Failed to list assets", "error", err) response.InternalError(c, err.Error()) return } response.SuccessWithPagination(c, assets, total, page, pageSize) } func (h *AssetHandler) DeleteAsset(c *gin.Context) { assetID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } if err := h.assetService.DeleteAsset(uint(assetID)); err != nil { h.log.Errorw("Failed to delete asset", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, nil) } func (h *AssetHandler) ImportFromImageGen(c *gin.Context) { imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } asset, err := h.assetService.ImportFromImageGen(uint(imageGenID)) if err != nil { h.log.Errorw("Failed to import from image gen", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, asset) } func (h *AssetHandler) ImportFromVideoGen(c *gin.Context) { videoGenID, err := strconv.ParseUint(c.Param("video_gen_id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } asset, err := h.assetService.ImportFromVideoGen(uint(videoGenID)) if err != nil { h.log.Errorw("Failed to import from video gen", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, asset) } ================================================ FILE: api/handlers/audio_extraction.go ================================================ package handlers import ( "net/http" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/gin-gonic/gin" ) type AudioExtractionHandler struct { service *services.AudioExtractionService log *logger.Logger dataDir string } func NewAudioExtractionHandler(log *logger.Logger, dataDir string) *AudioExtractionHandler { return &AudioExtractionHandler{ service: services.NewAudioExtractionService(log), log: log, dataDir: dataDir, } } // ExtractAudio 提取单个视频的音频 // @Summary 提取视频音频 // @Description 从视频URL中提取音频轨道 // @Tags Audio // @Accept json // @Produce json // @Param request body services.ExtractAudioRequest true "提取请求" // @Success 200 {object} services.ExtractAudioResponse // @Router /api/audio/extract [post] func (h *AudioExtractionHandler) ExtractAudio(c *gin.Context) { var req services.ExtractAudioRequest if err := c.ShouldBindJSON(&req); err != nil { h.log.Errorw("Invalid request body", "error", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } h.log.Infow("Received audio extraction request", "video_url", req.VideoURL) result, err := h.service.ExtractAudio(req.VideoURL, h.dataDir) if err != nil { h.log.Errorw("Failed to extract audio", "error", err, "video_url", req.VideoURL) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, result) } type BatchExtractAudioRequest struct { VideoURLs []string `json:"video_urls" binding:"required,min=1"` } // BatchExtractAudio 批量提取音频 // @Summary 批量提取视频音频 // @Description 从多个视频URL中提取音频轨道 // @Tags Audio // @Accept json // @Produce json // @Param request body BatchExtractAudioRequest true "批量提取请求" // @Success 200 {array} services.ExtractAudioResponse // @Router /api/audio/extract/batch [post] func (h *AudioExtractionHandler) BatchExtractAudio(c *gin.Context) { var req BatchExtractAudioRequest if err := c.ShouldBindJSON(&req); err != nil { h.log.Errorw("Invalid request body", "error", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } h.log.Infow("Received batch audio extraction request", "count", len(req.VideoURLs)) results, err := h.service.BatchExtractAudio(req.VideoURLs, h.dataDir) if err != nil { h.log.Errorw("Failed to batch extract audio", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "results": results, "total": len(results), }) } ================================================ FILE: api/handlers/character_batch.go ================================================ package handlers import ( "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" ) // BatchGenerateCharacterImages 批量生成角色图片 func (h *CharacterLibraryHandler) BatchGenerateCharacterImages(c *gin.Context) { var req struct { CharacterIDs []string `json:"character_ids" binding:"required,min=1"` Model string `json:"model"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } // 限制批量生成数量 if len(req.CharacterIDs) > 10 { response.BadRequest(c, "单次最多生成10个角色") return } // 异步批量生成 go h.libraryService.BatchGenerateCharacterImages(req.CharacterIDs, h.imageService, req.Model) response.Success(c, gin.H{ "message": "批量生成任务已提交", "count": len(req.CharacterIDs), }) } ================================================ FILE: api/handlers/character_library.go ================================================ package handlers import ( "strconv" services2 "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type CharacterLibraryHandler struct { libraryService *services2.CharacterLibraryService imageService *services2.ImageGenerationService log *logger.Logger } func NewCharacterLibraryHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services2.ResourceTransferService, localStorage *storage.LocalStorage) *CharacterLibraryHandler { return &CharacterLibraryHandler{ libraryService: services2.NewCharacterLibraryService(db, log, cfg), imageService: services2.NewImageGenerationService(db, cfg, transferService, localStorage, log), log: log, } } // ListLibraryItems 获取角色库列表 func (h *CharacterLibraryHandler) ListLibraryItems(c *gin.Context) { var query services2.CharacterLibraryQuery if err := c.ShouldBindQuery(&query); err != nil { response.BadRequest(c, err.Error()) return } if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 || query.PageSize > 100 { query.PageSize = 20 } items, total, err := h.libraryService.ListLibraryItems(&query) if err != nil { h.log.Errorw("Failed to list library items", "error", err) response.InternalError(c, "获取角色库失败") return } response.SuccessWithPagination(c, items, total, query.Page, query.PageSize) } // CreateLibraryItem 添加到角色库 func (h *CharacterLibraryHandler) CreateLibraryItem(c *gin.Context) { var req services2.CreateLibraryItemRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } item, err := h.libraryService.CreateLibraryItem(&req) if err != nil { h.log.Errorw("Failed to create library item", "error", err) response.InternalError(c, "添加到角色库失败") return } response.Created(c, item) } // GetLibraryItem 获取角色库项详情 func (h *CharacterLibraryHandler) GetLibraryItem(c *gin.Context) { itemID := c.Param("id") item, err := h.libraryService.GetLibraryItem(itemID) if err != nil { if err.Error() == "library item not found" { response.NotFound(c, "角色库项不存在") return } h.log.Errorw("Failed to get library item", "error", err) response.InternalError(c, "获取失败") return } response.Success(c, item) } // DeleteLibraryItem 删除角色库项 func (h *CharacterLibraryHandler) DeleteLibraryItem(c *gin.Context) { itemID := c.Param("id") if err := h.libraryService.DeleteLibraryItem(itemID); err != nil { if err.Error() == "library item not found" { response.NotFound(c, "角色库项不存在") return } h.log.Errorw("Failed to delete library item", "error", err) response.InternalError(c, "删除失败") return } response.Success(c, gin.H{"message": "删除成功"}) } // UploadCharacterImage 上传角色图片 func (h *CharacterLibraryHandler) UploadCharacterImage(c *gin.Context) { characterID := c.Param("id") // TODO: 处理文件上传 // 这里需要实现文件上传逻辑,保存到OSS或本地 // 暂时使用简单的实现 var req struct { ImageURL string `json:"image_url" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.libraryService.UploadCharacterImage(characterID, req.ImageURL); err != nil { if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权限") return } h.log.Errorw("Failed to upload character image", "error", err) response.InternalError(c, "上传失败") return } response.Success(c, gin.H{"message": "上传成功"}) } // ApplyLibraryItemToCharacter 从角色库应用形象 func (h *CharacterLibraryHandler) ApplyLibraryItemToCharacter(c *gin.Context) { characterID := c.Param("id") var req struct { LibraryItemID string `json:"library_item_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.libraryService.ApplyLibraryItemToCharacter(characterID, req.LibraryItemID); err != nil { if err.Error() == "library item not found" { response.NotFound(c, "角色库项不存在") return } if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权限") return } h.log.Errorw("Failed to apply library item", "error", err) response.InternalError(c, "应用失败") return } response.Success(c, gin.H{"message": "应用成功"}) } // AddCharacterToLibrary 将角色添加到角色库 func (h *CharacterLibraryHandler) AddCharacterToLibrary(c *gin.Context) { characterID := c.Param("id") var req struct { Category *string `json:"category"` } if err := c.ShouldBindJSON(&req); err != nil { // 允许空body req.Category = nil } item, err := h.libraryService.AddCharacterToLibrary(characterID, req.Category) if err != nil { if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权限") return } if err.Error() == "character has no image" { response.BadRequest(c, "角色还没有形象图片") return } h.log.Errorw("Failed to add character to library", "error", err) response.InternalError(c, "添加失败") return } response.Created(c, item) } // UpdateCharacter 更新角色信息 func (h *CharacterLibraryHandler) UpdateCharacter(c *gin.Context) { characterID := c.Param("id") var req services2.UpdateCharacterRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.libraryService.UpdateCharacter(characterID, &req); err != nil { if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权限") return } h.log.Errorw("Failed to update character", "error", err) response.InternalError(c, "更新失败") return } response.Success(c, gin.H{"message": "更新成功"}) } // DeleteCharacter 删除单个角色 func (h *CharacterLibraryHandler) DeleteCharacter(c *gin.Context) { characterIDStr := c.Param("id") characterID, err := strconv.ParseUint(characterIDStr, 10, 32) if err != nil { response.BadRequest(c, "无效的角色ID") return } if err := h.libraryService.DeleteCharacter(uint(characterID)); err != nil { h.log.Errorw("Failed to delete character", "error", err, "id", characterID) if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权删除此角色") return } response.InternalError(c, "删除失败") return } response.Success(c, gin.H{"message": "角色已删除"}) } // ExtractCharacters 从剧本提取角色 func (h *CharacterLibraryHandler) ExtractCharacters(c *gin.Context) { episodeIDStr := c.Param("episode_id") episodeID, err := strconv.ParseUint(episodeIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid episode_id") return } taskID, err := h.libraryService.ExtractCharactersFromScript(uint(episodeID)) if err != nil { h.log.Errorw("Failed to extract characters", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"task_id": taskID, "message": "角色提取任务已提交"}) } ================================================ FILE: api/handlers/character_library_gen.go ================================================ package handlers import ( "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" ) // GenerateCharacterImage AI生成角色形象 func (h *CharacterLibraryHandler) GenerateCharacterImage(c *gin.Context) { characterID := c.Param("id") // 获取请求体中的model和style参数 var req struct { Model string `json:"model"` Style string `json:"style"` } c.ShouldBindJSON(&req) imageGen, err := h.libraryService.GenerateCharacterImage(characterID, h.imageService, req.Model, req.Style) if err != nil { if err.Error() == "character not found" { response.NotFound(c, "角色不存在") return } if err.Error() == "unauthorized" { response.Forbidden(c, "无权限") return } h.log.Errorw("Failed to generate character image", "error", err) response.InternalError(c, "生成失败") return } response.Success(c, gin.H{ "message": "角色图片生成已启动", "image_generation": imageGen, }) } ================================================ FILE: api/handlers/drama.go ================================================ package handlers import ( "encoding/json" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type DramaHandler struct { db *gorm.DB dramaService *services.DramaService videoMergeService *services.VideoMergeService log *logger.Logger } func NewDramaHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService) *DramaHandler { return &DramaHandler{ db: db, dramaService: services.NewDramaService(db, cfg, log), videoMergeService: services.NewVideoMergeService(db, transferService, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log), log: log, } } func (h *DramaHandler) CreateDrama(c *gin.Context) { var req services.CreateDramaRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } drama, err := h.dramaService.CreateDrama(&req) if err != nil { response.InternalError(c, "创建失败") return } response.Created(c, drama) } func (h *DramaHandler) GetDrama(c *gin.Context) { dramaID := c.Param("id") drama, err := h.dramaService.GetDrama(dramaID) if err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "获取失败") return } response.Success(c, drama) } func (h *DramaHandler) ListDramas(c *gin.Context) { var query services.DramaListQuery if err := c.ShouldBindQuery(&query); err != nil { response.BadRequest(c, err.Error()) return } if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 || query.PageSize > 100 { query.PageSize = 20 } dramas, total, err := h.dramaService.ListDramas(&query) if err != nil { response.InternalError(c, "获取列表失败") return } response.SuccessWithPagination(c, dramas, total, query.Page, query.PageSize) } func (h *DramaHandler) UpdateDrama(c *gin.Context) { dramaID := c.Param("id") var req services.UpdateDramaRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } drama, err := h.dramaService.UpdateDrama(dramaID, &req) if err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "更新失败") return } response.Success(c, drama) } func (h *DramaHandler) DeleteDrama(c *gin.Context) { dramaID := c.Param("id") if err := h.dramaService.DeleteDrama(dramaID); err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "删除失败") return } response.Success(c, gin.H{"message": "删除成功"}) } func (h *DramaHandler) GetDramaStats(c *gin.Context) { stats, err := h.dramaService.GetDramaStats() if err != nil { response.InternalError(c, "获取统计失败") return } response.Success(c, stats) } func (h *DramaHandler) SaveOutline(c *gin.Context) { dramaID := c.Param("id") var req services.SaveOutlineRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.dramaService.SaveOutline(dramaID, &req); err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "保存失败") return } response.Success(c, gin.H{"message": "保存成功"}) } func (h *DramaHandler) GetCharacters(c *gin.Context) { dramaID := c.Param("id") episodeID := c.Query("episode_id") // 可选:如果提供则只返回该章节的角色 var episodeIDPtr *string if episodeID != "" { episodeIDPtr = &episodeID } characters, err := h.dramaService.GetCharacters(dramaID, episodeIDPtr) if err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } if err.Error() == "episode not found" { response.NotFound(c, "章节不存在") return } response.InternalError(c, "获取角色失败") return } response.Success(c, characters) } func (h *DramaHandler) SaveCharacters(c *gin.Context) { dramaID := c.Param("id") var req services.SaveCharactersRequest // 先尝试正常绑定JSON if err := c.ShouldBindJSON(&req); err != nil { // 如果绑定失败,检查是否是因为characters字段是字符串而不是数组 var rawReq map[string]interface{} if err := c.ShouldBindJSON(&rawReq); err != nil { // 如果连rawReq都绑定失败,直接返回错误 response.BadRequest(c, err.Error()) return } // 检查characters字段类型 if charField, ok := rawReq["characters"]; ok { if charStr, ok := charField.(string); ok { // 如果characters是字符串,尝试解析为JSON数组 var characters []models.Character if err := json.Unmarshal([]byte(charStr), &characters); err != nil { // 解析失败,返回错误 response.BadRequest(c, "characters字段格式错误,需要JSON数组或字符串格式的JSON数组") return } // 手动构造请求对象 req.Characters = characters // 处理episode_id字段 if epID, ok := rawReq["episode_id"]; ok { if epIDStr, ok := epID.(float64); ok { epIDUint := uint(epIDStr) req.EpisodeID = &epIDUint } } } else { // 如果characters不是字符串,直接返回原始错误 response.BadRequest(c, err.Error()) return } } else { // 如果没有characters字段,返回原始错误 response.BadRequest(c, err.Error()) return } } if err := h.dramaService.SaveCharacters(dramaID, &req); err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "保存失败") return } response.Success(c, gin.H{"message": "保存成功"}) } func (h *DramaHandler) SaveEpisodes(c *gin.Context) { dramaID := c.Param("id") var req services.SaveEpisodesRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.dramaService.SaveEpisodes(dramaID, &req); err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "保存失败") return } response.Success(c, gin.H{"message": "保存成功"}) } func (h *DramaHandler) SaveProgress(c *gin.Context) { dramaID := c.Param("id") var req services.SaveProgressRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.dramaService.SaveProgress(dramaID, &req); err != nil { if err.Error() == "drama not found" { response.NotFound(c, "剧本不存在") return } response.InternalError(c, "保存失败") return } response.Success(c, gin.H{"message": "保存成功"}) } // FinalizeEpisode 完成集数制作(触发视频合成) func (h *DramaHandler) FinalizeEpisode(c *gin.Context) { episodeID := c.Param("episode_id") if episodeID == "" { response.BadRequest(c, "episode_id不能为空") return } // 尝试读取时间线数据(可选) var timelineData *services.FinalizeEpisodeRequest if err := c.ShouldBindJSON(&timelineData); err != nil { // 如果没有请求体或解析失败,使用nil(将使用默认场景顺序) h.log.Warnw("No timeline data provided, will use default scene order", "error", err) timelineData = nil } else if timelineData != nil { h.log.Infow("Received timeline data", "clips_count", len(timelineData.Clips), "episode_id", episodeID) } // 触发视频合成任务 result, err := h.videoMergeService.FinalizeEpisode(episodeID, timelineData) if err != nil { h.log.Errorw("Failed to finalize episode", "error", err, "episode_id", episodeID) response.InternalError(c, err.Error()) return } response.Success(c, result) } // DownloadEpisodeVideo 下载剧集视频 func (h *DramaHandler) DownloadEpisodeVideo(c *gin.Context) { episodeID := c.Param("episode_id") if episodeID == "" { response.BadRequest(c, "episode_id不能为空") return } // 查询episode var episode models.Episode if err := h.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil { response.NotFound(c, "剧集不存在") return } // 检查是否有视频 if episode.VideoURL == nil || *episode.VideoURL == "" { response.BadRequest(c, "该剧集还没有生成视频") return } // 返回视频URL,让前端重定向下载 c.JSON(200, gin.H{ "video_url": *episode.VideoURL, "title": episode.Title, "episode_number": episode.EpisodeNum, }) } ================================================ FILE: api/handlers/frame_prompt.go ================================================ package handlers import ( "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" ) // FramePromptHandler 处理帧提示词生成请求 type FramePromptHandler struct { framePromptService *services.FramePromptService log *logger.Logger } // NewFramePromptHandler 创建帧提示词处理器 func NewFramePromptHandler(framePromptService *services.FramePromptService, log *logger.Logger) *FramePromptHandler { return &FramePromptHandler{ framePromptService: framePromptService, log: log, } } // GenerateFramePrompt 生成指定类型的帧提示词 // POST /api/v1/storyboards/:id/frame-prompt func (h *FramePromptHandler) GenerateFramePrompt(c *gin.Context) { storyboardID := c.Param("id") var req struct { FrameType string `json:"frame_type"` PanelCount int `json:"panel_count"` Model string `json:"model"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } serviceReq := services.GenerateFramePromptRequest{ StoryboardID: storyboardID, FrameType: services.FrameType(req.FrameType), PanelCount: req.PanelCount, } // 直接调用服务层的异步方法,该方法会创建任务并返回任务ID taskID, err := h.framePromptService.GenerateFramePrompt(serviceReq, req.Model) if err != nil { h.log.Errorw("Failed to generate frame prompt", "error", err) response.InternalError(c, err.Error()) return } // 立即返回任务ID response.Success(c, gin.H{ "task_id": taskID, "status": "pending", "message": "帧提示词生成任务已创建,正在后台处理...", }) } ================================================ FILE: api/handlers/frame_prompt_query.go ================================================ package handlers import ( "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // GetStoryboardFramePrompts 查询镜头的所有帧提示词 // GET /api/v1/storyboards/:id/frame-prompts func GetStoryboardFramePrompts(db *gorm.DB, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { storyboardID := c.Param("id") var framePrompts []models.FramePrompt if err := db.Where("storyboard_id = ?", storyboardID). Order("created_at DESC"). Find(&framePrompts).Error; err != nil { log.Errorw("Failed to query frame prompts", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{ "frame_prompts": framePrompts, }) } } ================================================ FILE: api/handlers/image_generation.go ================================================ package handlers import ( "strconv" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ImageGenerationHandler struct { imageService *services.ImageGenerationService taskService *services.TaskService log *logger.Logger config *config.Config db *gorm.DB } func NewImageGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage) *ImageGenerationHandler { return &ImageGenerationHandler{ imageService: services.NewImageGenerationService(db, cfg, transferService, localStorage, log), taskService: services.NewTaskService(db, log), log: log, config: cfg, db: db, } } func (h *ImageGenerationHandler) GenerateImage(c *gin.Context) { var req services.GenerateImageRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } imageGen, err := h.imageService.GenerateImage(&req) if err != nil { h.log.Errorw("Failed to generate image", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, imageGen) } func (h *ImageGenerationHandler) GenerateImagesForScene(c *gin.Context) { sceneID := c.Param("scene_id") images, err := h.imageService.GenerateImagesForScene(sceneID) if err != nil { h.log.Errorw("Failed to generate images for scene", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, images) } func (h *ImageGenerationHandler) GetBackgroundsForEpisode(c *gin.Context) { episodeID := c.Param("episode_id") backgrounds, err := h.imageService.GetScencesForEpisode(episodeID) if err != nil { h.log.Errorw("Failed to get backgrounds", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, backgrounds) } func (h *ImageGenerationHandler) ExtractBackgroundsForEpisode(c *gin.Context) { episodeID := c.Param("episode_id") // 接收可选的 model 和 style 参数 var req struct { Model string `json:"model"` Style string `json:"style"` } if err := c.ShouldBindJSON(&req); err != nil { // 如果没有提供body或者解析失败,使用空字符串(使用默认模型和风格) req.Model = "" req.Style = "" } // 如果style为空,从episode获取drama的style if req.Style == "" { var episode models.Episode if err := h.db.Preload("Drama").First(&episode, episodeID).Error; err == nil { req.Style = episode.Drama.Style } } // 直接调用服务层的异步方法,该方法会创建任务并返回任务ID taskID, err := h.imageService.ExtractBackgroundsForEpisode(episodeID, req.Model, req.Style) if err != nil { h.log.Errorw("Failed to extract backgrounds", "error", err, "episode_id", episodeID) response.InternalError(c, err.Error()) return } // 立即返回任务ID response.Success(c, gin.H{ "task_id": taskID, "status": "pending", "message": "场景提取任务已创建,正在后台处理...", }) } func (h *ImageGenerationHandler) BatchGenerateForEpisode(c *gin.Context) { episodeID := c.Param("episode_id") images, err := h.imageService.BatchGenerateImagesForEpisode(episodeID) if err != nil { h.log.Errorw("Failed to batch generate images", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, images) } func (h *ImageGenerationHandler) GetImageGeneration(c *gin.Context) { imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } imageGen, err := h.imageService.GetImageGeneration(uint(imageGenID)) if err != nil { response.NotFound(c, "图片生成记录不存在") return } response.Success(c, imageGen) } func (h *ImageGenerationHandler) ListImageGenerations(c *gin.Context) { var sceneID *uint if sceneIDStr := c.Query("scene_id"); sceneIDStr != "" { id, err := strconv.ParseUint(sceneIDStr, 10, 32) if err == nil { uid := uint(id) sceneID = &uid } } var storyboardID *uint if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { id, err := strconv.ParseUint(storyboardIDStr, 10, 32) if err == nil { uid := uint(id) storyboardID = &uid } } frameType := c.Query("frame_type") status := c.Query("status") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } var dramaIDUint *uint if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { did, _ := strconv.ParseUint(dramaIDStr, 10, 32) didUint := uint(did) dramaIDUint = &didUint } images, total, err := h.imageService.ListImageGenerations(dramaIDUint, sceneID, storyboardID, frameType, status, page, pageSize) if err != nil { h.log.Errorw("Failed to list images", "error", err) response.InternalError(c, err.Error()) return } response.SuccessWithPagination(c, images, total, page, pageSize) } func (h *ImageGenerationHandler) DeleteImageGeneration(c *gin.Context) { imageGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } if err := h.imageService.DeleteImageGeneration(uint(imageGenID)); err != nil { h.log.Errorw("Failed to delete image", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, nil) } // UploadImage 上传图片并创建图片生成记录 func (h *ImageGenerationHandler) UploadImage(c *gin.Context) { var req struct { StoryboardID uint `json:"storyboard_id" binding:"required"` DramaID uint `json:"drama_id" binding:"required"` FrameType string `json:"frame_type" binding:"required"` ImageURL string `json:"image_url" binding:"required"` Prompt string `json:"prompt"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } imageGen, err := h.imageService.CreateImageFromUpload(&services.UploadImageRequest{ StoryboardID: req.StoryboardID, DramaID: req.DramaID, FrameType: req.FrameType, ImageURL: req.ImageURL, Prompt: req.Prompt, }) if err != nil { h.log.Errorw("Failed to create image from upload", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, imageGen) } ================================================ FILE: api/handlers/prop.go ================================================ package handlers import ( "strconv" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type PropHandler struct { propService *services.PropService log *logger.Logger } func NewPropHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger, aiService *services.AIService, imageGenerationService *services.ImageGenerationService) *PropHandler { return &PropHandler{ propService: services.NewPropService(db, aiService, services.NewTaskService(db, log), imageGenerationService, log, cfg), log: log, } } // ListProps 获取道具列表 func (h *PropHandler) ListProps(c *gin.Context) { dramaIDStr := c.Query("drama_id") if dramaIDStr == "" { response.BadRequest(c, "drama_id is required") return } dramaID, err := strconv.ParseUint(dramaIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid drama_id") return } props, err := h.propService.ListProps(uint(dramaID)) if err != nil { response.InternalError(c, err.Error()) return } response.Success(c, props) } // CreateProp 创建道具 func (h *PropHandler) CreateProp(c *gin.Context) { var prop models.Prop if err := c.ShouldBindJSON(&prop); err != nil { response.BadRequest(c, err.Error()) return } if err := h.propService.CreateProp(&prop); err != nil { response.InternalError(c, err.Error()) return } response.Created(c, prop) } // UpdateProp 更新道具 func (h *PropHandler) UpdateProp(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid ID") return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { response.BadRequest(c, err.Error()) return } if err := h.propService.UpdateProp(uint(id), updates); err != nil { response.InternalError(c, err.Error()) return } response.Success(c, nil) } // DeleteProp 删除道具 func (h *PropHandler) DeleteProp(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid ID") return } if err := h.propService.DeleteProp(uint(id)); err != nil { response.InternalError(c, err.Error()) return } response.Success(c, nil) } // ExtractProps 提取道具 func (h *PropHandler) ExtractProps(c *gin.Context) { episodeIDStr := c.Param("episode_id") episodeID, err := strconv.ParseUint(episodeIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid episode_id") return } taskID, err := h.propService.ExtractPropsFromScript(uint(episodeID)) if err != nil { response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"task_id": taskID}) } // GenerateImage 生成道具图片 func (h *PropHandler) GenerateImage(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid ID") return } taskID, err := h.propService.GeneratePropImage(uint(id)) if err != nil { h.log.Errorw("Failed to generate prop image", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"task_id": taskID, "message": "图片生成任务已提交"}) } // AssociateProps 关联道具 func (h *PropHandler) AssociateProps(c *gin.Context) { storyboardIDStr := c.Param("id") storyboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid storyboard_id") return } var req struct { PropIDs []uint `json:"prop_ids"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } if err := h.propService.AssociatePropsWithStoryboard(uint(storyboardID), req.PropIDs); err != nil { response.InternalError(c, err.Error()) return } response.Success(c, nil) } ================================================ FILE: api/handlers/scene.go ================================================ package handlers import ( services2 "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type SceneHandler struct { sceneService *services2.StoryboardCompositionService log *logger.Logger } func NewSceneHandler(db *gorm.DB, log *logger.Logger, imageGenService *services2.ImageGenerationService) *SceneHandler { return &SceneHandler{ sceneService: services2.NewStoryboardCompositionService(db, log, imageGenService), log: log, } } func (h *SceneHandler) GetStoryboardsForEpisode(c *gin.Context) { episodeID := c.Param("episode_id") storyboards, err := h.sceneService.GetScenesForEpisode(episodeID) if err != nil { h.log.Errorw("Failed to get storyboards for episode", "error", err, "episode_id", episodeID) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{ "storyboards": storyboards, "total": len(storyboards), }) } func (h *SceneHandler) UpdateScene(c *gin.Context) { sceneID := c.Param("scene_id") var req services2.UpdateSceneInfoRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request") return } if err := h.sceneService.UpdateSceneInfo(sceneID, &req); err != nil { h.log.Errorw("Failed to update scene", "error", err, "scene_id", sceneID) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"message": "Scene updated successfully"}) } func (h *SceneHandler) GenerateSceneImage(c *gin.Context) { var req services2.GenerateSceneImageRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request") return } imageGen, err := h.sceneService.GenerateSceneImage(&req) if err != nil { h.log.Errorw("Failed to generate scene image", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{ "message": "Scene image generation started", "image_generation": imageGen, }) } func (h *SceneHandler) UpdateScenePrompt(c *gin.Context) { sceneID := c.Param("scene_id") var req services2.UpdateScenePromptRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request") return } if err := h.sceneService.UpdateScenePrompt(sceneID, &req); err != nil { h.log.Errorw("Failed to update scene prompt", "error", err, "scene_id", sceneID) if err.Error() == "scene not found" { response.NotFound(c, "场景不存在") return } response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"message": "场景提示词已更新"}) } func (h *SceneHandler) DeleteScene(c *gin.Context) { sceneID := c.Param("scene_id") if err := h.sceneService.DeleteScene(sceneID); err != nil { h.log.Errorw("Failed to delete scene", "error", err, "scene_id", sceneID) if err.Error() == "scene not found" { response.NotFound(c, "场景不存在") return } response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"message": "场景已删除"}) } func (h *SceneHandler) CreateScene(c *gin.Context) { var req services2.CreateSceneRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request") return } if req.DramaID == 0 { response.BadRequest(c, "drama_id is required") return } scene, err := h.sceneService.CreateScene(&req) if err != nil { h.log.Errorw("Failed to create scene", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, scene) } ================================================ FILE: api/handlers/script_generation.go ================================================ package handlers import ( "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ScriptGenerationHandler struct { scriptService *services.ScriptGenerationService taskService *services.TaskService log *logger.Logger } func NewScriptGenerationHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationHandler { return &ScriptGenerationHandler{ scriptService: services.NewScriptGenerationService(db, cfg, log), taskService: services.NewTaskService(db, log), log: log, } } func (h *ScriptGenerationHandler) GenerateCharacters(c *gin.Context) { var req services.GenerateCharactersRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } // 直接调用服务层的异步方法,该方法会创建任务并返回任务ID taskID, err := h.scriptService.GenerateCharacters(&req) if err != nil { h.log.Errorw("Failed to generate characters", "error", err, "drama_id", req.DramaID) response.InternalError(c, err.Error()) return } // 立即返回任务ID response.Success(c, gin.H{ "task_id": taskID, "status": "pending", "message": "角色生成任务已创建,正在后台处理...", }) } ================================================ FILE: api/handlers/settings.go ================================================ package handlers import ( "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "github.com/spf13/viper" ) type SettingsHandler struct { config *config.Config log *logger.Logger } func NewSettingsHandler(cfg *config.Config, log *logger.Logger) *SettingsHandler { return &SettingsHandler{ config: cfg, log: log, } } // GetLanguage 获取当前系统语言 func (h *SettingsHandler) GetLanguage(c *gin.Context) { language := h.config.App.Language if language == "" { language = "zh" // 默认中文 } response.Success(c, gin.H{ "language": language, }) } // UpdateLanguage 更新系统语言 func (h *SettingsHandler) UpdateLanguage(c *gin.Context) { var req struct { Language string `json:"language" binding:"required,oneof=zh en"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "语言参数错误,只支持 zh 或 en") return } // 更新内存中的配置 h.config.App.Language = req.Language // 更新配置文件 viper.Set("app.language", req.Language) if err := viper.WriteConfig(); err != nil { h.log.Warnw("Failed to write config file", "error", err) // 即使写入文件失败,内存配置也已更新,仍然可用 } h.log.Infow("System language updated", "language", req.Language) message := "语言已切换为中文" if req.Language == "en" { message = "Language switched to English" } response.Success(c, gin.H{ "message": message, "language": req.Language, }) } ================================================ FILE: api/handlers/storyboard.go ================================================ package handlers import ( "strconv" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type StoryboardHandler struct { storyboardService *services.StoryboardService taskService *services.TaskService log *logger.Logger } func NewStoryboardHandler(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardHandler { return &StoryboardHandler{ storyboardService: services.NewStoryboardService(db, cfg, log), taskService: services.NewTaskService(db, log), log: log, } } // GenerateStoryboard 生成分镜头(异步) func (h *StoryboardHandler) GenerateStoryboard(c *gin.Context) { episodeID := c.Param("episode_id") // 接收可选的 model 参数 var req struct { Model string `json:"model"` } if err := c.ShouldBindJSON(&req); err != nil { // 如果没有提供body或者解析失败,使用空字符串(使用默认模型) req.Model = "" } // 调用生成服务,该服务已经是异步的,会返回任务ID taskID, err := h.storyboardService.GenerateStoryboard(episodeID, req.Model) if err != nil { h.log.Errorw("Failed to generate storyboard", "error", err, "episode_id", episodeID) response.InternalError(c, err.Error()) return } // 立即返回任务ID response.Success(c, gin.H{ "task_id": taskID, "status": "pending", "message": "分镜头生成任务已创建,正在后台处理...", }) } // UpdateStoryboard 更新分镜 func (h *StoryboardHandler) UpdateStoryboard(c *gin.Context) { storyboardID := c.Param("id") var req map[string]interface{} if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request body") return } err := h.storyboardService.UpdateStoryboard(storyboardID, req) if err != nil { h.log.Errorw("Failed to update storyboard", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"message": "Storyboard updated successfully"}) } // CreateStoryboard 创建分镜 func (h *StoryboardHandler) CreateStoryboard(c *gin.Context) { var req services.CreateStoryboardRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } sb, err := h.storyboardService.CreateStoryboard(&req) if err != nil { h.log.Errorw("Failed to create storyboard", "error", err) response.InternalError(c, err.Error()) return } response.Created(c, sb) } // DeleteStoryboard 删除分镜 func (h *StoryboardHandler) DeleteStoryboard(c *gin.Context) { storyboardIDStr := c.Param("id") storyboardID, err := strconv.ParseUint(storyboardIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid ID") return } if err := h.storyboardService.DeleteStoryboard(uint(storyboardID)); err != nil { h.log.Errorw("Failed to delete storyboard", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, nil) } ================================================ FILE: api/handlers/task.go ================================================ package handlers import ( "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type TaskHandler struct { taskService *services.TaskService log *logger.Logger } func NewTaskHandler(db *gorm.DB, log *logger.Logger) *TaskHandler { return &TaskHandler{ taskService: services.NewTaskService(db, log), log: log, } } // GetTaskStatus 获取任务状态 func (h *TaskHandler) GetTaskStatus(c *gin.Context) { taskID := c.Param("task_id") task, err := h.taskService.GetTask(taskID) if err != nil { if err == gorm.ErrRecordNotFound { response.NotFound(c, "任务不存在") return } h.log.Errorw("Failed to get task", "error", err, "task_id", taskID) response.InternalError(c, err.Error()) return } response.Success(c, task) } // GetResourceTasks 获取资源相关的所有任务 func (h *TaskHandler) GetResourceTasks(c *gin.Context) { resourceID := c.Query("resource_id") if resourceID == "" { response.BadRequest(c, "缺少resource_id参数") return } tasks, err := h.taskService.GetTasksByResource(resourceID) if err != nil { h.log.Errorw("Failed to get resource tasks", "error", err, "resource_id", resourceID) response.InternalError(c, err.Error()) return } response.Success(c, tasks) } ================================================ FILE: api/handlers/upload.go ================================================ package handlers import ( services2 "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" ) type UploadHandler struct { uploadService *services2.UploadService characterLibraryService *services2.CharacterLibraryService log *logger.Logger } func NewUploadHandler(cfg *config.Config, log *logger.Logger, characterLibraryService *services2.CharacterLibraryService) (*UploadHandler, error) { uploadService, err := services2.NewUploadService(cfg, log) if err != nil { return nil, err } return &UploadHandler{ uploadService: uploadService, characterLibraryService: characterLibraryService, log: log, }, nil } // UploadImage 上传图片 func (h *UploadHandler) UploadImage(c *gin.Context) { // 获取上传的文件 file, header, err := c.Request.FormFile("file") if err != nil { response.BadRequest(c, "请选择文件") return } defer file.Close() // 检查文件类型 contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } // 验证是图片类型 allowedTypes := map[string]bool{ "image/jpeg": true, "image/jpg": true, "image/png": true, "image/gif": true, "image/webp": true, } if !allowedTypes[contentType] { response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)") return } // 检查文件大小 (10MB) if header.Size > 10*1024*1024 { response.BadRequest(c, "文件大小不能超过10MB") return } // 上传到本地存储 result, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType) if err != nil { h.log.Errorw("Failed to upload image", "error", err) response.InternalError(c, "上传失败") return } response.Success(c, gin.H{ "url": result.URL, "local_path": result.LocalPath, "filename": header.Filename, "size": header.Size, }) } // UploadCharacterImage 上传角色图片(带角色ID) func (h *UploadHandler) UploadCharacterImage(c *gin.Context) { characterID := c.Param("id") // 获取上传的文件 file, header, err := c.Request.FormFile("file") if err != nil { response.BadRequest(c, "请选择文件") return } defer file.Close() // 检查文件类型 contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } // 验证是图片类型 allowedTypes := map[string]bool{ "image/jpeg": true, "image/jpg": true, "image/png": true, "image/gif": true, "image/webp": true, } if !allowedTypes[contentType] { response.BadRequest(c, "只支持图片格式 (jpg, png, gif, webp)") return } // 检查文件大小 (10MB) if header.Size > 10*1024*1024 { response.BadRequest(c, "文件大小不能超过10MB") return } // 上传到本地存储 result, err := h.uploadService.UploadCharacterImage(file, header.Filename, contentType) if err != nil { h.log.Errorw("Failed to upload character image", "error", err) response.InternalError(c, "上传失败") return } // 更新角色的image_url字段到数据库 err = h.characterLibraryService.UploadCharacterImage(characterID, result.URL) if err != nil { h.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID) response.InternalError(c, "更新角色图片失败") return } h.log.Infow("Character image uploaded and saved", "character_id", characterID, "url", result.URL, "local_path", result.LocalPath) response.Success(c, gin.H{ "url": result.URL, "local_path": result.LocalPath, "filename": header.Filename, "size": header.Size, }) } ================================================ FILE: api/handlers/video_generation.go ================================================ package handlers import ( "strconv" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type VideoGenerationHandler struct { videoService *services.VideoGenerationService log *logger.Logger } func NewVideoGenerationHandler(db *gorm.DB, transferService *services.ResourceTransferService, localStorage *storage.LocalStorage, aiService *services.AIService, log *logger.Logger, promptI18n *services.PromptI18n) *VideoGenerationHandler { return &VideoGenerationHandler{ videoService: services.NewVideoGenerationService(db, transferService, localStorage, aiService, log, promptI18n), log: log, } } func (h *VideoGenerationHandler) GenerateVideo(c *gin.Context) { var req services.GenerateVideoRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err.Error()) return } videoGen, err := h.videoService.GenerateVideo(&req) if err != nil { h.log.Errorw("Failed to generate video", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, videoGen) } func (h *VideoGenerationHandler) GenerateVideoFromImage(c *gin.Context) { imageGenID, err := strconv.ParseUint(c.Param("image_gen_id"), 10, 32) if err != nil { response.BadRequest(c, "无效的图片ID") return } videoGen, err := h.videoService.GenerateVideoFromImage(uint(imageGenID)) if err != nil { h.log.Errorw("Failed to generate video from image", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, videoGen) } func (h *VideoGenerationHandler) BatchGenerateForEpisode(c *gin.Context) { episodeID := c.Param("episode_id") videos, err := h.videoService.BatchGenerateVideosForEpisode(episodeID) if err != nil { h.log.Errorw("Failed to batch generate videos", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, videos) } func (h *VideoGenerationHandler) GetVideoGeneration(c *gin.Context) { videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } videoGen, err := h.videoService.GetVideoGeneration(uint(videoGenID)) if err != nil { response.NotFound(c, "视频生成记录不存在") return } response.Success(c, videoGen) } func (h *VideoGenerationHandler) ListVideoGenerations(c *gin.Context) { var storyboardID *uint // 优先使用storyboard_id参数 if storyboardIDStr := c.Query("storyboard_id"); storyboardIDStr != "" { id, err := strconv.ParseUint(storyboardIDStr, 10, 32) if err == nil { uid := uint(id) storyboardID = &uid } } status := c.Query("status") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } var dramaIDUint *uint if dramaIDStr := c.Query("drama_id"); dramaIDStr != "" { did, _ := strconv.ParseUint(dramaIDStr, 10, 32) didUint := uint(did) dramaIDUint = &didUint } // 计算offset:(page - 1) * pageSize offset := (page - 1) * pageSize videos, total, err := h.videoService.ListVideoGenerations(dramaIDUint, storyboardID, status, pageSize, offset) if err != nil { h.log.Errorw("Failed to list videos", "error", err) response.InternalError(c, err.Error()) return } response.SuccessWithPagination(c, videos, total, page, pageSize) } func (h *VideoGenerationHandler) DeleteVideoGeneration(c *gin.Context) { videoGenID, err := strconv.ParseUint(c.Param("id"), 10, 32) if err != nil { response.BadRequest(c, "无效的ID") return } if err := h.videoService.DeleteVideoGeneration(uint(videoGenID)); err != nil { h.log.Errorw("Failed to delete video", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, nil) } ================================================ FILE: api/handlers/video_merge.go ================================================ package handlers import ( "strconv" services2 "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type VideoMergeHandler struct { mergeService *services2.VideoMergeService log *logger.Logger } func NewVideoMergeHandler(db *gorm.DB, transferService *services2.ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeHandler { return &VideoMergeHandler{ mergeService: services2.NewVideoMergeService(db, transferService, storagePath, baseURL, log), log: log, } } func (h *VideoMergeHandler) MergeVideos(c *gin.Context) { var req services2.MergeVideoRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request") return } merge, err := h.mergeService.MergeVideos(&req) if err != nil { h.log.Errorw("Failed to merge videos", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{ "message": "Video merge task created", "merge": merge, }) } func (h *VideoMergeHandler) GetMerge(c *gin.Context) { mergeIDStr := c.Param("merge_id") mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid merge ID") return } merge, err := h.mergeService.GetMerge(uint(mergeID)) if err != nil { h.log.Errorw("Failed to get merge", "error", err) response.NotFound(c, "Merge not found") return } response.Success(c, gin.H{"merge": merge}) } func (h *VideoMergeHandler) ListMerges(c *gin.Context) { episodeID := c.Query("episode_id") status := c.Query("status") page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) var episodeIDPtr *string if episodeID != "" { episodeIDPtr = &episodeID } merges, total, err := h.mergeService.ListMerges(episodeIDPtr, status, page, pageSize) if err != nil { h.log.Errorw("Failed to list merges", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{ "merges": merges, "total": total, "page": page, "page_size": pageSize, }) } func (h *VideoMergeHandler) DeleteMerge(c *gin.Context) { mergeIDStr := c.Param("merge_id") mergeID, err := strconv.ParseUint(mergeIDStr, 10, 32) if err != nil { response.BadRequest(c, "Invalid merge ID") return } if err := h.mergeService.DeleteMerge(uint(mergeID)); err != nil { h.log.Errorw("Failed to delete merge", "error", err) response.InternalError(c, err.Error()) return } response.Success(c, gin.H{"message": "Merge deleted successfully"}) } ================================================ FILE: api/middlewares/cors.go ================================================ package middlewares import ( "github.com/gin-gonic/gin" ) func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc { return func(c *gin.Context) { origin := c.Request.Header.Get("Origin") path := c.Request.URL.Path // 检查是否是静态文件路径(/static 或 /assets) isStaticPath := len(path) >= 7 && (path[:7] == "/static" || path[:7] == "/assets") allowed := false for _, o := range allowedOrigins { if o == "*" || o == origin { allowed = true break } } // 对于静态文件,如果有 Origin 头,总是允许跨域访问 if isStaticPath && origin != "" { allowed = true } if allowed && origin != "" { c.Writer.Header().Set("Access-Control-Allow-Origin", origin) } else if allowed && origin == "" { // 如果没有 Origin 头但是允许的请求,设置为 * c.Writer.Header().Set("Access-Control-Allow-Origin", "*") } c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() } } ================================================ FILE: api/middlewares/logger.go ================================================ package middlewares import ( "time" "github.com/drama-generator/backend/pkg/logger" "github.com/gin-gonic/gin" ) func LoggerMiddleware(log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery c.Next() duration := time.Since(start) log.Infow("HTTP Request", "method", c.Request.Method, "path", path, "query", query, "status", c.Writer.Status(), "duration", duration.Milliseconds(), "ip", c.ClientIP(), "user_agent", c.Request.UserAgent(), ) } } ================================================ FILE: api/middlewares/ratelimit.go ================================================ package middlewares import ( "sync" "time" "github.com/drama-generator/backend/pkg/response" "github.com/gin-gonic/gin" ) type rateLimiter struct { mu sync.Mutex requests map[string][]time.Time limit int window time.Duration } var limiter = &rateLimiter{ requests: make(map[string][]time.Time), limit: 2000, // 每分钟最多 2000 次请求 window: time.Minute, } func RateLimitMiddleware() gin.HandlerFunc { return func(c *gin.Context) { ip := c.ClientIP() limiter.mu.Lock() defer limiter.mu.Unlock() now := time.Now() requests := limiter.requests[ip] var validRequests []time.Time for _, t := range requests { if now.Sub(t) < limiter.window { validRequests = append(validRequests, t) } } if len(validRequests) >= limiter.limit { response.Error(c, 429, "RATE_LIMIT_EXCEEDED", "请求过于频繁,请稍后再试") c.Abort() return } validRequests = append(validRequests, now) limiter.requests[ip] = validRequests c.Next() } } ================================================ FILE: api/routes/routes.go ================================================ package routes import ( handlers2 "github.com/drama-generator/backend/api/handlers" middlewares2 "github.com/drama-generator/backend/api/middlewares" services2 "github.com/drama-generator/backend/application/services" storage2 "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/gin-gonic/gin" "gorm.io/gorm" ) func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStorage interface{}) *gin.Engine { r := gin.New() r.Use(gin.Recovery()) r.Use(middlewares2.LoggerMiddleware(log)) r.Use(middlewares2.CORSMiddleware(cfg.Server.CORSOrigins)) // 静态文件服务(用户上传的文件) r.Static("/static", cfg.Storage.LocalPath) r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "app": cfg.App.Name, "version": cfg.App.Version, }) }) aiService := services2.NewAIService(db, log) localStoragePtr := localStorage.(*storage2.LocalStorage) transferService := services2.NewResourceTransferService(db, log) promptI18n := services2.NewPromptI18n(cfg) dramaHandler := handlers2.NewDramaHandler(db, cfg, log, nil) aiConfigHandler := handlers2.NewAIConfigHandler(db, cfg, log) scriptGenHandler := handlers2.NewScriptGenerationHandler(db, cfg, log) imageGenService := services2.NewImageGenerationService(db, cfg, transferService, localStoragePtr, log) imageGenHandler := handlers2.NewImageGenerationHandler(db, cfg, log, transferService, localStoragePtr) videoGenHandler := handlers2.NewVideoGenerationHandler(db, transferService, localStoragePtr, aiService, log, promptI18n) videoMergeHandler := handlers2.NewVideoMergeHandler(db, nil, cfg.Storage.LocalPath, cfg.Storage.BaseURL, log) assetHandler := handlers2.NewAssetHandler(db, cfg, log) characterLibraryService := services2.NewCharacterLibraryService(db, log, cfg) characterLibraryHandler := handlers2.NewCharacterLibraryHandler(db, cfg, log, transferService, localStoragePtr) uploadHandler, err := handlers2.NewUploadHandler(cfg, log, characterLibraryService) if err != nil { log.Fatalw("Failed to create upload handler", "error", err) } storyboardHandler := handlers2.NewStoryboardHandler(db, cfg, log) sceneHandler := handlers2.NewSceneHandler(db, log, imageGenService) taskHandler := handlers2.NewTaskHandler(db, log) framePromptService := services2.NewFramePromptService(db, cfg, log) framePromptHandler := handlers2.NewFramePromptHandler(framePromptService, log) audioExtractionHandler := handlers2.NewAudioExtractionHandler(log, cfg.Storage.LocalPath) settingsHandler := handlers2.NewSettingsHandler(cfg, log) propHandler := handlers2.NewPropHandler(db, cfg, log, aiService, imageGenService) api := r.Group("/api/v1") { api.Use(middlewares2.RateLimitMiddleware()) dramas := api.Group("/dramas") { dramas.GET("", dramaHandler.ListDramas) dramas.POST("", dramaHandler.CreateDrama) dramas.GET("/stats", dramaHandler.GetDramaStats) // 统计接口放在/:id之前 dramas.GET("/:id", dramaHandler.GetDrama) dramas.PUT("/:id", dramaHandler.UpdateDrama) dramas.DELETE("/:id", dramaHandler.DeleteDrama) dramas.PUT("/:id/outline", dramaHandler.SaveOutline) dramas.GET("/:id/characters", dramaHandler.GetCharacters) dramas.PUT("/:id/characters", dramaHandler.SaveCharacters) dramas.PUT("/:id/episodes", dramaHandler.SaveEpisodes) dramas.PUT("/:id/progress", dramaHandler.SaveProgress) dramas.GET("/:id/props", propHandler.ListProps) // Added prop list route } aiConfigs := api.Group("/ai-configs") { aiConfigs.GET("", aiConfigHandler.ListConfigs) aiConfigs.POST("", aiConfigHandler.CreateConfig) aiConfigs.POST("/test", aiConfigHandler.TestConnection) aiConfigs.GET("/:id", aiConfigHandler.GetConfig) aiConfigs.PUT("/:id", aiConfigHandler.UpdateConfig) aiConfigs.DELETE("/:id", aiConfigHandler.DeleteConfig) } generation := api.Group("/generation") { generation.POST("/characters", scriptGenHandler.GenerateCharacters) } // 角色库路由 characterLibrary := api.Group("/character-library") { characterLibrary.GET("", characterLibraryHandler.ListLibraryItems) characterLibrary.POST("", characterLibraryHandler.CreateLibraryItem) characterLibrary.GET("/:id", characterLibraryHandler.GetLibraryItem) characterLibrary.DELETE("/:id", characterLibraryHandler.DeleteLibraryItem) } // 角色图片相关路由 characters := api.Group("/characters") { characters.PUT("/:id", characterLibraryHandler.UpdateCharacter) characters.DELETE("/:id", characterLibraryHandler.DeleteCharacter) characters.POST("/batch-generate-images", characterLibraryHandler.BatchGenerateCharacterImages) characters.POST("/:id/generate-image", characterLibraryHandler.GenerateCharacterImage) characters.POST("/:id/upload-image", uploadHandler.UploadCharacterImage) characters.PUT("/:id/image", characterLibraryHandler.UploadCharacterImage) characters.PUT("/:id/image-from-library", characterLibraryHandler.ApplyLibraryItemToCharacter) characters.POST("/:id/add-to-library", characterLibraryHandler.AddCharacterToLibrary) } props := api.Group("/props") { props.POST("", propHandler.CreateProp) props.PUT("/:id", propHandler.UpdateProp) props.DELETE("/:id", propHandler.DeleteProp) props.POST("/:id/generate", propHandler.GenerateImage) } // 文件上传路由 upload := api.Group("/upload") { upload.POST("/image", uploadHandler.UploadImage) } // 分镜头路由 episodes := api.Group("/episodes") { // 分镜头 episodes.POST("/:episode_id/storyboards", storyboardHandler.GenerateStoryboard) episodes.POST("/:episode_id/props/extract", propHandler.ExtractProps) episodes.POST("/:episode_id/characters/extract", characterLibraryHandler.ExtractCharacters) episodes.GET("/:episode_id/storyboards", sceneHandler.GetStoryboardsForEpisode) episodes.POST("/:episode_id/finalize", dramaHandler.FinalizeEpisode) episodes.GET("/:episode_id/download", dramaHandler.DownloadEpisodeVideo) } // 任务路由 tasks := api.Group("/tasks") { tasks.GET("/:task_id", taskHandler.GetTaskStatus) tasks.GET("", taskHandler.GetResourceTasks) } // 场景路由 scenes := api.Group("/scenes") { scenes.PUT("/:scene_id", sceneHandler.UpdateScene) scenes.PUT("/:scene_id/prompt", sceneHandler.UpdateScenePrompt) scenes.DELETE("/:scene_id", sceneHandler.DeleteScene) scenes.POST("/generate-image", sceneHandler.GenerateSceneImage) scenes.POST("", sceneHandler.CreateScene) } images := api.Group("/images") { images.GET("", imageGenHandler.ListImageGenerations) images.POST("", imageGenHandler.GenerateImage) images.GET("/:id", imageGenHandler.GetImageGeneration) images.DELETE("/:id", imageGenHandler.DeleteImageGeneration) images.POST("/scene/:scene_id", imageGenHandler.GenerateImagesForScene) images.POST("/upload", imageGenHandler.UploadImage) images.GET("/episode/:episode_id/backgrounds", imageGenHandler.GetBackgroundsForEpisode) images.POST("/episode/:episode_id/backgrounds/extract", imageGenHandler.ExtractBackgroundsForEpisode) images.POST("/episode/:episode_id/batch", imageGenHandler.BatchGenerateForEpisode) } videos := api.Group("/videos") { videos.GET("", videoGenHandler.ListVideoGenerations) videos.POST("", videoGenHandler.GenerateVideo) videos.GET("/:id", videoGenHandler.GetVideoGeneration) videos.DELETE("/:id", videoGenHandler.DeleteVideoGeneration) videos.POST("/image/:image_gen_id", videoGenHandler.GenerateVideoFromImage) videos.POST("/episode/:episode_id/batch", videoGenHandler.BatchGenerateForEpisode) } videoMerges := api.Group("/video-merges") { videoMerges.GET("", videoMergeHandler.ListMerges) videoMerges.POST("", videoMergeHandler.MergeVideos) videoMerges.GET("/:merge_id", videoMergeHandler.GetMerge) videoMerges.DELETE("/:merge_id", videoMergeHandler.DeleteMerge) } assets := api.Group("/assets") { assets.GET("", assetHandler.ListAssets) assets.POST("", assetHandler.CreateAsset) assets.GET("/:id", assetHandler.GetAsset) assets.PUT("/:id", assetHandler.UpdateAsset) assets.DELETE("/:id", assetHandler.DeleteAsset) assets.POST("/import/image/:image_gen_id", assetHandler.ImportFromImageGen) assets.POST("/import/video/:video_gen_id", assetHandler.ImportFromVideoGen) } storyboards := api.Group("/storyboards") { storyboards.GET("/episode/:episode_id/generate", storyboardHandler.GenerateStoryboard) storyboards.POST("", storyboardHandler.CreateStoryboard) storyboards.PUT("/:id", storyboardHandler.UpdateStoryboard) storyboards.DELETE("/:id", storyboardHandler.DeleteStoryboard) storyboards.POST("/:id/props", propHandler.AssociateProps) storyboards.POST("/:id/frame-prompt", framePromptHandler.GenerateFramePrompt) storyboards.GET("/:id/frame-prompts", handlers2.GetStoryboardFramePrompts(db, log)) } audio := api.Group("/audio") { audio.POST("/extract", audioExtractionHandler.ExtractAudio) audio.POST("/extract/batch", audioExtractionHandler.BatchExtractAudio) } settings := api.Group("/settings") { settings.GET("/language", settingsHandler.GetLanguage) settings.PUT("/language", settingsHandler.UpdateLanguage) } } // 前端静态文件服务(放在API路由之后,避免冲突) // 服务前端构建产物 r.Static("/assets", "./web/dist/assets") r.StaticFile("/favicon.ico", "./web/dist/favicon.ico") // NoRoute处理:对于所有未匹配的路由 r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // 如果是API路径,返回404 if len(path) >= 4 && path[:4] == "/api" { c.JSON(404, gin.H{"error": "API endpoint not found"}) return } // SPA fallback - 返回index.html c.File("./web/dist/index.html") }) return r } ================================================ FILE: application/services/ai_service.go ================================================ package services import ( "errors" "fmt" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type AIService struct { db *gorm.DB log *logger.Logger } func NewAIService(db *gorm.DB, log *logger.Logger) *AIService { return &AIService{ db: db, log: log, } } type CreateAIConfigRequest struct { ServiceType string `json:"service_type" binding:"required,oneof=text image video"` Name string `json:"name" binding:"required,min=1,max=100"` Provider string `json:"provider" binding:"required"` BaseURL string `json:"base_url" binding:"required,url"` APIKey string `json:"api_key" binding:"required"` Model models.ModelField `json:"model" binding:"required"` Endpoint string `json:"endpoint"` QueryEndpoint string `json:"query_endpoint"` Priority int `json:"priority"` IsDefault bool `json:"is_default"` Settings string `json:"settings"` } type UpdateAIConfigRequest struct { Name string `json:"name" binding:"omitempty,min=1,max=100"` Provider string `json:"provider"` BaseURL string `json:"base_url" binding:"omitempty,url"` APIKey string `json:"api_key"` Model *models.ModelField `json:"model"` Endpoint string `json:"endpoint"` QueryEndpoint string `json:"query_endpoint"` Priority *int `json:"priority"` IsDefault bool `json:"is_default"` IsActive bool `json:"is_active"` Settings string `json:"settings"` } type TestConnectionRequest struct { BaseURL string `json:"base_url" binding:"required,url"` APIKey string `json:"api_key" binding:"required"` Model models.ModelField `json:"model" binding:"required"` Provider string `json:"provider"` Endpoint string `json:"endpoint"` } func (s *AIService) CreateConfig(req *CreateAIConfigRequest) (*models.AIServiceConfig, error) { // 根据 provider 和 service_type 自动设置 endpoint endpoint := req.Endpoint queryEndpoint := req.QueryEndpoint if endpoint == "" { switch req.Provider { case "gemini", "google": if req.ServiceType == "text" { endpoint = "/v1beta/models/{model}:generateContent" } else if req.ServiceType == "image" { endpoint = "/v1beta/models/{model}:generateContent" } case "openai": if req.ServiceType == "text" { endpoint = "/chat/completions" } else if req.ServiceType == "image" { endpoint = "/images/generations" } else if req.ServiceType == "video" { endpoint = "/videos" if queryEndpoint == "" { queryEndpoint = "/videos/{taskId}" } } case "chatfire": if req.ServiceType == "text" { endpoint = "/chat/completions" } else if req.ServiceType == "image" { endpoint = "/images/generations" } else if req.ServiceType == "video" { endpoint = "/video/generations" if queryEndpoint == "" { queryEndpoint = "/video/task/{taskId}" } } case "doubao", "volcengine", "volces": if req.ServiceType == "video" { endpoint = "/contents/generations/tasks" if queryEndpoint == "" { queryEndpoint = "/generations/tasks/{taskId}" } } default: // 默认使用 OpenAI 格式 if req.ServiceType == "text" { endpoint = "/chat/completions" } else if req.ServiceType == "image" { endpoint = "/images/generations" } } } config := &models.AIServiceConfig{ ServiceType: req.ServiceType, Name: req.Name, Provider: req.Provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model, Endpoint: endpoint, QueryEndpoint: queryEndpoint, Priority: req.Priority, IsDefault: req.IsDefault, IsActive: true, Settings: req.Settings, } if err := s.db.Create(config).Error; err != nil { s.log.Errorw("Failed to create AI config", "error", err) return nil, err } s.log.Infow("AI config created", "config_id", config.ID, "provider", req.Provider, "endpoint", endpoint) return config, nil } func (s *AIService) GetConfig(configID uint) (*models.AIServiceConfig, error) { var config models.AIServiceConfig err := s.db.Where("id = ? ", configID).First(&config).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("config not found") } return nil, err } return &config, nil } func (s *AIService) ListConfigs(serviceType string) ([]models.AIServiceConfig, error) { var configs []models.AIServiceConfig query := s.db if serviceType != "" { query = query.Where("service_type = ?", serviceType) } err := query.Order("priority DESC, created_at DESC").Find(&configs).Error if err != nil { s.log.Errorw("Failed to list AI configs", "error", err) return nil, err } return configs, nil } func (s *AIService) UpdateConfig(configID uint, req *UpdateAIConfigRequest) (*models.AIServiceConfig, error) { var config models.AIServiceConfig if err := s.db.Where("id = ? ", configID).First(&config).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("config not found") } return nil, err } tx := s.db.Begin() // 不再需要is_default独占逻辑 updates := make(map[string]interface{}) if req.Name != "" { updates["name"] = req.Name } if req.Provider != "" { updates["provider"] = req.Provider } if req.BaseURL != "" { updates["base_url"] = req.BaseURL } if req.APIKey != "" { updates["api_key"] = req.APIKey } if req.Model != nil && len(*req.Model) > 0 { updates["model"] = *req.Model } if req.Priority != nil { updates["priority"] = *req.Priority } // 如果提供了 provider,根据 provider 和 service_type 自动设置 endpoint if req.Provider != "" && req.Endpoint == "" { provider := req.Provider serviceType := config.ServiceType switch provider { case "gemini", "google": if serviceType == "text" || serviceType == "image" { updates["endpoint"] = "/v1beta/models/{model}:generateContent" } case "openai": if serviceType == "text" { updates["endpoint"] = "/chat/completions" } else if serviceType == "image" { updates["endpoint"] = "/images/generations" } else if serviceType == "video" { updates["endpoint"] = "/videos" updates["query_endpoint"] = "/videos/{taskId}" } case "chatfire": if serviceType == "text" { updates["endpoint"] = "/chat/completions" } else if serviceType == "image" { updates["endpoint"] = "/images/generations" } else if serviceType == "video" { updates["endpoint"] = "/video/generations" updates["query_endpoint"] = "/video/task/{taskId}" } } } else if req.Endpoint != "" { updates["endpoint"] = req.Endpoint } // 允许清空query_endpoint,所以不检查是否为空 updates["query_endpoint"] = req.QueryEndpoint if req.Settings != "" { updates["settings"] = req.Settings } updates["is_default"] = req.IsDefault updates["is_active"] = req.IsActive if err := tx.Model(&config).Updates(updates).Error; err != nil { tx.Rollback() s.log.Errorw("Failed to update AI config", "error", err) return nil, err } if err := tx.Commit().Error; err != nil { return nil, err } s.log.Infow("AI config updated", "config_id", configID) return &config, nil } func (s *AIService) DeleteConfig(configID uint) error { result := s.db.Where("id = ? ", configID).Delete(&models.AIServiceConfig{}) if result.Error != nil { s.log.Errorw("Failed to delete AI config", "error", result.Error) return result.Error } if result.RowsAffected == 0 { return errors.New("config not found") } s.log.Infow("AI config deleted", "config_id", configID) return nil } func (s *AIService) TestConnection(req *TestConnectionRequest) error { s.log.Infow("TestConnection called", "baseURL", req.BaseURL, "provider", req.Provider, "endpoint", req.Endpoint, "modelCount", len(req.Model)) // 使用第一个模型进行测试 model := "" if len(req.Model) > 0 { model = req.Model[0] } s.log.Infow("Using model for test", "model", model, "provider", req.Provider) // 根据 provider 参数选择客户端 var client ai.AIClient var endpoint string switch req.Provider { case "gemini", "google": // Gemini s.log.Infow("Using Gemini client", "baseURL", req.BaseURL) endpoint = "/v1beta/models/{model}:generateContent" client = ai.NewGeminiClient(req.BaseURL, req.APIKey, model, endpoint) case "openai", "chatfire": // OpenAI 格式(包括 chatfire 等) s.log.Infow("Using OpenAI-compatible client", "baseURL", req.BaseURL, "provider", req.Provider) endpoint = req.Endpoint if endpoint == "" { endpoint = "/chat/completions" } client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint) default: // 默认使用 OpenAI 格式 s.log.Infow("Using default OpenAI-compatible client", "baseURL", req.BaseURL) endpoint = req.Endpoint if endpoint == "" { endpoint = "/chat/completions" } client = ai.NewOpenAIClient(req.BaseURL, req.APIKey, model, endpoint) } s.log.Infow("Calling TestConnection on client", "endpoint", endpoint) err := client.TestConnection() if err != nil { s.log.Errorw("TestConnection failed", "error", err) } else { s.log.Infow("TestConnection succeeded") } return err } func (s *AIService) GetDefaultConfig(serviceType string) (*models.AIServiceConfig, error) { var config models.AIServiceConfig // 按优先级降序获取第一个激活的配置 err := s.db.Where("service_type = ? AND is_active = ?", serviceType, true). Order("priority DESC, created_at DESC"). First(&config).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("no active config found") } return nil, err } return &config, nil } // GetConfigForModel 根据服务类型和模型名称获取优先级最高的激活配置 func (s *AIService) GetConfigForModel(serviceType string, modelName string) (*models.AIServiceConfig, error) { var configs []models.AIServiceConfig err := s.db.Where("service_type = ? AND is_active = ?", serviceType, true). Order("priority DESC, created_at DESC"). Find(&configs).Error if err != nil { return nil, err } // 查找包含指定模型的配置 for _, config := range configs { for _, model := range config.Model { if model == modelName { return &config, nil } } } return nil, errors.New("no active config found for model: " + modelName) } func (s *AIService) GetAIClient(serviceType string) (ai.AIClient, error) { config, err := s.GetDefaultConfig(serviceType) if err != nil { return nil, err } // 使用第一个模型 model := "" if len(config.Model) > 0 { model = config.Model[0] } // 使用数据库配置中的 endpoint,如果为空则根据 provider 设置默认值 endpoint := config.Endpoint if endpoint == "" { switch config.Provider { case "gemini", "google": endpoint = "/v1beta/models/{model}:generateContent" default: endpoint = "/chat/completions" } } // 根据 provider 创建对应的客户端 switch config.Provider { case "gemini", "google": return ai.NewGeminiClient(config.BaseURL, config.APIKey, model, endpoint), nil default: // openai, chatfire 等其他厂商都使用 OpenAI 格式 return ai.NewOpenAIClient(config.BaseURL, config.APIKey, model, endpoint), nil } } // GetAIClientForModel 根据服务类型和模型名称获取对应的AI客户端 func (s *AIService) GetAIClientForModel(serviceType string, modelName string) (ai.AIClient, error) { config, err := s.GetConfigForModel(serviceType, modelName) if err != nil { return nil, err } // 使用数据库配置中的 endpoint,如果为空则根据 provider 设置默认值 endpoint := config.Endpoint if endpoint == "" { switch config.Provider { case "gemini", "google": endpoint = "/v1beta/models/{model}:generateContent" default: endpoint = "/chat/completions" } } // 根据 provider 创建对应的客户端 switch config.Provider { case "gemini", "google": return ai.NewGeminiClient(config.BaseURL, config.APIKey, modelName, endpoint), nil default: // openai, chatfire 等其他厂商都使用 OpenAI 格式 return ai.NewOpenAIClient(config.BaseURL, config.APIKey, modelName, endpoint), nil } } func (s *AIService) GenerateText(prompt string, systemPrompt string, options ...func(*ai.ChatCompletionRequest)) (string, error) { client, err := s.GetAIClient("text") if err != nil { return "", fmt.Errorf("failed to get AI client: %w", err) } return client.GenerateText(prompt, systemPrompt, options...) } func (s *AIService) GenerateImage(prompt string, size string, n int) ([]string, error) { client, err := s.GetAIClient("image") if err != nil { return nil, fmt.Errorf("failed to get AI client for image: %w", err) } return client.GenerateImage(prompt, size, n) } ================================================ FILE: application/services/asset_duration_update.go ================================================ package services import ( "fmt" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/storage" ) // UpdateAssetDurationFromFile 从本地文件探测并更新视频Asset的时长 func (s *AssetService) UpdateAssetDurationFromFile(assetID uint, localFilePath string) error { var asset models.Asset if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil { return fmt.Errorf("asset not found") } if asset.Type != models.AssetTypeVideo { return fmt.Errorf("asset is not a video") } if s.ffmpeg == nil { return fmt.Errorf("ffmpeg not available") } duration, err := s.ffmpeg.GetVideoDuration(localFilePath) if err != nil { return fmt.Errorf("failed to probe video duration: %w", err) } durationInt := int(duration + 0.5) if err := s.db.Model(&asset).Update("duration", durationInt).Error; err != nil { return fmt.Errorf("failed to update duration: %w", err) } s.log.Infow("Updated asset duration from file", "asset_id", assetID, "duration", durationInt, "file", localFilePath) return nil } // UpdateAssetDurationFromURL 下载视频并探测时长 func (s *AssetService) UpdateAssetDurationFromURL(assetID uint, localStorage *storage.LocalStorage) error { var asset models.Asset if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil { return fmt.Errorf("asset not found") } if asset.Type != models.AssetTypeVideo { return fmt.Errorf("asset is not a video") } if localStorage == nil { return fmt.Errorf("local storage not available") } // 下载视频到本地 localPath, err := localStorage.DownloadFromURL(asset.URL, "videos") if err != nil { return fmt.Errorf("failed to download video: %w", err) } // 探测时长 return s.UpdateAssetDurationFromFile(assetID, localPath) } ================================================ FILE: application/services/asset_service.go ================================================ package services import ( "fmt" "strconv" "strings" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/external/ffmpeg" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type AssetService struct { db *gorm.DB log *logger.Logger ffmpeg *ffmpeg.FFmpeg } func NewAssetService(db *gorm.DB, log *logger.Logger) *AssetService { return &AssetService{ db: db, log: log, ffmpeg: ffmpeg.NewFFmpeg(log), } } type CreateAssetRequest struct { DramaID *string `json:"drama_id"` Name string `json:"name" binding:"required"` Description *string `json:"description"` Type models.AssetType `json:"type" binding:"required"` Category *string `json:"category"` URL string `json:"url" binding:"required"` ThumbnailURL *string `json:"thumbnail_url"` LocalPath *string `json:"local_path"` FileSize *int64 `json:"file_size"` MimeType *string `json:"mime_type"` Width *int `json:"width"` Height *int `json:"height"` Duration *int `json:"duration"` Format *string `json:"format"` ImageGenID *uint `json:"image_gen_id"` VideoGenID *uint `json:"video_gen_id"` TagIDs []uint `json:"tag_ids"` } type UpdateAssetRequest struct { Name *string `json:"name"` Description *string `json:"description"` Category *string `json:"category"` ThumbnailURL *string `json:"thumbnail_url"` TagIDs []uint `json:"tag_ids"` IsFavorite *bool `json:"is_favorite"` } type ListAssetsRequest struct { DramaID *string `json:"drama_id"` EpisodeID *uint `json:"episode_id"` StoryboardID *uint `json:"storyboard_id"` Type *models.AssetType `json:"type"` Category string `json:"category"` TagIDs []uint `json:"tag_ids"` IsFavorite *bool `json:"is_favorite"` Search string `json:"search"` Page int `json:"page"` PageSize int `json:"page_size"` } func (s *AssetService) CreateAsset(req *CreateAssetRequest) (*models.Asset, error) { var dramaID *uint if req.DramaID != nil && *req.DramaID != "" { id, err := strconv.ParseUint(*req.DramaID, 10, 32) if err == nil { uid := uint(id) dramaID = &uid } } if dramaID != nil { var drama models.Drama if err := s.db.Where("id = ?", *dramaID).First(&drama).Error; err != nil { return nil, fmt.Errorf("drama not found") } } asset := &models.Asset{ DramaID: dramaID, Name: req.Name, Description: req.Description, Type: req.Type, Category: req.Category, URL: req.URL, ThumbnailURL: req.ThumbnailURL, LocalPath: req.LocalPath, FileSize: req.FileSize, MimeType: req.MimeType, Width: req.Width, Height: req.Height, Duration: req.Duration, Format: req.Format, ImageGenID: req.ImageGenID, VideoGenID: req.VideoGenID, } if err := s.db.Create(asset).Error; err != nil { return nil, fmt.Errorf("failed to create asset: %w", err) } return asset, nil } func (s *AssetService) UpdateAsset(assetID uint, req *UpdateAssetRequest) (*models.Asset, error) { var asset models.Asset if err := s.db.Where("id = ?", assetID).First(&asset).Error; err != nil { return nil, fmt.Errorf("asset not found") } updates := make(map[string]interface{}) if req.Name != nil { updates["name"] = *req.Name } if req.Description != nil { updates["description"] = *req.Description } if req.Category != nil { updates["category"] = *req.Category } if req.ThumbnailURL != nil { updates["thumbnail_url"] = *req.ThumbnailURL } if req.IsFavorite != nil { updates["is_favorite"] = *req.IsFavorite } if len(updates) > 0 { if err := s.db.Model(&asset).Updates(updates).Error; err != nil { return nil, fmt.Errorf("failed to update asset: %w", err) } } if err := s.db.First(&asset, assetID).Error; err != nil { return nil, err } return &asset, nil } func (s *AssetService) GetAsset(assetID uint) (*models.Asset, error) { var asset models.Asset if err := s.db.Where("id = ? ", assetID).First(&asset).Error; err != nil { return nil, err } s.db.Model(&asset).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)) return &asset, nil } func (s *AssetService) ListAssets(req *ListAssetsRequest) ([]models.Asset, int64, error) { query := s.db.Model(&models.Asset{}) if req.DramaID != nil { var dramaID uint64 dramaID, _ = strconv.ParseUint(*req.DramaID, 10, 32) query = query.Where("drama_id = ?", uint(dramaID)) } if req.EpisodeID != nil { query = query.Where("episode_id = ?", *req.EpisodeID) } if req.StoryboardID != nil { query = query.Where("storyboard_id = ?", *req.StoryboardID) } if req.Type != nil { query = query.Where("type = ?", *req.Type) } if req.Category != "" { query = query.Where("category = ?", req.Category) } if req.IsFavorite != nil { query = query.Where("is_favorite = ?", *req.IsFavorite) } if req.Search != "" { searchTerm := "%" + strings.ToLower(req.Search) + "%" query = query.Where("LOWER(name) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var assets []models.Asset offset := (req.Page - 1) * req.PageSize if err := query.Order("created_at DESC"). Offset(offset).Limit(req.PageSize).Find(&assets).Error; err != nil { return nil, 0, err } return assets, total, nil } func (s *AssetService) DeleteAsset(assetID uint) error { result := s.db.Where("id = ?", assetID).Delete(&models.Asset{}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return fmt.Errorf("asset not found") } return nil } func (s *AssetService) ImportFromImageGen(imageGenID uint) (*models.Asset, error) { var imageGen models.ImageGeneration if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil { return nil, fmt.Errorf("image generation not found") } if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil { return nil, fmt.Errorf("image is not ready") } dramaID := imageGen.DramaID asset := &models.Asset{ Name: fmt.Sprintf("Image_%d", imageGen.ID), Type: models.AssetTypeImage, URL: *imageGen.ImageURL, DramaID: &dramaID, ImageGenID: &imageGenID, Width: imageGen.Width, Height: imageGen.Height, } if err := s.db.Create(asset).Error; err != nil { return nil, fmt.Errorf("failed to create asset: %w", err) } return asset, nil } func (s *AssetService) ImportFromVideoGen(videoGenID uint) (*models.Asset, error) { var videoGen models.VideoGeneration if err := s.db.Preload("Storyboard.Episode").Where("id = ? ", videoGenID).First(&videoGen).Error; err != nil { return nil, fmt.Errorf("video generation not found") } if videoGen.Status != models.VideoStatusCompleted || videoGen.VideoURL == nil { return nil, fmt.Errorf("video is not ready") } dramaID := videoGen.DramaID var episodeID *uint var storyboardNum *int if videoGen.Storyboard != nil { episodeID = &videoGen.Storyboard.Episode.ID storyboardNum = &videoGen.Storyboard.StoryboardNumber } asset := &models.Asset{ Name: fmt.Sprintf("Video_%d", videoGen.ID), Type: models.AssetTypeVideo, URL: *videoGen.VideoURL, LocalPath: videoGen.LocalPath, // 同步 local_path 到 assets 表 DramaID: &dramaID, EpisodeID: episodeID, StoryboardID: videoGen.StoryboardID, StoryboardNum: storyboardNum, VideoGenID: &videoGenID, Duration: videoGen.Duration, Width: videoGen.Width, Height: videoGen.Height, } if videoGen.FirstFrameURL != nil { asset.ThumbnailURL = videoGen.FirstFrameURL } if err := s.db.Create(asset).Error; err != nil { return nil, fmt.Errorf("failed to create asset: %w", err) } return asset, nil } ================================================ FILE: application/services/audio_extraction_service.go ================================================ package services import ( "fmt" "path/filepath" "time" "github.com/drama-generator/backend/infrastructure/external/ffmpeg" "github.com/drama-generator/backend/pkg/logger" ) type AudioExtractionService struct { ffmpeg *ffmpeg.FFmpeg log *logger.Logger } func NewAudioExtractionService(log *logger.Logger) *AudioExtractionService { return &AudioExtractionService{ ffmpeg: ffmpeg.NewFFmpeg(log), log: log, } } type ExtractAudioRequest struct { VideoURL string `json:"video_url" binding:"required"` } type ExtractAudioResponse struct { AudioURL string `json:"audio_url"` Duration float64 `json:"duration"` } // ExtractAudio 从视频URL提取音频并返回音频文件URL func (s *AudioExtractionService) ExtractAudio(videoURL string, dataDir string) (*ExtractAudioResponse, error) { s.log.Infow("Starting audio extraction", "video_url", videoURL) // 生成输出文件名 timestamp := time.Now().Unix() audioFileName := fmt.Sprintf("audio_%d.aac", timestamp) audioOutputPath := filepath.Join(dataDir, "audios", audioFileName) // 提取音频 extractedPath, err := s.ffmpeg.ExtractAudio(videoURL, audioOutputPath) if err != nil { s.log.Errorw("Failed to extract audio", "error", err, "video_url", videoURL) return nil, fmt.Errorf("failed to extract audio: %w", err) } // 获取音频时长(使用提取后的本地文件路径) duration, err := s.ffmpeg.GetVideoDuration(extractedPath) if err != nil { s.log.Errorw("Failed to get audio duration", "error", err, "path", extractedPath) return nil, fmt.Errorf("failed to get audio duration: %w", err) } if duration <= 0 { s.log.Errorw("Invalid audio duration", "duration", duration, "path", extractedPath) return nil, fmt.Errorf("invalid audio duration: %.2f", duration) } // 构建音频URL(相对于data目录) audioURL := fmt.Sprintf("/data/audios/%s", audioFileName) s.log.Infow("Audio extraction completed", "video_url", videoURL, "audio_url", audioURL, "duration", duration, "local_path", extractedPath) return &ExtractAudioResponse{ AudioURL: audioURL, Duration: duration, }, nil } // BatchExtractAudio 批量提取音频 func (s *AudioExtractionService) BatchExtractAudio(videoURLs []string, dataDir string) ([]*ExtractAudioResponse, error) { s.log.Infow("Starting batch audio extraction", "count", len(videoURLs)) results := make([]*ExtractAudioResponse, 0, len(videoURLs)) for i, videoURL := range videoURLs { s.log.Infow("Extracting audio", "index", i+1, "total", len(videoURLs), "video_url", videoURL) result, err := s.ExtractAudio(videoURL, dataDir) if err != nil { s.log.Errorw("Failed to extract audio in batch", "index", i, "video_url", videoURL, "error", err) // 继续处理其他视频,但记录错误 return nil, fmt.Errorf("failed to extract audio at index %d: %w", i, err) } results = append(results, result) } s.log.Infow("Batch audio extraction completed", "successful_count", len(results)) return results, nil } ================================================ FILE: application/services/character_library_service.go ================================================ package services import ( "errors" "fmt" "time" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "gorm.io/gorm" ) type CharacterLibraryService struct { db *gorm.DB log *logger.Logger config *config.Config aiService *AIService taskService *TaskService promptI18n *PromptI18n } func NewCharacterLibraryService(db *gorm.DB, log *logger.Logger, cfg *config.Config) *CharacterLibraryService { return &CharacterLibraryService{ db: db, log: log, config: cfg, aiService: NewAIService(db, log), taskService: NewTaskService(db, log), promptI18n: NewPromptI18n(cfg), } } type CreateLibraryItemRequest struct { Name string `json:"name" binding:"required,min=1,max=100"` Category *string `json:"category"` ImageURL string `json:"image_url" binding:"required"` LocalPath *string `json:"local_path"` Description *string `json:"description"` Tags *string `json:"tags"` SourceType string `json:"source_type"` } type CharacterLibraryQuery struct { Page int `form:"page,default=1"` PageSize int `form:"page_size,default=20"` Category string `form:"category"` SourceType string `form:"source_type"` Keyword string `form:"keyword"` } // ListLibraryItems 获取用户角色库列表 func (s *CharacterLibraryService) ListLibraryItems(query *CharacterLibraryQuery) ([]models.CharacterLibrary, int64, error) { var items []models.CharacterLibrary var total int64 db := s.db.Model(&models.CharacterLibrary{}) // 筛选条件 if query.Category != "" { db = db.Where("category = ?", query.Category) } if query.SourceType != "" { db = db.Where("source_type = ?", query.SourceType) } if query.Keyword != "" { db = db.Where("name LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") } // 获取总数 if err := db.Count(&total).Error; err != nil { s.log.Errorw("Failed to count character library", "error", err) return nil, 0, err } // 分页查询 offset := (query.Page - 1) * query.PageSize err := db.Order("created_at DESC"). Offset(offset). Limit(query.PageSize). Find(&items).Error if err != nil { s.log.Errorw("Failed to list character library", "error", err) return nil, 0, err } return items, total, nil } // CreateLibraryItem 添加到角色库 func (s *CharacterLibraryService) CreateLibraryItem(req *CreateLibraryItemRequest) (*models.CharacterLibrary, error) { sourceType := req.SourceType if sourceType == "" { sourceType = "generated" } item := &models.CharacterLibrary{ Name: req.Name, Category: req.Category, ImageURL: req.ImageURL, LocalPath: req.LocalPath, Description: req.Description, Tags: req.Tags, SourceType: sourceType, } if err := s.db.Create(item).Error; err != nil { s.log.Errorw("Failed to create library item", "error", err) return nil, err } s.log.Infow("Library item created", "item_id", item.ID) return item, nil } // GetLibraryItem 获取角色库项 func (s *CharacterLibraryService) GetLibraryItem(itemID string) (*models.CharacterLibrary, error) { var item models.CharacterLibrary err := s.db.Where("id = ? ", itemID).First(&item).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("library item not found") } s.log.Errorw("Failed to get library item", "error", err) return nil, err } return &item, nil } // DeleteLibraryItem 删除角色库项 func (s *CharacterLibraryService) DeleteLibraryItem(itemID string) error { result := s.db.Where("id = ? ", itemID).Delete(&models.CharacterLibrary{}) if result.Error != nil { s.log.Errorw("Failed to delete library item", "error", result.Error) return result.Error } if result.RowsAffected == 0 { return errors.New("library item not found") } s.log.Infow("Library item deleted", "item_id", itemID) return nil } // ApplyLibraryItemToCharacter 将角色库形象应用到角色 func (s *CharacterLibraryService) ApplyLibraryItemToCharacter(characterID string, libraryItemID string) error { // 验证角色库项存在且属于该用户 var libraryItem models.CharacterLibrary if err := s.db.Where("id = ? ", libraryItemID).First(&libraryItem).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("library item not found") } return err } // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("character not found") } return err } // 查询Drama验证权限 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("unauthorized") } return err } // 更新角色的 local_path 和 image_url updates := map[string]interface{}{} if libraryItem.LocalPath != nil && *libraryItem.LocalPath != "" { updates["local_path"] = libraryItem.LocalPath } if libraryItem.ImageURL != "" { updates["image_url"] = libraryItem.ImageURL } if len(updates) > 0 { if err := s.db.Model(&character).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update character image", "error", err) return err } } s.log.Infow("Library item applied to character", "character_id", characterID, "library_item_id", libraryItemID) return nil } // UploadCharacterImage 上传角色图片 func (s *CharacterLibraryService) UploadCharacterImage(characterID string, imageURL string) error { // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("character not found") } return err } // 查询Drama验证权限 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("unauthorized") } return err } // 更新图片URL if err := s.db.Model(&character).Update("image_url", imageURL).Error; err != nil { s.log.Errorw("Failed to update character image", "error", err) return err } s.log.Infow("Character image uploaded", "character_id", characterID) return nil } // AddCharacterToLibrary 将角色添加到角色库 func (s *CharacterLibraryService) AddCharacterToLibrary(characterID string, category *string) (*models.CharacterLibrary, error) { // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("character not found") } return nil, err } // 查询Drama验证权限 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("unauthorized") } return nil, err } // 检查是否有图片 if character.ImageURL == nil || *character.ImageURL == "" { return nil, fmt.Errorf("角色还没有形象图片") } // 创建角色库项 charLibrary := &models.CharacterLibrary{ Name: character.Name, ImageURL: *character.ImageURL, LocalPath: character.LocalPath, Description: character.Description, SourceType: "character", } if err := s.db.Create(charLibrary).Error; err != nil { s.log.Errorw("Failed to add character to library", "error", err) return nil, err } s.log.Infow("Character added to library", "character_id", characterID, "library_item_id", charLibrary.ID) return charLibrary, nil } // DeleteCharacter 删除单个角色 func (s *CharacterLibraryService) DeleteCharacter(characterID uint) error { // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("character not found") } return err } // 验证权限:检查角色所属的drama是否属于当前用户 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("unauthorized") } return err } // 删除角色 if err := s.db.Delete(&character).Error; err != nil { s.log.Errorw("Failed to delete character", "error", err, "id", characterID) return err } s.log.Infow("Character deleted", "id", characterID) return nil } // GenerateCharacterImage AI生成角色形象 func (s *CharacterLibraryService) GenerateCharacterImage(characterID string, imageService *ImageGenerationService, modelName string, style string) (*models.ImageGeneration, error) { // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("character not found") } return nil, err } // 查询Drama验证权限 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("unauthorized") } return nil, err } // 构建生成提示词 - 使用详细的外貌描述,添加干净背景要求 prompt := "" // 优先使用appearance字段,它包含了最详细的外貌描述 if character.Appearance != nil && *character.Appearance != "" { prompt = *character.Appearance } else if character.Description != nil && *character.Description != "" { prompt = *character.Description } else { prompt = character.Name } // 使用已经加载的 drama 的 style 信息 if drama.Style != "" && drama.Style != "realistic" { prompt += ", " + drama.Style } // 调用图片生成服务 dramaIDStr := fmt.Sprintf("%d", character.DramaID) imageType := "character" req := &GenerateImageRequest{ DramaID: dramaIDStr, CharacterID: &character.ID, ImageType: imageType, Prompt: prompt, Provider: "openai", // 或从配置读取 Model: modelName, // 使用用户指定的模型 Size: "2560x1440", // 3,686,400像素,满足API最低要求(16:9比例) Quality: "standard", } imageGen, err := imageService.GenerateImage(req) if err != nil { s.log.Errorw("Failed to generate character image", "error", err) return nil, fmt.Errorf("图片生成失败: %w", err) } // 异步处理:在后台监听图片生成完成,然后更新角色image_url go s.waitAndUpdateCharacterImage(character.ID, imageGen.ID) // 立即返回ImageGeneration对象,让前端可以轮询状态 s.log.Infow("Character image generation started", "character_id", characterID, "image_gen_id", imageGen.ID) return imageGen, nil } // waitAndUpdateCharacterImage 后台异步等待图片生成完成并更新角色image_url func (s *CharacterLibraryService) waitAndUpdateCharacterImage(characterID uint, imageGenID uint) { maxAttempts := 60 pollInterval := 5 * time.Second for i := 0; i < maxAttempts; i++ { time.Sleep(pollInterval) // 查询图片生成状态 var imageGen models.ImageGeneration if err := s.db.First(&imageGen, imageGenID).Error; err != nil { s.log.Errorw("Failed to query image generation status", "error", err, "image_gen_id", imageGenID) continue } // 检查是否完成 if imageGen.Status == models.ImageStatusCompleted && imageGen.ImageURL != nil && *imageGen.ImageURL != "" { // 更新角色的image_url if err := s.db.Model(&models.Character{}).Where("id = ?", characterID).Update("image_url", *imageGen.ImageURL).Error; err != nil { s.log.Errorw("Failed to update character image_url", "error", err, "character_id", characterID) return } s.log.Infow("Character image updated successfully", "character_id", characterID, "image_url", *imageGen.ImageURL) return } // 检查是否失败 if imageGen.Status == models.ImageStatusFailed { s.log.Errorw("Character image generation failed", "character_id", characterID, "image_gen_id", imageGenID, "error", imageGen.ErrorMsg) return } } s.log.Warnw("Character image generation timeout", "character_id", characterID, "image_gen_id", imageGenID) } type UpdateCharacterRequest struct { Name *string `json:"name"` Role *string `json:"role"` Appearance *string `json:"appearance"` Personality *string `json:"personality"` Description *string `json:"description"` ImageURL *string `json:"image_url"` LocalPath *string `json:"local_path"` } // UpdateCharacter 更新角色信息 func (s *CharacterLibraryService) UpdateCharacter(characterID string, req *UpdateCharacterRequest) error { // 查找角色 var character models.Character if err := s.db.Where("id = ?", characterID).First(&character).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("character not found") } return err } // 验证权限:查询角色所属的drama是否属于该用户 var drama models.Drama if err := s.db.Where("id = ? ", character.DramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("unauthorized") } return err } // 构建更新数据 updates := make(map[string]interface{}) if req.Name != nil && *req.Name != "" { updates["name"] = *req.Name } if req.Role != nil { updates["role"] = *req.Role } if req.Appearance != nil { updates["appearance"] = *req.Appearance } if req.Personality != nil { updates["personality"] = *req.Personality } if req.Description != nil { updates["description"] = *req.Description } if req.ImageURL != nil { updates["image_url"] = *req.ImageURL } if req.LocalPath != nil { updates["local_path"] = *req.LocalPath } if len(updates) == 0 { return errors.New("no fields to update") } // 更新角色信息 if err := s.db.Model(&character).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update character", "error", err, "character_id", characterID) return err } s.log.Infow("Character updated", "character_id", characterID, "updates", updates) return nil } // BatchGenerateCharacterImages 批量生成角色图片(并发执行) func (s *CharacterLibraryService) BatchGenerateCharacterImages(characterIDs []string, imageService *ImageGenerationService, modelName string) { s.log.Infow("Starting batch character image generation", "count", len(characterIDs), "model", modelName) // 使用 goroutine 并发生成所有角色图片 for _, characterID := range characterIDs { // 为每个角色启动单独的 goroutine go func(charID string) { imageGen, err := s.GenerateCharacterImage(charID, imageService, modelName, "") // 批量生成暂不支持自定义风格,使用默认值 if err != nil { s.log.Errorw("Failed to generate character image in batch", "character_id", charID, "error", err) return } s.log.Infow("Character image generated in batch", "character_id", charID, "image_gen_id", imageGen.ID) }(characterID) } s.log.Infow("Batch character image generation tasks submitted", "total", len(characterIDs)) } // ExtractCharactersFromScript 从分集剧本中提取角色 func (s *CharacterLibraryService) ExtractCharactersFromScript(episodeID uint) (string, error) { var episode models.Episode if err := s.db.First(&episode, episodeID).Error; err != nil { return "", fmt.Errorf("episode not found") } if episode.ScriptContent == nil || *episode.ScriptContent == "" { return "", fmt.Errorf("剧本内容为空") } task, err := s.taskService.CreateTask("character_extraction", fmt.Sprintf("%d", episode.DramaID)) if err != nil { return "", fmt.Errorf("创建任务失败: %w", err) } go s.processCharacterExtraction(task.ID, episode) return task.ID, nil } func (s *CharacterLibraryService) processCharacterExtraction(taskID string, episode models.Episode) { s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在分析剧本...") script := "" if episode.ScriptContent != nil { script = *episode.ScriptContent } // 获取 drama 的 style 信息 var drama models.Drama if err := s.db.First(&drama, episode.DramaID).Error; err != nil { s.log.Warnw("Failed to load drama", "error", err, "drama_id", episode.DramaID) } prompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style) userPrompt := fmt.Sprintf("【剧本内容】\n%s", script) response, err := s.aiService.GenerateText(userPrompt, prompt, ai.WithMaxTokens(3000)) if err != nil { s.taskService.UpdateTaskError(taskID, err) return } s.taskService.UpdateTaskStatus(taskID, "processing", 50, "正在整理角色数据...") var extractedCharacters []struct { Name string `json:"name"` Role string `json:"role"` Appearance string `json:"appearance"` Personality string `json:"personality"` Description string `json:"description"` } if err := utils.SafeParseAIJSON(response, &extractedCharacters); err != nil { s.log.Errorw("Failed to parse AI response for characters", "error", err, "response", response) s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析AI响应失败")) return } var savedCharacters []models.Character for _, charData := range extractedCharacters { // 检查是否已存在同名角色 var existingCharacter models.Character err := s.db.Where("drama_id = ? AND name = ?", episode.DramaID, charData.Name).First(&existingCharacter).Error if err == nil { // 如果存在,只关联,不更新(或者可以选更新,这里暂不更新) if err := s.db.Model(&episode).Association("Characters").Append(&existingCharacter); err != nil { s.log.Warnw("Failed to associate existing character", "error", err) } savedCharacters = append(savedCharacters, existingCharacter) } else { // 创建新角色 newCharacter := models.Character{ DramaID: episode.DramaID, Name: charData.Name, Role: &charData.Role, Appearance: &charData.Appearance, Personality: &charData.Personality, Description: &charData.Description, } if err := s.db.Create(&newCharacter).Error; err != nil { s.log.Errorw("Failed to create extracted character", "error", err) continue } // 关联到分集 if err := s.db.Model(&episode).Association("Characters").Append(&newCharacter); err != nil { s.log.Warnw("Failed to associate new character", "error", err) } savedCharacters = append(savedCharacters, newCharacter) } } s.taskService.UpdateTaskResult(taskID, map[string]interface{}{ "characters": savedCharacters, "count": len(savedCharacters), }) } ================================================ FILE: application/services/data_migration_service.go ================================================ package services import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type DataMigrationService struct { db *gorm.DB log *logger.Logger storageRoot string urlMapping map[string]string // 原始URL -> 本地路径的映射 } func NewDataMigrationService(db *gorm.DB, log *logger.Logger) *DataMigrationService { return &DataMigrationService{ db: db, log: log, storageRoot: "data/storage", urlMapping: make(map[string]string), } } // MigrateLocalPaths 迁移所有表中 local_path 为空的数据 func (s *DataMigrationService) MigrateLocalPaths() error { s.log.Info("开始数据清洗:迁移 local_path 为空的数据") startTime := time.Now() // 确保存储目录存在 if err := s.ensureStorageDirectories(); err != nil { return fmt.Errorf("创建存储目录失败: %w", err) } // 迁移各个表的数据(按指定顺序) stats := &MigrationStats{} // 1. 迁移 assets 表 if err := s.migrateAssets(stats); err != nil { s.log.Errorw("迁移 assets 数据失败", "error", err) } // 2. 迁移 character_libraries 表 if err := s.migrateCharacterLibraries(stats); err != nil { s.log.Errorw("迁移 character_libraries 数据失败", "error", err) } // 3. 迁移 characters 表 if err := s.migrateCharacters(stats); err != nil { s.log.Errorw("迁移 characters 数据失败", "error", err) } // 4. 迁移 image_generations 表 if err := s.migrateImageGenerations(stats); err != nil { s.log.Errorw("迁移 image_generations 数据失败", "error", err) } // 5. 迁移 scenes 表 if err := s.migrateScenes(stats); err != nil { s.log.Errorw("迁移 scenes 数据失败", "error", err) } // 6. 迁移 video_generations 表 if err := s.migrateVideoGenerations(stats); err != nil { s.log.Errorw("迁移 video_generations 数据失败", "error", err) } duration := time.Since(startTime) s.log.Infow("数据清洗完成", "总耗时", duration.String(), "URL映射缓存数", len(s.urlMapping), "Assets成功", stats.AssetsSuccess, "Assets失败", stats.AssetsFailed, "角色库成功", stats.CharacterLibrariesSuccess, "角色库失败", stats.CharacterLibrariesFailed, "角色成功", stats.CharactersSuccess, "角色失败", stats.CharactersFailed, "图片生成成功", stats.ImageGenerationsSuccess, "图片生成失败", stats.ImageGenerationsFailed, "场景成功", stats.ScenesSuccess, "场景失败", stats.ScenesFailed, "视频成功", stats.VideosSuccess, "视频失败", stats.VideosFailed, ) return nil } // MigrationStats 迁移统计信息 type MigrationStats struct { AssetsSuccess int AssetsFailed int CharacterLibrariesSuccess int CharacterLibrariesFailed int CharactersSuccess int CharactersFailed int ImageGenerationsSuccess int ImageGenerationsFailed int ScenesSuccess int ScenesFailed int VideosSuccess int VideosFailed int } // ensureStorageDirectories 确保存储目录存在 func (s *DataMigrationService) ensureStorageDirectories() error { dirs := []string{ filepath.Join(s.storageRoot, "images"), filepath.Join(s.storageRoot, "characters"), filepath.Join(s.storageRoot, "videos"), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("创建目录 %s 失败: %w", dir, err) } } s.log.Infow("存储目录创建成功", "root", s.storageRoot) return nil } // migrateAssets 迁移 assets 表数据 func (s *DataMigrationService) migrateAssets(stats *MigrationStats) error { s.log.Info("开始迁移 assets 数据...") var assets []models.Asset // 查询 local_path 为空但 url 不为空的资源 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''").Find(&assets).Error; err != nil { return fmt.Errorf("查询 assets 数据失败: %w", err) } s.log.Infow("找到需要迁移的 assets", "数量", len(assets)) for _, asset := range assets { s.log.Infow("处理 asset", "id", asset.ID, "name", asset.Name, "type", asset.Type, "url", asset.URL) // 根据类型选择存储目录 subDir := "images" if asset.Type == models.AssetTypeVideo { subDir = "videos" } localPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf("asset_%d", asset.ID)) if err != nil { s.log.Errorw("下载 asset 失败", "asset_id", asset.ID, "error", err) stats.AssetsFailed++ continue } // 更新 local_path if err := s.db.Model(&asset).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 asset local_path 失败", "asset_id", asset.ID, "error", err) stats.AssetsFailed++ continue } s.log.Infow("asset 迁移成功", "asset_id", asset.ID, "local_path", localPath) stats.AssetsSuccess++ } return nil } // migrateCharacterLibraries 迁移 character_libraries 表数据 func (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error { s.log.Info("开始迁移 character_libraries 数据...") var charLibs []models.CharacterLibrary // 查询 local_path 为空但 image_url 不为空的角色库 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&charLibs).Error; err != nil { return fmt.Errorf("查询 character_libraries 数据失败: %w", err) } s.log.Infow("找到需要迁移的 character_libraries", "数量", len(charLibs)) for _, charLib := range charLibs { s.log.Infow("处理 character_library", "id", charLib.ID, "name", charLib.Name, "image_url", charLib.ImageURL) localPath, err := s.downloadOrGetCached(charLib.ImageURL, "characters", fmt.Sprintf("charlib_%d", charLib.ID)) if err != nil { s.log.Errorw("下载 character_library 图片失败", "charlib_id", charLib.ID, "error", err) stats.CharacterLibrariesFailed++ continue } // 更新 local_path if err := s.db.Model(&charLib).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 character_library local_path 失败", "charlib_id", charLib.ID, "error", err) stats.CharacterLibrariesFailed++ continue } s.log.Infow("character_library 迁移成功", "charlib_id", charLib.ID, "local_path", localPath) stats.CharacterLibrariesSuccess++ } return nil } // migrateImageGenerations 迁移 image_generations 表数据 func (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error { s.log.Info("开始迁移 image_generations 数据...") var imageGens []models.ImageGeneration // 查询 local_path 为空但 image_url 不为空的图片生成记录 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&imageGens).Error; err != nil { return fmt.Errorf("查询 image_generations 数据失败: %w", err) } s.log.Infow("找到需要迁移的 image_generations", "数量", len(imageGens)) for _, imageGen := range imageGens { if imageGen.ImageURL == nil { continue } imageTypeStr := string(imageGen.ImageType) s.log.Infow("处理 image_generation", "id", imageGen.ID, "image_type", imageTypeStr, "image_url", *imageGen.ImageURL) // 根据图片类型选择存储目录 subDir := "images" if imageGen.ImageType == "character" { subDir = "characters" } localPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf("imggen_%d", imageGen.ID)) if err != nil { s.log.Errorw("下载 image_generation 图片失败", "imggen_id", imageGen.ID, "error", err) stats.ImageGenerationsFailed++ continue } // 更新 local_path if err := s.db.Model(&imageGen).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 image_generation local_path 失败", "imggen_id", imageGen.ID, "error", err) stats.ImageGenerationsFailed++ continue } s.log.Infow("image_generation 迁移成功", "imggen_id", imageGen.ID, "local_path", localPath) stats.ImageGenerationsSuccess++ } return nil } // migrateScenes 迁移场景数据 func (s *DataMigrationService) migrateScenes(stats *MigrationStats) error { s.log.Info("开始迁移场景数据...") var scenes []models.Scene // 查询 local_path 为空但 image_url 不为空的场景 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&scenes).Error; err != nil { return fmt.Errorf("查询场景数据失败: %w", err) } s.log.Infow("找到需要迁移的场景", "数量", len(scenes)) for _, scene := range scenes { if scene.ImageURL == nil { continue } s.log.Infow("处理场景", "id", scene.ID, "location", scene.Location, "image_url", *scene.ImageURL) localPath, err := s.downloadOrGetCached(*scene.ImageURL, "images", fmt.Sprintf("scene_%d", scene.ID)) if err != nil { s.log.Errorw("下载场景图片失败", "scene_id", scene.ID, "error", err) stats.ScenesFailed++ continue } // 更新 local_path if err := s.db.Model(&scene).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新场景 local_path 失败", "scene_id", scene.ID, "error", err) stats.ScenesFailed++ continue } s.log.Infow("场景迁移成功", "scene_id", scene.ID, "local_path", localPath) stats.ScenesSuccess++ } return nil } // migrateCharacters 迁移角色数据 func (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error { s.log.Info("开始迁移角色数据...") var characters []models.Character // 查询 local_path 为空但 image_url 不为空的角色 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&characters).Error; err != nil { return fmt.Errorf("查询角色数据失败: %w", err) } s.log.Infow("找到需要迁移的角色", "数量", len(characters)) for _, character := range characters { if character.ImageURL == nil { continue } s.log.Infow("处理角色", "id", character.ID, "name", character.Name, "image_url", *character.ImageURL) localPath, err := s.downloadOrGetCached(*character.ImageURL, "characters", fmt.Sprintf("character_%d", character.ID)) if err != nil { s.log.Errorw("下载角色图片失败", "character_id", character.ID, "error", err) stats.CharactersFailed++ continue } // 更新 local_path if err := s.db.Model(&character).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新角色 local_path 失败", "character_id", character.ID, "error", err) stats.CharactersFailed++ continue } s.log.Infow("角色迁移成功", "character_id", character.ID, "local_path", localPath) stats.CharactersSuccess++ } return nil } // migrateVideoGenerations 迁移视频生成数据 func (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error { s.log.Info("开始迁移视频生成数据...") var videoGens []models.VideoGeneration // 查询 local_path 为空但 video_url 不为空的视频 if err := s.db.Where("(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''").Find(&videoGens).Error; err != nil { return fmt.Errorf("查询视频生成数据失败: %w", err) } s.log.Infow("找到需要迁移的视频", "数量", len(videoGens)) for _, videoGen := range videoGens { if videoGen.VideoURL == nil { continue } s.log.Infow("处理视频", "id", videoGen.ID, "video_url", *videoGen.VideoURL) localPath, err := s.downloadOrGetCached(*videoGen.VideoURL, "videos", fmt.Sprintf("video_%d", videoGen.ID)) if err != nil { s.log.Errorw("下载视频失败", "video_gen_id", videoGen.ID, "error", err) stats.VideosFailed++ continue } // 更新 local_path if err := s.db.Model(&videoGen).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新视频 local_path 失败", "video_gen_id", videoGen.ID, "error", err) stats.VideosFailed++ continue } s.log.Infow("视频迁移成功", "video_gen_id", videoGen.ID, "local_path", localPath) stats.VideosSuccess++ } return nil } // downloadOrGetCached 下载文件或从缓存获取本地路径 func (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) { // 1. 检查 URL 映射缓存 if localPath, exists := s.urlMapping[url]; exists { s.log.Infow("使用缓存的本地路径", "url", url, "local_path", localPath) return localPath, nil } // 2. 如果缓存中没有,则下载文件 var localPath string var err error // 根据子目录判断是图片还是视频 if subDir == "videos" { localPath, err = s.downloadAndSaveVideo(url, subDir, prefix) } else { localPath, err = s.downloadAndSaveImage(url, subDir, prefix) } if err != nil { return "", err } // 3. 将 URL 和本地路径的映射关系存入缓存 s.urlMapping[url] = localPath s.log.Infow("已缓存 URL 映射", "url", url, "local_path", localPath) return localPath, nil } // downloadAndSaveImage 下载并保存图片 func (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) { if imageURL == "" { return "", fmt.Errorf("图片 URL 为空") } // 如果已经是本地路径,直接返回 if strings.HasPrefix(imageURL, "/static/") || strings.HasPrefix(imageURL, "data/") { return imageURL, nil } // 从 URL 中提取文件扩展名(去掉查询参数) ext := s.extractFileExtension(imageURL) // 生成文件名 timestamp := time.Now().Unix() filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext) relativePath := filepath.Join(subDir, filename) fullPath := filepath.Join(s.storageRoot, relativePath) // 下载文件 if err := s.downloadFile(imageURL, fullPath); err != nil { return "", fmt.Errorf("下载文件失败: %w", err) } // 返回相对路径(用于存储到数据库) return relativePath, nil } // downloadAndSaveVideo 下载并保存视频 func (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) { if videoURL == "" { return "", fmt.Errorf("视频 URL 为空") } // 如果已经是本地路径,直接返回 if strings.HasPrefix(videoURL, "/static/") || strings.HasPrefix(videoURL, "data/") { return videoURL, nil } // 从 URL 中提取文件扩展名(去掉查询参数) ext := s.extractFileExtension(videoURL) if ext == "" || ext == ".jpeg" || ext == ".jpg" || ext == ".png" { ext = ".mp4" // 视频默认扩展名 } // 生成文件名 timestamp := time.Now().Unix() filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext) relativePath := filepath.Join(subDir, filename) fullPath := filepath.Join(s.storageRoot, relativePath) // 下载文件 if err := s.downloadFile(videoURL, fullPath); err != nil { return "", fmt.Errorf("下载文件失败: %w", err) } // 返回相对路径(用于存储到数据库) return relativePath, nil } // extractFileExtension 从 URL 中提取文件扩展名(去掉查询参数) func (s *DataMigrationService) extractFileExtension(url string) string { // 去掉查询参数 if idx := strings.Index(url, "?"); idx != -1 { url = url[:idx] } // 去掉 fragment if idx := strings.Index(url, "#"); idx != -1 { url = url[:idx] } // 获取文件扩展名 ext := filepath.Ext(url) if ext == "" { // 如果没有扩展名,默认返回 .jpg return ".jpg" } // 转换为小写 ext = strings.ToLower(ext) // 验证扩展名是否合理(限制长度) if len(ext) > 10 { return ".jpg" } return ext } // downloadFile 下载文件到指定路径 func (s *DataMigrationService) downloadFile(url, filepath string) error { s.log.Infow("开始下载文件", "url", url, "filepath", filepath) // 创建 HTTP 请求 client := &http.Client{ Timeout: 60 * time.Second, } resp, err := client.Get(url) if err != nil { return fmt.Errorf("HTTP 请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP 状态码错误: %d", resp.StatusCode) } // 创建文件 out, err := os.Create(filepath) if err != nil { return fmt.Errorf("创建文件失败: %w", err) } defer out.Close() // 复制内容 written, err := io.Copy(out, resp.Body) if err != nil { return fmt.Errorf("写入文件失败: %w", err) } s.log.Infow("文件下载成功", "filepath", filepath, "size", written) return nil } ================================================ FILE: application/services/drama_service.go ================================================ package services import ( "encoding/json" "errors" "fmt" "strconv" "time" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type DramaService struct { db *gorm.DB log *logger.Logger baseURL string } func NewDramaService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *DramaService { return &DramaService{ db: db, log: log, baseURL: cfg.Storage.BaseURL, } } type CreateDramaRequest struct { Title string `json:"title" binding:"required,min=1,max=100"` Description string `json:"description"` Genre string `json:"genre"` Style string `json:"style"` Tags string `json:"tags"` } type UpdateDramaRequest struct { Title string `json:"title" binding:"omitempty,min=1,max=100"` Description string `json:"description"` Genre string `json:"genre"` Style string `json:"style"` Tags string `json:"tags"` Status string `json:"status" binding:"omitempty,oneof=draft planning production completed archived"` } type DramaListQuery struct { Page int `form:"page,default=1"` PageSize int `form:"page_size,default=20"` Status string `form:"status"` Genre string `form:"genre"` Keyword string `form:"keyword"` } func (s *DramaService) CreateDrama(req *CreateDramaRequest) (*models.Drama, error) { drama := &models.Drama{ Title: req.Title, Status: "draft", Style: "ghibli", // 默认风格 } if req.Description != "" { drama.Description = &req.Description } if req.Genre != "" { drama.Genre = &req.Genre } if req.Style != "" { drama.Style = req.Style } if err := s.db.Create(drama).Error; err != nil { s.log.Errorw("Failed to create drama", "error", err) return nil, err } s.log.Infow("Drama created", "drama_id", drama.ID) return drama, nil } func (s *DramaService) GetDrama(dramaID string) (*models.Drama, error) { var drama models.Drama err := s.db.Where("id = ? ", dramaID). Preload("Characters"). // 加载Drama级别的角色 Preload("Scenes"). // 加载Drama级别的场景 Preload("Props"). // 加载Drama级别的道具 Preload("Episodes.Characters"). // 加载每个章节关联的角色 Preload("Episodes.Scenes"). // 加载每个章节关联的场景 Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB { return db.Order("storyboards.storyboard_number ASC") }). Preload("Episodes.Storyboards.Props"). // 加载分镜关联的道具 First(&drama).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("drama not found") } s.log.Errorw("Failed to get drama", "error", err) return nil, err } // 统计每个剧集的时长(基于场景时长之和) for i := range drama.Episodes { totalDuration := 0 for _, scene := range drama.Episodes[i].Storyboards { totalDuration += scene.Duration } // 更新剧集时长(秒转分钟,向上取整) durationMinutes := (totalDuration + 59) / 60 drama.Episodes[i].Duration = durationMinutes // 如果数据库中的时长与计算的不一致,更新数据库 if drama.Episodes[i].Duration != durationMinutes { s.db.Model(&models.Episode{}).Where("id = ?", drama.Episodes[i].ID).Update("duration", durationMinutes) } // 查询角色的图片生成状态 for j := range drama.Episodes[i].Characters { var imageGen models.ImageGeneration // 查询进行中或失败的任务状态 err := s.db.Where("character_id = ? AND (status = ? OR status = ?)", drama.Episodes[i].Characters[j].ID, "pending", "processing"). Order("created_at DESC"). First(&imageGen).Error if err == nil { // 找到生成中的记录,设置状态 statusStr := string(imageGen.Status) drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr if imageGen.ErrorMsg != nil { drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg } } else if errors.Is(err, gorm.ErrRecordNotFound) { // 检查是否有失败的记录 err := s.db.Where("character_id = ? AND status = ?", drama.Episodes[i].Characters[j].ID, "failed"). Order("created_at DESC"). First(&imageGen).Error if err == nil { statusStr := string(imageGen.Status) drama.Episodes[i].Characters[j].ImageGenerationStatus = &statusStr if imageGen.ErrorMsg != nil { drama.Episodes[i].Characters[j].ImageGenerationError = imageGen.ErrorMsg } } } } // 查询场景的图片生成状态 for j := range drama.Episodes[i].Scenes { var imageGen models.ImageGeneration // 查询进行中或失败的任务状态 err := s.db.Where("scene_id = ? AND (status = ? OR status = ?)", drama.Episodes[i].Scenes[j].ID, "pending", "processing"). Order("created_at DESC"). First(&imageGen).Error if err == nil { // 找到生成中的记录,设置状态 statusStr := string(imageGen.Status) drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr if imageGen.ErrorMsg != nil { drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg } } else if errors.Is(err, gorm.ErrRecordNotFound) { // 检查是否有失败的记录 err := s.db.Where("scene_id = ? AND status = ?", drama.Episodes[i].Scenes[j].ID, "failed"). Order("created_at DESC"). First(&imageGen).Error if err == nil { statusStr := string(imageGen.Status) drama.Episodes[i].Scenes[j].ImageGenerationStatus = &statusStr if imageGen.ErrorMsg != nil { drama.Episodes[i].Scenes[j].ImageGenerationError = imageGen.ErrorMsg } } } } } // 整合所有剧集的场景到Drama级别的Scenes字段 sceneMap := make(map[uint]*models.Scene) // 用于去重 for i := range drama.Episodes { for j := range drama.Episodes[i].Scenes { scene := &drama.Episodes[i].Scenes[j] sceneMap[scene.ID] = scene } } // 将整合的场景添加到drama.Scenes drama.Scenes = make([]models.Scene, 0, len(sceneMap)) for _, scene := range sceneMap { drama.Scenes = append(drama.Scenes, *scene) } // 为所有场景的 local_path 添加 base_url 前缀 // s.addBaseURLToScenes(&drama) return &drama, nil } func (s *DramaService) ListDramas(query *DramaListQuery) ([]models.Drama, int64, error) { var dramas []models.Drama var total int64 db := s.db.Model(&models.Drama{}) if query.Status != "" { db = db.Where("status = ?", query.Status) } if query.Genre != "" { db = db.Where("genre = ?", query.Genre) } if query.Keyword != "" { db = db.Where("title LIKE ? OR description LIKE ?", "%"+query.Keyword+"%", "%"+query.Keyword+"%") } if err := db.Count(&total).Error; err != nil { s.log.Errorw("Failed to count dramas", "error", err) return nil, 0, err } offset := (query.Page - 1) * query.PageSize err := db.Order("updated_at DESC"). Offset(offset). Limit(query.PageSize). Preload("Episodes.Storyboards", func(db *gorm.DB) *gorm.DB { return db.Order("storyboards.storyboard_number ASC") }). Find(&dramas).Error if err != nil { s.log.Errorw("Failed to list dramas", "error", err) return nil, 0, err } // 统计每个剧本的每个剧集的时长(基于场景时长之和) for i := range dramas { for j := range dramas[i].Episodes { totalDuration := 0 for _, scene := range dramas[i].Episodes[j].Storyboards { totalDuration += scene.Duration } // 更新剧集时长(秒转分钟,向上取整) durationMinutes := (totalDuration + 59) / 60 dramas[i].Episodes[j].Duration = durationMinutes } } return dramas, total, nil } func (s *DramaService) UpdateDrama(dramaID string, req *UpdateDramaRequest) (*models.Drama, error) { var drama models.Drama if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("drama not found") } return nil, err } updates := make(map[string]interface{}) if req.Title != "" { updates["title"] = req.Title } if req.Description != "" { updates["description"] = req.Description } if req.Genre != "" { updates["genre"] = req.Genre } if req.Style != "" { updates["style"] = req.Style } if req.Tags != "" { updates["tags"] = req.Tags } if req.Status != "" { updates["status"] = req.Status } updates["updated_at"] = time.Now() if err := s.db.Model(&drama).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update drama", "error", err) return nil, err } s.log.Infow("Drama updated", "drama_id", dramaID) return &drama, nil } func (s *DramaService) DeleteDrama(dramaID string) error { result := s.db.Where("id = ? ", dramaID).Delete(&models.Drama{}) if result.Error != nil { s.log.Errorw("Failed to delete drama", "error", result.Error) return result.Error } if result.RowsAffected == 0 { return errors.New("drama not found") } s.log.Infow("Drama deleted", "drama_id", dramaID) return nil } func (s *DramaService) GetDramaStats() (map[string]interface{}, error) { var total int64 var byStatus []struct { Status string Count int64 } if err := s.db.Model(&models.Drama{}).Count(&total).Error; err != nil { return nil, err } if err := s.db.Model(&models.Drama{}). Select("status, count(*) as count"). Group("status"). Scan(&byStatus).Error; err != nil { return nil, err } stats := map[string]interface{}{ "total": total, "by_status": byStatus, } return stats, nil } type SaveOutlineRequest struct { Title string `json:"title" binding:"required"` Summary string `json:"summary" binding:"required"` Genre string `json:"genre"` Tags []string `json:"tags"` } type SaveCharactersRequest struct { Characters []models.Character `json:"characters" binding:"required"` EpisodeID *uint `json:"episode_id"` // 可选:如果提供则关联到指定章节 } type SaveProgressRequest struct { CurrentStep string `json:"current_step" binding:"required"` StepData map[string]interface{} `json:"step_data"` } type SaveEpisodesRequest struct { Episodes []models.Episode `json:"episodes" binding:"required"` } func (s *DramaService) SaveOutline(dramaID string, req *SaveOutlineRequest) error { var drama models.Drama if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("drama not found") } return err } updates := map[string]interface{}{ "title": req.Title, "description": req.Summary, "updated_at": time.Now(), } if req.Genre != "" { updates["genre"] = req.Genre } if len(req.Tags) > 0 { tagsJSON, err := json.Marshal(req.Tags) if err != nil { s.log.Errorw("Failed to marshal tags", "error", err) return err } updates["tags"] = tagsJSON } if err := s.db.Model(&drama).Updates(updates).Error; err != nil { s.log.Errorw("Failed to save outline", "error", err) return err } s.log.Infow("Outline saved", "drama_id", dramaID) return nil } func (s *DramaService) GetCharacters(dramaID string, episodeID *string) ([]models.Character, error) { var drama models.Drama if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("drama not found") } return nil, err } var characters []models.Character // 如果指定了episodeID,只获取该章节关联的角色 if episodeID != nil { var episode models.Episode if err := s.db.Preload("Characters").Where("id = ? AND drama_id = ?", *episodeID, dramaID).First(&episode).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("episode not found") } return nil, err } characters = episode.Characters } else { // 如果没有指定episodeID,获取项目的所有角色 if err := s.db.Where("drama_id = ?", dramaID).Find(&characters).Error; err != nil { s.log.Errorw("Failed to get characters", "error", err) return nil, err } } // 查询每个角色的图片生成任务状态 for i := range characters { // 查询该角色最新的图片生成任务 var imageGen models.ImageGeneration err := s.db.Where("character_id = ?", characters[i].ID). Order("created_at DESC"). First(&imageGen).Error if err == nil { // 如果有进行中的任务,填充状态信息 if imageGen.Status == models.ImageStatusPending || imageGen.Status == models.ImageStatusProcessing { statusStr := string(imageGen.Status) characters[i].ImageGenerationStatus = &statusStr } else if imageGen.Status == models.ImageStatusFailed { statusStr := "failed" characters[i].ImageGenerationStatus = &statusStr if imageGen.ErrorMsg != nil { characters[i].ImageGenerationError = imageGen.ErrorMsg } } } } return characters, nil } func (s *DramaService) SaveCharacters(dramaID string, req *SaveCharactersRequest) error { // 转换dramaID id, err := strconv.ParseUint(dramaID, 10, 32) if err != nil { return fmt.Errorf("invalid drama ID") } dramaIDUint := uint(id) var drama models.Drama if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("drama not found") } return err } // 如果指定了EpisodeID,验证章节存在性 if req.EpisodeID != nil { var episode models.Episode if err := s.db.Where("id = ? AND drama_id = ?", *req.EpisodeID, dramaIDUint).First(&episode).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("episode not found") } return err } } // 获取该项目已存在的所有角色 var existingCharacters []models.Character if err := s.db.Where("drama_id = ?", dramaIDUint).Find(&existingCharacters).Error; err != nil { s.log.Errorw("Failed to get existing characters", "error", err) return err } // 创建角色名称到角色的映射 existingCharMap := make(map[string]*models.Character) for i := range existingCharacters { existingCharMap[existingCharacters[i].Name] = &existingCharacters[i] } // 收集需要关联到章节的角色ID var characterIDs []uint // 创建新角色或复用/更新已有角色 for _, char := range req.Characters { // 1. 如果提供了ID,尝试更新已有角色 if char.ID > 0 { var existing models.Character if err := s.db.Where("id = ? AND drama_id = ?", char.ID, dramaIDUint).First(&existing).Error; err == nil { // 更新角色信息 updates := map[string]interface{}{ "name": char.Name, "role": char.Role, "description": char.Description, "personality": char.Personality, "appearance": char.Appearance, "image_url": char.ImageURL, } if err := s.db.Model(&existing).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update character", "error", err, "id", char.ID) } characterIDs = append(characterIDs, existing.ID) continue } } // 2. 如果没有ID但名字已存在,直接复用(可选:也可以选择更新) if existingChar, exists := existingCharMap[char.Name]; exists { s.log.Infow("Character already exists, reusing", "name", char.Name, "character_id", existingChar.ID) characterIDs = append(characterIDs, existingChar.ID) continue } // 3. 角色不存在,创建新角色 character := models.Character{ DramaID: dramaIDUint, Name: char.Name, Role: char.Role, Description: char.Description, Personality: char.Personality, Appearance: char.Appearance, ImageURL: char.ImageURL, } if err := s.db.Create(&character).Error; err != nil { s.log.Errorw("Failed to create character", "error", err, "name", char.Name) continue } s.log.Infow("New character created", "character_id", character.ID, "name", char.Name) characterIDs = append(characterIDs, character.ID) } // 如果指定了EpisodeID,建立角色与章节的关联 if req.EpisodeID != nil && len(characterIDs) > 0 { var episode models.Episode if err := s.db.First(&episode, *req.EpisodeID).Error; err != nil { return err } // 获取角色对象 var characters []models.Character if err := s.db.Where("id IN ?", characterIDs).Find(&characters).Error; err != nil { s.log.Errorw("Failed to get characters", "error", err) return err } // 使用GORM的Association API建立多对多关系(会自动去重) if err := s.db.Model(&episode).Association("Characters").Append(&characters); err != nil { s.log.Errorw("Failed to associate characters with episode", "error", err) return err } s.log.Infow("Characters associated with episode", "episode_id", *req.EpisodeID, "character_count", len(characterIDs)) } if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil { s.log.Errorw("Failed to update drama timestamp", "error", err) } s.log.Infow("Characters saved", "drama_id", dramaID, "count", len(req.Characters)) return nil } func (s *DramaService) SaveEpisodes(dramaID string, req *SaveEpisodesRequest) error { // 转换dramaID id, err := strconv.ParseUint(dramaID, 10, 32) if err != nil { return fmt.Errorf("invalid drama ID") } dramaIDUint := uint(id) var drama models.Drama if err := s.db.Where("id = ? ", dramaIDUint).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("drama not found") } return err } // 删除旧剧集 if err := s.db.Where("drama_id = ?", dramaIDUint).Delete(&models.Episode{}).Error; err != nil { s.log.Errorw("Failed to delete old episodes", "error", err) return err } // 创建新剧集(不包含场景,场景由后续步骤生成) for _, ep := range req.Episodes { episode := models.Episode{ DramaID: dramaIDUint, EpisodeNum: ep.EpisodeNum, Title: ep.Title, Description: ep.Description, ScriptContent: ep.ScriptContent, Duration: ep.Duration, Status: "draft", } if err := s.db.Create(&episode).Error; err != nil { s.log.Errorw("Failed to create episode", "error", err, "episode", ep.EpisodeNum) continue } } if err := s.db.Model(&drama).Update("updated_at", time.Now()).Error; err != nil { s.log.Errorw("Failed to update drama timestamp", "error", err) } s.log.Infow("Episodes saved", "drama_id", dramaID, "count", len(req.Episodes)) return nil } func (s *DramaService) SaveProgress(dramaID string, req *SaveProgressRequest) error { var drama models.Drama if err := s.db.Where("id = ? ", dramaID).First(&drama).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("drama not found") } return err } // 构建metadata对象 metadata := make(map[string]interface{}) // 保留现有metadata if drama.Metadata != nil { if err := json.Unmarshal(drama.Metadata, &metadata); err != nil { s.log.Warnw("Failed to unmarshal existing metadata", "error", err) } } // 更新progress信息 metadata["current_step"] = req.CurrentStep if req.StepData != nil { metadata["step_data"] = req.StepData } // 序列化metadata metadataJSON, err := json.Marshal(metadata) if err != nil { s.log.Errorw("Failed to marshal metadata", "error", err) return err } updates := map[string]interface{}{ "metadata": metadataJSON, "updated_at": time.Now(), } if err := s.db.Model(&drama).Updates(updates).Error; err != nil { s.log.Errorw("Failed to save progress", "error", err) return err } s.log.Infow("Progress saved", "drama_id", dramaID, "step", req.CurrentStep) return nil } // addBaseURLToScenes 为剧本中所有场景的 local_path 添加 base_url 前缀 func (s *DramaService) addBaseURLToScenes(drama *models.Drama) { // 处理 drama.Scenes for i := range drama.Scenes { if drama.Scenes[i].LocalPath != nil && *drama.Scenes[i].LocalPath != "" { fullPath := fmt.Sprintf("%s/%s", s.baseURL, *drama.Scenes[i].LocalPath) drama.Scenes[i].LocalPath = &fullPath } } // 处理 drama.Episodes[].Scenes for i := range drama.Episodes { for j := range drama.Episodes[i].Scenes { if drama.Episodes[i].Scenes[j].LocalPath != nil && *drama.Episodes[i].Scenes[j].LocalPath != "" { fullPath := fmt.Sprintf("%s/%s", s.baseURL, *drama.Episodes[i].Scenes[j].LocalPath) drama.Episodes[i].Scenes[j].LocalPath = &fullPath } } } } ================================================ FILE: application/services/frame_prompt_helper.go ================================================ package services import ( "encoding/json" "regexp" "strings" ) // parseFramePromptJSON 解析AI返回的JSON格式提示词 func (s *FramePromptService) parseFramePromptJSON(aiResponse string) *SingleFramePrompt { // 清理可能的markdown代码块标记 cleaned := strings.TrimSpace(aiResponse) // 移除 ```json 和 ``` 标记 re := regexp.MustCompile("(?s)```json\\s*(.+?)\\s*```") if matches := re.FindStringSubmatch(cleaned); len(matches) > 1 { cleaned = strings.TrimSpace(matches[1]) } else { // 移除单独的 ``` 标记 cleaned = strings.Trim(cleaned, "`") cleaned = strings.TrimSpace(cleaned) } // 尝试解析JSON var result SingleFramePrompt if err := json.Unmarshal([]byte(cleaned), &result); err != nil { s.log.Warnw("Failed to parse JSON", "error", err, "cleaned_response", cleaned) return nil } // 验证必需字段 if result.Prompt == "" { s.log.Warnw("Parsed JSON missing prompt field", "response", cleaned) return nil } return &result } ================================================ FILE: application/services/frame_prompt_service.go ================================================ package services import ( "fmt" "strings" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) // FramePromptService 处理帧提示词生成 type FramePromptService struct { db *gorm.DB aiService *AIService log *logger.Logger config *config.Config promptI18n *PromptI18n taskService *TaskService } // NewFramePromptService 创建帧提示词服务 func NewFramePromptService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *FramePromptService { return &FramePromptService{ db: db, aiService: NewAIService(db, log), log: log, config: cfg, promptI18n: NewPromptI18n(cfg), taskService: NewTaskService(db, log), } } // FrameType 帧类型 type FrameType string const ( FrameTypeFirst FrameType = "first" // 首帧 FrameTypeKey FrameType = "key" // 关键帧 FrameTypeLast FrameType = "last" // 尾帧 FrameTypePanel FrameType = "panel" // 分镜板(3格组合) FrameTypeAction FrameType = "action" // 动作序列(5格) ) // GenerateFramePromptRequest 生成帧提示词请求 type GenerateFramePromptRequest struct { StoryboardID string `json:"storyboard_id"` FrameType FrameType `json:"frame_type"` // 可选参数 PanelCount int `json:"panel_count,omitempty"` // 分镜板格数,默认3 } // FramePromptResponse 帧提示词响应 type FramePromptResponse struct { FrameType FrameType `json:"frame_type"` SingleFrame *SingleFramePrompt `json:"single_frame,omitempty"` // 单帧提示词 MultiFrame *MultiFramePrompt `json:"multi_frame,omitempty"` // 多帧提示词 } // SingleFramePrompt 单帧提示词 type SingleFramePrompt struct { Prompt string `json:"prompt"` Description string `json:"description"` } // MultiFramePrompt 多帧提示词 type MultiFramePrompt struct { Layout string `json:"layout"` // horizontal_3, grid_2x2 等 Frames []SingleFramePrompt `json:"frames"` } // GenerateFramePrompt 生成指定类型的帧提示词并保存到frame_prompts表 func (s *FramePromptService) GenerateFramePrompt(req GenerateFramePromptRequest, model string) (string, error) { // 查询分镜信息 var storyboard models.Storyboard if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil { return "", fmt.Errorf("storyboard not found: %w", err) } // 创建任务 task, err := s.taskService.CreateTask("frame_prompt_generation", req.StoryboardID) if err != nil { s.log.Errorw("Failed to create frame prompt generation task", "error", err, "storyboard_id", req.StoryboardID) return "", fmt.Errorf("创建任务失败: %w", err) } // 异步处理帧提示词生成 go s.processFramePromptGeneration(task.ID, req, model) s.log.Infow("Frame prompt generation task created", "task_id", task.ID, "storyboard_id", req.StoryboardID, "frame_type", req.FrameType) return task.ID, nil } // processFramePromptGeneration 异步处理帧提示词生成 func (s *FramePromptService) processFramePromptGeneration(taskID string, req GenerateFramePromptRequest, model string) { // 更新任务状态为处理中 s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成帧提示词...") // 查询分镜信息 var storyboard models.Storyboard if err := s.db.Preload("Characters").First(&storyboard, req.StoryboardID).Error; err != nil { s.log.Errorw("Storyboard not found during frame prompt generation", "error", err, "storyboard_id", req.StoryboardID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "分镜信息不存在") return } // 获取场景信息 var scene *models.Scene if storyboard.SceneID != nil { scene = &models.Scene{} if err := s.db.First(scene, *storyboard.SceneID).Error; err != nil { s.log.Warnw("Scene not found during frame prompt generation", "scene_id", *storyboard.SceneID, "task_id", taskID) scene = nil } } // 获取 drama 的 style 信息 var episode models.Episode if err := s.db.Preload("Drama").First(&episode, storyboard.EpisodeID).Error; err != nil { s.log.Warnw("Failed to load episode and drama", "error", err, "episode_id", storyboard.EpisodeID) } dramaStyle := episode.Drama.Style response := &FramePromptResponse{ FrameType: req.FrameType, } // 生成提示词 switch req.FrameType { case FrameTypeFirst: response.SingleFrame = s.generateFirstFrame(storyboard, scene, dramaStyle, model) // 保存单帧提示词 s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") case FrameTypeKey: response.SingleFrame = s.generateKeyFrame(storyboard, scene, dramaStyle, model) s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") case FrameTypeLast: response.SingleFrame = s.generateLastFrame(storyboard, scene, dramaStyle, model) s.saveFramePrompt(req.StoryboardID, string(req.FrameType), response.SingleFrame.Prompt, response.SingleFrame.Description, "") case FrameTypePanel: count := req.PanelCount if count == 0 { count = 3 } response.MultiFrame = s.generatePanelFrames(storyboard, scene, count, dramaStyle, model) // 保存多帧提示词(合并为一条记录) var prompts []string for _, frame := range response.MultiFrame.Frames { prompts = append(prompts, frame.Prompt) } combinedPrompt := strings.Join(prompts, "\n---\n") s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "分镜板组合提示词", response.MultiFrame.Layout) case FrameTypeAction: response.MultiFrame = s.generateActionSequence(storyboard, scene, dramaStyle, model) var prompts []string for _, frame := range response.MultiFrame.Frames { prompts = append(prompts, frame.Prompt) } combinedPrompt := strings.Join(prompts, "\n---\n") s.saveFramePrompt(req.StoryboardID, string(req.FrameType), combinedPrompt, "动作序列组合提示词", response.MultiFrame.Layout) default: s.log.Errorw("Unsupported frame type during frame prompt generation", "frame_type", req.FrameType, "task_id", taskID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "不支持的帧类型") return } // 更新任务状态为完成 s.taskService.UpdateTaskResult(taskID, map[string]interface{}{ "response": response, "storyboard_id": req.StoryboardID, "frame_type": string(req.FrameType), }) s.log.Infow("Frame prompt generation completed", "task_id", taskID, "storyboard_id", req.StoryboardID, "frame_type", req.FrameType) } // saveFramePrompt 保存帧提示词到数据库 func (s *FramePromptService) saveFramePrompt(storyboardID, frameType, prompt, description, layout string) { framePrompt := models.FramePrompt{ StoryboardID: uint(mustParseUint(storyboardID)), FrameType: frameType, Prompt: prompt, } if description != "" { framePrompt.Description = &description } if layout != "" { framePrompt.Layout = &layout } // 先删除同类型的旧记录(保持最新) s.db.Where("storyboard_id = ? AND frame_type = ?", storyboardID, frameType).Delete(&models.FramePrompt{}) // 插入新记录 if err := s.db.Create(&framePrompt).Error; err != nil { s.log.Warnw("Failed to save frame prompt", "error", err, "storyboard_id", storyboardID, "frame_type", frameType) } } // mustParseUint 辅助函数 func mustParseUint(s string) uint64 { var result uint64 fmt.Sscanf(s, "%d", &result) return result } // generateFirstFrame 生成首帧提示词 func (s *FramePromptService) generateFirstFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt { // 构建上下文信息 contextInfo := s.buildStoryboardContext(sb, scene) // 使用国际化提示词 systemPrompt := s.promptI18n.GetFirstFramePrompt(dramaStyle) userPrompt := s.promptI18n.FormatUserPrompt("frame_info", contextInfo) // 调用AI生成(如果指定了模型则使用指定的模型) var aiResponse string var err error if model != "" { client, getErr := s.aiService.GetAIClientForModel("text", model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr) aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } else { aiResponse, err = client.GenerateText(userPrompt, systemPrompt) } } else { aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } if err != nil { s.log.Warnw("AI generation failed, using fallback", "error", err) // 降级方案:使用简单拼接 fallbackPrompt := s.buildFallbackPrompt(sb, scene, "first frame, static shot") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "镜头开始的静态画面,展示初始状态", } } // 解析AI返回的JSON result := s.parseFramePromptJSON(aiResponse) if result == nil { // JSON解析失败,使用降级方案 s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "first frame, static shot") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "镜头开始的静态画面,展示初始状态", } } return result } // generateKeyFrame 生成关键帧提示词 func (s *FramePromptService) generateKeyFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt { // 构建上下文信息 contextInfo := s.buildStoryboardContext(sb, scene) // 使用国际化提示词 systemPrompt := s.promptI18n.GetKeyFramePrompt(dramaStyle) userPrompt := s.promptI18n.FormatUserPrompt("key_frame_info", contextInfo) // 调用AI生成(如果指定了模型则使用指定的模型) var aiResponse string var err error if model != "" { client, getErr := s.aiService.GetAIClientForModel("text", model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr) aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } else { aiResponse, err = client.GenerateText(userPrompt, systemPrompt) } } else { aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } if err != nil { s.log.Warnw("AI generation failed, using fallback", "error", err) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "key frame, dynamic action") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "动作高潮瞬间,展示关键动作", } } // 解析AI返回的JSON result := s.parseFramePromptJSON(aiResponse) if result == nil { // JSON解析失败,使用降级方案 s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "key frame, dynamic action") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "动作高潮瞬间,展示关键动作", } } return result } // generateLastFrame 生成尾帧提示词 func (s *FramePromptService) generateLastFrame(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *SingleFramePrompt { // 构建上下文信息 contextInfo := s.buildStoryboardContext(sb, scene) // 使用国际化提示词 systemPrompt := s.promptI18n.GetLastFramePrompt(dramaStyle) userPrompt := s.promptI18n.FormatUserPrompt("last_frame_info", contextInfo) // 调用AI生成(如果指定了模型则使用指定的模型) var aiResponse string var err error if model != "" { client, getErr := s.aiService.GetAIClientForModel("text", model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr) aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } else { aiResponse, err = client.GenerateText(userPrompt, systemPrompt) } } else { aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } if err != nil { s.log.Warnw("AI generation failed, using fallback", "error", err) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "last frame, final state") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "镜头结束画面,展示最终状态和结果", } } // 解析AI返回的JSON result := s.parseFramePromptJSON(aiResponse) if result == nil { // JSON解析失败,使用降级方案 s.log.Warnw("Failed to parse AI JSON response, using fallback", "storyboard_id", sb.ID, "response", aiResponse) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "last frame, final state") return &SingleFramePrompt{ Prompt: fallbackPrompt, Description: "镜头结束画面,展示最终状态和结果", } } return result } // generatePanelFrames 生成分镜板提示词(多格组合) func (s *FramePromptService) generatePanelFrames(sb models.Storyboard, scene *models.Scene, count int, dramaStyle string, model string) *MultiFramePrompt { layout := fmt.Sprintf("horizontal_%d", count) frames := make([]SingleFramePrompt, count) // 固定生成:首帧 -> 关键帧 -> 尾帧 if count == 3 { frames[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model) frames[0].Description = "第1格:初始状态" frames[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model) frames[1].Description = "第2格:动作高潮" frames[2] = *s.generateLastFrame(sb, scene, dramaStyle, model) frames[2].Description = "第3格:最终状态" } else if count == 4 { // 4格:首帧 -> 中间帧1 -> 中间帧2 -> 尾帧 frames[0] = *s.generateFirstFrame(sb, scene, dramaStyle, model) frames[1] = *s.generateKeyFrame(sb, scene, dramaStyle, model) frames[2] = *s.generateKeyFrame(sb, scene, dramaStyle, model) frames[3] = *s.generateLastFrame(sb, scene, dramaStyle, model) } return &MultiFramePrompt{ Layout: layout, Frames: frames, } } // generateActionSequence 生成动作序列提示词(3x3宫格) func (s *FramePromptService) generateActionSequence(sb models.Storyboard, scene *models.Scene, dramaStyle string, model string) *MultiFramePrompt { // 构建上下文信息 contextInfo := s.buildStoryboardContext(sb, scene) // 使用国际化提示词 - 专门为动作序列设计的提示词 systemPrompt := s.promptI18n.GetActionSequenceFramePrompt(dramaStyle) userPrompt := s.promptI18n.FormatUserPrompt("frame_info", contextInfo) // 调用AI生成(如果指定了模型则使用指定的模型) var aiResponse string var err error if model != "" { client, getErr := s.aiService.GetAIClientForModel("text", model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr) aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } else { aiResponse, err = client.GenerateText(userPrompt, systemPrompt) } } else { aiResponse, err = s.aiService.GenerateText(userPrompt, systemPrompt) } if err != nil { s.log.Warnw("AI generation failed for action sequence, using fallback", "error", err) // 降级方案:使用简单拼接 fallbackPrompt := s.buildFallbackPrompt(sb, scene, "3x3 storyboard grid action sequence, character consistency, continuous movement progression") return &MultiFramePrompt{ Layout: "grid_3x3", Frames: []SingleFramePrompt{ { Prompt: fallbackPrompt, Description: "3x3宫格动作序列,展示连贯的动作演进", }, }, } } // 解析AI返回的JSON result := s.parseFramePromptJSON(aiResponse) if result == nil { // JSON解析失败,使用降级方案 s.log.Warnw("Failed to parse AI JSON response for action sequence, using fallback", "storyboard_id", sb.ID, "response", aiResponse) fallbackPrompt := s.buildFallbackPrompt(sb, scene, "3x3 storyboard grid action sequence, character consistency, continuous movement progression") return &MultiFramePrompt{ Layout: "grid_3x3", Frames: []SingleFramePrompt{ { Prompt: fallbackPrompt, Description: "3x3宫格动作序列,展示连贯的动作演进", }, }, } } // 动作序列是一个整体的3x3宫格图片,所以只返回一个prompt return &MultiFramePrompt{ Layout: "grid_3x3", Frames: []SingleFramePrompt{*result}, } } // buildStoryboardContext 构建镜头上下文信息 func (s *FramePromptService) buildStoryboardContext(sb models.Storyboard, scene *models.Scene) string { var parts []string // 镜头描述(最重要) if sb.Description != nil && *sb.Description != "" { parts = append(parts, s.promptI18n.FormatUserPrompt("shot_description_label", *sb.Description)) } // 场景信息 if scene != nil { parts = append(parts, s.promptI18n.FormatUserPrompt("scene_label", scene.Location, scene.Time)) } else if sb.Location != nil && sb.Time != nil { parts = append(parts, s.promptI18n.FormatUserPrompt("scene_label", *sb.Location, *sb.Time)) } // 角色 if len(sb.Characters) > 0 { var charNames []string for _, char := range sb.Characters { charNames = append(charNames, char.Name) } parts = append(parts, s.promptI18n.FormatUserPrompt("characters_label", strings.Join(charNames, ", "))) } // 动作 if sb.Action != nil && *sb.Action != "" { parts = append(parts, s.promptI18n.FormatUserPrompt("action_label", *sb.Action)) } // 结果 if sb.Result != nil && *sb.Result != "" { parts = append(parts, s.promptI18n.FormatUserPrompt("result_label", *sb.Result)) } // 对白 if sb.Dialogue != nil && *sb.Dialogue != "" { parts = append(parts, s.promptI18n.FormatUserPrompt("dialogue_label", *sb.Dialogue)) } // 氛围 if sb.Atmosphere != nil && *sb.Atmosphere != "" { parts = append(parts, s.promptI18n.FormatUserPrompt("atmosphere_label", *sb.Atmosphere)) } // 镜头参数 if sb.ShotType != nil { parts = append(parts, s.promptI18n.FormatUserPrompt("shot_type_label", *sb.ShotType)) } if sb.Angle != nil { parts = append(parts, s.promptI18n.FormatUserPrompt("angle_label", *sb.Angle)) } if sb.Movement != nil { parts = append(parts, s.promptI18n.FormatUserPrompt("movement_label", *sb.Movement)) } return strings.Join(parts, "\n") } // buildFallbackPrompt 构建降级提示词(AI失败时使用) func (s *FramePromptService) buildFallbackPrompt(sb models.Storyboard, scene *models.Scene, suffix string) string { var parts []string // 场景 if scene != nil { parts = append(parts, fmt.Sprintf("%s, %s", scene.Location, scene.Time)) } // 角色 if len(sb.Characters) > 0 { for _, char := range sb.Characters { parts = append(parts, char.Name) } } // 氛围 if sb.Atmosphere != nil { parts = append(parts, *sb.Atmosphere) } parts = append(parts, "anime style", suffix) return strings.Join(parts, ", ") } ================================================ FILE: application/services/image_generation_service.go ================================================ package services import ( "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/image" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "gorm.io/gorm" ) type ImageGenerationService struct { db *gorm.DB aiService *AIService transferService *ResourceTransferService localStorage *storage.LocalStorage log *logger.Logger config *config.Config promptI18n *PromptI18n taskService *TaskService } // truncateImageURL 截断图片 URL,避免 base64 格式的 URL 占满日志 func truncateImageURL(url string) string { if url == "" { return "" } // 如果是 data URI 格式(base64),只显示前缀 if strings.HasPrefix(url, "data:") { if len(url) > 50 { return url[:50] + "...[base64 data]" } } // 普通 URL 如果过长也截断 if len(url) > 100 { return url[:100] + "..." } return url } func NewImageGenerationService(db *gorm.DB, cfg *config.Config, transferService *ResourceTransferService, localStorage *storage.LocalStorage, log *logger.Logger) *ImageGenerationService { return &ImageGenerationService{ db: db, aiService: NewAIService(db, log), transferService: transferService, localStorage: localStorage, config: cfg, promptI18n: NewPromptI18n(cfg), log: log, taskService: NewTaskService(db, log), } } // GetDB 获取数据库连接 func (s *ImageGenerationService) GetDB() *gorm.DB { return s.db } type GenerateImageRequest struct { StoryboardID *uint `json:"storyboard_id"` DramaID string `json:"drama_id" binding:"required"` SceneID *uint `json:"scene_id"` CharacterID *uint `json:"character_id"` PropID *uint `json:"prop_id"` ImageType string `json:"image_type"` // character, scene, storyboard FrameType *string `json:"frame_type"` // first, key, last, panel, action Prompt string `json:"prompt" binding:"required,min=5,max=2000"` NegativePrompt *string `json:"negative_prompt"` Provider string `json:"provider"` Model string `json:"model"` Size string `json:"size"` Quality string `json:"quality"` Style *string `json:"style"` Steps *int `json:"steps"` CfgScale *float64 `json:"cfg_scale"` Seed *int64 `json:"seed"` Width *int `json:"width"` Height *int `json:"height"` ImageLocalPath *string `json:"image_local_path"` // 本地图片路径,用于图生图 ReferenceImages []string `json:"reference_images"` // 参考图片URL列表 } func (s *ImageGenerationService) GenerateImage(request *GenerateImageRequest) (*models.ImageGeneration, error) { var drama models.Drama if err := s.db.Where("id = ? ", request.DramaID).First(&drama).Error; err != nil { return nil, fmt.Errorf("drama not found") } // 注意:SceneID可能指向Scene或Storyboard表,调用方已经做过权限验证,这里不再重复验证 provider := request.Provider if provider == "" { provider = "openai" } // 序列化参考图片 var referenceImagesJSON []byte if len(request.ReferenceImages) > 0 { referenceImagesJSON, _ = json.Marshal(request.ReferenceImages) } // 转换DramaID dramaIDParsed, err := strconv.ParseUint(request.DramaID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid drama ID") } // 设置默认图片类型 imageType := request.ImageType if imageType == "" { imageType = string(models.ImageTypeStoryboard) } imageGen := &models.ImageGeneration{ StoryboardID: request.StoryboardID, DramaID: uint(dramaIDParsed), SceneID: request.SceneID, CharacterID: request.CharacterID, PropID: request.PropID, ImageType: imageType, FrameType: request.FrameType, Provider: provider, Prompt: request.Prompt, NegPrompt: request.NegativePrompt, Model: request.Model, Size: request.Size, ReferenceImages: referenceImagesJSON, Quality: request.Quality, Style: request.Style, Steps: request.Steps, CfgScale: request.CfgScale, Seed: request.Seed, Width: request.Width, Height: request.Height, LocalPath: request.ImageLocalPath, Status: models.ImageStatusPending, } if err := s.db.Create(imageGen).Error; err != nil { return nil, fmt.Errorf("failed to create record: %w", err) } go s.ProcessImageGeneration(imageGen.ID) return imageGen, nil } func (s *ImageGenerationService) ProcessImageGeneration(imageGenID uint) { var imageGen models.ImageGeneration imageRatio := "16:9" if err := s.db.First(&imageGen, imageGenID).Error; err != nil { s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID) return } // 获取drama的style信息 var drama models.Drama if err := s.db.First(&drama, imageGen.DramaID).Error; err != nil { s.log.Warnw("Failed to load drama for style", "error", err, "drama_id", imageGen.DramaID) } s.db.Model(&imageGen).Update("status", models.ImageStatusProcessing) // 如果关联了background,同步更新background为generating状态 if imageGen.StoryboardID != nil { if err := s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.StoryboardID).Update("status", "generating").Error; err != nil { s.log.Warnw("Failed to update background status to generating", "scene_id", *imageGen.StoryboardID, "error", err) } else { s.log.Infow("Background status updated to generating", "scene_id", *imageGen.StoryboardID) } } client, err := s.getImageClientWithModel(imageGen.Provider, imageGen.Model) if err != nil { s.log.Errorw("Failed to get image client", "error", err, "provider", imageGen.Provider, "model", imageGen.Model) s.updateImageGenError(imageGenID, err.Error()) return } // 解析参考图片 var referenceImagePaths []string if len(imageGen.ReferenceImages) > 0 { if err := json.Unmarshal(imageGen.ReferenceImages, &referenceImagePaths); err == nil { s.log.Infow("Using reference images for generation", "id", imageGenID, "reference_count", len(referenceImagePaths), "references", referenceImagePaths) } } // 如果有 local_path,添加到参考图片列表的开头 if imageGen.LocalPath != nil && *imageGen.LocalPath != "" { referenceImagePaths = append([]string{*imageGen.LocalPath}, referenceImagePaths...) } // 将所有参考图片路径转换为 base64(如果是本地路径)或保持原样(如果是 URL) var referenceImages []string for _, imgPath := range referenceImagePaths { // 判断是否为 HTTP/HTTPS URL if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") { // 保持 URL 原样 referenceImages = append(referenceImages, imgPath) } else { // 视为本地路径,转换为 base64 base64Image, err := s.loadImageAsBase64(imgPath) if err != nil { s.log.Warnw("Failed to load local image as base64", "error", err, "id", imageGenID, "local_path", imgPath) } else { referenceImages = append(referenceImages, base64Image) s.log.Infow("Loaded local image for generation", "id", imageGenID, "local_path", imgPath) } } } s.log.Infow("Starting image generation", "id", imageGenID, "prompt", imageGen.Prompt, "provider", imageGen.Provider) var opts []image.ImageOption if imageGen.NegPrompt != nil && *imageGen.NegPrompt != "" { opts = append(opts, image.WithNegativePrompt(*imageGen.NegPrompt)) } if imageGen.Size != "" { opts = append(opts, image.WithSize(imageGen.Size)) } if imageGen.Quality != "" { opts = append(opts, image.WithQuality(imageGen.Quality)) } if imageGen.Style != nil && *imageGen.Style != "" { opts = append(opts, image.WithStyle(*imageGen.Style)) } if imageGen.Steps != nil { opts = append(opts, image.WithSteps(*imageGen.Steps)) } if imageGen.CfgScale != nil { opts = append(opts, image.WithCfgScale(*imageGen.CfgScale)) } if imageGen.Seed != nil { opts = append(opts, image.WithSeed(*imageGen.Seed)) } if imageGen.Model != "" { opts = append(opts, image.WithModel(imageGen.Model)) } if imageGen.Width != nil && imageGen.Height != nil { opts = append(opts, image.WithDimensions(*imageGen.Width, *imageGen.Height)) } // 添加参考图片 if len(referenceImages) > 0 { opts = append(opts, image.WithReferenceImages(referenceImages)) } // 构建完整的提示词:风格提示词 + 用户提示词 prompt := imageGen.Prompt // 如果drama有风格设置,添加风格提示词 if drama.Style != "" && drama.Style != "realistic" { stylePrompt := s.promptI18n.GetStylePrompt(drama.Style) if stylePrompt != "" { // 将风格提示词作为系统级约束添加到提示词前面 prompt = stylePrompt + "\n\n" + prompt s.log.Infow("Added style prompt to image generation", "id", imageGenID, "style", drama.Style, "style_prompt_length", len(stylePrompt)) } } prompt += ", imageRatio:" + imageRatio // 如果有参考图,在提示词末尾添加参考图一致性说明 if len(referenceImages) > 0 { prompt += "\n\n**重要:**\n**必须严格**遵守参考图内的内容元素,保持场景和角色的**一致性**" s.log.Infow("Added reference image consistency instruction to prompt", "id", imageGenID, "reference_count", len(referenceImages)) } result, err := client.GenerateImage(prompt, opts...) if err != nil { s.log.Errorw("Image generation API call failed", "error", err, "id", imageGenID, "prompt", imageGen.Prompt) s.updateImageGenError(imageGenID, err.Error()) return } s.log.Infow("Image generation API call completed", "id", imageGenID, "completed", result.Completed, "has_url", result.ImageURL != "") if !result.Completed { s.db.Model(&imageGen).Updates(map[string]interface{}{ "status": models.ImageStatusProcessing, "task_id": result.TaskID, }) go s.pollTaskStatus(imageGenID, client, result.TaskID) return } s.completeImageGeneration(imageGenID, result) } func (s *ImageGenerationService) pollTaskStatus(imageGenID uint, client image.ImageClient, taskID string) { maxAttempts := 60 pollInterval := 5 * time.Second for i := 0; i < maxAttempts; i++ { time.Sleep(pollInterval) result, err := client.GetTaskStatus(taskID) if err != nil { s.log.Errorw("Failed to get task status", "error", err, "task_id", taskID) continue } if result.Completed { s.completeImageGeneration(imageGenID, result) return } if result.Error != "" { s.updateImageGenError(imageGenID, result.Error) return } } s.updateImageGenError(imageGenID, "timeout: image generation took too long") } func (s *ImageGenerationService) completeImageGeneration(imageGenID uint, result *image.ImageResult) { now := time.Now() // 下载图片到本地存储并保存相对路径到数据库 var localPath *string if s.localStorage != nil && result.ImageURL != "" && (strings.HasPrefix(result.ImageURL, "http://") || strings.HasPrefix(result.ImageURL, "https://")) { downloadResult, err := s.localStorage.DownloadFromURLWithPath(result.ImageURL, "images") if err != nil { errStr := err.Error() if len(errStr) > 200 { errStr = errStr[:200] + "..." } s.log.Warnw("Failed to download image to local storage", "error", errStr, "id", imageGenID, "original_url", truncateImageURL(result.ImageURL)) } else { localPath = &downloadResult.RelativePath s.log.Infow("Image downloaded to local storage", "id", imageGenID, "original_url", truncateImageURL(result.ImageURL), "local_path", downloadResult.RelativePath) } } // 数据库中保存原始URL和本地路径 updates := map[string]interface{}{ "status": models.ImageStatusCompleted, "image_url": result.ImageURL, "local_path": localPath, "completed_at": now, } if result.Width > 0 { updates["width"] = result.Width } if result.Height > 0 { updates["height"] = result.Height } // 更新image_generation记录 var imageGen models.ImageGeneration if err := s.db.Where("id = ?", imageGenID).First(&imageGen).Error; err != nil { s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID) return } // 使用 Updates 更新基本字段 if err := s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update image generation", "error", err, "id", imageGenID) return } // 单独更新 local_path 字段(即使为 nil 也要更新) if err := s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Update("local_path", localPath).Error; err != nil { s.log.Errorw("Failed to update local_path", "error", err, "id", imageGenID) } s.log.Infow("Image generation completed", "id", imageGenID) // 如果关联了storyboard,同步更新storyboard的composed_image if imageGen.StoryboardID != nil { if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *imageGen.StoryboardID).Update("composed_image", result.ImageURL).Error; err != nil { s.log.Errorw("Failed to update storyboard composed_image", "error", err, "storyboard_id", *imageGen.StoryboardID) } else { s.log.Infow("Storyboard updated with composed image", "storyboard_id", *imageGen.StoryboardID, "composed_image", truncateImageURL(result.ImageURL)) } } // 如果关联了scene,同步更新scene的image_url、local_path和status(仅当ImageType是scene时) if imageGen.SceneID != nil && imageGen.ImageType == string(models.ImageTypeScene) { sceneUpdates := map[string]interface{}{ "status": "generated", "image_url": result.ImageURL, } if localPath != nil { sceneUpdates["local_path"] = localPath } if err := s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Updates(sceneUpdates).Error; err != nil { s.log.Errorw("Failed to update scene", "error", err, "scene_id", *imageGen.SceneID) } else { s.log.Infow("Scene updated with generated image", "scene_id", *imageGen.SceneID, "image_url", truncateImageURL(result.ImageURL), "local_path", localPath) } } // 如果关联了角色,同步更新角色的image_url和local_path if imageGen.CharacterID != nil { characterUpdates := map[string]interface{}{ "image_url": result.ImageURL, } if localPath != nil { characterUpdates["local_path"] = localPath } if err := s.db.Model(&models.Character{}).Where("id = ?", *imageGen.CharacterID).Updates(characterUpdates).Error; err != nil { s.log.Errorw("Failed to update character", "error", err, "character_id", *imageGen.CharacterID) } else { s.log.Infow("Character updated with generated image", "character_id", *imageGen.CharacterID, "image_url", truncateImageURL(result.ImageURL), "local_path", localPath) } } // 如果关联了道具,同步更新道具的image_url和local_path if imageGen.PropID != nil { propUpdates := map[string]interface{}{ "image_url": result.ImageURL, } if localPath != nil { propUpdates["local_path"] = localPath } if err := s.db.Model(&models.Prop{}).Where("id = ?", *imageGen.PropID).Updates(propUpdates).Error; err != nil { s.log.Errorw("Failed to update prop", "error", err, "prop_id", *imageGen.PropID) } else { s.log.Infow("Prop updated with generated image", "prop_id", *imageGen.PropID, "image_url", truncateImageURL(result.ImageURL), "local_path", localPath) } } } func (s *ImageGenerationService) updateImageGenError(imageGenID uint, errorMsg string) { // 先获取image_generation记录 var imageGen models.ImageGeneration if err := s.db.Where("id = ?", imageGenID).First(&imageGen).Error; err != nil { s.log.Errorw("Failed to load image generation", "error", err, "id", imageGenID) return } // 更新image_generation状态 s.db.Model(&models.ImageGeneration{}).Where("id = ?", imageGenID).Updates(map[string]interface{}{ "status": models.ImageStatusFailed, "error_msg": errorMsg, }) s.log.Errorw("Image generation failed", "id", imageGenID, "error", errorMsg) // 如果关联了scene,同步更新scene为失败状态 if imageGen.SceneID != nil { s.db.Model(&models.Scene{}).Where("id = ?", *imageGen.SceneID).Update("status", "failed") s.log.Warnw("Scene marked as failed", "scene_id", *imageGen.SceneID) } } func (s *ImageGenerationService) getImageClient(provider string) (image.ImageClient, error) { config, err := s.aiService.GetDefaultConfig("image") if err != nil { return nil, fmt.Errorf("no image AI config found: %w", err) } // 使用第一个模型 model := "" if len(config.Model) > 0 { model = config.Model[0] } // 使用配置中的 provider,如果没有则使用传入的 provider actualProvider := config.Provider if actualProvider == "" { actualProvider = provider } // 根据 provider 自动设置默认端点 var endpoint string var queryEndpoint string switch actualProvider { case "openai", "dalle": endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil case "chatfire": endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil case "volcengine", "volces", "doubao": endpoint = "/images/generations" queryEndpoint = "" return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil case "gemini", "google": endpoint = "/v1beta/models/{model}:generateContent" return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil default: endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil } } // getImageClientWithModel 根据模型名称获取图片客户端 func (s *ImageGenerationService) getImageClientWithModel(provider string, modelName string) (image.ImageClient, error) { var config *models.AIServiceConfig var err error // 如果指定了模型,尝试获取对应的配置 if modelName != "" { config, err = s.aiService.GetConfigForModel("image", modelName) if err != nil { s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err) config, err = s.aiService.GetDefaultConfig("image") if err != nil { return nil, fmt.Errorf("no image AI config found: %w", err) } } } else { config, err = s.aiService.GetDefaultConfig("image") if err != nil { return nil, fmt.Errorf("no image AI config found: %w", err) } } // 使用指定的模型或配置中的第一个模型 model := modelName if model == "" && len(config.Model) > 0 { model = config.Model[0] } // 使用配置中的 provider,如果没有则使用传入的 provider actualProvider := config.Provider if actualProvider == "" { actualProvider = provider } // 根据 provider 自动设置默认端点 var endpoint string var queryEndpoint string switch actualProvider { case "openai", "dalle": endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil case "chatfire": endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil case "volcengine", "volces", "doubao": endpoint = "/images/generations" queryEndpoint = "" return image.NewVolcEngineImageClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil case "gemini", "google": endpoint = "/v1beta/models/{model}:generateContent" return image.NewGeminiImageClient(config.BaseURL, config.APIKey, model, endpoint), nil default: endpoint = "/images/generations" return image.NewOpenAIImageClient(config.BaseURL, config.APIKey, model, endpoint), nil } } func (s *ImageGenerationService) GetImageGeneration(imageGenID uint) (*models.ImageGeneration, error) { var imageGen models.ImageGeneration if err := s.db.Where("id = ? ", imageGenID).First(&imageGen).Error; err != nil { return nil, err } return &imageGen, nil } func (s *ImageGenerationService) ListImageGenerations(dramaID *uint, sceneID *uint, storyboardID *uint, frameType string, status string, page, pageSize int) ([]models.ImageGeneration, int64, error) { query := s.db.Model(&models.ImageGeneration{}) if dramaID != nil { query = query.Where("drama_id = ?", *dramaID) } if sceneID != nil { query = query.Where("scene_id = ?", *sceneID) } if storyboardID != nil { query = query.Where("storyboard_id = ?", *storyboardID) } if frameType != "" { query = query.Where("frame_type = ?", frameType) } if status != "" { query = query.Where("status = ?", status) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var images []models.ImageGeneration offset := (page - 1) * pageSize if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&images).Error; err != nil { return nil, 0, err } return images, total, nil } func (s *ImageGenerationService) DeleteImageGeneration(imageGenID uint) error { result := s.db.Where("id = ? ", imageGenID).Delete(&models.ImageGeneration{}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return fmt.Errorf("image generation not found") } return nil } // UploadImageRequest 上传图片请求 type UploadImageRequest struct { StoryboardID uint `json:"storyboard_id"` DramaID uint `json:"drama_id"` FrameType string `json:"frame_type"` ImageURL string `json:"image_url"` Prompt string `json:"prompt"` } // CreateImageFromUpload 从上传的图片URL创建图片生成记录 func (s *ImageGenerationService) CreateImageFromUpload(req *UploadImageRequest) (*models.ImageGeneration, error) { // 验证storyboard存在 var storyboard models.Storyboard if err := s.db.First(&storyboard, req.StoryboardID).Error; err != nil { return nil, fmt.Errorf("storyboard not found") } // 验证drama存在 var drama models.Drama if err := s.db.First(&drama, req.DramaID).Error; err != nil { return nil, fmt.Errorf("drama not found") } prompt := req.Prompt if prompt == "" { prompt = "用户上传图片" } now := time.Now() imageGen := &models.ImageGeneration{ StoryboardID: &req.StoryboardID, DramaID: req.DramaID, ImageType: string(models.ImageTypeStoryboard), FrameType: &req.FrameType, Provider: "upload", Prompt: prompt, Model: "upload", ImageURL: &req.ImageURL, Status: models.ImageStatusCompleted, CompletedAt: &now, } if err := s.db.Create(imageGen).Error; err != nil { return nil, fmt.Errorf("failed to create image record: %w", err) } s.log.Infow("Image created from upload", "id", imageGen.ID, "storyboard_id", req.StoryboardID, "frame_type", req.FrameType) return imageGen, nil } func (s *ImageGenerationService) GenerateImagesForScene(sceneID string) ([]*models.ImageGeneration, error) { // 转换sceneID sid, err := strconv.ParseUint(sceneID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid scene ID") } sceneIDUint := uint(sid) var scene models.Scene if err := s.db.Where("id = ?", sceneIDUint).First(&scene).Error; err != nil { return nil, fmt.Errorf("scene not found") } // 构建场景图片生成提示词 prompt := scene.Prompt if prompt == "" { // 如果Prompt为空,使用Location和Time构建 prompt = fmt.Sprintf("%s场景,%s", scene.Location, scene.Time) } req := &GenerateImageRequest{ SceneID: &sceneIDUint, DramaID: fmt.Sprintf("%d", scene.DramaID), ImageType: string(models.ImageTypeScene), Prompt: prompt, } imageGen, err := s.GenerateImage(req) if err != nil { return nil, err } return []*models.ImageGeneration{imageGen}, nil } // BackgroundInfo 背景信息结构 type BackgroundInfo struct { Location string `json:"location"` Time string `json:"time"` Atmosphere string `json:"atmosphere"` Prompt string `json:"prompt"` StoryboardNumbers []int `json:"storyboard_numbers"` SceneIDs []uint `json:"scene_ids"` StoryboardCount int `json:"scene_count"` } func (s *ImageGenerationService) BatchGenerateImagesForEpisode(episodeID string) ([]*models.ImageGeneration, error) { var ep models.Episode if err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&ep).Error; err != nil { return nil, fmt.Errorf("episode not found") } // 从数据库读取已保存的场景 var scenes []models.Storyboard if err := s.db.Where("episode_id = ?", episodeID).Find(&scenes).Error; err != nil { return nil, fmt.Errorf("failed to get scenes: %w", err) } backgrounds := s.extractUniqueBackgrounds(scenes) s.log.Infow("Extracted unique backgrounds", "episode_id", episodeID, "background_count", len(backgrounds)) // 为每个背景生成图片 var results []*models.ImageGeneration for _, bg := range scenes { if bg.ImagePrompt == nil || *bg.ImagePrompt == "" { s.log.Warnw("Background has no prompt, skipping", "scene_id", bg.ID) continue } // 更新背景状态为处理中 s.db.Model(bg).Update("status", "generating") req := &GenerateImageRequest{ StoryboardID: &bg.ID, DramaID: fmt.Sprintf("%d", ep.DramaID), Prompt: *bg.ImagePrompt, } imageGen, err := s.GenerateImage(req) if err != nil { s.log.Errorw("Failed to generate image for background", "scene_id", bg.ID, "location", bg.Location, "error", err) s.db.Model(bg).Update("status", "failed") continue } s.log.Infow("Background image generation started", "scene_id", bg.ID, "image_gen_id", imageGen.ID, "location", bg.Location, "time", bg.Time) results = append(results, imageGen) } return results, nil } // GetScencesForEpisode 获取项目的场景列表(项目级) func (s *ImageGenerationService) GetScencesForEpisode(episodeID string) ([]*models.Scene, error) { var episode models.Episode if err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error; err != nil { return nil, fmt.Errorf("episode not found") } // 场景是项目级的,通过drama_id查询 var scenes []*models.Scene if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil { return nil, fmt.Errorf("failed to load scenes: %w", err) } return scenes, nil } // ExtractBackgroundsForEpisode 从剧本内容中提取场景并保存到项目级别数据库 func (s *ImageGenerationService) ExtractBackgroundsForEpisode(episodeID string, model string, style string) (string, error) { var episode models.Episode if err := s.db.Preload("Storyboards").First(&episode, episodeID).Error; err != nil { return "", fmt.Errorf("episode not found") } // 如果没有剧本内容,无法提取场景 if episode.ScriptContent == nil || *episode.ScriptContent == "" { return "", fmt.Errorf("episode has no script content") } // 创建任务 task, err := s.taskService.CreateTask("background_extraction", episodeID) if err != nil { s.log.Errorw("Failed to create background extraction task", "error", err, "episode_id", episodeID) return "", fmt.Errorf("创建任务失败: %w", err) } // 异步处理场景提取 go s.processBackgroundExtraction(task.ID, episodeID, model, style) s.log.Infow("Background extraction task created", "task_id", task.ID, "episode_id", episodeID) return task.ID, nil } // processBackgroundExtraction 异步处理场景提取 func (s *ImageGenerationService) processBackgroundExtraction(taskID string, episodeID string, model string, style string) { // 更新任务状态为处理中 s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在提取场景信息...") var episode models.Episode if err := s.db.Preload("Storyboards").First(&episode, episodeID).Error; err != nil { s.log.Errorw("Episode not found during background extraction", "error", err, "episode_id", episodeID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧集信息不存在") return } if episode.ScriptContent == nil || *episode.ScriptContent == "" { s.log.Errorw("Episode has no script content during background extraction", "episode_id", episodeID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧本内容为空") return } s.log.Infow("Extracting backgrounds from script", "episode_id", episodeID, "model", model, "task_id", taskID) dramaID := episode.DramaID // 使用AI从剧本内容中提取场景 backgroundsInfo, err := s.extractBackgroundsFromScript(*episode.ScriptContent, dramaID, model, style) if err != nil { s.log.Errorw("Failed to extract backgrounds from script", "error", err, "task_id", taskID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "AI提取场景失败: "+err.Error()) return } // 保存到数据库(不涉及Storyboard关联,因为此时还没有生成分镜) var scenes []*models.Scene err = s.db.Transaction(func(tx *gorm.DB) error { // 先删除该章节的所有场景(实现重新提取覆盖功能) if err := tx.Where("episode_id = ?", episode.ID).Delete(&models.Scene{}).Error; err != nil { s.log.Errorw("Failed to delete old scenes", "error", err, "task_id", taskID) return err } s.log.Infow("Deleted old scenes for re-extraction", "episode_id", episode.ID, "task_id", taskID) // 创建新提取的场景 for _, bgInfo := range backgroundsInfo { // 保存新场景到数据库(章节级) episodeIDVal := episode.ID scene := &models.Scene{ DramaID: dramaID, EpisodeID: &episodeIDVal, Location: bgInfo.Location, Time: bgInfo.Time, Prompt: bgInfo.Prompt, StoryboardCount: 1, // 默认为1 Status: "pending", } if err := tx.Create(scene).Error; err != nil { return err } scenes = append(scenes, scene) s.log.Infow("Created new scene from script", "scene_id", scene.ID, "location", scene.Location, "time", scene.Time, "task_id", taskID) } return nil }) if err != nil { s.log.Errorw("Failed to save scenes to database", "error", err, "task_id", taskID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "保存场景信息失败: "+err.Error()) return } // 更新任务状态为完成 resultData := map[string]interface{}{ "scenes": scenes, "count": len(scenes), "episode_id": episodeID, "drama_id": dramaID, } s.taskService.UpdateTaskResult(taskID, resultData) s.log.Infow("Background extraction completed", "task_id", taskID, "episode_id", episodeID, "total_storyboards", len(episode.Storyboards), "unique_scenes", len(scenes)) } // extractBackgroundsFromScript 从剧本内容中使用AI提取场景信息 func (s *ImageGenerationService) extractBackgroundsFromScript(scriptContent string, dramaID uint, model string, style string) ([]BackgroundInfo, error) { if scriptContent == "" { return []BackgroundInfo{}, nil } // 获取AI客户端(如果指定了模型则使用指定的模型) var client ai.AIClient var err error if model != "" { s.log.Infow("Using specified model for background extraction", "model", model) client, err = s.aiService.GetAIClientForModel("text", model) if err != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", err) client, err = s.aiService.GetAIClient("text") } } else { client, err = s.aiService.GetAIClient("text") } if err != nil { return nil, fmt.Errorf("failed to get AI client: %w", err) } // 使用国际化提示词 systemPrompt := s.promptI18n.GetSceneExtractionPrompt(style) contentLabel := s.promptI18n.FormatUserPrompt("script_content_label") // 根据语言构建不同的格式说明 var formatInstructions string if s.promptI18n.IsEnglish() { formatInstructions = `[Output JSON Format] { "backgrounds": [ { "location": "Location name (English)", "time": "Time description (English)", "atmosphere": "Atmosphere description (English)", "prompt": "A cinematic anime-style pure background scene depicting [location description] at [time]. The scene shows [environment details, architecture, objects, lighting, no characters]. Style: rich details, high quality, atmospheric lighting. Mood: [environment mood description]." } ] } [Example] Correct example (note: no characters): { "backgrounds": [ { "location": "Repair Shop Interior", "time": "Late Night", "atmosphere": "Dim, lonely, industrial", "prompt": "A cinematic anime-style pure background scene depicting a messy repair shop interior at late night. Under dim fluorescent lights, the workbench is scattered with various wrenches, screwdrivers and mechanical parts, oil-stained tool boards and faded posters hang on walls, oil stains on the floor, used tires piled in corners. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial." }, { "location": "City Street", "time": "Dusk", "atmosphere": "Warm, busy, lively", "prompt": "A cinematic anime-style pure background scene depicting a bustling city street at dusk. Sunset afterglow shines on the asphalt road, neon lights of shops on both sides begin to light up, bicycle racks and bus stops on the street, high-rise buildings in the distance, sky showing orange-red gradient. Style: rich details, high quality, warm atmosphere. Mood: lively, busy." } ] } [Wrong Examples (containing characters, forbidden)]: ❌ "Depicting protagonist standing on the street" - contains character ❌ "People hurrying by" - contains characters ❌ "Character moving in the room" - contains character Please strictly follow the JSON format and ensure all fields use English.` } else { formatInstructions = `【输出JSON格式】 { "backgrounds": [ { "location": "地点名称(中文)", "time": "时间描述(中文)", "atmosphere": "氛围描述(中文)", "prompt": "一个电影感的动漫风格纯背景场景,展现[地点描述]在[时间]的环境。画面呈现[环境细节、建筑、物品、光线等,不包含人物]。风格:细节丰富,高质量,氛围光照。情绪:[环境情绪描述]。" } ] } 【示例】 正确示例(注意:不包含人物): { "backgrounds": [ { "location": "维修店内部", "time": "深夜", "atmosphere": "昏暗、孤独、工业感", "prompt": "一个电影感的动漫风格纯背景场景,展现凌乱的维修店内部在深夜的环境。昏暗的日光灯照射下,工作台上散落着各种扳手、螺丝刀和机械零件,墙上挂着油污斑斑的工具挂板和褪色海报,地面有油渍痕迹,角落堆放着废旧轮胎。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。" }, { "location": "城市街道", "time": "黄昏", "atmosphere": "温暖、繁忙、生活气息", "prompt": "一个电影感的动漫风格纯背景场景,展现繁华的城市街道在黄昏时分的环境。夕阳的余晖洒在街道的沥青路面上,两旁的商铺霓虹灯开始点亮,街边有自行车停靠架和公交站牌,远处高楼林立,天空呈现橙红色渐变。风格:细节丰富,高质量,温暖氛围。情绪:生活气息、繁忙。" } ] } 【错误示例(包含人物,禁止)】: ❌ "展现主角站在街道上的场景" - 包含人物 ❌ "人们匆匆而过" - 包含人物 ❌ "角色在房间里活动" - 包含人物 请严格按照JSON格式输出,确保所有字段都使用中文。` } prompt := fmt.Sprintf(`%s %s %s %s`, systemPrompt, contentLabel, scriptContent, formatInstructions) // 打印完整提示词用于调试 s.log.Infow("=== AI Prompt for Background Extraction (extractBackgroundsFromScript) ===", "language", s.promptI18n.GetLanguage(), "prompt_length", len(prompt), "full_prompt", prompt) response, err := client.GenerateText(prompt, "", ai.WithTemperature(0.7)) if err != nil { s.log.Errorw("Failed to extract backgrounds with AI", "error", err) return nil, fmt.Errorf("AI提取场景失败: %w", err) } // 打印AI返回的原始响应 s.log.Infow("=== AI Response for Background Extraction (extractBackgroundsFromScript) ===", "response_length", len(response), "raw_response", response) // 解析AI返回的JSON var backgrounds []BackgroundInfo // 先尝试解析为数组格式 if err := utils.SafeParseAIJSON(response, &backgrounds); err == nil { s.log.Infow("Parsed backgrounds as array format", "count", len(backgrounds)) } else { // 尝试解析为对象格式 var result struct { Backgrounds []BackgroundInfo `json:"backgrounds"` } if err := utils.SafeParseAIJSON(response, &result); err != nil { s.log.Errorw("Failed to parse AI response in both formats", "error", err, "response", response[:min(len(response), 500)]) return nil, fmt.Errorf("解析AI响应失败: %w", err) } backgrounds = result.Backgrounds s.log.Infow("Parsed backgrounds as object format", "count", len(backgrounds)) } s.log.Infow("Extracted backgrounds from script", "drama_id", dramaID, "backgrounds_count", len(backgrounds)) return backgrounds, nil } // extractBackgroundsWithAI 使用AI智能分析场景并提取唯一背景 func (s *ImageGenerationService) extractBackgroundsWithAI(storyboards []models.Storyboard, style string) ([]BackgroundInfo, error) { if len(storyboards) == 0 { return []BackgroundInfo{}, nil } // 构建场景列表文本,使用SceneNumber而不是索引 var scenesText string for _, storyboard := range storyboards { location := "" if storyboard.Location != nil { location = *storyboard.Location } time := "" if storyboard.Time != nil { time = *storyboard.Time } action := "" if storyboard.Action != nil { action = *storyboard.Action } description := "" if storyboard.Description != nil { description = *storyboard.Description } scenesText += fmt.Sprintf("镜头%d:\n地点: %s\n时间: %s\n动作: %s\n描述: %s\n\n", storyboard.StoryboardNumber, location, time, action, description) } // 使用国际化提示词 systemPrompt := s.promptI18n.GetSceneExtractionPrompt(style) storyboardLabel := s.promptI18n.FormatUserPrompt("storyboard_list_label") // 根据语言构建不同的提示词 var formatInstructions string if s.promptI18n.IsEnglish() { formatInstructions = `[Output JSON Format] { "backgrounds": [ { "location": "Location name (English)", "time": "Time description (English)", "prompt": "A cinematic anime-style background depicting [location description] at [time]. The scene shows [detail description]. Style: rich details, high quality, atmospheric lighting. Mood: [mood description].", "scene_numbers": [1, 2, 3] } ] } [Example] Correct example: { "backgrounds": [ { "location": "Repair Shop", "time": "Late Night", "prompt": "A cinematic anime-style background depicting a messy repair shop interior at late night. Under dim lighting, the workbench is scattered with various tools and parts, with greasy posters hanging on the walls. Style: rich details, high quality, dim atmosphere. Mood: lonely, industrial.", "scene_numbers": [1, 5, 6, 10, 15] }, { "location": "City Panorama", "time": "Late Night with Acid Rain", "prompt": "A cinematic anime-style background depicting a coastal city panorama in late night acid rain. Neon lights blur in the rain, skyscrapers shrouded in gray-green rain curtain, streets reflecting colorful lights. Style: rich details, high quality, cyberpunk atmosphere. Mood: oppressive, sci-fi, apocalyptic.", "scene_numbers": [2, 7] } ] } Please strictly follow the JSON format and ensure: 1. prompt field uses English 2. scene_numbers includes all scene numbers using this background 3. All scenes are assigned to a background` } else { formatInstructions = `【输出JSON格式】 { "backgrounds": [ { "location": "地点名称(中文)", "time": "时间描述(中文)", "prompt": "一个电影感的动漫风格背景,展现[地点描述]在[时间]的场景。画面呈现[细节描述]。风格:细节丰富,高质量,氛围光照。情绪:[情绪描述]。", "scene_numbers": [1, 2, 3] } ] } 【示例】 正确示例: { "backgrounds": [ { "location": "维修店", "time": "深夜", "prompt": "一个电影感的动漫风格背景,展现凌乱的维修店内部在深夜的场景。昏暗的灯光下,工作台上散落着各种工具和零件,墙上挂着油污的海报。风格:细节丰富,高质量,昏暗氛围。情绪:孤独、工业感。", "scene_numbers": [1, 5, 6, 10, 15] }, { "location": "城市全景", "time": "深夜·酸雨", "prompt": "一个电影感的动漫风格背景,展现沿海城市全景在深夜酸雨中的场景。霓虹灯在雨中模糊,高楼大厦笼罩在灰绿色的雨幕中,街道反射着五颜六色的光。风格:细节丰富,高质量,赛博朋克氛围。情绪:压抑、科幻、末世感。", "scene_numbers": [2, 7] } ] } 请严格按照JSON格式输出,确保: 1. prompt字段使用中文 2. scene_numbers包含所有使用该背景的场景编号 3. 所有场景都被分配到某个背景` } prompt := fmt.Sprintf(`%s %s %s %s`, systemPrompt, storyboardLabel, scenesText, formatInstructions) // 打印完整提示词用于调试 s.log.Infow("=== AI Prompt for Background Extraction (extractBackgroundsWithAI) ===", "language", s.promptI18n.GetLanguage(), "prompt_length", len(prompt), "full_prompt", prompt) // 调用AI服务 text, err := s.aiService.GenerateText(prompt, "") if err != nil { return nil, fmt.Errorf("AI analysis failed: %w", err) } // 打印AI返回的原始响应 s.log.Infow("=== AI Response for Background Extraction ===", "response_length", len(text), "raw_response", text) // 解析AI返回的JSON var result struct { Scenes []struct { Location string `json:"location"` Time string `json:"time"` Prompt string `json:"prompt"` StoryboardNumber []int `json:"storyboard_number"` } `json:"backgrounds"` } if err := utils.SafeParseAIJSON(text, &result); err != nil { return nil, fmt.Errorf("failed to parse AI response: %w", err) } // 构建场景编号到场景ID的映射 storyboardNumberToID := make(map[int]uint) for _, scene := range storyboards { storyboardNumberToID[scene.StoryboardNumber] = scene.ID } // 转换为BackgroundInfo var backgrounds []BackgroundInfo for _, bg := range result.Scenes { // 将场景编号转换为场景ID var sceneIDs []uint for _, storyboardNum := range bg.StoryboardNumber { if storyboardID, ok := storyboardNumberToID[storyboardNum]; ok { sceneIDs = append(sceneIDs, storyboardID) } } backgrounds = append(backgrounds, BackgroundInfo{ Location: bg.Location, Time: bg.Time, Prompt: bg.Prompt, StoryboardNumbers: bg.StoryboardNumber, SceneIDs: sceneIDs, StoryboardCount: len(sceneIDs), }) } s.log.Infow("AI extracted backgrounds", "total_scenes", len(storyboards), "extracted_backgrounds", len(backgrounds)) return backgrounds, nil } // extractUniqueBackgrounds 从分镜头中提取唯一背景(代码逻辑,作为AI提取的备份) func (s *ImageGenerationService) extractUniqueBackgrounds(scenes []models.Storyboard) []BackgroundInfo { backgroundMap := make(map[string]*BackgroundInfo) for _, scene := range scenes { if scene.Location == nil || scene.Time == nil { continue } // 使用 location + time 作为唯一标识 key := *scene.Location + "|" + *scene.Time if bg, exists := backgroundMap[key]; exists { // 背景已存在,添加scene ID bg.SceneIDs = append(bg.SceneIDs, scene.ID) bg.StoryboardCount++ } else { // 新背景 - 使用ImagePrompt构建背景提示词 prompt := "" if scene.ImagePrompt != nil { prompt = *scene.ImagePrompt } backgroundMap[key] = &BackgroundInfo{ Location: *scene.Location, Time: *scene.Time, Prompt: prompt, SceneIDs: []uint{scene.ID}, StoryboardCount: 1, } } } // 转换为切片 var backgrounds []BackgroundInfo for _, bg := range backgroundMap { backgrounds = append(backgrounds, *bg) } return backgrounds } // loadImageAsBase64 读取本地图片文件并转换为 base64 格式的 data URI func (s *ImageGenerationService) loadImageAsBase64(localPath string) (string, error) { // 构建完整的文件路径 var fullPath string if filepath.IsAbs(localPath) { fullPath = localPath } else { // 如果是相对路径,拼接存储根目录 if s.localStorage != nil { fullPath = s.localStorage.GetAbsolutePath(localPath) } else { fullPath = filepath.Join(s.config.Storage.LocalPath, localPath) } } // 读取文件 fileData, err := os.ReadFile(fullPath) if err != nil { return "", fmt.Errorf("failed to read image file: %w", err) } // 根据文件扩展名确定 MIME 类型 ext := strings.ToLower(filepath.Ext(fullPath)) mimeType := "image/jpeg" // 默认 switch ext { case ".png": mimeType = "image/png" case ".jpg", ".jpeg": mimeType = "image/jpeg" case ".gif": mimeType = "image/gif" case ".webp": mimeType = "image/webp" } // 转换为 base64 base64Data := base64.StdEncoding.EncodeToString(fileData) // 构建 data URI dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) return dataURI, nil } ================================================ FILE: application/services/prompt_i18n.go ================================================ package services import ( "fmt" "github.com/drama-generator/backend/pkg/config" ) // PromptI18n 提示词国际化工具 type PromptI18n struct { config *config.Config } // NewPromptI18n 创建提示词国际化工具 func NewPromptI18n(cfg *config.Config) *PromptI18n { return &PromptI18n{config: cfg} } // GetLanguage 获取当前语言设置 func (p *PromptI18n) GetLanguage() string { lang := p.config.App.Language if lang == "" { return "zh" // 默认中文 } return lang } // IsEnglish 判断是否为英文模式(动态读取配置) func (p *PromptI18n) IsEnglish() bool { return p.GetLanguage() == "en" } // GetStoryboardSystemPrompt 获取分镜生成系统提示词 func (p *PromptI18n) GetStoryboardSystemPrompt() string { if p.IsEnglish() { return `[Role] You are a senior film storyboard artist, proficient in Robert McKee's shot breakdown theory, skilled at building emotional rhythm. [Task] Break down the novel script into storyboard shots based on **independent action units**. [Shot Breakdown Principles] 1. **Action Unit Division**: Each shot must correspond to a complete and independent action - One action = one shot (character stands up, walks over, speaks a line, reacts with an expression, etc.) - Do NOT merge multiple actions (standing up + walking over should be split into 2 shots) 2. **Shot Type Standards** (choose based on storytelling needs): - Extreme Long Shot (ELS): Environment, atmosphere building - Long Shot (LS): Full body action, spatial relationships - Medium Shot (MS): Interactive dialogue, emotional communication - Close-Up (CU): Detail display, emotional expression - Extreme Close-Up (ECU): Key props, intense emotions 3. **Camera Movement Requirements**: - Fixed Shot: Stable focus on one subject - Push In: Approaching subject, increasing tension - Pull Out: Expanding field of view, revealing context - Pan: Horizontal camera movement, spatial transitions - Follow: Following subject movement - Tracking: Linear movement with subject 4. **Emotion & Intensity Markers**: - Emotion: Brief description (excited, sad, nervous, happy, etc.) - Intensity: Emotion level using arrows * Extremely strong ↑↑↑ (3): Emotional peak, high tension * Strong ↑↑ (2): Significant emotional fluctuation * Moderate ↑ (1): Noticeable emotional change * Stable → (0): Emotion remains unchanged * Weak ↓ (-1): Emotion subsiding [Output Requirements] 1. Generate an array, each element is a shot containing: - shot_number: Shot number - scene_description: Scene (location + time, e.g., "bedroom interior, morning") - shot_type: Shot type (extreme long shot/long shot/medium shot/close-up/extreme close-up) - camera_angle: Camera angle (eye-level/low-angle/high-angle/side/back) - camera_movement: Camera movement (fixed/push/pull/pan/follow/tracking) - action: Action description - result: Visual result of the action - dialogue: Character dialogue or narration (if any) - emotion: Current emotion - emotion_intensity: Emotion intensity level (3/2/1/0/-1) **CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].** [Important Notes] - Shot count must match number of independent actions in the script (not allowed to merge or reduce) - Each shot must have clear action and result - Shot types must match storytelling rhythm (don't use same shot type continuously) - Emotion intensity must accurately reflect script atmosphere changes` } return `【角色】你是一位资深影视分镜师,精通罗伯特·麦基的镜头拆解理论,擅长构建情绪节奏。 【任务】将小说剧本按**独立动作单元**拆解为分镜头方案。 【分镜拆解原则】 1. **动作单元划分**:每个镜头必须对应一个完整且独立的动作 - 一个动作 = 一个镜头(角色站起来、走过去、说一句话、做一个反应表情等) - 禁止合并多个动作(站起+走过去应拆分为2个镜头) 2. **景别标准**(根据叙事需要选择): - 大远景:环境、氛围营造 - 远景:全身动作、空间关系 - 中景:交互对话、情感交流 - 近景:细节展示、情绪表达 - 特写:关键道具、强烈情绪 3. **运镜要求**: - 固定镜头:稳定聚焦于一个主体 - 推镜:接近主体,增强紧张感 - 拉镜:扩大视野,交代环境 - 摇镜:水平移动摄像机,空间转换 - 跟镜:跟随主体移动 - 移镜:摄像机与主体同向移动 4. **情绪与强度标记**: - emotion:简短描述(兴奋、悲伤、紧张、愉快等) - emotion_intensity:用箭头表示情绪等级 * 极强 ↑↑↑ (3):情绪高峰、高度紧张 * 强 ↑↑ (2):情绪明显波动 * 中 ↑ (1):情绪有所变化 * 平稳 → (0):情绪不变 * 弱 ↓ (-1):情绪回落 【输出要求】 1. 生成一个数组,每个元素是一个镜头,包含: - shot_number:镜头号 - scene_description:场景(地点+时间,如"卧室内,早晨") - shot_type:景别(大远景/远景/中景/近景/特写) - camera_angle:机位角度(平视/仰视/俯视/侧面/背面) - camera_movement:运镜方式(固定/推镜/拉镜/摇镜/跟镜/移镜) - action:动作描述 - result:动作完成后的画面结果 - dialogue:角色对话或旁白(如有) - emotion:当前情绪 - emotion_intensity:情绪强度等级(3/2/1/0/-1) **重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。** 【重要提示】 - 镜头数量必须与剧本中的独立动作数量匹配(不允许合并或减少) - 每个镜头必须有明确的动作和结果 - 景别选择必须符合叙事节奏(不要连续使用同一景别) - 情绪强度必须准确反映剧本氛围变化` } // GetSceneExtractionPrompt 获取场景提取提示词 func (p *PromptI18n) GetSceneExtractionPrompt(style string) string { // 默认图片比例 imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`[Task] Extract all unique scene backgrounds from the script [Requirements] 1. Identify all different scenes (location + time combinations) in the script 2. Generate detailed **English** image generation prompts for each scene 3. **Important**: Scene descriptions must be **pure backgrounds** without any characters, people, or actions 4. Prompt requirements: - Must use **English**, no Chinese characters - Detailed description of scene, time, atmosphere, style - Must explicitly specify "no people, no characters, empty scene" - Must match the drama's genre and tone - **Style Requirement**: %s - **Image Ratio**: %s [Output Format] **CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].** Each element containing: - location: Location (e.g., "luxurious office") - time: Time period (e.g., "afternoon") - prompt: Complete English image generation prompt (pure background, explicitly stating no people)`, style, imageRatio) } return fmt.Sprintf(`【任务】从剧本中提取所有唯一的场景背景 【要求】 1. 识别剧本中所有不同的场景(地点+时间组合) 2. 为每个场景生成详细的**中文**图片生成提示词(Prompt) 3. **重要**:场景描述必须是**纯背景**,不能包含人物、角色、动作等元素 4. Prompt要求: - **必须使用中文**,不能包含英文字符 - 详细描述场景、时间、氛围、风格 - 必须明确说明"无人物、无角色、空场景" - 要符合剧本的题材和氛围 - **风格要求**:%s - **图片比例**:%s 【输出格式】 **重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。** 每个元素包含: - location:地点(如"豪华办公室") - time:时间(如"下午") - prompt:完整的中文图片生成提示词(纯背景,明确说明无人物)`, style, imageRatio) } // GetFirstFramePrompt 获取首帧提示词 func (p *PromptI18n) GetFirstFramePrompt(style string) string { imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information. Important: This is the first frame of the shot - a completely static image showing the initial state before the action begins. Key Points: 1. Focus on the initial static state - the moment before the action 2. Must NOT include any action or movement 3. Describe the character's initial posture, position, and expression 4. Can include scene atmosphere and environmental details 5. Shot type determines composition and framing - **Style Requirement**: %s - **Image Ratio**: %s Output Format: Return a JSON object containing: - prompt: Complete English image generation prompt (detailed description, suitable for AI image generation) - description: Simplified Chinese description (for reference)`, style, imageRatio) } return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 重要:这是镜头的首帧 - 一个完全静态的画面,展示动作发生之前的初始状态。 关键要点: 1. 聚焦初始静态状态 - 动作发生之前的那一瞬间 2. 必须不包含任何动作或运动 3. 描述角色的初始姿态、位置和表情 4. 可以包含场景氛围和环境细节 5. 景别决定构图和取景范围 - **风格要求**:%s - **图片比例**:%s 输出格式: 返回一个JSON对象,包含: - prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成) - description:简化的中文描述(供参考)`, style, imageRatio) } // GetKeyFramePrompt 获取关键帧提示词 func (p *PromptI18n) GetKeyFramePrompt(style string) string { imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information. Important: This is the key frame of the shot - capturing the most intense and exciting moment of the action. Key Points: 1. Focus on the most exciting moment of the action 2. Capture peak emotional expression 3. Emphasize dynamic tension 4. Show character actions and expressions at their climax 5. Can include motion blur or dynamic effects - **Style Requirement**: %s - **Image Ratio**: %s Output Format: Return a JSON object containing: - prompt: Complete English image generation prompt (detailed description, suitable for AI image generation) - description: Simplified Chinese description (for reference)`, style, imageRatio) } return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 重要:这是镜头的关键帧 - 捕捉动作最激烈、最精彩的瞬间。 关键要点: 1. 聚焦动作最精彩的时刻 2. 捕捉情绪表达的顶点 3. 强调动态张力 4. 展示角色动作和表情的高潮状态 5. 可以包含动作模糊或动态效果 - **风格要求**:%s - **图片比例**:%s 输出格式: 返回一个JSON对象,包含: - prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成) - description:简化的中文描述(供参考)`, style, imageRatio) } // GetActionSequenceFramePrompt 获取动作序列提示词 func (p *PromptI18n) GetActionSequenceFramePrompt(style string) string { imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`**Role:** You are an expert in visual storytelling and image generation prompting. You need to generate a single prompt that describes a 3x3 grid action sequence. **Core Logic:** 1. **Holistic Integration:** This is a single, complete image containing a 3x3 grid layout, showcasing 9 sequential actions of the same subject. 2. **Visual Anchoring:** The subject, clothing, art style, and character consistency must be identical across all 9 frames. 3. **Action Evolution:** From Frame 1 to Frame 9, display a complete action sequence (e.g., Standing → Walking → Running → Jumping → Landing). 4. **Prompt Engineering:** Use high-quality visual vocabulary (lighting, textures, composition, depth of field). **Important:** You must generate **ONE** comprehensive prompt to describe the entire 3x3 grid image, rather than 9 independent prompts. Each frame **must** follow these specific rules: - **Frame 1:** Preparation/Initial stance - **Frame 2:** Anticipation/Body adjustment - **Frame 3:** Initiation/Beginning of movement - **Frame 4:** Acceleration/Power building - **Frame 5:** Peak of tension/Just before the burst - **Frame 6:** Action burst/The climax moment - **Frame 7:** Power release/Inertia continuation - **Frame 8:** Deceleration/Follow-through - **Frame 9:** Complete conclusion/Return to stillness **Aspect Ratio:** * %s **Output Specification:** You must return a **JSON object** with the following structure: - **prompt**: A **complete English image generation prompt** (describing the 3x3 grid layout, subject features, the evolution of the 9 actions, environment, and lighting details to ensure the AI generates one single image containing 9 frames). - **description**: A **simplified English description** (summarizing the core content of the action sequence). **Example Format:** { "prompt": "Action sequence layout, 3x3 grid composition\n [Frame 1]: [Subject] standing naturally in [Setting], feet shoulder-width apart...\n---\n [Frame 2]: [Subject] locking eyes forward, leaning body slightly...\n---\n [Frame 3]: [Subject's legs] bending slightly, center of gravity lowering...\n---\n [Frame 4]: [Subject] pushing off with back leg, body moving forward, dust rising from [Setting's ground]...\n---\n [Frame 5]: [Subject's clothing] fluttering, body leaning deep, fist charging power...\n---\n [Frame 6]: [Subject] sprinting at full speed, fist striking out...\n---\n [Frame 7]: [Subject] impact moment, body lunging forward...\n---\n [Frame 8]: [Subject] slowing down, pulling back the fist...\n---\n [Frame 9]: [Subject's full appearance] standing firm in [Setting], recovering original stance.", "description": "Complete action sequence of a swordsman in black from drawing a blade to striking." } `, style, imageRatio) } return fmt.Sprintf(`**Role:** 你是一位精通视觉叙事与图像生成提示词的专家。你需要生成一个描述 3x3 九宫格动作序列的提示词。 **Core Logic:** 1. **整体性:** 这是一张完整的图片,包含 3x3 九宫格布局,展示同一主体的 9 个连续动作。 2. **视觉锚定:** 所有 9 个格子中的主体、服装、画风必须高度一致。 3. **动作演进:** 从格子 1 到格子 9,展示一个完整的动作序列(如:从站立→行走→奔跑→跳跃→落地)。 4. **提示词工程:** 使用高质量的视觉词汇(光影、材质、构图、景深)。 **重要:** 你需要生成 **一个** 完整的提示词来描述整个 3x3 九宫格图片,而不是 9 个独立的提示词。 每一格要求**必须**遵守如下规则: - **第1格**:动作准备/初始姿态 - **第2格**:预备动作/身体调整 - **第3格**:动作启动/开始移动 - **第4格**:加速阶段/力量积蓄 - **第5格**:蓄力顶点/即将爆发 - **第6格**:动作爆发/高潮瞬间 - **第7格**:力量释放/惯性延续 - **第8格**:动作缓冲/逐渐收势 - **第9格**:完全收尾/回归静止 **Aspect Ratio:** * %s **Output Specification:** 必须返回一个 **JSON 对象**,其结构如下: * prompt: **完整的中文图片生成提示词**(描述整个 3x3 九宫格的布局、主体特征、9 个动作的演进过程、环境、光影细节,确保 AI 能直接生成一张包含 9 个格子的完整图像)。 * description: **简化的中文描述**(概括这个动作序列的核心内容)。 **示例格式:** { "prompt": "动作序列布局,3x3方格布局\n [第1格]: [角色参考图2] 在 [场景参考图1] 中自然站立,双脚分开...\n---\n [第2格]: [角色参考图2] 眼神锁定,身体前倾...\n---\n [第3格]: [角色参考图2的腿部] 双腿微屈,重心下沉...\n---\n [第4格]: [角色参考图2] 后腿蹬地,身体前移,[场景参考图1的地面] 扬起尘土...\n---\n [第5格]: [角色参考图2的服装] 身体前倾,拳头蓄力...\n---\n [第6格]: [角色参考图2] 全速冲刺,拳头击出...\n---\n [第7格]: [角色参考图2] 拳头击中,身体前冲...\n---\n [第8格]: [角色参考图2] 减速收拳...\n---\n [第9格]: [角色参考图2的完整外观] 在 [场景参考图1] 中站稳,恢复姿态。\n", "description": "黑衣剑客从拔剑到攻击的完整动作序列" }`, imageRatio) } // GetLastFramePrompt 获取尾帧提示词 func (p *PromptI18n) GetLastFramePrompt(style string) string { imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`You are a professional image generation prompt expert. Please generate prompts suitable for AI image generation based on the provided shot information. Important: This is the last frame of the shot - a static image showing the final state and result after the action ends. Key Points: 1. Focus on the final state after action completion 2. Show the result of the action 3. Describe character's final posture and expression after action 4. Emphasize emotional state after action 5. Capture the calm moment after action ends - **Style Requirement**: %s - **Image Ratio**: %s Output Format: Return a JSON object containing: - prompt: Complete English image generation prompt (detailed description, suitable for AI image generation) - description: Simplified Chinese description (for reference)`, style, imageRatio) } return fmt.Sprintf(`你是一个专业的图像生成提示词专家。请根据提供的镜头信息,生成适合用于AI图像生成的提示词。 重要:这是镜头的尾帧 - 一个静态画面,展示动作结束后的最终状态和结果。 关键要点: 1. 聚焦动作完成后的最终状态 2. 展示动作的结果 3. 描述角色在动作完成后的姿态和表情 4. 强调动作后的情绪状态 5. 捕捉动作结束后的平静瞬间 - **风格要求**:%s - **图片比例**:%s 输出格式: 返回一个JSON对象,包含: - prompt:完整的中文图片生成提示词(详细描述,适合AI图像生成) - description:简化的中文描述(供参考)`, style, imageRatio) } // GetOutlineGenerationPrompt 获取大纲生成提示词 func (p *PromptI18n) GetOutlineGenerationPrompt() string { if p.IsEnglish() { return `You are a professional short drama screenwriter. Based on the theme and number of episodes, create a complete short drama outline and plan the plot direction for each episode. Requirements: 1. Compact plot with strong conflicts and fast pace 2. Each episode should have independent conflicts while connecting the main storyline 3. Clear character arcs and growth 4. Cliffhanger endings to hook viewers 5. Clear theme and emotional core Output Format: Return a JSON object containing: - title: Drama title (creative and attractive) - episodes: Episode list, each containing: - episode_number: Episode number - title: Episode title - summary: Episode content summary (50-100 words) - conflict: Main conflict point - cliffhanger: Cliffhanger ending (if any)` } return `你是专业短剧编剧。根据主题和剧集数量,创作完整的短剧大纲,规划好每一集的剧情走向。 要求: 1. 剧情紧凑,矛盾冲突强烈,节奏快 2. 每集都有独立的矛盾冲突,同时推进主线 3. 角色弧光清晰,成长变化明显 4. 悬念设置合理,吸引观众继续观看 5. 主题明确,情感内核清晰 输出格式: 返回一个JSON对象,包含: - title: 剧名(富有创意和吸引力) - episodes: 分集列表,每集包含: - episode_number: 集数 - title: 本集标题 - summary: 本集内容概要(50-100字) - conflict: 主要矛盾点 - cliffhanger: 悬念结尾(如有)` } // GetCharacterExtractionPrompt 获取角色提取提示词 func (p *PromptI18n) GetCharacterExtractionPrompt(style string) string { imageRatio := "16:9" if p.IsEnglish() { return fmt.Sprintf(`You are a professional character analyst, skilled at extracting and analyzing character information from scripts. Your task is to extract and organize detailed character settings for all characters appearing in the script based on the provided script content. Requirements: 1. Extract all characters with names (ignore unnamed passersby or background characters) 2. For each character, extract: - name: Character name - role: Character role (main/supporting/minor) - appearance: Physical appearance description (150-300 words) - personality: Personality traits (100-200 words) - description: Background story and character relationships (100-200 words) 3. Appearance must be detailed enough for AI image generation, including: gender, age, body type, facial features, hairstyle, clothing style, etc. but do not include any scene, background, environment information 4. Main characters require more detailed descriptions, supporting characters can be simplified - **Style Requirement**: %s - **Image Ratio**: %s Output Format: **CRITICAL: Return ONLY a valid JSON array. Do NOT include any markdown code blocks, explanations, or other text. Start directly with [ and end with ].** Each element is a character object containing the above fields.`, style, imageRatio) } return fmt.Sprintf(`你是一个专业的角色分析师,擅长从剧本中提取和分析角色信息。 你的任务是根据提供的剧本内容,提取并整理剧中出现的所有角色的详细设定。 要求: 1. 提取所有有名字的角色(忽略无名路人或背景角色) 2. 对每个角色,提取以下信息: - name: 角色名字 - role: 角色类型(main/supporting/minor) - appearance: 外貌描述(150-300字) - personality: 性格特点(100-200字) - description: 背景故事和角色关系(100-200字) 3. 外貌描述要足够详细,适合AI生成图片,包括:性别、年龄、体型、面部特征、发型、服装风格等,但不要包含任何场景、背景、环境等信息 4. 主要角色需要更详细的描述,次要角色可以简化 - **风格要求**:%s - **图片比例**:%s 输出格式: **重要:必须只返回纯JSON数组,不要包含任何markdown代码块、说明文字或其他内容。直接以 [ 开头,以 ] 结尾。** 每个元素是一个角色对象,包含上述字段。`, style, imageRatio) } // GetPropExtractionPrompt 获取道具提取提示词 func (p *PromptI18n) GetPropExtractionPrompt(style string) string { imageRatio := "1:1" if p.IsEnglish() { return fmt.Sprintf(`Please extract key props from the following script. [Script Content] %%s [Requirements] 1. Extract ONLY key props that are important to the plot or have special visual characteristics. 2. Do NOT extract common daily items (e.g., normal cups, pens) unless they have special plot significance. 3. If a prop has a clear owner, please note it in the description. 4. "image_prompt" field is for AI image generation, must describe the prop's appearance, material, color, and style in detail. - **Style Requirement**: %s - **Image Ratio**: %s [Output Format] JSON array, each object containing: - name: Prop Name - type: Type (e.g., Weapon/Key Item/Daily Item/Special Device) - description: Role in the drama and visual description - image_prompt: English image generation prompt (Focus on the object, isolated, detailed, cinematic lighting, high quality) Please return JSON array directly.`, style, imageRatio) } return fmt.Sprintf(`请从以下剧本中提取关键道具。 【剧本内容】 %%s 【要求】 1. 只提取对剧情发展有重要作用、或有特殊视觉特征的关键道具。 2. 普通的生活用品(如普通的杯子、笔)如果无特殊剧情意义不需要提取。 3. 如果道具有明确的归属者,请在描述中注明。 4. "image_prompt"字段是用于AI生成图片的英文提示词,必须详细描述道具的外观、材质、颜色、风格。 - **风格要求**:%s - **图片比例**:%s 【输出格式】 JSON数组,每个对象包含: - name: 道具名称 - type: 类型 (如:武器/关键证物/日常用品/特殊装置) - description: 在剧中的作用和中文外观描述 - image_prompt: 英文图片生成提示词 (Focus on the object, isolated, detailed, cinematic lighting, high quality) 请直接返回JSON数组。`, style, imageRatio) } // GetEpisodeScriptPrompt 获取分集剧本生成提示词 func (p *PromptI18n) GetEpisodeScriptPrompt() string { if p.IsEnglish() { return `You are a professional short drama screenwriter. You excel at creating detailed plot content based on episode plans. Your task is to expand the summary in the outline into detailed plot narratives for each episode. Each episode is about 180 seconds (3 minutes) and requires substantial content. Requirements: 1. Expand the outline summary into detailed plot development 2. Write character dialogue and actions, not just description 3. Highlight conflict progression and emotional changes 4. Add scene transitions and atmosphere descriptions 5. Control rhythm, with climax at 2/3 point, resolution at the end 6. Each episode 800-1200 words, dialogue-rich 7. Keep consistent with character settings Output Format: **CRITICAL: Return ONLY a valid JSON object. Do NOT include any markdown code blocks, explanations, or other text. Start directly with { and end with }.** - episodes: Episode list, each containing: - episode_number: Episode number - title: Episode title - script_content: Detailed script content (800-1200 words)` } return `你是一个专业的短剧编剧。你擅长根据分集规划创作详细的剧情内容。 你的任务是根据大纲中的分集规划,将每一集的概要扩展为详细的剧情叙述。每集约180秒(3分钟),需要充实的内容。 要求: 1. 将大纲中的概要扩展为具体的剧情发展 2. 写出角色的对话和动作,不是简单描述 3. 突出冲突的递进和情感的变化 4. 增加场景转换和氛围描写 5. 控制节奏,高潮在2/3处,结尾有收束 6. 每集800-1200字,对话丰富 7. 与角色设定保持一致 输出格式: **重要:必须只返回纯JSON对象,不要包含任何markdown代码块、说明文字或其他内容。直接以 { 开头,以 } 结尾。** - episodes: 分集列表,每集包含: - episode_number: 集数 - title: 本集标题 - script_content: 详细剧本内容(800-1200字)` } // FormatUserPrompt 格式化用户提示词的通用文本 func (p *PromptI18n) FormatUserPrompt(key string, args ...interface{}) string { templates := map[string]map[string]string{ "en": { "outline_request": "Please create a short drama outline for the following theme:\n\nTheme: %s", "genre_preference": "\nGenre preference: %s", "style_requirement": "\nStyle requirement: %s", "episode_count": "\nNumber of episodes: %d episodes", "episode_importance": "\n\n**Important: Must plan complete storylines for all %d episodes in the episodes array, each with clear story content!**", "character_request": "Script content:\n%s\n\nPlease extract and organize detailed character profiles for up to %d main characters from the script.", "episode_script_request": "Drama outline:\n%s\n%s\nPlease create detailed scripts for %d episodes based on the above outline and characters.\n\n**Important requirements:**\n- Must generate all %d episodes, from episode 1 to episode %d, cannot skip any\n- Each episode is about 3-5 minutes (150-300 seconds)\n- The duration field for each episode should be set reasonably based on script content length, not all the same value\n- The episodes array in the returned JSON must contain %d elements", "frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the first frame without any explanation:", "key_frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the key frame without any explanation:", "last_frame_info": "Shot information:\n%s\n\nPlease directly generate the image prompt for the last frame without any explanation:", "script_content_label": "【Script Content】", "storyboard_list_label": "【Storyboard List】", "task_label": "【Task】", "character_list_label": "【Available Character List】", "scene_list_label": "【Extracted Scene Backgrounds】", "task_instruction": "Break down the novel script into storyboard shots based on **independent action units**.", "character_constraint": "**Important**: In the characters field, only use character IDs (numbers) from the above character list. Do not create new characters or use other IDs.", "scene_constraint": "**Important**: In the scene_id field, select the most matching background ID (number) from the above background list. If no suitable background exists, use null.", "shot_description_label": "Shot description: %s", "scene_label": "Scene: %s, %s", "characters_label": "Characters: %s", "action_label": "Action: %s", "result_label": "Result: %s", "dialogue_label": "Dialogue: %s", "atmosphere_label": "Atmosphere: %s", "shot_type_label": "Shot type: %s", "angle_label": "Angle: %s", "movement_label": "Movement: %s", "drama_info_template": "Title: %s\nSummary: %s\nGenre: %s", }, "zh": { "outline_request": "请为以下主题创作短剧大纲:\n\n主题:%s", "genre_preference": "\n类型偏好:%s", "style_requirement": "\n风格要求:%s", "episode_count": "\n剧集数量:%d集", "episode_importance": "\n\n**重要:必须在episodes数组中规划完整的%d集剧情,每集都要有明确的故事内容!**", "character_request": "剧本内容:\n%s\n\n请从剧本中提取并整理最多 %d 个主要角色的详细设定。", "episode_script_request": "剧本大纲:\n%s\n%s\n请基于以上大纲和角色,创作 %d 集的详细剧本。\n\n**重要要求:**\n- 必须生成完整的 %d 集,从第1集到第%d集,不能遗漏\n- 每集约3-5分钟(150-300秒)\n- 每集的duration字段要根据剧本内容长度合理设置,不要都设置为同一个值\n- 返回的JSON中episodes数组必须包含 %d 个元素", "frame_info": "镜头信息:\n%s\n\n请直接生成首帧的图像提示词,不要任何解释:", "key_frame_info": "镜头信息:\n%s\n\n请直接生成关键帧的图像提示词,不要任何解释:", "last_frame_info": "镜头信息:\n%s\n\n请直接生成尾帧的图像提示词,不要任何解释:", "script_content_label": "【剧本内容】", "storyboard_list_label": "【分镜头列表】", "task_label": "【任务】", "character_list_label": "【本剧可用角色列表】", "scene_list_label": "【本剧已提取的场景背景列表】", "task_instruction": "将小说剧本按**独立动作单元**拆解为分镜头方案。", "character_constraint": "**重要**:在characters字段中,只能使用上述角色列表中的角色ID(数字),不得自创角色或使用其他ID。", "scene_constraint": "**重要**:在scene_id字段中,必须从上述背景列表中选择最匹配的背景ID(数字)。如果没有合适的背景,则填null。", "shot_description_label": "镜头描述: %s", "scene_label": "场景: %s, %s", "characters_label": "角色: %s", "action_label": "动作: %s", "result_label": "结果: %s", "dialogue_label": "对白: %s", "atmosphere_label": "氛围: %s", "shot_type_label": "景别: %s", "angle_label": "角度: %s", "movement_label": "运镜: %s", "drama_info_template": "剧名:%s\n简介:%s\n类型:%s", }, } lang := "zh" if p.IsEnglish() { lang = "en" } template, ok := templates[lang][key] if !ok { return "" } if len(args) > 0 { return fmt.Sprintf(template, args...) } return template } // GetStylePrompt 获取风格提示词 func (p *PromptI18n) GetStylePrompt(style string) string { if style == "" { return "" } stylePrompts := map[string]map[string]string{ "zh": { "ghibli": `**[专家角色定位]** 你现在是一位吉卜力工作室顶级美术指导与背景画师,擅长捕捉"宏大自然与微观生活"的平衡感,深谙宫崎骏式的色彩心理学。 **[风格核心逻辑]** - **视觉流派与质感**:采用经典的吉卜力风格。画面具有浓郁的水彩晕染质感(Watercolor texture),拒绝冰冷的3D渲染,强调温暖且有呼吸感的笔触。线条清晰且细腻,呈现出赛璐珞(Cel-shading)上色的明快感。 - **色彩与光影美学**:使用**"高调色彩美学"**。主色调明亮、通透、高饱和度但色相柔和。光影模拟"夏日午后"的自然采光,光线如同浸透在空气中,具有极佳的明度。阴影部分带有微妙的蓝紫色调,增加画面的通透感。 - **氛围意向**:怀旧、宁静、牧歌式的(Pastoral)、微风感。画面要传达出一种"世界依然美好"的宁静感和探索欲。`, "guoman": `**[专家角色定位]** 你是一位顶尖的数字插画艺术家,擅长将传统东方韵味与现代游戏美术的华丽视觉特效(VFX)相结合,是"东方幻想主义"构图的大师。 **[风格核心逻辑]** - **视觉流派与质感**:融合了**新国风数字艺术(Modern Zen Illustration)**与**史诗级奇幻渲染**。画面质感细腻且带有微微的丝滑感,类似高精度的2D数字绘画。强调光影的体积感,画面中包含大量微小的粒子效果和发光氛围。 - **核心色彩与发光美学**:使用**"撞色与内生光影"**。主色调通常是冷暖色调的剧烈碰撞(如靛青色与金橙色)。画面逻辑的核心在于**"局部发光"**:暗部点缀着发光的荧光元素(如荧光植物、灯火或水晶质感),这种对比营造了强烈的魔法感和神秘感。 - **装饰性元素逻辑**:强调**"线条的流动感"**。画面中充斥着优美的曲线,这些线条通常由发光带、飘带或自然界的纹理(如流水的走势)组成,增强了整体的装饰性和节奏感。`, "wasteland": `**[专家角色定位]** 你是一位专注于"末世叙事"的视觉艺术家,擅长运用**硬核线条(Hard Line-art)**和**复古平面印刷感**来营造史诗般的荒凉氛围,深受让·吉罗(Moebius)和现代废土科幻插画的影响。 **[风格核心逻辑]** - **视觉流派与笔触质感**:采用**硬缘线条绘图风格(Hard-edged Line Art)**。画面强调清晰的黑色轮廓线,具有强烈的漫画插图感。质感上呈现出一种**颗粒状的平面印刷感(Grainy textures)**或类似旧报纸、复古海报的纹理,拒绝平滑的渐变,倾向于使用排线或点阵来表现阴影。 - **色彩美学逻辑**:采用**"低频限色色调(Limited Palette)"**。画面通常被一种压抑且统一的色调统治(如灰土色、铁锈橙、荒漠黄)。核心视觉冲击力来自于**一个强烈的对比色点**(如此处巨大的红色落日),这种"单点高亮"的逻辑在灰暗的废土背景中能瞬间抓住视线。 - **光影表现手法**:使用**"高对比度强侧光(High-contrast Side Lighting)"**。模拟黄昏或黎明的低角度光线,产生极长的投影。光影逻辑极其简化,明暗交界线生硬且明确,营造出一种干枯、灼热且寂静的戏剧张力。`, "nostalgia": `**[专家角色定位]** 你是一位专注于**"怀旧赛璐珞(Nostalgic Cel-shading)"**风格的视觉艺术家,擅长模拟20世纪80-90年代手绘动画的质感,利用色彩与噪点营造一种温和、感性且略带忧郁的都市氛围。 **[风格核心逻辑]** - **视觉流派与画面质感**:采用经典的**90年代复古动画风格(90s Retro Anime Style)**。画面具有明显的**胶片颗粒感(Film grain)**和微弱的**色散效果(Chromatic aberration)**,模拟旧式电视或磁带的播放质感。质感上强调"不完美的细腻",即线条略显柔和,不像现代矢量图那样锐利,给人一种手工绘制的温度感。 - **色彩美学逻辑**:使用**"低对比度粉紫色调(Muted Pastel Palette)"**。画面被一种柔和的、如梦境般的暮色统治,通常以淡紫色、藕粉色或灰蓝色为主基调。色彩逻辑的核心在于**"弱化的黑场"**:没有纯黑,所有深色都带有紫色或蓝色的倾向。这种色调能瞬间勾勒出一种孤独但温馨的"都市黄昏"感。 - **光影表现手法**:强调**"弥散的点光源(Diffuse Point Lights)"**。光线不是硬性的投射,而是呈晕染状。例如,路灯、车灯或月亮周围有一圈柔和的朦胧光晕(Glow effect)。地面通常带有微弱的雨后反光或湿润感,增加光影的层次感和梦幻感。`, "pixel": `**[专家角色定位]** 你是一位资深的**8位/16位像素艺术家 (Pixel Art Consultant)**,擅长利用受限的分辨率和调色盘来构建具有极强代入感的虚拟世界,模拟早期电子游戏(如《星露谷物语》或经典RPG)的视觉美学。 **[风格核心逻辑]** - **视觉流派与画面质感**:采用纯正的**像素艺术风格 (Pixel Art)**。画面由清晰可见的方格(Pixels)组成,强调**"阶梯状线条 (Aliased lines)"**。质感上完全摒弃平滑的渐变和模糊,追求一种数码化的、网格化的块状美感。 - **色彩美学逻辑**:使用**"受限调色盘 (Limited Color Palette)"**。色彩选择极度精简,不追求自然的过渡,而是通过大面积的色块叠加。色彩逻辑的核心在于**"抖动算法思维 (Dithering logic)"**:通过不同颜色方格的交替排列来模拟明暗变化,色调通常饱和度中等,呈现出一种清爽、明快的电子游戏感。 - **光影表现手法**:强调**"色块式阴影 (Flat Shading)"**。光影表现不使用羽化或软光,而是通过增加一层更深的同色系像素块来表示投影。光线通常是恒定的,没有复杂的反射或折射,太阳或光源本身也被处理成一个规则的像素圆点。`, "voxel": `**[专家角色定位]** 你是一位顶尖的**3D体素建模师 (Voxel Artist)**,擅长利用统一规格的立方体单位构建充满童趣、模块化且具有高度秩序感的微缩世界。你的视觉风格强调**低多边形(Low-poly)的纯粹性**与**现代实时光影渲染**的结合。 **[风格核心逻辑]** - **视觉流派与质感**:采用**三维体素风格 (3D Voxel Style)**。画面由无数等比例的立方体单元(Voxels)堆叠而成,呈现出一种强烈的模块化感。质感上具有明显的**"方块化线条"**,物体表面是平整的色块,这种简化的几何语言创造了一种独特的数字美感。 - **色彩美学逻辑**:使用**"自然饱和度与渐变光影"**。色彩通常根据环境属性进行大块划分(如草地的绿、土地的褐),但关键在于**色彩的微小扰动 (Color Jitter)**:同一区域的方块颜色会有微妙的深浅差异,模拟真实环境的随机感。色调通常明亮、清新,充满活力感。 - **光影表现手法**:强调**"全局光照渲染 (Global Illumination)"**。这是体素艺术升华的关键:尽管物体是方块状的,但光影必须是**电影级的写实渲染**。光线具有温暖的体积感(如耶稣光),阴影边缘柔和且带有环境遮蔽(AO)效果,方块边缘会被高亮勾勒,使画面看起来像是一个精致的现实微缩模型。`, "urban": `**[专家角色定位]** 你是一位顶尖的**网漫主笔(Lead Webtoon Artist)**,擅长创作具有现代都市感的人物立绘。你的视觉风格强调**锐利的轮廓线**、**利落的穿搭逻辑**以及**冷色调的都市氛围**,旨在营造一种"高冷、精致、工业化美感"的视觉冲击。 **[风格核心逻辑]** - **视觉流派与画面质感**:采用**现代韩漫数字绘图风格 (Modern Webtoon Art Style)**。画面具有极干净的**矢量线条 (Crisp line art)**,没有任何多余的笔触。质感上呈现出一种平滑的数字皮肤质感,强调色彩的整洁度,避免了复杂的笔触叠加。 - **色彩美学逻辑**:使用**"冷调都市灰(Muted Urban Tones)"**。画面以黑、白、灰、深蓝等中性色为主色调。色彩逻辑的核心在于**"高对比度的荧光色反差"**:整体处于清冷的低饱和度环境下,但利用背景中的**霓虹灯(Neon glow)**或电子屏产生高亮的粉、蓝、紫偏色,营造出一种深夜都市的疏离感。 - **光影表现手法**:强调**"硬边赛璐珞阴影 (Hard Cel-shading)"**。阴影边缘极其干脆,没有渐变。光影逻辑模仿**"环境侧光"**:光线通常来自侧方的霓虹招牌,在人物一侧留下窄长的亮边(Rim lighting),增强了人物的轮廓感和立体感。`, "guoman3d": `**[专家角色定位]** 你是一位顶级**次世代游戏美术总监 (Lead Technical Artist)**,擅长使用虚幻引擎 5 (UE5) 创作高精度的 3D 仙侠角色。你的风格以**物理渲染 (PBR)** 的极高真实度、复杂的服饰层次感以及极具东方美学的全局光照处理著称。 **[风格核心逻辑]** - **视觉流派与画面质感**:采用**高精细 3D 写实渲染风格 (High-fidelity 3D Rendering)**。画面具有极强的**次世代游戏质感 (Next-gen game aesthetic)**,强调皮肤的次表面散射 (SSS) 效果和极其真实的服饰纹理(如丝绸的平滑感、皮革的磨损感、金属的拉丝质感)。整体呈现出一种细腻的数码雕琢美,边缘锐利且细节丰富。 - **色彩美学逻辑**:使用**"素雅沉稳的中性色调 (Sophisticated Neutral Palette)"**。不同于高饱和度的动漫风格,这种逻辑倾向于使用低饱和、高明度的色彩(如米白、石青、灰褐),并配以小面积的暗红色或金色作为高级感点缀。光影色彩通常偏向**清晨或傍晚的自然日光**,给人一种宁静、肃穆且大气的东方韵味。 - **光影表现手法**:强调**"电影级动态光影 (Cinematic Lighting)"**。光源方向明确(通常是明亮的侧逆光),在人物边缘勾勒出一层淡淡的金边 (Rim Light),将主体与背景完美分离。同时利用环境遮蔽 (AO) 增加细节深度,让服饰的每一个褶皱都清晰可见,呈现出一种沉浸式的戏剧张力。`, "chibi3d": `**[专家角色定位]** 你是一位顶尖的 **3D 玩具设计师与灯光渲染师**,擅长创作高精细度的数字手办。你的视觉风格结合了 **Q 版二头身比例 (Chibi proportions)** 与 **超写实材质渲染 (PBR Rendering)**,旨在营造一种精致、可爱且具有高级触感的"数字潮流玩具"视觉效果。 **[风格核心逻辑]** - **视觉流派与画面质感**:采用 **3D 盲盒艺术风格 (Blind Box / Toy Art Style)**。画面具有极强的 **类塑料与树脂质感 (Plastic and Resin texture)**,表面圆润、平滑,边缘带有微妙的倒角。主体呈现出明显的 **Q 版比例**(大头小身),增强了亲和力。 - **色彩美学逻辑**:使用 **"温和的高饱和调色盘 (Muted Vibrant Palette)"**。色彩鲜艳但并不刺眼。色彩分布遵循"主次分明"原则,利用大面积的自然底色(如森林绿、泥土褐)衬托主体鲜明的服饰色彩。 - **光影表现手法**:光源通常柔和且均匀。**顶光/面光**:均匀照亮主体正面,突出五官和服饰细节。**环境遮蔽 (Ambient Occlusion)**:在缝隙和接触面产生细腻的阴影,增强物体的重量感和真实感。`, }, "en": { "ghibli": `**[Expert Role]** You are a top Art Director and Background Artist from Studio Ghibli. You excel at capturing the balance between "grand nature and microscopic life," and you possess a deep understanding of Hayao Miyazaki's color psychology. **[Core Style Logic]** - **Visual Genre & Texture**: Adopts the classic Ghibli style. The imagery features a rich **watercolor texture**, rejecting cold 3D rendering in favor of warm, "breathing" brushstrokes. Lines are clear yet delicate, presenting the vibrant feel of **cel-shading**. - **Color & Lighting Aesthetics**: Utilizes **"High-key Color Aesthetics."** The palette is bright, transparent, and high-saturated but with soft hues. Lighting simulates the natural light of a "summer afternoon," where light feels soaked into the air with excellent luminosity. Shadows contain subtle blue-purple tones to enhance the transparency of the frame. - **Atmospheric Intent**: Nostalgic, serene, **pastoral**, and breezy. The image should convey a sense of tranquility and a desire for exploration—a feeling that "the world is still beautiful."`, "guoman": `**[Expert Role]** You are a top-tier digital illustration artist, skilled at merging traditional Eastern charm with the magnificent Visual Effects (VFX) of modern game art. You are a master of "Oriental Fantasy" composition. **[Core Style Logic]** - **Visual Genre & Texture**: A fusion of **Modern Zen Illustration (New Guofeng)** and epic fantasy rendering. The texture is delicate with a silky feel, similar to high-precision 2D digital painting. It emphasizes volumetric lighting and includes a large amount of tiny particle effects and glowing atmospheres. - **Core Color & Luminous Aesthetics**: Employs **"Contrasting Colors & Endogenous Lighting."** The main palette usually features intense collisions of cool and warm tones (e.g., indigo and golden orange). The core logic lies in **"Local Luminescence"**: dark areas are dotted with bioluminescent elements (like fluorescent plants, lanterns, or crystal textures), creating a strong sense of magic and mystery. - **Decorative Element Logic**: Emphasizes the **"Flow of Lines."** The frame is filled with elegant curves, often composed of light trails, ribbons, or natural textures (like the flow of water), enhancing the overall decorativeness and rhythm.`, "wasteland": `**[Expert Role]** You are a visual artist focused on "Post-Apocalyptic Narrative," skilled at using **Hard Line-art** and a **retro print feel** to create epic, desolate atmospheres, heavily influenced by Moebius and modern wasteland sci-fi illustrations. **[Core Style Logic]** - **Visual Genre & Brushwork Texture**: Adopts a **Hard-edged Line Art** style. The image emphasizes bold black outlines with a strong comic illustration feel. The texture presents a **grainy, flat-print quality**, similar to old newspapers or retro posters, rejecting smooth gradients in favor of hatching or stippling for shadows. - **Color Aesthetic Logic**: Employs a **"Limited Palette."** The frame is typically dominated by an oppressive, unified tone (e.g., dusty earth, rust orange, desert yellow). The core visual impact comes from a **single strong contrast point** (such as a massive red setting sun), a "single-point highlight" logic that instantly grabs attention against the gloomy background. - **Lighting Technique**: Uses **"High-contrast Side Lighting."** Simulates the low-angle light of dusk or dawn, producing extremely long shadows. The lighting logic is highly simplified with sharp, distinct terminators, creating a dry, scorching, and silent dramatic tension.`, "nostalgia": `**[Expert Role]** You are a visual artist specializing in the **"Nostalgic Cel-shading"** style, expert at simulating the texture of 1980s-90s hand-drawn animation. You use color and noise to create a gentle, emotional, and slightly melancholic urban atmosphere. **[Core Style Logic]** - **Visual Genre & Frame Texture**: Adopts the classic **90s Retro Anime Style**. The image features obvious **film grain** and slight **chromatic aberration**, simulating the playback quality of old TVs or VHS tapes. The texture emphasizes "imperfect delicacy"—lines are soft rather than sharp like modern vectors, giving a sense of handcrafted warmth. - **Color Aesthetic Logic**: Uses a **"Muted Pastel Palette."** The frame is dominated by a soft, dreamlike twilight, usually featuring lavender, lotus pink, or grayish-blue. The core logic is the **"Weakened Black Point"**: there are no pure blacks; all dark colors lean toward purple or blue. This tone instantly outlines a lonely but cozy "urban dusk" feel. - **Lighting Technique**: Emphasizes **"Diffuse Point Lights."** Light is not a hard projection but a bleeding glow. For example, streetlights, car headlights, or the moon have a soft, hazy halo (Glow effect). Surfaces often have a slight post-rain reflection or dampness, increasing the layers and dreaminess of the light.`, "pixel": `**[Expert Role]** You are a senior **Pixel Art Consultant (8-bit/16-bit)**, skilled at using restricted resolutions and palettes to build highly immersive virtual worlds, simulating the aesthetics of early video games like *Stardew Valley* or classic RPGs. **[Core Style Logic]** - **Visual Genre & Frame Texture**: Adopts a pure **Pixel Art** style. The image consists of clearly visible squares (pixels), emphasizing **"Aliased lines."** It completely discards smooth gradients and blurring, pursuing a digital, grid-based blocky beauty. - **Color Aesthetic Logic**: Uses a **"Limited Color Palette."** Color choices are extremely streamlined, avoiding natural transitions in favor of large color block overlays. The core logic is **"Dithering logic"**: alternating pixel patterns of different colors to simulate shading. Tones are usually medium saturation, presenting a crisp, bright video game feel. - **Lighting Technique**: Emphasizes **"Flat Shading."** Lighting does not use feathering or soft light; instead, it uses a layer of darker pixels from the same color family to represent shadows. Light sources are constant without complex reflections, and even the sun or light sources are treated as regular pixel circles.`, "voxel": `**[Expert Role]** You are a top-tier **3D Voxel Artist**, skilled at using uniform cube units to build whimsical, modular, and highly ordered miniature worlds. Your style combines the purity of **Low-poly** geometry with modern real-time lighting rendering. **[Core Style Logic]** - **Visual Genre & Texture**: Adopts a **3D Voxel Style**. The image is composed of countless proportional cubes (voxels) stacked together, presenting a strong modular feel. The texture features obvious **"blocky lines"** and flat color surfaces; this simplified geometric language creates a unique digital aesthetic. - **Color Aesthetic Logic**: Uses **"Natural Saturation & Gradient Lighting."** Colors are divided into large blocks based on environmental attributes (green for grass, brown for soil), but the key lies in **"Color Jitter"**: subtle shade variations between blocks in the same area to simulate the randomness of real environments. Tones are bright, fresh, and full of vitality. - **Lighting Technique**: Emphasizes **"Global Illumination Rendering."** This is the key to elevating voxel art: while objects are blocky, the lighting must be **cinematic and realistic**. Light has warm volumetric qualities (e.g., God rays), shadows are soft with Ambient Occlusion (AO) effects, and voxel edges are highlighted, making the scene look like an exquisite real-life miniature model.`, "urban": `**[Expert Role]** You are a leading **Webtoon Artist**, specializing in modern urban character illustrations. Your visual style emphasizes **sharp outlines**, **slick fashion logic**, and a **cool-toned urban atmosphere**, aiming to create a "high-cold, sophisticated, industrial-chic" visual impact. **[Core Style Logic]** - **Visual Genre & Frame Texture**: Adopts the **Modern Webtoon Art Style**. The image features extremely clean **crisp line art** (vector-like) without any redundant strokes. The texture presents a smooth digital skin quality, emphasizing color cleanliness and avoiding complex brushwork layering. - **Color Aesthetic Logic**: Uses **"Muted Urban Tones."** The palette is dominated by neutral colors like black, white, gray, and deep blue. The core logic is **"High-contrast Neon Accents"**: while the overall environment is cool and low-saturation, highlights from **neon glows** or electronic screens (pink, blue, purple) create a sense of late-night urban detachment. - **Lighting Technique**: Emphasizes **"Hard Cel-shading."** Shadow edges are extremely crisp with no gradients. The logic mimics **"Environmental Rim Lighting"**: light usually comes from side neon signs, leaving a narrow bright edge (Rim lighting) on one side of the character, enhancing their silhouette and 3D feel.`, "guoman3d": `**[Expert Role]** You are a top-tier **Next-gen Lead Technical Artist**, skilled in using Unreal Engine 5 (UE5) to create high-precision 3D Xianxia (Immortal Hero) characters. Your style is known for high-fidelity **Physically Based Rendering (PBR)**, complex clothing layers, and global illumination with an Eastern aesthetic. **[Core Style Logic]** - **Visual Genre & Frame Texture**: Adopts a **High-fidelity 3D Rendering style**. The image has a strong **next-gen game aesthetic**, emphasizing Subsurface Scattering (SSS) for skin and realistic fabric textures (smoothness of silk, wear on leather, brushed metal). The overall look is a delicate digital sculpture with sharp edges and rich details. - **Color Aesthetic Logic**: Uses a **"Sophisticated Neutral Palette."** Unlike high-saturation anime styles, this logic leans toward low-saturation, high-brightness colors (off-white, stone green, gray-brown), accented with small areas of dark red or gold for a premium feel. Lighting colors typically mimic **natural morning or evening sunlight**, giving an air of tranquility, solemnity, and grand Eastern charm. - **Lighting Technique**: Emphasizes **"Cinematic Lighting."** Light directions are clear (usually bright side-backlighting), creating a faint golden **Rim Light** that perfectly separates the subject from the background. Ambient Occlusion (AO) is used to increase detail depth, making every fold in the clothing visible and creating immersive dramatic tension.`, "chibi3d": `**[Expert Role]** You are a top-tier **3D Toy Designer and Rendering Artist**, specializing in high-precision digital figurines. Your visual style combines **Chibi proportions** with **Ultra-realistic PBR rendering**, aiming to create a sophisticated, cute, and tactile "Art Toy" visual effect. **[Core Style Logic]** - **Visual Genre & Frame Texture**: Adopts a **3D Blind Box / Toy Art Style**. The image features strong **plastic and resin textures**; surfaces are rounded and smooth with subtle beveled edges. The subject uses **Chibi proportions** (large head, small body) to enhance appeal. - **Color Aesthetic Logic**: Uses a **"Muted Vibrant Palette."** Colors are vivid but not piercing. Color distribution follows a "primary-secondary" principle, using large areas of natural base colors (forest green, earth brown) to set off the bright colors of the character's outfit. - **Lighting Technique**: Light sources are typically soft and even: **Top/Key Light**: Evenly illuminates the subject's front, highlighting facial features and clothing details. **Ambient Occlusion (AO)**: Produces delicate shadows in crevices and contact points, enhancing the object's sense of weight and realism.`, }, } lang := "zh" if p.IsEnglish() { lang = "en" } if prompts, ok := stylePrompts[lang]; ok { if prompt, exists := prompts[style]; exists { return prompt } } return "" } // GetVideoConstraintPrompt 获取视频生成的约束提示词 // referenceMode: "single" (单图), "first_last" (首尾帧), "multiple" (多图), "action_sequence" (动作序列) func (p *PromptI18n) GetVideoConstraintPrompt(referenceMode string) string { // 动作序列图(九宫格)的约束提示词 actionSequencePrompts := map[string]string{ "zh": `### 角色定义 你是一个极高精度的视频生成专家,擅长将九宫格(3x3)序列图转化为具有电影质感的连贯视频。你的核心任务是解析图像中的时空逻辑,并严格遵守首尾帧约束。 ### 核心执行逻辑 1. **首尾帧锚定:** 必须提取九宫格的第一格(左上角)作为视频的起始帧(Frame 0),提取第九格(右下角)作为视频的结束帧(Final Frame)。 2. **序列插值(Interpolation):** 九宫格的第 2 至 第 8 格定义了动作的关键路径。你需分析这些关键帧之间的逻辑位移、光影变化和物体形变。 3. **一致性维护:** 确保角色特征(面部、服装)、场景细节、艺术风格在全视频中保持 100% 的时空稳定性。 4. **动态补充:** 在九宫格定义的关键动作之间,自动补全流畅的过渡帧,确保视频动作频率自然(建议 24fps 或 30fps)。 ### 结构化约束指令 * **输入解析:** 识别用户提供的场景描述词(Prompt)与九宫格参考图。 * **动作矢量化:** 计算物体从 Grid 1 到 Grid 9 的运动矢量。如果九宫格展示的是缩放或平移,请在视频中还原精准的运镜。 * **严禁幻觉:** 禁止引入九宫格和提示词中未提及的新元素或背景切换。`, "en": `### Role Definition You are an ultra-high-precision video generation expert, specializing in transforming 9-grid (3x3) sequential images into coherent videos with cinematic quality. Your core task is to parse the spatiotemporal logic within the images and strictly adhere to first-and-last frame constraints. ### Core Execution Logic 1. **First-Last Frame Anchoring:** You must extract Grid 1 (top-left corner) as the video's starting frame (Frame 0) and Grid 9 (bottom-right corner) as the ending frame (Final Frame). 2. **Sequence Interpolation:** Grids 2 through 8 define the key action path. You need to analyze the logical displacement, lighting changes, and object deformations between these keyframes. 3. **Consistency Maintenance:** Ensure that character features (face, clothing), scene details, and artistic style maintain 100% spatiotemporal stability throughout the entire video. 4. **Dynamic Supplementation:** Automatically fill in smooth transition frames between the keyframes defined by the 9-grid, ensuring natural video motion frequency (recommended 24fps or 30fps). ### Structured Constraint Instructions * **Input Parsing:** Identify the scene description (Prompt) and 9-grid reference images provided by the user. * **Motion Vectorization:** Calculate the motion vectors of objects from Grid 1 to Grid 9. If the 9-grid shows scaling or panning, restore precise camera movements in the video. * **Hallucination Prohibition:** Do not introduce new elements or background switches not mentioned in the 9-grid and prompt.`, } // 通用约束提示词(单图、首尾帧、多图) generalPrompts := map[string]string{ "zh": `### 角色定义 你是一个顶级的视频动态分析师与合成专家。你能够仅凭一张静态图或一组起始/结束帧,精准识别画面中的物理属性、光影流向及潜在的运动趋势,生成符合物理定律的高质量视频。 ### 核心执行逻辑 1. **模式识别:** * **单图模式(Single Image):** 将输入图视为 Frame 0。分析画面中的"张力点"(如倾斜的身体、流动的液体、眼神的方向),并向该方向延续动作。 * **双图模式(First & Last Frames):** 严格锚定第一张图为起始,第二张图为终点。通过**语义插值算法**,计算两图之间所有元素的位移轨迹。 2. **物理一致性(Physics Preservation):** * **质量守恒:** 确保物体在运动过程中体积、密度和材质质感不发生突变。 * **运动惯性:** 遵循经典力学,起步平稳,加速自然,停止时不应有生硬的切断感。 3. **环境外推:** 自动补充主画面之外的背景延伸,确保运镜(Pan/Tilt/Zoom)时不会出现画面空洞或黑边。`, "en": `### Role Definition You are a top-tier video dynamics analyst and synthesis expert. You can accurately identify physical properties, light flow, and potential motion trends in a static image or a set of start/end frames, generating high-quality videos that comply with physical laws. ### Core Execution Logic 1. **Mode Recognition:** * **Single Image Mode:** Treat the input image as Frame 0. Analyze "tension points" in the frame (such as tilted bodies, flowing liquids, eye direction) and extend the action in that direction. * **First & Last Frames Mode:** Strictly anchor the first image as the start and the second image as the endpoint. Use **semantic interpolation algorithms** to calculate the displacement trajectories of all elements between the two images. 2. **Physics Preservation:** * **Mass Conservation:** Ensure that objects do not undergo sudden changes in volume, density, or material texture during motion. * **Motion Inertia:** Follow classical mechanics with smooth starts, natural acceleration, and no abrupt stops. 3. **Environment Extrapolation:** Automatically supplement background extensions beyond the main frame to ensure no voids or black edges appear during camera movements (Pan/Tilt/Zoom).`, } lang := "zh" if p.IsEnglish() { lang = "en" } // 如果是动作序列模式,返回九宫格约束提示词 if referenceMode == "action_sequence" { if prompt, ok := actionSequencePrompts[lang]; ok { return prompt } } // 其他模式返回通用约束提示词 if prompt, ok := generalPrompts[lang]; ok { return prompt } return "" } ================================================ FILE: application/services/prop_service.go ================================================ package services import ( "fmt" "time" // Added missing import models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "gorm.io/gorm" ) type PropService struct { db *gorm.DB aiService *AIService taskService *TaskService imageGenerationService *ImageGenerationService log *logger.Logger config *config.Config promptI18n *PromptI18n } func NewPropService(db *gorm.DB, aiService *AIService, taskService *TaskService, imageGenerationService *ImageGenerationService, log *logger.Logger, cfg *config.Config) *PropService { return &PropService{ db: db, aiService: aiService, taskService: taskService, imageGenerationService: imageGenerationService, log: log, config: cfg, promptI18n: NewPromptI18n(cfg), } } // ListProps 获取剧本的道具列表 func (s *PropService) ListProps(dramaID uint) ([]models.Prop, error) { var props []models.Prop if err := s.db.Where("drama_id = ?", dramaID).Find(&props).Error; err != nil { return nil, err } return props, nil } // CreateProp 创建道具 func (s *PropService) CreateProp(prop *models.Prop) error { return s.db.Create(prop).Error } // UpdateProp 更新道具 func (s *PropService) UpdateProp(id uint, updates map[string]interface{}) error { return s.db.Model(&models.Prop{}).Where("id = ?", id).Updates(updates).Error } // DeleteProp 删除道具 func (s *PropService) DeleteProp(id uint) error { return s.db.Delete(&models.Prop{}, id).Error } // ExtractPropsFromScript 从剧本提取道具(异步) func (s *PropService) ExtractPropsFromScript(episodeID uint) (string, error) { var episode models.Episode if err := s.db.First(&episode, episodeID).Error; err != nil { return "", fmt.Errorf("episode not found: %w", err) } task, err := s.taskService.CreateTask("prop_extraction", fmt.Sprintf("%d", episodeID)) if err != nil { return "", err } go s.processPropExtraction(task.ID, episode) return task.ID, nil } func (s *PropService) processPropExtraction(taskID string, episode models.Episode) { s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在分析剧本...") script := "" if episode.ScriptContent != nil { script = *episode.ScriptContent } // 获取 drama 的 style 信息 var drama models.Drama if err := s.db.First(&drama, episode.DramaID).Error; err != nil { s.log.Warnw("Failed to load drama", "error", err, "drama_id", episode.DramaID) } promptTemplate := s.promptI18n.GetPropExtractionPrompt(drama.Style) prompt := fmt.Sprintf(promptTemplate, script) response, err := s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(2000)) if err != nil { s.taskService.UpdateTaskError(taskID, err) return } var extractedProps []struct { Name string `json:"name"` Type string `json:"type"` Description string `json:"description"` ImagePrompt string `json:"image_prompt"` } if err := utils.SafeParseAIJSON(response, &extractedProps); err != nil { s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析AI结果失败: %w", err)) return } s.taskService.UpdateTaskStatus(taskID, "processing", 50, "正在保存道具...") var createdProps []models.Prop for _, p := range extractedProps { prop := models.Prop{ DramaID: episode.DramaID, Name: p.Name, Type: &p.Type, Description: &p.Description, Prompt: &p.ImagePrompt, } // 检查是否已存在同名道具(避免重复) var count int64 s.db.Model(&models.Prop{}).Where("drama_id = ? AND name = ?", episode.DramaID, p.Name).Count(&count) if count == 0 { if err := s.db.Create(&prop).Error; err == nil { createdProps = append(createdProps, prop) } } } s.taskService.UpdateTaskResult(taskID, createdProps) } // GeneratePropImage 生成道具图片 // 这里可以复用 ImageGenerationService,或者直接调用 AI Service // 简单起见,这里直接调用 ImageGenerationService 如果可以,或者 AI Service. // 为了保持架构一致性,应该创建一个 ImageGeneration 记录,然后复用现有的图片生成流程? // 但为了简单快速实现,这里先写一个专用的方法,或者更好的方式是: // 创建一个 ImageGeneration 记录,类型设为 "prop",然后复用 ImageGenerationService 的逻辑。 // 但 ImageGenerationService 目前绑定了 Storyboard/Scene ID 等。 // 所以这里实现一个简化的直接生成逻辑,或者扩展 ImageGenerationService。 // 鉴于时间,我实现一个简化的直接生成并保存图片的方法。 func (s *PropService) GeneratePropImage(propID uint) (string, error) { // 1. 获取道具信息 var prop models.Prop if err := s.db.First(&prop, propID).Error; err != nil { return "", err } if prop.Prompt == nil || *prop.Prompt == "" { return "", fmt.Errorf("道具没有图片提示词") } // 2. 创建任务 task, err := s.taskService.CreateTask("prop_image_generation", fmt.Sprintf("%d", propID)) if err != nil { return "", err } go s.processPropImageGeneration(task.ID, prop) return task.ID, nil } func (s *PropService) processPropImageGeneration(taskID string, prop models.Prop) { s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成图片...") // 准备生成参数 imageStyle := "Modern Japanese anime style" imageSize := "1024x1024" // 创建生成请求 req := &GenerateImageRequest{ DramaID: fmt.Sprintf("%d", prop.DramaID), PropID: &prop.ID, ImageType: string(models.ImageTypeProp), Prompt: *prop.Prompt, Size: imageSize, Style: &imageStyle, Provider: s.config.AI.DefaultImageProvider, // 使用默认配置 } // 调用 ImageGenerationService imageGen, err := s.imageGenerationService.GenerateImage(req) if err != nil { s.taskService.UpdateTaskError(taskID, err) return } // 轮询 ImageGeneration 状态直到完成 maxAttempts := 60 pollInterval := 2 * time.Second for i := 0; i < maxAttempts; i++ { time.Sleep(pollInterval) // 重新加载 imageGen var currentImageGen models.ImageGeneration if err := s.db.First(¤tImageGen, imageGen.ID).Error; err != nil { s.log.Errorw("Failed to poll image generation", "error", err, "id", imageGen.ID) continue } if currentImageGen.Status == models.ImageStatusCompleted { if currentImageGen.ImageURL != nil { // 任务成功 // ImageGenerationService 已经更新了 Prop.ImageURL,这里只需要更新 TaskService s.taskService.UpdateTaskResult(taskID, map[string]string{"image_url": *currentImageGen.ImageURL}) return } } else if currentImageGen.Status == models.ImageStatusFailed { errMsg := "图片生成失败" if currentImageGen.ErrorMsg != nil { errMsg = *currentImageGen.ErrorMsg } s.taskService.UpdateTaskError(taskID, fmt.Errorf(errMsg)) return } // 更新进度(可选) s.taskService.UpdateTaskStatus(taskID, "processing", 10+i, "正在生成图片...") } s.taskService.UpdateTaskError(taskID, fmt.Errorf("生成超时")) } // AssociatePropsWithStoryboard 关联道具到分镜 func (s *PropService) AssociatePropsWithStoryboard(storyboardID uint, propIDs []uint) error { var storyboard models.Storyboard if err := s.db.First(&storyboard, storyboardID).Error; err != nil { return err } var props []models.Prop if len(propIDs) > 0 { if err := s.db.Where("id IN ?", propIDs).Find(&props).Error; err != nil { return err } } return s.db.Model(&storyboard).Association("Props").Replace(props) } ================================================ FILE: application/services/resource_transfer_service.go ================================================ package services import ( "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type ResourceTransferService struct { db *gorm.DB log *logger.Logger } func NewResourceTransferService(db *gorm.DB, log *logger.Logger) *ResourceTransferService { return &ResourceTransferService{ db: db, log: log, } } // ResourceTransferService 现在只保留基本结构,MinIO相关功能已移除 // 如需资源转存功能,请使用本地存储 ================================================ FILE: application/services/script_generation_service.go ================================================ package services import ( "fmt" "strconv" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "gorm.io/gorm" ) type ScriptGenerationService struct { db *gorm.DB aiService *AIService log *logger.Logger config *config.Config promptI18n *PromptI18n taskService *TaskService } func NewScriptGenerationService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *ScriptGenerationService { return &ScriptGenerationService{ db: db, aiService: NewAIService(db, log), log: log, config: cfg, promptI18n: NewPromptI18n(cfg), taskService: NewTaskService(db, log), } } type GenerateCharactersRequest struct { DramaID string `json:"drama_id" binding:"required"` EpisodeID uint `json:"episode_id"` Outline string `json:"outline"` Count int `json:"count"` Temperature float64 `json:"temperature"` Model string `json:"model"` // 指定使用的文本模型 } func (s *ScriptGenerationService) GenerateCharacters(req *GenerateCharactersRequest) (string, error) { var drama models.Drama if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil { return "", fmt.Errorf("drama not found") } // 创建任务 task, err := s.taskService.CreateTask("character_generation", req.DramaID) if err != nil { s.log.Errorw("Failed to create character generation task", "error", err) return "", fmt.Errorf("创建任务失败: %w", err) } // 异步处理角色生成 go s.processCharacterGeneration(task.ID, req) s.log.Infow("Character generation task created", "task_id", task.ID, "drama_id", req.DramaID) return task.ID, nil } // processCharacterGeneration 异步处理角色生成 func (s *ScriptGenerationService) processCharacterGeneration(taskID string, req *GenerateCharactersRequest) { // 更新任务状态为处理中 s.taskService.UpdateTaskStatus(taskID, "processing", 0, "正在生成角色...") count := req.Count if count == 0 { count = 5 } // 获取 drama 的 style 信息 var drama models.Drama if err := s.db.Where("id = ? ", req.DramaID).First(&drama).Error; err != nil { s.log.Errorw("Drama not found during character generation", "error", err, "drama_id", req.DramaID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "剧本信息不存在") return } systemPrompt := s.promptI18n.GetCharacterExtractionPrompt(drama.Style) outlineText := req.Outline if outlineText == "" { outlineText = s.promptI18n.FormatUserPrompt("drama_info_template", drama.Title, drama.Description, drama.Genre) } userPrompt := s.promptI18n.FormatUserPrompt("character_request", outlineText, count) temperature := req.Temperature if temperature == 0 { temperature = 0.7 } // 如果指定了模型,使用指定的模型;否则使用默认配置 var text string var err error if req.Model != "" { s.log.Infow("Using specified model for character generation", "model", req.Model, "task_id", taskID) client, getErr := s.aiService.GetAIClientForModel("text", req.Model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", req.Model, "error", getErr, "task_id", taskID) text, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature)) } else { text, err = client.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature)) } } else { text, err = s.aiService.GenerateText(userPrompt, systemPrompt, ai.WithTemperature(temperature)) } if err != nil { s.log.Errorw("Failed to generate characters", "error", err, "task_id", taskID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "AI生成失败: "+err.Error()) return } s.log.Infow("AI response received for character generation", "length", len(text), "preview", text[:minInt(200, len(text))], "task_id", taskID) // AI直接返回数组格式 var result []struct { Name string `json:"name"` Role string `json:"role"` Description string `json:"description"` Personality string `json:"personality"` Appearance string `json:"appearance"` VoiceStyle string `json:"voice_style"` } if err := utils.SafeParseAIJSON(text, &result); err != nil { s.log.Errorw("Failed to parse characters JSON", "error", err, "raw_response", text[:minInt(500, len(text))], "task_id", taskID) s.taskService.UpdateTaskStatus(taskID, "failed", 0, "解析AI返回结果失败") return } var characters []models.Character for _, char := range result { // 检查角色是否已存在 var existingChar models.Character err := s.db.Where("drama_id = ? AND name = ?", req.DramaID, char.Name).First(&existingChar).Error if err == nil { // 角色已存在,直接使用已存在的角色,不覆盖 s.log.Infow("Character already exists, skipping", "drama_id", req.DramaID, "name", char.Name, "task_id", taskID) characters = append(characters, existingChar) continue } // 角色不存在,创建新角色 dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32) character := models.Character{ DramaID: uint(dramaID), Name: char.Name, Role: &char.Role, Description: &char.Description, Personality: &char.Personality, Appearance: &char.Appearance, VoiceStyle: &char.VoiceStyle, } if err := s.db.Create(&character).Error; err != nil { s.log.Errorw("Failed to create character", "error", err, "task_id", taskID) continue } characters = append(characters, character) } // 如果提供了 EpisodeID,建立 episode_characters 关联关系 if req.EpisodeID > 0 { var episode models.Episode if err := s.db.First(&episode, req.EpisodeID).Error; err == nil { // 使用 GORM 的 Association 建立多对多关联 if err := s.db.Model(&episode).Association("Characters").Append(characters); err != nil { s.log.Errorw("Failed to associate characters with episode", "error", err, "episode_id", req.EpisodeID, "task_id", taskID) } else { s.log.Infow("Characters associated with episode", "episode_id", req.EpisodeID, "character_count", len(characters), "task_id", taskID) } } else { s.log.Errorw("Episode not found for association", "episode_id", req.EpisodeID, "error", err, "task_id", taskID) } } // 更新任务状态为完成 resultData := map[string]interface{}{ "characters": characters, "count": len(characters), } s.taskService.UpdateTaskResult(taskID, resultData) s.log.Infow("Character generation completed", "task_id", taskID, "drama_id", req.DramaID, "character_count", len(characters)) } // GenerateScenesForEpisode 已废弃,使用 StoryboardService.GenerateStoryboard 替代 // ParseScript 已废弃,使用 GenerateCharacters 替代 // minInt 返回两个整数中较小的一个 func minInt(a, b int) int { if a < b { return a } return b } ================================================ FILE: application/services/storyboard_composition_service.go ================================================ package services import ( "encoding/json" "fmt" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) type StoryboardCompositionService struct { db *gorm.DB log *logger.Logger imageGen *ImageGenerationService } func NewStoryboardCompositionService(db *gorm.DB, log *logger.Logger, imageGen *ImageGenerationService) *StoryboardCompositionService { return &StoryboardCompositionService{ db: db, log: log, imageGen: imageGen, } } type SceneCharacterInfo struct { ID uint `json:"id"` Name string `json:"name"` ImageURL *string `json:"image_url,omitempty"` LocalPath *string `json:"local_path,omitempty"` } type SceneBackgroundInfo struct { ID uint `json:"id"` Location string `json:"location"` Time string `json:"time"` ImageURL *string `json:"image_url,omitempty"` LocalPath *string `json:"local_path,omitempty"` Status string `json:"status"` } type SceneCompositionInfo struct { ID uint `json:"id"` StoryboardNumber int `json:"storyboard_number"` Title *string `json:"title"` Description *string `json:"description"` ShotType *string `json:"shot_type"` Angle *string `json:"angle"` Movement *string `json:"movement"` Location *string `json:"location"` Time *string `json:"time"` Duration int `json:"duration"` Dialogue *string `json:"dialogue"` Action *string `json:"action"` Result *string `json:"result"` Atmosphere *string `json:"atmosphere"` BgmPrompt *string `json:"bgm_prompt,omitempty"` SoundEffect *string `json:"sound_effect,omitempty"` ImagePrompt *string `json:"image_prompt,omitempty"` VideoPrompt *string `json:"video_prompt,omitempty"` Characters []SceneCharacterInfo `json:"characters"` Background *SceneBackgroundInfo `json:"background"` SceneID *uint `json:"scene_id"` ComposedImage *string `json:"composed_image,omitempty"` VideoURL *string `json:"video_url,omitempty"` ImageGenerationID *uint `json:"image_generation_id,omitempty"` ImageGenerationStatus *string `json:"image_generation_status,omitempty"` VideoGenerationID *uint `json:"video_generation_id,omitempty"` VideoGenerationStatus *string `json:"video_generation_status,omitempty"` } func (s *StoryboardCompositionService) GetScenesForEpisode(episodeID string) ([]SceneCompositionInfo, error) { // 验证权限 var episode models.Episode err := s.db.Preload("Drama").Where("id = ?", episodeID).First(&episode).Error if err != nil { s.log.Errorw("Episode not found", "episode_id", episodeID, "error", err) return nil, fmt.Errorf("episode not found") } s.log.Infow("GetScenesForEpisode auth check", "episode_id", episodeID, "drama_id", episode.DramaID) // 获取分镜列表 var storyboards []models.Storyboard if err := s.db.Where("episode_id = ?", episodeID). Preload("Characters"). Order("storyboard_number ASC"). Find(&storyboards).Error; err != nil { return nil, fmt.Errorf("failed to load storyboards: %w", err) } // 获取所有角色(用于匹配角色信息) var characters []models.Character if err := s.db.Where("drama_id = ?", episode.DramaID).Find(&characters).Error; err != nil { s.log.Warnw("Failed to load characters", "error", err) } // 创建角色ID到角色信息的映射 charIDToInfo := make(map[uint]*models.Character) for i := range characters { charIDToInfo[characters[i].ID] = &characters[i] } // 获取所有场景ID var sceneIDs []uint for _, storyboard := range storyboards { if storyboard.SceneID != nil { sceneIDs = append(sceneIDs, *storyboard.SceneID) } } // 批量获取场景信息 var scenes []models.Scene sceneMap := make(map[uint]*models.Scene) if len(sceneIDs) > 0 { if err := s.db.Where("id IN ?", sceneIDs).Find(&scenes).Error; err == nil { for i := range scenes { sceneMap[scenes[i].ID] = &scenes[i] } } } // 获取分镜的合成图片(从 image_generations 表) storyboardIDs := make([]uint, len(storyboards)) for i, storyboard := range storyboards { storyboardIDs[i] = storyboard.ID } imageGenMap := make(map[uint]string) // storyboard_id -> image_url imageGenTaskMap := make(map[uint]*models.ImageGeneration) // storyboard_id -> processing task if len(storyboardIDs) > 0 { var imageGens []models.ImageGeneration // 查询已完成的图片生成记录,每个镜头只取最新的一条 if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusCompleted). Order("created_at DESC"). Find(&imageGens).Error; err == nil { // 为每个镜头保留最新的一条记录 for _, ig := range imageGens { if ig.StoryboardID != nil { if _, exists := imageGenMap[*ig.StoryboardID]; !exists { if ig.ImageURL != nil { imageGenMap[*ig.StoryboardID] = *ig.ImageURL } } } } } // 查询进行中的图片生成任务 var processingImageGens []models.ImageGeneration if err := s.db.Where("storyboard_id IN ? AND status = ?", storyboardIDs, models.ImageStatusProcessing). Order("created_at DESC"). Find(&processingImageGens).Error; err == nil { for _, ig := range processingImageGens { if ig.StoryboardID != nil { if _, exists := imageGenTaskMap[*ig.StoryboardID]; !exists { igCopy := ig imageGenTaskMap[*ig.StoryboardID] = &igCopy } } } } } // 批量查询进行中的视频生成任务 videoGenTaskMap := make(map[uint]*models.VideoGeneration) // storyboard_id -> processing task if len(storyboardIDs) > 0 { var processingVideoGens []models.VideoGeneration if err := s.db.Where("scene_id IN ? AND status = ?", storyboardIDs, models.VideoStatusProcessing). Order("created_at DESC"). Find(&processingVideoGens).Error; err == nil { for _, vg := range processingVideoGens { if vg.StoryboardID != nil { if _, exists := videoGenTaskMap[*vg.StoryboardID]; !exists { vgCopy := vg videoGenTaskMap[*vg.StoryboardID] = &vgCopy } } } } } // 构建返回结果 var result []SceneCompositionInfo for _, storyboard := range storyboards { storyboardInfo := SceneCompositionInfo{ ID: storyboard.ID, StoryboardNumber: storyboard.StoryboardNumber, Title: storyboard.Title, Description: storyboard.Description, ShotType: storyboard.ShotType, Angle: storyboard.Angle, Movement: storyboard.Movement, Location: storyboard.Location, Time: storyboard.Time, Duration: storyboard.Duration, Action: storyboard.Action, Dialogue: storyboard.Dialogue, Result: storyboard.Result, Atmosphere: storyboard.Atmosphere, BgmPrompt: storyboard.BgmPrompt, SoundEffect: storyboard.SoundEffect, ImagePrompt: storyboard.ImagePrompt, VideoPrompt: storyboard.VideoPrompt, SceneID: storyboard.SceneID, } // 直接使用关联的角色信息 if len(storyboard.Characters) > 0 { for _, char := range storyboard.Characters { storyboardChar := SceneCharacterInfo{ ID: char.ID, Name: char.Name, ImageURL: char.ImageURL, LocalPath: char.LocalPath, } storyboardInfo.Characters = append(storyboardInfo.Characters, storyboardChar) } } // 添加场景信息 if storyboard.SceneID != nil { if scene, ok := sceneMap[*storyboard.SceneID]; ok { storyboardInfo.Background = &SceneBackgroundInfo{ ID: scene.ID, Location: scene.Location, Time: scene.Time, ImageURL: scene.ImageURL, LocalPath: scene.LocalPath, Status: scene.Status, } } } // 添加合成图片 if imageURL, ok := imageGenMap[storyboard.ID]; ok { storyboardInfo.ComposedImage = &imageURL } // 添加视频URL if storyboard.VideoURL != nil { storyboardInfo.VideoURL = storyboard.VideoURL } // 添加进行中的图片生成任务信息 if imageTask, ok := imageGenTaskMap[storyboard.ID]; ok { storyboardInfo.ImageGenerationID = &imageTask.ID statusStr := string(imageTask.Status) storyboardInfo.ImageGenerationStatus = &statusStr } // 添加进行中的视频生成任务信息 if videoTask, ok := videoGenTaskMap[storyboard.ID]; ok { storyboardInfo.VideoGenerationID = &videoTask.ID statusStr := string(videoTask.Status) storyboardInfo.VideoGenerationStatus = &statusStr } result = append(result, storyboardInfo) } return result, nil } type UpdateSceneRequest struct { SceneID *uint `json:"scene_id"` Characters []uint `json:"characters"` // 改为存储角色ID数组 Location *string `json:"location"` Time *string `json:"time"` Action *string `json:"action"` Dialogue *string `json:"dialogue"` Description *string `json:"description"` Duration *int `json:"duration"` ImageURL *string `json:"image_url"` LocalPath *string `json:"local_path"` ImagePrompt *string `json:"image_prompt"` VideoPrompt *string `json:"video_prompt"` } func (s *StoryboardCompositionService) UpdateScene(sceneID string, req *UpdateSceneRequest) error { // 获取分镜并验证权限 var storyboard models.Storyboard err := s.db.Preload("Episode.Drama").Where("id = ?", sceneID).First(&storyboard).Error if err != nil { return fmt.Errorf("scene not found") } // 构建更新数据 updates := make(map[string]interface{}) // 更新背景ID if req.SceneID != nil { updates["scene_id"] = req.SceneID } // 更新角色列表(直接存储ID数组) if req.Characters != nil { charactersJSON, err := json.Marshal(req.Characters) if err != nil { return fmt.Errorf("failed to serialize characters: %w", err) } updates["characters"] = charactersJSON } // 更新场景信息字段 if req.Location != nil { updates["location"] = req.Location } if req.Time != nil { updates["time"] = req.Time } if req.Action != nil { updates["action"] = req.Action } if req.Dialogue != nil { updates["dialogue"] = req.Dialogue } if req.Description != nil { updates["description"] = req.Description } if req.Duration != nil { updates["duration"] = *req.Duration } if req.ImageURL != nil { updates["image_url"] = req.ImageURL } if req.LocalPath != nil { updates["local_path"] = req.LocalPath } if req.ImagePrompt != nil { updates["image_prompt"] = req.ImagePrompt } if req.VideoPrompt != nil { updates["video_prompt"] = req.VideoPrompt } // 执行更新 if len(updates) > 0 { if err := s.db.Model(&models.Storyboard{}).Where("id = ?", sceneID).Updates(updates).Error; err != nil { return fmt.Errorf("failed to update scene: %w", err) } } s.log.Infow("Scene updated", "scene_id", sceneID, "updates", updates) return nil } type GenerateSceneImageRequest struct { SceneID uint `json:"scene_id"` Prompt string `json:"prompt"` Model string `json:"model"` } func (s *StoryboardCompositionService) GenerateSceneImage(req *GenerateSceneImageRequest) (*models.ImageGeneration, error) { // 获取场景并验证权限 var scene models.Scene err := s.db.Where("id = ?", req.SceneID).First(&scene).Error if err != nil { return nil, fmt.Errorf("scene not found") } // 验证权限:通过DramaID查询Drama var drama models.Drama if err := s.db.Where("id = ? ", scene.DramaID).First(&drama).Error; err != nil { return nil, fmt.Errorf("unauthorized") } // 构建场景图片生成提示词 prompt := req.Prompt if prompt == "" { // 使用场景的Prompt字段 prompt = scene.Prompt if prompt == "" { // 如果Prompt为空,使用Location和Time构建 prompt = fmt.Sprintf("%s场景,%s", scene.Location, scene.Time) } s.log.Infow("Using scene prompt", "scene_id", req.SceneID, "prompt", prompt) } // 使用imageGen服务直接生成 if s.imageGen != nil { genReq := &GenerateImageRequest{ SceneID: &req.SceneID, DramaID: fmt.Sprintf("%d", scene.DramaID), ImageType: string(models.ImageTypeScene), Prompt: prompt, Model: req.Model, // 使用用户指定的模型 Size: "2560x1440", // 3,686,400像素,满足doubao模型最低要求(16:9比例) Quality: "standard", } imageGen, err := s.imageGen.GenerateImage(genReq) if err != nil { return nil, fmt.Errorf("failed to generate image: %w", err) } // 更新场景的image_url if imageGen.ImageURL != nil { scene.ImageURL = imageGen.ImageURL scene.Status = "generated" if err := s.db.Save(&scene).Error; err != nil { s.log.Errorw("Failed to update scene image url", "error", err) } } s.log.Infow("Scene image generation created", "scene_id", req.SceneID, "image_gen_id", imageGen.ID) return imageGen, nil } return nil, fmt.Errorf("image generation service not available") } type UpdateScenePromptRequest struct { Prompt string `json:"prompt"` } func (s *StoryboardCompositionService) UpdateScenePrompt(sceneID string, req *UpdateScenePromptRequest) error { var scene models.Scene if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("scene not found") } return fmt.Errorf("failed to find scene: %w", err) } scene.Prompt = req.Prompt if err := s.db.Save(&scene).Error; err != nil { return fmt.Errorf("failed to update scene prompt: %w", err) } s.log.Infow("Scene prompt updated", "scene_id", sceneID, "prompt", req.Prompt) return nil } type UpdateSceneInfoRequest struct { Location *string `json:"location"` Time *string `json:"time"` Prompt *string `json:"prompt"` Description *string `json:"description"` ImageURL *string `json:"image_url"` LocalPath *string `json:"local_path"` } func (s *StoryboardCompositionService) UpdateSceneInfo(sceneID string, req *UpdateSceneInfoRequest) error { var scene models.Scene if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("scene not found") } return fmt.Errorf("failed to find scene: %w", err) } updates := make(map[string]interface{}) if req.Location != nil { updates["location"] = *req.Location } if req.Time != nil { updates["time"] = *req.Time } if req.Prompt != nil { updates["prompt"] = *req.Prompt } if req.Description != nil { updates["description"] = *req.Description } if req.ImageURL != nil { updates["image_url"] = *req.ImageURL } if req.LocalPath != nil { updates["local_path"] = *req.LocalPath } if len(updates) > 0 { if err := s.db.Model(&scene).Updates(updates).Error; err != nil { return fmt.Errorf("failed to update scene: %w", err) } } s.log.Infow("Scene info updated", "scene_id", sceneID, "updates", updates) return nil } func (s *StoryboardCompositionService) DeleteScene(sceneID string) error { var scene models.Scene if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("scene not found") } return fmt.Errorf("failed to find scene: %w", err) } // 删除场景 if err := s.db.Delete(&scene).Error; err != nil { return fmt.Errorf("failed to delete scene: %w", err) } s.log.Infow("Scene deleted successfully", "scene_id", sceneID) return nil } func getStringValue(s *string) string { if s != nil { return *s } return "" } type CreateSceneRequest struct { DramaID uint `json:"drama_id"` EpisodeID *uint `json:"episode_id"` // 添加章节ID字段 Location string `json:"location"` Time string `json:"time"` Prompt string `json:"prompt"` ImageURL string `json:"image_url"` LocalPath string `json:"local_path"` Description string `json:"description"` } func (s *StoryboardCompositionService) CreateScene(req *CreateSceneRequest) (*models.Scene, error) { scene := &models.Scene{ DramaID: req.DramaID, EpisodeID: req.EpisodeID, // 设置章节ID Location: req.Location, Time: req.Time, Prompt: req.Prompt, Status: "draft", } if req.ImageURL != "" { scene.ImageURL = &req.ImageURL scene.Status = "completed" } if req.LocalPath != "" { scene.LocalPath = &req.LocalPath } if err := s.db.Create(scene).Error; err != nil { return nil, fmt.Errorf("failed to create scene: %w", err) } s.log.Infow("Scene created successfully", "scene_id", scene.ID, "drama_id", scene.DramaID, "episode_id", req.EpisodeID) return scene, nil } ================================================ FILE: application/services/storyboard_service.go ================================================ package services import ( "strconv" "fmt" "strings" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/ai" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type StoryboardService struct { db *gorm.DB aiService *AIService taskService *TaskService log *logger.Logger config *config.Config promptI18n *PromptI18n } func NewStoryboardService(db *gorm.DB, cfg *config.Config, log *logger.Logger) *StoryboardService { return &StoryboardService{ db: db, aiService: NewAIService(db, log), taskService: NewTaskService(db, log), log: log, config: cfg, promptI18n: NewPromptI18n(cfg), } } type Storyboard struct { ShotNumber int `json:"shot_number"` Title string `json:"title"` // 镜头标题 ShotType string `json:"shot_type"` // 景别 Angle string `json:"angle"` // 镜头角度 Time string `json:"time"` // 时间 Location string `json:"location"` // 地点 SceneID *uint `json:"scene_id"` // 背景ID(AI直接返回,可为null) Movement string `json:"movement"` // 运镜 Action string `json:"action"` // 动作 Dialogue string `json:"dialogue"` // 对话/独白 Result string `json:"result"` // 画面结果 Atmosphere string `json:"atmosphere"` // 环境氛围 Emotion string `json:"emotion"` // 情绪 Duration int `json:"duration"` // 时长(秒) BgmPrompt string `json:"bgm_prompt"` // 配乐提示词 SoundEffect string `json:"sound_effect"` // 音效描述 Characters []uint `json:"characters"` // 涉及的角色ID列表 IsPrimary bool `json:"is_primary"` // 是否主镜 } type GenerateStoryboardResult struct { Storyboards []Storyboard `json:"storyboards"` Total int `json:"total"` } func (s *StoryboardService) GenerateStoryboard(episodeID string, model string) (string, error) { // 从数据库获取剧集信息 var episode struct { ID string ScriptContent *string Description *string DramaID string } err := s.db.Table("episodes"). Select("episodes.id, episodes.script_content, episodes.description, episodes.drama_id"). Joins("INNER JOIN dramas ON dramas.id = episodes.drama_id"). Where("episodes.id = ?", episodeID). First(&episode).Error if err != nil { return "", fmt.Errorf("剧集不存在或无权限访问") } // 获取剧本内容 var scriptContent string if episode.ScriptContent != nil && *episode.ScriptContent != "" { scriptContent = *episode.ScriptContent } else if episode.Description != nil && *episode.Description != "" { scriptContent = *episode.Description } else { return "", fmt.Errorf("剧本内容为空,请先生成剧集内容") } // 获取该剧本的所有角色 var characters []models.Character if err := s.db.Where("drama_id = ?", episode.DramaID).Order("name ASC").Find(&characters).Error; err != nil { return "", fmt.Errorf("获取角色列表失败: %w", err) } // 构建角色列表字符串(包含ID和名称) characterList := "无角色" if len(characters) > 0 { var charInfoList []string for _, char := range characters { charInfoList = append(charInfoList, fmt.Sprintf(`{"id": %d, "name": "%s"}`, char.ID, char.Name)) } characterList = fmt.Sprintf("[%s]", strings.Join(charInfoList, ", ")) } // 获取该项目已提取的场景列表(项目级) var scenes []models.Scene if err := s.db.Where("drama_id = ?", episode.DramaID).Order("location ASC, time ASC").Find(&scenes).Error; err != nil { s.log.Warnw("Failed to get scenes", "error", err) } // 构建场景列表字符串(包含ID、地点、时间) sceneList := "无场景" if len(scenes) > 0 { var sceneInfoList []string for _, bg := range scenes { sceneInfoList = append(sceneInfoList, fmt.Sprintf(`{"id": %d, "location": "%s", "time": "%s"}`, bg.ID, bg.Location, bg.Time)) } sceneList = fmt.Sprintf("[%s]", strings.Join(sceneInfoList, ", ")) } // 使用国际化提示词 systemPrompt := s.promptI18n.GetStoryboardSystemPrompt() scriptLabel := s.promptI18n.FormatUserPrompt("script_content_label") taskLabel := s.promptI18n.FormatUserPrompt("task_label") taskInstruction := s.promptI18n.FormatUserPrompt("task_instruction") charListLabel := s.promptI18n.FormatUserPrompt("character_list_label") charConstraint := s.promptI18n.FormatUserPrompt("character_constraint") sceneListLabel := s.promptI18n.FormatUserPrompt("scene_list_label") sceneConstraint := s.promptI18n.FormatUserPrompt("scene_constraint") prompt := fmt.Sprintf(`%s %s %s %s%s %s %s %s %s %s %s 【剧本原文】 %s 【分镜要素】每个镜头聚焦单一动作,描述要详尽具体: 1. **镜头标题(title)**:用3-5个字概括该镜头的核心内容或情绪 - 例如:"噩梦惊醒"、"对视沉思"、"逃离现场"、"意外发现" 2. **时间**:[清晨/午后/深夜/具体时分+详细光线描述] - 例如:"深夜22:30·月光从破窗斜射入室内,形成明暗分界" 3. **地点**:[场景完整描述+空间布局+环境细节] - 例如:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱" 4. **镜头设计**: - **景别(shot_type)**:[远景/全景/中景/近景/特写] - **镜头角度(angle)**:[平视/仰视/俯视/侧面/背面] - **运镜方式(movement)**:[固定镜头/推镜/拉镜/摇镜/跟镜/移镜] 5. **人物行为**:**详细动作描述**,包含[谁+具体怎么做+肢体细节+表情状态] - 例如:"陈峥弯腰用撬棍撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水滑落脸颊" 6. **对话/独白**:提取该镜头中的完整对话或独白内容(如无对话则为空字符串) 7. **画面结果**:动作的即时后果+视觉细节+氛围变化 - 例如:"保险箱门弹开发出金属碰撞声,扬起灰尘在光束中飘散,箱内空无一物只有陈旧报纸,陈峥表情从期待转为失望" 8. **环境氛围**:光线质感+色调+声音环境+整体氛围 - 例如:"昏暗冷色调,只有手电筒光束晃动,远处传来海浪拍打声,压抑沉闷" 9. **配乐提示(bgm_prompt)**:描述该镜头配乐的氛围、节奏、情绪(如无特殊要求则为空字符串) - 例如:"低沉紧张的弦乐,节奏缓慢,营造压抑氛围" 10. **音效描述(sound_effect)**:描述该镜头的关键音效(如无特殊音效则为空字符串) - 例如:"金属碰撞声、脚步声、海浪拍打声" 11. **观众情绪**:[情绪类型]([强度:↑↑↑/↑↑/↑/→/↓] + [落点:悬置/释放/反转]) 【输出格式】请以JSON格式输出,每个镜头包含以下字段(**所有描述性字段都要详细完整**): { "storyboards": [ { "shot_number": 1, "title": "噩梦惊醒", "shot_type": "全景", "angle": "俯视45度角", "time": "深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清", "location": "废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味", "scene_id": 1, "movement": "固定镜头", "action": "陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促", "dialogue": "(独白)这么多年了,里面到底藏着什么秘密?", "result": "保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大", "atmosphere": "昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重", "emotion": "好奇感↑↑转失望↓(情绪反转)", "duration": 9, "bgm_prompt": "低沉紧张的弦乐,节奏缓慢,营造压抑悬疑氛围", "sound_effect": "金属碰撞声、灰尘飘散声、海浪拍打声", "characters": [159], "is_primary": true }, { "shot_number": 2, "title": "对视沉思", "shot_type": "近景", "angle": "平视", "time": "深夜22:31·仓库内光线昏暗,只有手电筒光从侧面照亮两人脸部轮廓", "location": "废弃码头仓库·保险箱旁,背景是模糊的货架剪影", "scene_id": 1, "movement": "推镜", "action": "陈峥缓缓转身,目光与身后的李芳对视,李芳手握手电筒,光束在两人之间晃动,眼神中透露疑惑和警惕", "dialogue": "陈峥:\"我们被耍了,这里根本没有我们要找的东西。\" 李芳:\"现在怎么办?我们的时间不多了。\"", "result": "两人站在昏暗中陷入沉思,手电筒光束照在地面形成圆形光斑,背景传来微弱的金属摩擦声,气氛紧张凝重", "atmosphere": "低调光线·暗部占画面70%,侧面硬光勾勒人物轮廓,冷暖光对比强烈,海风吹过产生呼啸声,营造紧迫感", "emotion": "紧张感↑↑·警惕↑↑(悬置)", "duration": 7, "bgm_prompt": "紧张感逐渐升级的音效,低频持续音", "sound_effect": "呼吸声、金属摩擦声、海风呼啸声", "characters": [159, 160], "is_primary": true } ] } **dialogue字段说明**: - 如果有对话,格式为:角色名:"台词内容" - 多人对话用空格分隔:角色A:"..." 角色B:"..." - 独白格式为:(独白)内容 - 旁白格式为:(旁白)内容 - 无对话时填写空字符串:"" - **对话内容必须从原剧本中提取,保持原汁原味** **角色和背景要求**: - characters字段必须包含该镜头中出现的所有角色ID(数字数组格式) - 只提取实际出现的角色ID,不出现角色则为空数组[] - **角色ID必须严格使用【本剧可用角色列表】中的id字段(数字),不得使用其他ID或自创角色** - 例如:如果镜头中出现李明(id:159)和王芳(id:160),则characters字段应为[159, 160] - scene_id字段必须从【本剧已提取的场景背景列表】中选择最匹配的背景ID(数字) - 如果列表中没有合适的背景,则scene_id填null - 例如:如果镜头发生在"城市公寓卧室·凌晨",应选择id为1的场景背景 **duration时长估算规则(秒)**: - **所有镜头时长必须在4-12秒范围内**,确保节奏合理流畅 - **综合估算原则**:时长由对话内容、动作复杂度、情绪节奏三方面综合决定 **估算步骤**: 1. **基础时长**(从场景内容判断): - 纯对话场景(无明显动作):基础4秒 - 纯动作场景(无对话):基础5秒 - 对话+动作混合场景:基础6秒 2. **对话调整**(根据台词字数增加时长): - 无对话:+0秒 - 短对话(1-20字):+1-2秒 - 中等对话(21-50字):+2-4秒 - 长对话(51字以上):+4-6秒 3. **动作调整**(根据动作复杂度增加时长): - 无动作/静态:+0秒 - 简单动作(表情、转身、拿物品):+0-1秒 - 一般动作(走动、开门、坐下):+1-2秒 - 复杂动作(打斗、追逐、大幅度移动):+2-4秒 - 环境展示(全景扫描、氛围营造):+2-5秒 4. **最终时长** = 基础时长 + 对话调整 + 动作调整,确保结果在4-12秒范围内 **示例**: - "陈峥转身离开"(简单动作,无对话):5 + 0 + 1 = 6秒 - "李芳:\"你要去哪里?\""(短对话,无动作):4 + 2 + 0 = 6秒 - "陈峥推开房门,李芳:\"终于找到你了,这些年你去哪了?\""(一般动作+中等对话):6 + 3 + 2 = 11秒 - "两人在雨中激烈搏斗,陈峥:\"住手!\""(复杂动作+短对话):6 + 2 + 4 = 12秒 **重要**:准确估算每个镜头时长,所有分镜时长之和将作为剧集总时长 **特别要求**: - **【极其重要】必须100%%完整拆解整个剧本,不得省略、跳过、压缩任何剧情内容** - **从剧本第一个字到最后一个字,逐句逐段转换为分镜** - **每个对话、每个动作、每个场景转换都必须有对应的分镜** - 剧本越长,分镜数量越多(短剧本15-30个,中等剧本30-60个,长剧本60-100个甚至更多) - **宁可分镜多,也不要遗漏剧情**:一个长场景可拆分为多个连续分镜 - 每个镜头只描述一个主要动作 - 区分主镜(is_primary: true)和链接镜(is_primary: false) - 确保情绪节奏有变化 - **duration字段至关重要**:准确估算每个镜头时长,这将用于计算整集时长 - 严格按照JSON格式输出 **【禁止行为】**: - ❌ 禁止用一个镜头概括多个场景 - ❌ 禁止跳过任何对话或独白 - ❌ 禁止省略剧情发展过程 - ❌ 禁止合并本应分开的镜头 - ✅ 正确做法:剧本有多少内容,就拆解出对应数量的分镜,确保观众看完所有分镜能完整了解剧情 **【关键】场景描述详细度要求**(这些描述将直接用于视频生成模型): 1. **时间(time)字段**:必须包含≥15字的详细描述 - ✓ 好例子:"深夜22:30·月光从破窗斜射入仓库,在地面积水中形成银白色反光,墙角昏暗不清" - ✗ 差例子:"深夜" 2. **地点(location)字段**:必须包含≥20字的详细场景描述 - ✓ 好例子:"废弃码头仓库·锈蚀货架林立,地面积水反射微弱灯光,墙角堆放腐朽木箱和渔网,空气中弥漫潮湿霉味" - ✗ 差例子:"仓库" 3. **动作(action)字段**:必须包含≥25字的详细动作描述,包括肢体细节和表情 - ✓ 好例子:"陈峥弯腰双手握住撬棍用力撬动保险箱门,手臂青筋暴起,眉头紧锁,汗水从额头滑落脸颊,呼吸急促" - ✗ 差例子:"陈峥打开保险箱" 4. **结果(result)字段**:必须包含≥25字的详细视觉结果描述 - ✓ 好例子:"保险箱门突然弹开发出刺耳金属声,扬起灰尘在手电筒光束中飘散,箱内空无一物只有几张发黄的旧报纸,陈峥表情从期待转为震惊和失望,瞳孔放大" - ✗ 差例子:"门打开了" 5. **氛围(atmosphere)字段**:必须包含≥20字的环境氛围描述,包括光线、色调、声音 - ✓ 好例子:"昏暗冷色调·青灰色为主,只有手电筒光束在黑暗中晃动,远处传来海浪拍打码头的沉闷声,整体氛围压抑沉重" - ✗ 差例子:"昏暗" **描述原则**: - 所有描述性字段要像为盲人讲述画面一样详细 - 包含感官细节:视觉、听觉、触觉、嗅觉 - 描述光线、色彩、质感、动态 - 为视频生成AI提供足够的画面构建信息 - 避免抽象词汇,使用具象的视觉化描述`, systemPrompt, scriptLabel, scriptContent, taskLabel, taskInstruction, charListLabel, characterList, charConstraint, sceneListLabel, sceneList, sceneConstraint) // 创建异步任务 task, err := s.taskService.CreateTask("storyboard_generation", episodeID) if err != nil { s.log.Errorw("Failed to create task", "error", err) return "", fmt.Errorf("创建任务失败: %w", err) } s.log.Infow("Generating storyboard asynchronously", "task_id", task.ID, "episode_id", episodeID, "drama_id", episode.DramaID, "script_length", len(scriptContent), "character_count", len(characters), "characters", characterList, "scene_count", len(scenes), "scenes", sceneList) // 启动后台goroutine处理AI调用和后续逻辑 go s.processStoryboardGeneration(task.ID, episodeID, model, prompt) // 立即返回任务ID return task.ID, nil } // processStoryboardGeneration 后台处理故事板生成 func (s *StoryboardService) processStoryboardGeneration(taskID, episodeID, model, prompt string) { // 更新任务状态为处理中 if err := s.taskService.UpdateTaskStatus(taskID, "processing", 10, "开始生成分镜头..."); err != nil { s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID) return } s.log.Infow("Processing storyboard generation", "task_id", taskID, "episode_id", episodeID) // 调用AI服务生成(如果指定了模型则使用指定的模型) // 设置较大的max_tokens以确保完整返回所有分镜的JSON var text string var err error if model != "" { s.log.Infow("Using specified model for storyboard generation", "model", model, "task_id", taskID) client, getErr := s.aiService.GetAIClientForModel("text", model) if getErr != nil { s.log.Warnw("Failed to get client for specified model, using default", "model", model, "error", getErr, "task_id", taskID) text, err = s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(16000)) } else { text, err = client.GenerateText(prompt, "", ai.WithMaxTokens(16000)) } } else { text, err = s.aiService.GenerateText(prompt, "", ai.WithMaxTokens(16000)) } if err != nil { s.log.Errorw("Failed to generate storyboard", "error", err, "task_id", taskID) if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("生成分镜头失败: %w", err)); updateErr != nil { s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID) } return } // 更新任务进度 if err := s.taskService.UpdateTaskStatus(taskID, "processing", 50, "分镜头生成完成,正在解析结果..."); err != nil { s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID) return } // 解析JSON结果 // AI可能返回两种格式: // 1. 数组格式: [{...}, {...}] // 2. 对象格式: {"storyboards": [{...}, {...}]} var result GenerateStoryboardResult // 先尝试解析为数组格式 var storyboards []Storyboard if err := utils.SafeParseAIJSON(text, &storyboards); err == nil { // 成功解析为数组,包装为对象 result.Storyboards = storyboards result.Total = len(storyboards) s.log.Infow("Parsed storyboard as array format", "count", len(storyboards), "task_id", taskID) } else { // 尝试解析为对象格式 if err := utils.SafeParseAIJSON(text, &result); err != nil { s.log.Errorw("Failed to parse storyboard JSON in both formats", "error", err, "response", text[:min(500, len(text))], "task_id", taskID) if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("解析分镜头结果失败: %w", err)); updateErr != nil { s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID) } return } result.Total = len(result.Storyboards) s.log.Infow("Parsed storyboard as object format", "count", len(result.Storyboards), "task_id", taskID) } // 计算总时长(所有分镜时长之和) totalDuration := 0 for _, sb := range result.Storyboards { totalDuration += sb.Duration } s.log.Infow("Storyboard generated", "task_id", taskID, "episode_id", episodeID, "count", result.Total, "total_duration_seconds", totalDuration) // 更新任务进度 if err := s.taskService.UpdateTaskStatus(taskID, "processing", 70, "正在保存分镜头..."); err != nil { s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID) return } // 保存分镜头到数据库 if err := s.saveStoryboards(episodeID, result.Storyboards); err != nil { s.log.Errorw("Failed to save storyboards", "error", err, "task_id", taskID) if updateErr := s.taskService.UpdateTaskError(taskID, fmt.Errorf("保存分镜头失败: %w", err)); updateErr != nil { s.log.Errorw("Failed to update task error", "error", updateErr, "task_id", taskID) } return } // 更新任务进度 if err := s.taskService.UpdateTaskStatus(taskID, "processing", 90, "正在更新剧集时长..."); err != nil { s.log.Errorw("Failed to update task status", "error", err, "task_id", taskID) return } // 更新剧集时长(秒转分钟,向上取整) durationMinutes := (totalDuration + 59) / 60 if err := s.db.Model(&models.Episode{}).Where("id = ?", episodeID).Update("duration", durationMinutes).Error; err != nil { s.log.Errorw("Failed to update episode duration", "error", err, "task_id", taskID) // 不中断流程,只记录错误 } else { s.log.Infow("Episode duration updated", "task_id", taskID, "episode_id", episodeID, "duration_seconds", totalDuration, "duration_minutes", durationMinutes) } // 更新任务结果 resultData := gin.H{ "storyboards": result.Storyboards, "total": result.Total, "total_duration": totalDuration, "duration_minutes": durationMinutes, } if err := s.taskService.UpdateTaskResult(taskID, resultData); err != nil { s.log.Errorw("Failed to update task result", "error", err, "task_id", taskID) return } s.log.Infow("Storyboard generation completed", "task_id", taskID, "episode_id", episodeID) } // generateImagePrompt 生成专门用于图片生成的提示词(首帧静态画面) func (s *StoryboardService) generateImagePrompt(sb Storyboard) string { var parts []string // 1. 完整的场景背景描述 if sb.Location != "" { locationDesc := sb.Location if sb.Time != "" { locationDesc += ", " + sb.Time } parts = append(parts, locationDesc) } // 2. 角色初始静态姿态(去除动作过程,只保留起始状态) if sb.Action != "" { initialPose := extractInitialPose(sb.Action) if initialPose != "" { parts = append(parts, initialPose) } } // 3. 情绪氛围 if sb.Emotion != "" { parts = append(parts, sb.Emotion) } // 4. 动漫风格 parts = append(parts, "anime style, first frame") if len(parts) > 0 { return strings.Join(parts, ", ") } return "anime scene" } // extractInitialPose 提取初始静态姿态(去除动作过程) func extractInitialPose(action string) string { // 去除动作过程关键词,保留初始状态描述 processWords := []string{ "然后", "接着", "接下来", "随后", "紧接着", "向下", "向上", "向前", "向后", "向左", "向右", "开始", "继续", "逐渐", "慢慢", "快速", "突然", "猛然", } result := action for _, word := range processWords { if idx := strings.Index(result, word); idx > 0 { // 在动作过程词之前截断 result = result[:idx] break } } // 清理末尾标点 result = strings.TrimRight(result, ",。,. ") return strings.TrimSpace(result) } // extractSimpleLocation 提取简化的场景地点(去除详细描述) func extractSimpleLocation(location string) string { // 在"·"符号处截断,只保留主场景名称 if idx := strings.Index(location, "·"); idx > 0 { return strings.TrimSpace(location[:idx]) } // 如果有逗号,只保留第一部分 if idx := strings.Index(location, ","); idx > 0 { return strings.TrimSpace(location[:idx]) } if idx := strings.Index(location, ","); idx > 0 { return strings.TrimSpace(location[:idx]) } // 限制长度不超过15个字符 maxLen := 15 if len(location) > maxLen { return strings.TrimSpace(location[:maxLen]) } return strings.TrimSpace(location) } // extractSimplePose 提取简单的核心姿态关键词(不超过10个字) func extractSimplePose(action string) string { // 只提取前面最多10个字符作为核心姿态 runes := []rune(action) maxLen := 10 if len(runes) > maxLen { // 在标点符号处截断 truncated := runes[:maxLen] for i := maxLen - 1; i >= 0; i-- { if truncated[i] == ',' || truncated[i] == '。' || truncated[i] == ',' || truncated[i] == '.' { truncated = runes[:i] break } } return strings.TrimSpace(string(truncated)) } return strings.TrimSpace(action) } // extractFirstFramePose 从动作描述中提取首帧静态姿态 func extractFirstFramePose(action string) string { // 去除表示动作过程的关键词,保留初始状态 processWords := []string{ "然后", "接着", "向下", "向前", "走向", "冲向", "转身", "开始", "继续", "逐渐", "慢慢", "快速", "突然", } pose := action for _, word := range processWords { // 简单处理:在这些词之前截断 if idx := strings.Index(pose, word); idx > 0 { pose = pose[:idx] break } } // 清理末尾标点 pose = strings.TrimRight(pose, ",。,.") return strings.TrimSpace(pose) } // extractCompositionType 从镜头类型中提取构图类型(去除运镜) func extractCompositionType(shotType string) string { // 去除运镜相关描述 cameraMovements := []string{ "晃动", "摇晃", "推进", "拉远", "跟随", "环绕", "运镜", "摄影", "移动", "旋转", } comp := shotType for _, movement := range cameraMovements { comp = strings.ReplaceAll(comp, movement, "") } // 清理多余的标点和空格 comp = strings.ReplaceAll(comp, "··", "·") comp = strings.ReplaceAll(comp, "·", " ") comp = strings.TrimSpace(comp) return comp } // generateVideoPrompt 生成专门用于视频生成的提示词(包含运镜和动态元素) func (s *StoryboardService) generateVideoPrompt(sb Storyboard) string { var parts []string videoRatio := "16:9" // 1. 人物动作 if sb.Action != "" { parts = append(parts, fmt.Sprintf("Action: %s", sb.Action)) } // 2. 对话 if sb.Dialogue != "" { parts = append(parts, fmt.Sprintf("Dialogue: %s", sb.Dialogue)) } // 3. 镜头运动(视频特有) if sb.Movement != "" { parts = append(parts, fmt.Sprintf("Camera movement: %s", sb.Movement)) } // 4. 镜头类型和角度 if sb.ShotType != "" { parts = append(parts, fmt.Sprintf("Shot type: %s", sb.ShotType)) } if sb.Angle != "" { parts = append(parts, fmt.Sprintf("Camera angle: %s", sb.Angle)) } // 5. 场景环境 if sb.Location != "" { locationDesc := sb.Location if sb.Time != "" { locationDesc += ", " + sb.Time } parts = append(parts, fmt.Sprintf("Scene: %s", locationDesc)) } // 6. 环境氛围 if sb.Atmosphere != "" { parts = append(parts, fmt.Sprintf("Atmosphere: %s", sb.Atmosphere)) } // 7. 情绪和结果 if sb.Emotion != "" { parts = append(parts, fmt.Sprintf("Mood: %s", sb.Emotion)) } if sb.Result != "" { parts = append(parts, fmt.Sprintf("Result: %s", sb.Result)) } // 8. 音频元素 if sb.BgmPrompt != "" { parts = append(parts, fmt.Sprintf("BGM: %s", sb.BgmPrompt)) } if sb.SoundEffect != "" { parts = append(parts, fmt.Sprintf("Sound effects: %s", sb.SoundEffect)) } // 9. 视频比例 parts = append(parts, fmt.Sprintf("=VideoRatio: %s", videoRatio)) if len(parts) > 0 { return strings.Join(parts, ". ") } return "Anime style video scene" } func (s *StoryboardService) saveStoryboards(episodeID string, storyboards []Storyboard) error { // 验证 episodeID epID, err := strconv.ParseUint(episodeID, 10, 32) if err != nil { s.log.Errorw("Invalid episode ID", "episode_id", episodeID, "error", err) return fmt.Errorf("无效的章节ID: %s", episodeID) } // 防御性检查:如果AI返回的分镜数量为0,不应该删除旧分镜 if len(storyboards) == 0 { s.log.Errorw("AI返回的分镜数量为0,拒绝保存以避免删除现有分镜", "episode_id", episodeID) return fmt.Errorf("AI生成分镜失败:返回的分镜数量为0") } s.log.Infow("开始保存分镜头", "episode_id", episodeID, "episode_id_uint", uint(epID), "storyboard_count", len(storyboards)) // 开启事务 return s.db.Transaction(func(tx *gorm.DB) error { // 验证该章节是否存在 var episode models.Episode if err := tx.First(&episode, epID).Error; err != nil { s.log.Errorw("Episode not found", "episode_id", episodeID, "error", err) return fmt.Errorf("章节不存在: %s", episodeID) } s.log.Infow("找到章节信息", "episode_id", episode.ID, "episode_number", episode.EpisodeNum, "drama_id", episode.DramaID, "title", episode.Title) // 获取该剧集所有的分镜ID(使用 uint 类型) var storyboardIDs []uint if err := tx.Model(&models.Storyboard{}). Where("episode_id = ?", uint(epID)). Pluck("id", &storyboardIDs).Error; err != nil { return err } s.log.Infow("查询到现有分镜", "episode_id_string", episodeID, "episode_id_uint", uint(epID), "existing_storyboard_count", len(storyboardIDs), "storyboard_ids", storyboardIDs) // 如果有分镜,先清理关联的image_generations的storyboard_id if len(storyboardIDs) > 0 { if err := tx.Model(&models.ImageGeneration{}). Where("storyboard_id IN ?", storyboardIDs). Update("storyboard_id", nil).Error; err != nil { return err } s.log.Infow("已清理关联的图片生成记录", "count", len(storyboardIDs)) } // 删除该剧集已有的分镜头(使用 uint 类型确保类型匹配) s.log.Warnw("准备删除分镜数据", "episode_id_string", episodeID, "episode_id_uint", uint(epID), "episode_id_from_db", episode.ID, "will_delete_count", len(storyboardIDs)) result := tx.Where("episode_id = ?", uint(epID)).Delete(&models.Storyboard{}) if result.Error != nil { s.log.Errorw("删除旧分镜失败", "episode_id", uint(epID), "error", result.Error) return result.Error } s.log.Infow("已删除旧分镜头", "episode_id", uint(epID), "deleted_count", result.RowsAffected) // 注意:不删除背景,因为背景是在分镜拆解前就提取好的 // AI会直接返回scene_id,不需要在这里做字符串匹配 // 保存新的分镜头 for _, sb := range storyboards { // 构建描述信息,包含对话 description := fmt.Sprintf("【镜头类型】%s\n【运镜】%s\n【动作】%s\n【对话】%s\n【结果】%s\n【情绪】%s", sb.ShotType, sb.Movement, sb.Action, sb.Dialogue, sb.Result, sb.Emotion) // 生成两种专用提示词 imagePrompt := s.generateImagePrompt(sb) // 专用于图片生成 videoPrompt := s.generateVideoPrompt(sb) // 专用于视频生成 // 处理 dialogue 字段 var dialoguePtr *string if sb.Dialogue != "" { dialoguePtr = &sb.Dialogue } // 使用AI直接返回的SceneID if sb.SceneID != nil { s.log.Infow("Background ID from AI", "shot_number", sb.ShotNumber, "scene_id", *sb.SceneID) } // 处理 title 字段 var titlePtr *string if sb.Title != "" { titlePtr = &sb.Title } // 处理shot_type、angle、movement字段 var shotTypePtr, anglePtr, movementPtr *string if sb.ShotType != "" { shotTypePtr = &sb.ShotType } if sb.Angle != "" { anglePtr = &sb.Angle } if sb.Movement != "" { movementPtr = &sb.Movement } // 处理bgm_prompt、sound_effect字段 var bgmPromptPtr, soundEffectPtr *string if sb.BgmPrompt != "" { bgmPromptPtr = &sb.BgmPrompt } if sb.SoundEffect != "" { soundEffectPtr = &sb.SoundEffect } // 处理result、atmosphere字段 var resultPtr, atmospherePtr *string if sb.Result != "" { resultPtr = &sb.Result } if sb.Atmosphere != "" { atmospherePtr = &sb.Atmosphere } scene := models.Storyboard{ EpisodeID: uint(epID), SceneID: sb.SceneID, StoryboardNumber: sb.ShotNumber, Title: titlePtr, Location: &sb.Location, Time: &sb.Time, ShotType: shotTypePtr, Angle: anglePtr, Movement: movementPtr, Description: &description, Action: &sb.Action, Result: resultPtr, Atmosphere: atmospherePtr, Dialogue: dialoguePtr, ImagePrompt: &imagePrompt, VideoPrompt: &videoPrompt, BgmPrompt: bgmPromptPtr, SoundEffect: soundEffectPtr, Duration: sb.Duration, } if err := tx.Create(&scene).Error; err != nil { s.log.Errorw("Failed to create scene", "error", err, "shot_number", sb.ShotNumber) return err } // 关联角色 if len(sb.Characters) > 0 { var characters []models.Character if err := tx.Where("id IN ?", sb.Characters).Find(&characters).Error; err != nil { s.log.Warnw("Failed to load characters for association", "error", err, "character_ids", sb.Characters) } else if len(characters) > 0 { if err := tx.Model(&scene).Association("Characters").Append(characters); err != nil { s.log.Warnw("Failed to associate characters", "error", err, "shot_number", sb.ShotNumber) } else { s.log.Infow("Characters associated successfully", "shot_number", sb.ShotNumber, "character_ids", sb.Characters, "count", len(characters)) } } } } s.log.Infow("Storyboards saved successfully", "episode_id", episodeID, "count", len(storyboards)) return nil }) } // CreateStoryboardRequest 创建分镜请求 type CreateStoryboardRequest struct { EpisodeID uint `json:"episode_id"` SceneID *uint `json:"scene_id"` StoryboardNumber int `json:"storyboard_number"` Title *string `json:"title"` Location *string `json:"location"` Time *string `json:"time"` ShotType *string `json:"shot_type"` Angle *string `json:"angle"` Movement *string `json:"movement"` Description *string `json:"description"` Action *string `json:"action"` Result *string `json:"result"` Atmosphere *string `json:"atmosphere"` Dialogue *string `json:"dialogue"` BgmPrompt *string `json:"bgm_prompt"` SoundEffect *string `json:"sound_effect"` Duration int `json:"duration"` Characters []uint `json:"characters"` } // CreateStoryboard 创建单个分镜 func (s *StoryboardService) CreateStoryboard(req *CreateStoryboardRequest) (*models.Storyboard, error) { // 构建Storyboard对象 sb := Storyboard{ ShotNumber: req.StoryboardNumber, ShotType: getString(req.ShotType), Angle: getString(req.Angle), Time: getString(req.Time), Location: getString(req.Location), SceneID: req.SceneID, Movement: getString(req.Movement), Action: getString(req.Action), Dialogue: getString(req.Dialogue), Result: getString(req.Result), Atmosphere: getString(req.Atmosphere), Emotion: "", // 可以后续添加 Duration: req.Duration, BgmPrompt: getString(req.BgmPrompt), SoundEffect: getString(req.SoundEffect), Characters: req.Characters, } if req.Title != nil { sb.Title = *req.Title } // 生成提示词 imagePrompt := s.generateImagePrompt(sb) videoPrompt := s.generateVideoPrompt(sb) // 构建 description desc := "" if req.Description != nil { desc = *req.Description } modelSB := &models.Storyboard{ EpisodeID: req.EpisodeID, SceneID: req.SceneID, StoryboardNumber: req.StoryboardNumber, Title: req.Title, Location: req.Location, Time: req.Time, ShotType: req.ShotType, Angle: req.Angle, Movement: req.Movement, Description: &desc, Action: req.Action, Result: req.Result, Atmosphere: req.Atmosphere, Dialogue: req.Dialogue, ImagePrompt: &imagePrompt, VideoPrompt: &videoPrompt, BgmPrompt: req.BgmPrompt, SoundEffect: req.SoundEffect, Duration: req.Duration, } if err := s.db.Create(modelSB).Error; err != nil { return nil, fmt.Errorf("failed to create storyboard: %w", err) } // 关联角色 if len(req.Characters) > 0 { var characters []models.Character if err := s.db.Where("id IN ?", req.Characters).Find(&characters).Error; err != nil { s.log.Warnw("Failed to find characters for new storyboard", "error", err) } else if len(characters) > 0 { s.db.Model(modelSB).Association("Characters").Append(characters) } } s.log.Infow("Storyboard created", "id", modelSB.ID, "episode_id", req.EpisodeID) return modelSB, nil } // DeleteStoryboard 删除分镜 func (s *StoryboardService) DeleteStoryboard(storyboardID uint) error { result := s.db.Where("id = ? ", storyboardID).Delete(&models.Storyboard{}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return fmt.Errorf("storyboard not found") } return nil } func min(a, b int) int { if a < b { return a } return b } func getString(s *string) string { if s == nil { return "" } return *s } ================================================ FILE: application/services/storyboard_update_full.go ================================================ package services import ( "fmt" "github.com/drama-generator/backend/domain/models" ) // UpdateStoryboard 更新分镜的所有字段,并重新生成提示词 func (s *StoryboardService) UpdateStoryboard(storyboardID string, updates map[string]interface{}) error { // 查找分镜 var storyboard models.Storyboard if err := s.db.First(&storyboard, storyboardID).Error; err != nil { return fmt.Errorf("storyboard not found: %w", err) } // 构建用于重新生成提示词的Storyboard结构 sb := Storyboard{ ShotNumber: storyboard.StoryboardNumber, } // 从updates中提取字段并更新 updateData := make(map[string]interface{}) if val, ok := updates["title"].(string); ok && val != "" { updateData["title"] = val sb.Title = val } if val, ok := updates["shot_type"].(string); ok && val != "" { updateData["shot_type"] = val sb.ShotType = val } if val, ok := updates["angle"].(string); ok && val != "" { updateData["angle"] = val sb.Angle = val } if val, ok := updates["movement"].(string); ok && val != "" { updateData["movement"] = val sb.Movement = val } if val, ok := updates["location"].(string); ok && val != "" { updateData["location"] = val sb.Location = val } if val, ok := updates["time"].(string); ok && val != "" { updateData["time"] = val sb.Time = val } if val, ok := updates["action"].(string); ok && val != "" { updateData["action"] = val sb.Action = val } if val, ok := updates["dialogue"].(string); ok && val != "" { updateData["dialogue"] = val sb.Dialogue = val } if val, ok := updates["result"].(string); ok && val != "" { updateData["result"] = val sb.Result = val } if val, ok := updates["atmosphere"].(string); ok && val != "" { updateData["atmosphere"] = val sb.Atmosphere = val } if val, ok := updates["description"].(string); ok && val != "" { updateData["description"] = val } if val, ok := updates["bgm_prompt"].(string); ok && val != "" { updateData["bgm_prompt"] = val sb.BgmPrompt = val } if val, ok := updates["sound_effect"].(string); ok && val != "" { updateData["sound_effect"] = val sb.SoundEffect = val } if val, ok := updates["duration"].(float64); ok { updateData["duration"] = int(val) sb.Duration = int(val) } if val, ok := updates["scene_id"].(float64); ok { sceneID := uint(val) updateData["scene_id"] = sceneID } // 使用当前数据库值填充缺失字段(用于生成提示词) if sb.Title == "" && storyboard.Title != nil { sb.Title = *storyboard.Title } if sb.ShotType == "" && storyboard.ShotType != nil { sb.ShotType = *storyboard.ShotType } if sb.Angle == "" && storyboard.Angle != nil { sb.Angle = *storyboard.Angle } if sb.Movement == "" && storyboard.Movement != nil { sb.Movement = *storyboard.Movement } if sb.Location == "" && storyboard.Location != nil { sb.Location = *storyboard.Location } if sb.Time == "" && storyboard.Time != nil { sb.Time = *storyboard.Time } if sb.Action == "" && storyboard.Action != nil { sb.Action = *storyboard.Action } if sb.Dialogue == "" && storyboard.Dialogue != nil { sb.Dialogue = *storyboard.Dialogue } if sb.Result == "" && storyboard.Result != nil { sb.Result = *storyboard.Result } if sb.Atmosphere == "" && storyboard.Atmosphere != nil { sb.Atmosphere = *storyboard.Atmosphere } if sb.BgmPrompt == "" && storyboard.BgmPrompt != nil { sb.BgmPrompt = *storyboard.BgmPrompt } if sb.SoundEffect == "" && storyboard.SoundEffect != nil { sb.SoundEffect = *storyboard.SoundEffect } if sb.Duration == 0 { sb.Duration = storyboard.Duration } // 只重新生成video_prompt // image_prompt不自动更新,因为可能对应多张已生成的帧图片 videoPrompt := s.generateVideoPrompt(sb) updateData["video_prompt"] = videoPrompt // 更新数据库 if err := s.db.Model(&storyboard).Updates(updateData).Error; err != nil { return fmt.Errorf("failed to update storyboard: %w", err) } s.log.Infow("Storyboard updated successfully", "storyboard_id", storyboardID, "fields_updated", len(updateData)) return nil } ================================================ FILE: application/services/task_service.go ================================================ package services import ( "encoding/json" "fmt" "time" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/logger" "github.com/google/uuid" "gorm.io/gorm" ) type TaskService struct { db *gorm.DB log *logger.Logger } func NewTaskService(db *gorm.DB, log *logger.Logger) *TaskService { return &TaskService{ db: db, log: log, } } // CreateTask 创建新任务 func (s *TaskService) CreateTask(taskType, resourceID string) (*models.AsyncTask, error) { task := &models.AsyncTask{ ID: uuid.New().String(), Type: taskType, Status: "pending", Progress: 0, ResourceID: resourceID, } if err := s.db.Create(task).Error; err != nil { return nil, fmt.Errorf("failed to create task: %w", err) } return task, nil } // UpdateTaskStatus 更新任务状态 func (s *TaskService) UpdateTaskStatus(taskID, status string, progress int, message string) error { updates := map[string]interface{}{ "status": status, "progress": progress, "message": message, "updated_at": time.Now(), } if status == "completed" || status == "failed" { now := time.Now() updates["completed_at"] = &now } return s.db.Model(&models.AsyncTask{}). Where("id = ?", taskID). Updates(updates).Error } // UpdateTaskError 更新任务错误 func (s *TaskService) UpdateTaskError(taskID string, err error) error { now := time.Now() return s.db.Model(&models.AsyncTask{}). Where("id = ?", taskID). Updates(map[string]interface{}{ "status": "failed", "error": err.Error(), "progress": 0, "completed_at": &now, "updated_at": time.Now(), }).Error } // UpdateTaskResult 更新任务结果 func (s *TaskService) UpdateTaskResult(taskID string, result interface{}) error { resultJSON, err := json.Marshal(result) if err != nil { return fmt.Errorf("failed to marshal result: %w", err) } now := time.Now() return s.db.Model(&models.AsyncTask{}). Where("id = ?", taskID). Updates(map[string]interface{}{ "status": "completed", "progress": 100, "result": string(resultJSON), "completed_at": &now, "updated_at": time.Now(), }).Error } // GetTask 获取任务信息 func (s *TaskService) GetTask(taskID string) (*models.AsyncTask, error) { var task models.AsyncTask if err := s.db.Where("id = ?", taskID).First(&task).Error; err != nil { return nil, err } return &task, nil } // GetTasksByResource 获取资源相关的所有任务 func (s *TaskService) GetTasksByResource(resourceID string) ([]*models.AsyncTask, error) { var tasks []*models.AsyncTask if err := s.db.Where("resource_id = ?", resourceID). Order("created_at DESC"). Find(&tasks).Error; err != nil { return nil, err } return tasks, nil } ================================================ FILE: application/services/upload_service.go ================================================ package services import ( "fmt" "io" "os" "path/filepath" "time" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/google/uuid" ) type UploadService struct { storagePath string baseURL string log *logger.Logger } func NewUploadService(cfg *config.Config, log *logger.Logger) (*UploadService, error) { // 确保存储目录存在 if err := os.MkdirAll(cfg.Storage.LocalPath, 0755); err != nil { return nil, fmt.Errorf("failed to create storage directory: %w", err) } return &UploadService{ storagePath: cfg.Storage.LocalPath, baseURL: cfg.Storage.BaseURL, log: log, }, nil } // UploadResult 上传结果 type UploadResult struct { URL string // 完整访问URL LocalPath string // 相对路径(相对于 storage 根目录) } // UploadFile 上传文件到本地存储 func (s *UploadService) UploadFile(file io.Reader, fileName, contentType string, category string) (*UploadResult, error) { // 创建分类目录 categoryPath := filepath.Join(s.storagePath, category) if err := os.MkdirAll(categoryPath, 0755); err != nil { return nil, fmt.Errorf("failed to create category directory: %w", err) } // 生成唯一文件名 ext := filepath.Ext(fileName) uniqueID := uuid.New().String() timestamp := time.Now().Format("20060102_150405") newFileName := fmt.Sprintf("%s_%s%s", timestamp, uniqueID, ext) filePath := filepath.Join(categoryPath, newFileName) // 创建文件 dst, err := os.Create(filePath) if err != nil { s.log.Errorw("Failed to create file", "error", err, "path", filePath) return nil, fmt.Errorf("创建文件失败: %w", err) } defer dst.Close() // 写入文件 if _, err := io.Copy(dst, file); err != nil { s.log.Errorw("Failed to write file", "error", err, "path", filePath) return nil, fmt.Errorf("写入文件失败: %w", err) } // 构建访问URL和相对路径 fileURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFileName) localPath := fmt.Sprintf("%s/%s", category, newFileName) s.log.Infow("File uploaded successfully", "path", filePath, "url", fileURL, "local_path", localPath) return &UploadResult{ URL: fileURL, LocalPath: localPath, }, nil } // UploadCharacterImage 上传角色图片 func (s *UploadService) UploadCharacterImage(file io.Reader, fileName, contentType string) (*UploadResult, error) { return s.UploadFile(file, fileName, contentType, "characters") } // DeleteFile 删除本地文件 func (s *UploadService) DeleteFile(fileURL string) error { // 从URL中提取相对路径 // URL格式: http://localhost:8080/static/characters/20060102_150405_uuid.jpg relPath := s.extractRelativePathFromURL(fileURL) if relPath == "" { return fmt.Errorf("invalid file URL") } filePath := filepath.Join(s.storagePath, relPath) err := os.Remove(filePath) if err != nil { s.log.Errorw("Failed to delete file", "error", err, "path", filePath) return fmt.Errorf("删除文件失败: %w", err) } s.log.Infow("File deleted successfully", "path", filePath) return nil } // extractRelativePathFromURL 从URL中提取相对路径 func (s *UploadService) extractRelativePathFromURL(fileURL string) string { // 从baseURL后面提取路径 // 例如: http://localhost:8080/static/characters/xxx.jpg -> characters/xxx.jpg if len(fileURL) <= len(s.baseURL) { return "" } return fileURL[len(s.baseURL)+1:] // +1 for the '/' } // GetPresignedURL 本地存储不需要预签名URL,直接返回原URL func (s *UploadService) GetPresignedURL(objectName string, expiry time.Duration) (string, error) { // 本地存储通过静态文件服务直接访问,不需要预签名 return fmt.Sprintf("%s/%s", s.baseURL, objectName), nil } ================================================ FILE: application/services/video_generation_service.go ================================================ package services import ( "encoding/json" "fmt" "strconv" "strings" "time" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/external/ffmpeg" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/utils" "github.com/drama-generator/backend/pkg/video" "gorm.io/gorm" ) type VideoGenerationService struct { db *gorm.DB transferService *ResourceTransferService log *logger.Logger localStorage *storage.LocalStorage aiService *AIService ffmpeg *ffmpeg.FFmpeg promptI18n *PromptI18n } func NewVideoGenerationService(db *gorm.DB, transferService *ResourceTransferService, localStorage *storage.LocalStorage, aiService *AIService, log *logger.Logger, promptI18n *PromptI18n) *VideoGenerationService { service := &VideoGenerationService{ db: db, localStorage: localStorage, transferService: transferService, aiService: aiService, log: log, ffmpeg: ffmpeg.NewFFmpeg(log), promptI18n: promptI18n, } go service.RecoverPendingTasks() return service } type GenerateVideoRequest struct { StoryboardID *uint `json:"storyboard_id"` DramaID string `json:"drama_id" binding:"required"` ImageGenID *uint `json:"image_gen_id"` // 参考图模式:single, first_last, multiple, none ReferenceMode string `json:"reference_mode"` // 单图模式 ImageURL string `json:"image_url"` ImageLocalPath *string `json:"image_local_path"` // 单图模式的本地路径 // 首尾帧模式 FirstFrameURL *string `json:"first_frame_url"` FirstFrameLocalPath *string `json:"first_frame_local_path"` // 首帧本地路径 LastFrameURL *string `json:"last_frame_url"` LastFrameLocalPath *string `json:"last_frame_local_path"` // 尾帧本地路径 // 多图模式 ReferenceImageURLs []string `json:"reference_image_urls"` Prompt string `json:"prompt" binding:"required,min=5,max=2000"` Provider string `json:"provider"` Model string `json:"model"` Duration *int `json:"duration"` FPS *int `json:"fps"` AspectRatio *string `json:"aspect_ratio"` Style *string `json:"style"` MotionLevel *int `json:"motion_level"` CameraMotion *string `json:"camera_motion"` Seed *int64 `json:"seed"` } func (s *VideoGenerationService) GenerateVideo(request *GenerateVideoRequest) (*models.VideoGeneration, error) { if request.StoryboardID != nil { var storyboard models.Storyboard if err := s.db.Preload("Episode").Where("id = ?", *request.StoryboardID).First(&storyboard).Error; err != nil { return nil, fmt.Errorf("storyboard not found") } if fmt.Sprintf("%d", storyboard.Episode.DramaID) != request.DramaID { return nil, fmt.Errorf("storyboard does not belong to drama") } } if request.ImageGenID != nil { var imageGen models.ImageGeneration if err := s.db.Where("id = ?", *request.ImageGenID).First(&imageGen).Error; err != nil { return nil, fmt.Errorf("image generation not found") } } provider := request.Provider if provider == "" { provider = "doubao" } dramaID, _ := strconv.ParseUint(request.DramaID, 10, 32) videoGen := &models.VideoGeneration{ StoryboardID: request.StoryboardID, DramaID: uint(dramaID), ImageGenID: request.ImageGenID, Provider: provider, Prompt: request.Prompt, Model: request.Model, Duration: request.Duration, FPS: request.FPS, AspectRatio: request.AspectRatio, Style: request.Style, MotionLevel: request.MotionLevel, CameraMotion: request.CameraMotion, Seed: request.Seed, Status: models.VideoStatusPending, } // 根据参考图模式处理不同的参数 if request.ReferenceMode != "" { videoGen.ReferenceMode = &request.ReferenceMode } switch request.ReferenceMode { case "single": // 单图模式 - 优先使用 local_path if request.ImageLocalPath != nil && *request.ImageLocalPath != "" { videoGen.ImageURL = request.ImageLocalPath } else if request.ImageURL != "" { videoGen.ImageURL = &request.ImageURL } case "first_last": // 首尾帧模式 - 优先使用 local_path if request.FirstFrameLocalPath != nil && *request.FirstFrameLocalPath != "" { videoGen.FirstFrameURL = request.FirstFrameLocalPath } else if request.FirstFrameURL != nil { videoGen.FirstFrameURL = request.FirstFrameURL } if request.LastFrameLocalPath != nil && *request.LastFrameLocalPath != "" { videoGen.LastFrameURL = request.LastFrameLocalPath } else if request.LastFrameURL != nil { videoGen.LastFrameURL = request.LastFrameURL } case "multiple": // 多图模式 if len(request.ReferenceImageURLs) > 0 { referenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs) if err == nil { referenceImagesStr := string(referenceImagesJSON) videoGen.ReferenceImageURLs = &referenceImagesStr } } case "none": // 无参考图,纯文本生成 default: // 向后兼容:如果没有指定模式,根据提供的参数自动判断 if request.ImageURL != "" { videoGen.ImageURL = &request.ImageURL mode := "single" videoGen.ReferenceMode = &mode } else if request.FirstFrameURL != nil || request.LastFrameURL != nil { videoGen.FirstFrameURL = request.FirstFrameURL videoGen.LastFrameURL = request.LastFrameURL mode := "first_last" videoGen.ReferenceMode = &mode } else if len(request.ReferenceImageURLs) > 0 { referenceImagesJSON, err := json.Marshal(request.ReferenceImageURLs) if err == nil { referenceImagesStr := string(referenceImagesJSON) videoGen.ReferenceImageURLs = &referenceImagesStr mode := "multiple" videoGen.ReferenceMode = &mode } } } if err := s.db.Create(videoGen).Error; err != nil { return nil, fmt.Errorf("failed to create record: %w", err) } // Start background goroutine to process video generation asynchronously // This allows the API to return immediately while video generation happens in background // CRITICAL: The goroutine will handle all video generation logic including API calls and polling go s.ProcessVideoGeneration(videoGen.ID) return videoGen, nil } func (s *VideoGenerationService) ProcessVideoGeneration(videoGenID uint) { var videoGen models.VideoGeneration if err := s.db.First(&videoGen, videoGenID).Error; err != nil { s.log.Errorw("Failed to load video generation", "error", err, "id", videoGenID) return } // 获取drama的style信息 var drama models.Drama if err := s.db.First(&drama, videoGen.DramaID).Error; err != nil { s.log.Warnw("Failed to load drama for style", "error", err, "drama_id", videoGen.DramaID) } s.db.Model(&videoGen).Update("status", models.VideoStatusProcessing) client, err := s.getVideoClient(videoGen.Provider, videoGen.Model) if err != nil { s.log.Errorw("Failed to get video client", "error", err, "provider", videoGen.Provider, "model", videoGen.Model) s.updateVideoGenError(videoGenID, err.Error()) return } s.log.Infow("Starting video generation", "id", videoGenID, "prompt", videoGen.Prompt, "provider", videoGen.Provider) var opts []video.VideoOption if videoGen.Model != "" { opts = append(opts, video.WithModel(videoGen.Model)) } if videoGen.Duration != nil { opts = append(opts, video.WithDuration(*videoGen.Duration)) } if videoGen.FPS != nil { opts = append(opts, video.WithFPS(*videoGen.FPS)) } if videoGen.AspectRatio != nil { opts = append(opts, video.WithAspectRatio(*videoGen.AspectRatio)) } if videoGen.Style != nil { opts = append(opts, video.WithStyle(*videoGen.Style)) } if videoGen.MotionLevel != nil { opts = append(opts, video.WithMotionLevel(*videoGen.MotionLevel)) } if videoGen.CameraMotion != nil { opts = append(opts, video.WithCameraMotion(*videoGen.CameraMotion)) } if videoGen.Seed != nil { opts = append(opts, video.WithSeed(*videoGen.Seed)) } // 根据参考图模式添加相应的选项,并将本地图片转换为base64 if videoGen.ReferenceMode != nil { switch *videoGen.ReferenceMode { case "first_last": // 首尾帧模式 - 转换本地图片为base64 if videoGen.FirstFrameURL != nil { firstFrameBase64, err := s.convertImageToBase64(*videoGen.FirstFrameURL) if err != nil { s.log.Warnw("Failed to convert first frame to base64, using original URL", "error", err) opts = append(opts, video.WithFirstFrame(*videoGen.FirstFrameURL)) } else { opts = append(opts, video.WithFirstFrame(firstFrameBase64)) } } if videoGen.LastFrameURL != nil { lastFrameBase64, err := s.convertImageToBase64(*videoGen.LastFrameURL) if err != nil { s.log.Warnw("Failed to convert last frame to base64, using original URL", "error", err) opts = append(opts, video.WithLastFrame(*videoGen.LastFrameURL)) } else { opts = append(opts, video.WithLastFrame(lastFrameBase64)) } } case "multiple": // 多图模式 - 转换本地图片为base64 if videoGen.ReferenceImageURLs != nil { var imageURLs []string if err := json.Unmarshal([]byte(*videoGen.ReferenceImageURLs), &imageURLs); err == nil { var base64Images []string for _, imgURL := range imageURLs { base64Img, err := s.convertImageToBase64(imgURL) if err != nil { s.log.Warnw("Failed to convert reference image to base64, using original URL", "error", err, "url", imgURL) base64Images = append(base64Images, imgURL) } else { base64Images = append(base64Images, base64Img) } } opts = append(opts, video.WithReferenceImages(base64Images)) } } } } // 构造imageURL参数(单图模式使用,其他模式传空字符串) // 如果是本地图片,转换为base64 imageURL := "" if videoGen.ImageURL != nil { base64Image, err := s.convertImageToBase64(*videoGen.ImageURL) if err != nil { s.log.Warnw("Failed to convert image to base64, using original URL", "error", err) imageURL = *videoGen.ImageURL } else { imageURL = base64Image } } // 构建完整的提示词:风格提示词 + 约束提示词 + 用户提示词 prompt := videoGen.Prompt // 2. 添加视频约束提示词 // 根据参考图模式选择对应的约束提示词 referenceMode := "single" // 默认单图模式 if videoGen.ReferenceMode != nil { referenceMode = *videoGen.ReferenceMode } // 如果是单图模式,需要检查图片是否为动作序列图 if referenceMode == "single" && videoGen.ImageGenID != nil { var imageGen models.ImageGeneration if err := s.db.First(&imageGen, *videoGen.ImageGenID).Error; err == nil { // 如果图片的frame_type是action,使用动作序列约束提示词 if imageGen.FrameType != nil && *imageGen.FrameType == "action" { referenceMode = "action_sequence" s.log.Infow("Detected action sequence image in single mode", "id", videoGenID, "image_gen_id", *videoGen.ImageGenID, "frame_type", *imageGen.FrameType) } } } constraintPrompt := s.promptI18n.GetVideoConstraintPrompt(referenceMode) if constraintPrompt != "" { prompt = constraintPrompt + "\n\n" + prompt s.log.Infow("Added constraint prompt to video generation", "id", videoGenID, "reference_mode", referenceMode, "constraint_prompt_length", len(constraintPrompt)) } // 打印完整的提示词信息 s.log.Infow("Video generation prompts", "id", videoGenID, "user_prompt", videoGen.Prompt, "constraint_prompt", constraintPrompt, "final_prompt", prompt) result, err := client.GenerateVideo(imageURL, prompt, opts...) if err != nil { s.log.Errorw("Video generation API call failed", "error", err, "id", videoGenID) s.updateVideoGenError(videoGenID, err.Error()) return } // CRITICAL FIX: Validate TaskID before starting polling goroutine // Empty TaskID would cause polling to fail silently or cause issues if result.TaskID != "" { s.db.Model(&videoGen).Updates(map[string]interface{}{ "task_id": result.TaskID, "status": models.VideoStatusProcessing, }) // Start background goroutine to poll task status // This allows the API to return immediately while video generation continues asynchronously // The goroutine will poll until completion, failure, or timeout (max 300 attempts * 10s = 50 minutes) go s.pollTaskStatus(videoGenID, result.TaskID, videoGen.Provider, videoGen.Model) return } if result.VideoURL != "" { s.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil) return } s.updateVideoGenError(videoGenID, "no task ID or video URL returned") } func (s *VideoGenerationService) pollTaskStatus(videoGenID uint, taskID string, provider string, model string) { // CRITICAL FIX: Validate taskID parameter to prevent invalid API calls // Empty taskID would cause unnecessary API calls and potential errors if taskID == "" { s.log.Errorw("Invalid empty taskID for polling", "video_gen_id", videoGenID) s.updateVideoGenError(videoGenID, "invalid task ID for polling") return } client, err := s.getVideoClient(provider, model) if err != nil { s.log.Errorw("Failed to get video client for polling", "error", err) s.updateVideoGenError(videoGenID, "failed to get video client") return } // Polling configuration: max 300 attempts with 10 second intervals // Total maximum polling time: 300 * 10s = 50 minutes // This prevents infinite polling if the task never completes maxAttempts := 300 interval := 10 * time.Second for attempt := 0; attempt < maxAttempts; attempt++ { // Sleep before each poll attempt to avoid overwhelming the API // First iteration sleeps before the first check (after 0 attempts) time.Sleep(interval) var videoGen models.VideoGeneration if err := s.db.First(&videoGen, videoGenID).Error; err != nil { s.log.Errorw("Failed to load video generation", "error", err, "id", videoGenID) return } // CRITICAL FIX: Check if status was manually changed (e.g., cancelled by user) // If status is no longer "processing", stop polling to avoid unnecessary API calls // This prevents polling when the task has been cancelled or failed externally if videoGen.Status != models.VideoStatusProcessing { s.log.Infow("Video generation status changed, stopping poll", "id", videoGenID, "status", videoGen.Status) return } // Poll the video generation API for task status // Continue polling on transient errors (network issues, temporary API failures) // Only stop on permanent errors or task completion result, err := client.GetTaskStatus(taskID) if err != nil { s.log.Errorw("Failed to get task status", "error", err, "task_id", taskID, "attempt", attempt+1) // Continue polling on error - might be transient network issue // Will eventually timeout after maxAttempts if error persists continue } // Check if task completed successfully // CRITICAL FIX: Validate that video URL exists when task is marked as completed // Some APIs may mark task as completed but fail to provide the video URL if result.Completed { if result.VideoURL != "" { // Successfully completed with video URL - download and update database s.completeVideoGeneration(videoGenID, result.VideoURL, &result.Duration, &result.Width, &result.Height, nil) return } // Task marked as completed but no video URL - this is an error condition s.updateVideoGenError(videoGenID, "task completed but no video URL") return } // Check if task failed with an error message if result.Error != "" { s.updateVideoGenError(videoGenID, result.Error) return } // Task still in progress - log and continue polling s.log.Infow("Video generation in progress", "id", videoGenID, "attempt", attempt+1, "max_attempts", maxAttempts) } // CRITICAL FIX: Handle polling timeout gracefully // After maxAttempts (50 minutes), mark task as failed if still not completed // This prevents indefinite polling and resource waste s.updateVideoGenError(videoGenID, fmt.Sprintf("polling timeout after %d attempts (%.1f minutes)", maxAttempts, float64(maxAttempts*int(interval))/60.0)) } func (s *VideoGenerationService) completeVideoGeneration(videoGenID uint, videoURL string, duration *int, width *int, height *int, firstFrameURL *string) { var localVideoPath *string // 下载视频到本地存储并保存相对路径到数据库 if s.localStorage != nil && videoURL != "" { downloadResult, err := s.localStorage.DownloadFromURLWithPath(videoURL, "videos") if err != nil { s.log.Warnw("Failed to download video to local storage", "error", err, "id", videoGenID, "original_url", videoURL) } else { localVideoPath = &downloadResult.RelativePath s.log.Infow("Video downloaded to local storage", "id", videoGenID, "original_url", videoURL, "local_path", downloadResult.RelativePath) } } // 如果视频已下载到本地,探测真实时长 // 特别是当 AI 服务返回的 duration 为 0 或 nil 时,必须探测 shouldProbe := localVideoPath != nil && s.ffmpeg != nil && (duration == nil || *duration == 0) if shouldProbe { absPath := s.localStorage.GetAbsolutePath(*localVideoPath) if probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil { // 转换为整数秒(向上取整) durationInt := int(probedDuration + 0.5) duration = &durationInt s.log.Infow("Probed video duration (was 0 or nil)", "id", videoGenID, "duration_seconds", durationInt, "duration_float", probedDuration) } else { s.log.Errorw("Failed to probe video duration, duration will be 0", "error", err, "id", videoGenID, "local_path", *localVideoPath) } } else if localVideoPath != nil && s.ffmpeg != nil && duration != nil && *duration > 0 { // 即使有 duration,也验证一下(可选) absPath := s.localStorage.GetAbsolutePath(*localVideoPath) if probedDuration, err := s.ffmpeg.GetVideoDuration(absPath); err == nil { durationInt := int(probedDuration + 0.5) if durationInt != *duration { s.log.Warnw("Probed duration differs from provided duration", "id", videoGenID, "provided", *duration, "probed", durationInt) // 使用探测到的时长(更准确) duration = &durationInt } } } // 下载首帧图片到本地存储(仅用于缓存,不更新数据库) if firstFrameURL != nil && *firstFrameURL != "" && s.localStorage != nil { _, err := s.localStorage.DownloadFromURL(*firstFrameURL, "video_frames") if err != nil { s.log.Warnw("Failed to download first frame to local storage", "error", err, "id", videoGenID, "original_url", *firstFrameURL) } else { s.log.Infow("First frame downloaded to local storage for caching", "id", videoGenID, "original_url", *firstFrameURL) } } // 数据库中保存原始URL和本地路径 updates := map[string]interface{}{ "status": models.VideoStatusCompleted, "video_url": videoURL, "local_path": localVideoPath, } // 只有当 duration 大于 0 时才保存,避免保存无效的 0 值 if duration != nil && *duration > 0 { updates["duration"] = *duration } if width != nil { updates["width"] = *width } if height != nil { updates["height"] = *height } if firstFrameURL != nil { updates["first_frame_url"] = *firstFrameURL } if err := s.db.Model(&models.VideoGeneration{}).Where("id = ?", videoGenID).Updates(updates).Error; err != nil { s.log.Errorw("Failed to update video generation", "error", err, "id", videoGenID) return } var videoGen models.VideoGeneration if err := s.db.First(&videoGen, videoGenID).Error; err == nil { if videoGen.StoryboardID != nil { // 更新 Storyboard 的 video_url 和 duration storyboardUpdates := map[string]interface{}{ "video_url": videoURL, } // 只有当 duration 大于 0 时才更新,避免用无效的 0 值覆盖 if duration != nil && *duration > 0 { storyboardUpdates["duration"] = *duration } if err := s.db.Model(&models.Storyboard{}).Where("id = ?", *videoGen.StoryboardID).Updates(storyboardUpdates).Error; err != nil { s.log.Warnw("Failed to update storyboard", "storyboard_id", *videoGen.StoryboardID, "error", err) } else { s.log.Infow("Updated storyboard with video info", "storyboard_id", *videoGen.StoryboardID, "duration", duration) } } } s.log.Infow("Video generation completed", "id", videoGenID, "url", videoURL, "duration", duration) } func (s *VideoGenerationService) updateVideoGenError(videoGenID uint, errorMsg string) { if err := s.db.Model(&models.VideoGeneration{}).Where("id = ?", videoGenID).Updates(map[string]interface{}{ "status": models.VideoStatusFailed, "error_msg": errorMsg, }).Error; err != nil { s.log.Errorw("Failed to update video generation error", "error", err, "id", videoGenID) } } func (s *VideoGenerationService) getVideoClient(provider string, modelName string) (video.VideoClient, error) { // 根据模型名称获取AI配置 var config *models.AIServiceConfig var err error if modelName != "" { config, err = s.aiService.GetConfigForModel("video", modelName) if err != nil { s.log.Warnw("Failed to get config for model, using default", "model", modelName, "error", err) config, err = s.aiService.GetDefaultConfig("video") if err != nil { return nil, fmt.Errorf("no video AI config found: %w", err) } } } else { config, err = s.aiService.GetDefaultConfig("video") if err != nil { return nil, fmt.Errorf("no video AI config found: %w", err) } } // 使用配置中的信息创建客户端 baseURL := config.BaseURL apiKey := config.APIKey model := modelName if model == "" && len(config.Model) > 0 { model = config.Model[0] } // 根据配置中的 provider 创建对应的客户端 var endpoint string var queryEndpoint string switch config.Provider { case "chatfire": endpoint = "/video/generations" queryEndpoint = "/video/task/{taskId}" return video.NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil case "doubao", "volcengine", "volces": endpoint = "/contents/generations/tasks" queryEndpoint = "/contents/generations/tasks/{taskId}" return video.NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint), nil case "openai": // OpenAI Sora 使用 /v1/videos 端点 return video.NewOpenAISoraClient(baseURL, apiKey, model), nil case "runway": return video.NewRunwayClient(baseURL, apiKey, model), nil case "pika": return video.NewPikaClient(baseURL, apiKey, model), nil case "minimax": return video.NewMinimaxClient(baseURL, apiKey, model), nil default: return nil, fmt.Errorf("unsupported video provider: %s", provider) } } func (s *VideoGenerationService) RecoverPendingTasks() { var pendingVideos []models.VideoGeneration // Query for pending tasks with non-empty task_id // Note: Using IS NOT NULL and != '' to ensure we only get valid task IDs if err := s.db.Where("status = ? AND task_id IS NOT NULL AND task_id != ''", models.VideoStatusProcessing).Find(&pendingVideos).Error; err != nil { s.log.Errorw("Failed to load pending video tasks", "error", err) return } s.log.Infow("Recovering pending video generation tasks", "count", len(pendingVideos)) for _, videoGen := range pendingVideos { // CRITICAL FIX: Check for nil TaskID before dereferencing to prevent panic // Even though we filter for non-empty task_id, GORM might still return nil pointers // This nil check prevents a potential runtime panic if videoGen.TaskID == nil || *videoGen.TaskID == "" { s.log.Warnw("Skipping video generation with nil or empty TaskID", "id", videoGen.ID) continue } // Start goroutine to poll task status for each pending video // Each goroutine will poll independently until completion or timeout go s.pollTaskStatus(videoGen.ID, *videoGen.TaskID, videoGen.Provider, videoGen.Model) } } func (s *VideoGenerationService) GetVideoGeneration(id uint) (*models.VideoGeneration, error) { var videoGen models.VideoGeneration if err := s.db.First(&videoGen, id).Error; err != nil { return nil, err } return &videoGen, nil } func (s *VideoGenerationService) ListVideoGenerations(dramaID *uint, storyboardID *uint, status string, limit int, offset int) ([]*models.VideoGeneration, int64, error) { var videos []*models.VideoGeneration var total int64 query := s.db.Model(&models.VideoGeneration{}) if dramaID != nil { query = query.Where("drama_id = ?", *dramaID) } if storyboardID != nil { query = query.Where("storyboard_id = ?", *storyboardID) } if status != "" { query = query.Where("status = ?", status) } if err := query.Count(&total).Error; err != nil { return nil, 0, err } if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&videos).Error; err != nil { return nil, 0, err } return videos, total, nil } func (s *VideoGenerationService) GenerateVideoFromImage(imageGenID uint) (*models.VideoGeneration, error) { var imageGen models.ImageGeneration if err := s.db.First(&imageGen, imageGenID).Error; err != nil { return nil, fmt.Errorf("image generation not found") } if imageGen.Status != models.ImageStatusCompleted || imageGen.ImageURL == nil { return nil, fmt.Errorf("image is not ready") } // 获取关联的Storyboard以获取时长 var duration *int if imageGen.StoryboardID != nil { var storyboard models.Storyboard if err := s.db.Where("id = ?", *imageGen.StoryboardID).First(&storyboard).Error; err == nil { duration = &storyboard.Duration s.log.Infow("Using storyboard duration for video generation", "storyboard_id", *imageGen.StoryboardID, "duration", storyboard.Duration) } } req := &GenerateVideoRequest{ DramaID: fmt.Sprintf("%d", imageGen.DramaID), StoryboardID: imageGen.StoryboardID, ImageGenID: &imageGenID, ImageURL: *imageGen.ImageURL, Prompt: imageGen.Prompt, Provider: "doubao", Duration: duration, } return s.GenerateVideo(req) } func (s *VideoGenerationService) BatchGenerateVideosForEpisode(episodeID string) ([]*models.VideoGeneration, error) { var episode models.Episode if err := s.db.Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil { return nil, fmt.Errorf("episode not found") } var results []*models.VideoGeneration for _, storyboard := range episode.Storyboards { if storyboard.ImagePrompt == nil { continue } var imageGen models.ImageGeneration if err := s.db.Where("storyboard_id = ? AND status = ?", storyboard.ID, models.ImageStatusCompleted). Order("created_at DESC").First(&imageGen).Error; err != nil { s.log.Warnw("No completed image for storyboard", "storyboard_id", storyboard.ID) continue } videoGen, err := s.GenerateVideoFromImage(imageGen.ID) if err != nil { s.log.Errorw("Failed to generate video", "storyboard_id", storyboard.ID, "error", err) continue } results = append(results, videoGen) } return results, nil } func (s *VideoGenerationService) DeleteVideoGeneration(id uint) error { return s.db.Delete(&models.VideoGeneration{}, id).Error } // convertImageToBase64 将图片转换为base64格式 // 优先使用本地存储的图片,如果没有则使用URL func (s *VideoGenerationService) convertImageToBase64(imageURL string) (string, error) { // 如果已经是base64格式,直接返回 if strings.HasPrefix(imageURL, "data:") { return imageURL, nil } // 尝试从本地存储读取 if s.localStorage != nil { var relativePath string // 1. 检查是否是本地URL(包含 /static/) if strings.Contains(imageURL, "/static/") { // 提取相对路径,例如从 "http://localhost:5678/static/images/xxx.jpg" 提取 "images/xxx.jpg" parts := strings.Split(imageURL, "/static/") if len(parts) == 2 { relativePath = parts[1] } } else if !strings.HasPrefix(imageURL, "http://") && !strings.HasPrefix(imageURL, "https://") { // 2. 如果不是 HTTP/HTTPS URL,视为相对路径(如 "images/xxx.jpg") relativePath = imageURL } // 如果识别出相对路径,尝试读取本地文件 if relativePath != "" { absPath := s.localStorage.GetAbsolutePath(relativePath) // 使用工具函数转换为base64 base64Str, err := utils.ImageToBase64(absPath) if err == nil { s.log.Infow("Converted local image to base64", "path", relativePath) return base64Str, nil } s.log.Warnw("Failed to convert local image to base64, will try URL", "error", err, "path", absPath) } } // 如果本地读取失败或不是本地路径,尝试从URL下载并转换 base64Str, err := utils.ImageToBase64(imageURL) if err != nil { return "", fmt.Errorf("failed to convert image to base64: %w", err) } urlLen := len(imageURL) if urlLen > 50 { urlLen = 50 } s.log.Infow("Converted remote image to base64", "url", imageURL[:urlLen]) return base64Str, nil } ================================================ FILE: application/services/video_merge_service.go ================================================ package services import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "time" models "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/external/ffmpeg" "github.com/drama-generator/backend/pkg/logger" "github.com/drama-generator/backend/pkg/video" "gorm.io/gorm" ) type VideoMergeService struct { db *gorm.DB aiService *AIService transferService *ResourceTransferService ffmpeg *ffmpeg.FFmpeg storagePath string baseURL string log *logger.Logger } func NewVideoMergeService(db *gorm.DB, transferService *ResourceTransferService, storagePath, baseURL string, log *logger.Logger) *VideoMergeService { return &VideoMergeService{ db: db, aiService: NewAIService(db, log), transferService: transferService, ffmpeg: ffmpeg.NewFFmpeg(log), storagePath: storagePath, baseURL: baseURL, log: log, } } type MergeVideoRequest struct { EpisodeID string `json:"episode_id" binding:"required"` DramaID string `json:"drama_id" binding:"required"` Title string `json:"title"` Scenes []models.SceneClip `json:"scenes" binding:"required,min=1"` Provider string `json:"provider"` Model string `json:"model"` } func (s *VideoMergeService) MergeVideos(req *MergeVideoRequest) (*models.VideoMerge, error) { // 验证episode权限 var episode models.Episode if err := s.db.Preload("Drama").Where("id = ?", req.EpisodeID).First(&episode).Error; err != nil { return nil, fmt.Errorf("episode not found") } // 验证所有场景都有视频 for i, scene := range req.Scenes { if scene.VideoURL == "" { return nil, fmt.Errorf("scene %d has no video", i+1) } } provider := req.Provider if provider == "" { provider = "doubao" } // 序列化场景列表 scenesJSON, err := json.Marshal(req.Scenes) if err != nil { return nil, fmt.Errorf("failed to serialize scenes: %w", err) } s.log.Infow("Serialized scenes to JSON", "scenes_count", len(req.Scenes), "scenes_json", string(scenesJSON)) epID, _ := strconv.ParseUint(req.EpisodeID, 10, 32) dramaID, _ := strconv.ParseUint(req.DramaID, 10, 32) videoMerge := &models.VideoMerge{ EpisodeID: uint(epID), DramaID: uint(dramaID), Title: req.Title, Provider: provider, Model: &req.Model, Scenes: scenesJSON, Status: models.VideoMergeStatusPending, } if err := s.db.Create(videoMerge).Error; err != nil { return nil, fmt.Errorf("failed to create merge record: %w", err) } go s.processMergeVideo(videoMerge.ID) return videoMerge, nil } func (s *VideoMergeService) processMergeVideo(mergeID uint) { var videoMerge models.VideoMerge if err := s.db.First(&videoMerge, mergeID).Error; err != nil { s.log.Errorw("Failed to load video merge", "error", err, "id", mergeID) return } s.db.Model(&videoMerge).Update("status", models.VideoMergeStatusProcessing) client, err := s.getVideoClient(videoMerge.Provider) if err != nil { s.updateMergeError(mergeID, err.Error()) return } // 解析场景列表 var scenes []models.SceneClip if err := json.Unmarshal(videoMerge.Scenes, &scenes); err != nil { s.updateMergeError(mergeID, fmt.Sprintf("failed to parse scenes: %v", err)) return } // 调用视频合并API result, err := s.mergeVideoClips(client, scenes) if err != nil { s.updateMergeError(mergeID, err.Error()) return } if !result.Completed { s.db.Model(&videoMerge).Updates(map[string]interface{}{ "status": models.VideoMergeStatusProcessing, "task_id": result.TaskID, }) go s.pollMergeStatus(mergeID, client, result.TaskID) return } s.completeMerge(mergeID, result) } func (s *VideoMergeService) mergeVideoClips(client video.VideoClient, scenes []models.SceneClip) (*video.VideoResult, error) { if len(scenes) == 0 { return nil, fmt.Errorf("no scenes to merge") } // 按Order字段排序场景 sort.Slice(scenes, func(i, j int) bool { return scenes[i].Order < scenes[j].Order }) s.log.Infow("Merging video clips with FFmpeg", "scene_count", len(scenes)) // 计算总时长 var totalDuration float64 for _, scene := range scenes { totalDuration += scene.Duration } // 准备FFmpeg合成选项 clips := make([]ffmpeg.VideoClip, len(scenes)) for i, scene := range scenes { // 使用 scene.VideoURL,它已经在前面的代码中被正确处理 // 如果是本地文件,已经包含了完整路径(storagePath + LocalPath) // 如果是 HTTP URL,则直接使用 videoPath := scene.VideoURL clips[i] = ffmpeg.VideoClip{ URL: videoPath, Duration: scene.Duration, StartTime: scene.StartTime, EndTime: scene.EndTime, Transition: scene.Transition, } s.log.Infow("Clip added to merge queue", "order", scene.Order, "index", i, "video_path", videoPath, "duration", scene.Duration, "start_time", scene.StartTime, "end_time", scene.EndTime) } // 创建视频输出目录 videoDir := filepath.Join(s.storagePath, "videos", "merged") if err := os.MkdirAll(videoDir, 0755); err != nil { return nil, fmt.Errorf("failed to create video directory: %w", err) } // 生成输出文件名 fileName := fmt.Sprintf("merged_%d.mp4", time.Now().Unix()) outputPath := filepath.Join(videoDir, fileName) // 使用FFmpeg合成视频 mergedPath, err := s.ffmpeg.MergeVideos(&ffmpeg.MergeOptions{ OutputPath: outputPath, Clips: clips, }) if err != nil { return nil, fmt.Errorf("ffmpeg merge failed: %w", err) } s.log.Infow("Video merged successfully", "path", mergedPath) // 生成相对路径(不包含协议、IP、端口) relPath := filepath.Join("videos", "merged", fileName) result := &video.VideoResult{ VideoURL: relPath, // 只保存相对路径 Duration: int(totalDuration), Completed: true, Status: "completed", } return result, nil } func (s *VideoMergeService) pollMergeStatus(mergeID uint, client video.VideoClient, taskID string) { maxAttempts := 240 pollInterval := 5 * time.Second for i := 0; i < maxAttempts; i++ { time.Sleep(pollInterval) result, err := client.GetTaskStatus(taskID) if err != nil { s.log.Errorw("Failed to get merge task status", "error", err, "task_id", taskID) continue } if result.Completed { s.completeMerge(mergeID, result) return } if result.Error != "" { s.updateMergeError(mergeID, result.Error) return } } s.updateMergeError(mergeID, "timeout: video merge took too long") } func (s *VideoMergeService) completeMerge(mergeID uint, result *video.VideoResult) { now := time.Now() // 获取merge记录 var videoMerge models.VideoMerge if err := s.db.First(&videoMerge, mergeID).Error; err != nil { s.log.Errorw("Failed to load video merge for completion", "error", err, "id", mergeID) return } finalVideoURL := result.VideoURL // 使用本地存储,不再使用MinIO s.log.Infow("Video merge completed, using local storage", "merge_id", mergeID, "local_path", result.VideoURL) updates := map[string]interface{}{ "status": models.VideoMergeStatusCompleted, "merged_url": finalVideoURL, "completed_at": now, } if result.Duration > 0 { updates["duration"] = result.Duration } s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(updates) // 更新episode的状态和最终视频URL if videoMerge.EpisodeID != 0 { s.db.Model(&models.Episode{}).Where("id = ?", videoMerge.EpisodeID).Updates(map[string]interface{}{ "status": "completed", "video_url": finalVideoURL, }) s.log.Infow("Episode finalized", "episode_id", videoMerge.EpisodeID, "video_url", finalVideoURL) } s.log.Infow("Video merge completed", "id", mergeID, "url", finalVideoURL) } func (s *VideoMergeService) updateMergeError(mergeID uint, errorMsg string) { s.db.Model(&models.VideoMerge{}).Where("id = ?", mergeID).Updates(map[string]interface{}{ "status": models.VideoMergeStatusFailed, "error_msg": errorMsg, }) s.log.Errorw("Video merge failed", "id", mergeID, "error", errorMsg) } func (s *VideoMergeService) getVideoClient(provider string) (video.VideoClient, error) { config, err := s.aiService.GetDefaultConfig("video") if err != nil { return nil, fmt.Errorf("failed to get video config: %w", err) } // 使用第一个模型 model := "" if len(config.Model) > 0 { model = config.Model[0] } // 根据配置中的 provider 创建对应的客户端 var endpoint string var queryEndpoint string switch config.Provider { case "runway": return video.NewRunwayClient(config.BaseURL, config.APIKey, model), nil case "pika": return video.NewPikaClient(config.BaseURL, config.APIKey, model), nil case "openai", "sora": return video.NewOpenAISoraClient(config.BaseURL, config.APIKey, model), nil case "minimax": return video.NewMinimaxClient(config.BaseURL, config.APIKey, model), nil case "chatfire": endpoint = "/video/generations" queryEndpoint = "/video/task/{taskId}" return video.NewChatfireClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil case "doubao", "volces", "ark": endpoint = "/contents/generations/tasks" queryEndpoint = "/generations/tasks/{taskId}" return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil default: endpoint = "/contents/generations/tasks" queryEndpoint = "/generations/tasks/{taskId}" return video.NewVolcesArkClient(config.BaseURL, config.APIKey, model, endpoint, queryEndpoint), nil } } func (s *VideoMergeService) GetMerge(mergeID uint) (*models.VideoMerge, error) { var merge models.VideoMerge if err := s.db.Where("id = ? ", mergeID).First(&merge).Error; err != nil { return nil, err } return &merge, nil } func (s *VideoMergeService) ListMerges(episodeID *string, status string, page, pageSize int) ([]models.VideoMerge, int64, error) { query := s.db.Model(&models.VideoMerge{}) if episodeID != nil && *episodeID != "" { query = query.Where("episode_id = ?", *episodeID) } if status != "" { query = query.Where("status = ?", status) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var merges []models.VideoMerge offset := (page - 1) * pageSize if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&merges).Error; err != nil { return nil, 0, err } return merges, total, nil } func (s *VideoMergeService) DeleteMerge(mergeID uint) error { result := s.db.Where("id = ? ", mergeID).Delete(&models.VideoMerge{}) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return fmt.Errorf("merge not found") } return nil } // TimelineClip 时间线片段数据 type TimelineClip struct { AssetID interface{} `json:"asset_id"` // 素材库视频ID(优先使用,可以是数字或字符串) StoryboardID string `json:"storyboard_id"` // 分镜ID(fallback) Order int `json:"order"` StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` Duration float64 `json:"duration"` Transition map[string]interface{} `json:"transition"` } // getAssetIDString 将 AssetID 转换为字符串 func getAssetIDString(assetID interface{}) string { if assetID == nil { return "" } switch v := assetID.(type) { case string: return v case float64: return fmt.Sprintf("%.0f", v) case int: return fmt.Sprintf("%d", v) default: return fmt.Sprintf("%v", v) } } // FinalizeEpisodeRequest 完成剧集制作请求 type FinalizeEpisodeRequest struct { EpisodeID string `json:"episode_id"` Clips []TimelineClip `json:"clips"` } // FinalizeEpisode 完成集数制作,根据时间线场景顺序合成最终视频 func (s *VideoMergeService) FinalizeEpisode(episodeID string, timelineData *FinalizeEpisodeRequest) (map[string]interface{}, error) { // 验证episode存在且属于该用户 var episode models.Episode if err := s.db.Preload("Drama").Preload("Storyboards").Where("id = ?", episodeID).First(&episode).Error; err != nil { return nil, fmt.Errorf("episode not found") } // 构建分镜ID映射 sceneMap := make(map[string]models.Storyboard) for _, scene := range episode.Storyboards { sceneMap[fmt.Sprintf("%d", scene.ID)] = scene } // 根据时间线数据构建场景片段 var sceneClips []models.SceneClip var skippedScenes []int if timelineData != nil && len(timelineData.Clips) > 0 { s.log.Infow("Processing timeline data", "clips_count", len(timelineData.Clips)) // 使用前端提供的时间线数据 for i, clip := range timelineData.Clips { assetIDStr := getAssetIDString(clip.AssetID) s.log.Infow("Processing clip", "index", i, "storyboard_id", clip.StoryboardID, "asset_id", assetIDStr, "order", clip.Order) // 优先使用素材库中的视频(通过AssetID) var videoURL string var sceneID uint if assetIDStr != "" { // 从素材库获取视频,优先使用 local_path var asset models.Asset if err := s.db.Where("id = ? AND type = ?", assetIDStr, models.AssetTypeVideo).First(&asset).Error; err == nil { // 优先使用 local_path if asset.LocalPath != nil && *asset.LocalPath != "" { // 检查是否已经是完整路径 if filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) { videoURL = *asset.LocalPath } else { videoURL = filepath.Join(s.storagePath, *asset.LocalPath) } s.log.Infow("Using local video from asset library", "asset_id", assetIDStr, "local_path", videoURL) } else { // 回退到远程 URL videoURL = asset.URL s.log.Infow("Using remote video from asset library", "asset_id", assetIDStr, "video_url", videoURL) } // 如果asset关联了storyboard,使用关联的storyboard_id if asset.StoryboardID != nil { sceneID = *asset.StoryboardID } } else { s.log.Warnw("Asset not found, will try storyboard video", "asset_id", assetIDStr, "error", err) } } // 如果没有从素材库获取到视频,尝试从storyboard获取 if videoURL == "" && clip.StoryboardID != "" { scene, exists := sceneMap[clip.StoryboardID] if !exists { s.log.Warnw("Storyboard not found in episode, skipping", "storyboard_id", clip.StoryboardID) continue } // 查找关联的 video_generation 记录以获取 local_path var videoGen models.VideoGeneration if err := s.db.Where("storyboard_id = ? AND status = ?", scene.ID, "completed").Order("created_at DESC").First(&videoGen).Error; err == nil { if videoGen.LocalPath != nil && *videoGen.LocalPath != "" { // 检查是否已经是完整路径 if filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) { videoURL = *videoGen.LocalPath } else { videoURL = filepath.Join(s.storagePath, *videoGen.LocalPath) } sceneID = scene.ID s.log.Infow("Using local video from video_generation", "storyboard_id", clip.StoryboardID, "local_path", videoURL) } else if scene.VideoURL != nil && *scene.VideoURL != "" { // 回退到远程 URL videoURL = *scene.VideoURL sceneID = scene.ID s.log.Infow("Using remote video from storyboard", "storyboard_id", clip.StoryboardID, "video_url", videoURL) } } else if scene.VideoURL != nil && *scene.VideoURL != "" { // 如果没有找到 video_generation,直接使用 storyboard 的 video_url videoURL = *scene.VideoURL sceneID = scene.ID s.log.Infow("Using video from storyboard (no video_generation found)", "storyboard_id", clip.StoryboardID, "video_url", videoURL) } } // 如果仍然没有视频URL,跳过该片段 if videoURL == "" { s.log.Warnw("No video available for clip, skipping", "clip", clip) if clip.StoryboardID != "" { if scene, exists := sceneMap[clip.StoryboardID]; exists { skippedScenes = append(skippedScenes, scene.StoryboardNumber) } } continue } sceneClip := models.SceneClip{ SceneID: sceneID, VideoURL: videoURL, Duration: clip.Duration, Order: clip.Order, StartTime: clip.StartTime, EndTime: clip.EndTime, Transition: clip.Transition, } s.log.Infow("Adding scene clip with transition", "scene_id", sceneID, "order", clip.Order, "video_url", videoURL, "transition", clip.Transition) sceneClips = append(sceneClips, sceneClip) s.log.Infow("Scene clip added", "total_clips", len(sceneClips)) } } else { // 没有时间线数据,使用默认场景顺序 if len(episode.Storyboards) == 0 { return nil, fmt.Errorf("no scenes found for this episode") } order := 0 for _, scene := range episode.Storyboards { // 优先从素材库查找该分镜关联的视频 var videoURL string var asset models.Asset if err := s.db.Where("storyboard_id = ? AND type = ? AND episode_id = ?", scene.ID, models.AssetTypeVideo, episode.ID). Order("created_at DESC"). First(&asset).Error; err == nil { // 优先使用 local_path if asset.LocalPath != nil && *asset.LocalPath != "" { // 检查是否已经是完整路径 if filepath.IsAbs(*asset.LocalPath) || filepath.HasPrefix(*asset.LocalPath, s.storagePath) { videoURL = *asset.LocalPath } else { videoURL = filepath.Join(s.storagePath, *asset.LocalPath) } s.log.Infow("Using local video from asset library for storyboard", "storyboard_id", scene.ID, "asset_id", asset.ID, "local_path", videoURL) } else { videoURL = asset.URL s.log.Infow("Using remote video from asset library for storyboard", "storyboard_id", scene.ID, "asset_id", asset.ID, "video_url", videoURL) } } else { // 如果素材库没有,查找 video_generation 记录 var videoGen models.VideoGeneration if err := s.db.Where("storyboard_id = ? AND status = ?", scene.ID, "completed").Order("created_at DESC").First(&videoGen).Error; err == nil { if videoGen.LocalPath != nil && *videoGen.LocalPath != "" { // 检查是否已经是完整路径 if filepath.IsAbs(*videoGen.LocalPath) || filepath.HasPrefix(*videoGen.LocalPath, s.storagePath) { videoURL = *videoGen.LocalPath } else { videoURL = filepath.Join(s.storagePath, *videoGen.LocalPath) } s.log.Infow("Using local video from video_generation for storyboard", "storyboard_id", scene.ID, "local_path", videoURL) } else if scene.VideoURL != nil && *scene.VideoURL != "" { videoURL = *scene.VideoURL s.log.Infow("Using remote video from storyboard", "storyboard_id", scene.ID, "video_url", videoURL) } } else if scene.VideoURL != nil && *scene.VideoURL != "" { // 最后回退到 storyboard 的 video_url videoURL = *scene.VideoURL s.log.Infow("Using fallback video from storyboard", "storyboard_id", scene.ID, "video_url", videoURL) } } // 跳过没有视频的场景 if videoURL == "" { s.log.Warnw("Scene has no video, skipping", "storyboard_number", scene.StoryboardNumber) skippedScenes = append(skippedScenes, scene.StoryboardNumber) continue } clip := models.SceneClip{ SceneID: scene.ID, VideoURL: videoURL, Duration: float64(scene.Duration), Order: order, } sceneClips = append(sceneClips, clip) order++ } } // 检查是否至少有一个场景可以合成 if len(sceneClips) == 0 { return nil, fmt.Errorf("no scenes with videos available for merging") } // 创建视频合成任务 title := fmt.Sprintf("%s - 第%d集", episode.Drama.Title, episode.EpisodeNum) finalReq := &MergeVideoRequest{ EpisodeID: episodeID, DramaID: fmt.Sprintf("%d", episode.DramaID), Title: title, Scenes: sceneClips, Provider: "doubao", // 默认使用doubao } // 执行视频合成 videoMerge, err := s.MergeVideos(finalReq) if err != nil { return nil, fmt.Errorf("failed to start video merge: %w", err) } // 更新episode状态为processing s.db.Model(&episode).Updates(map[string]interface{}{ "status": "processing", }) result := map[string]interface{}{ "message": "视频合成任务已创建,正在后台处理", "merge_id": videoMerge.ID, "episode_id": episodeID, "scenes_count": len(sceneClips), } // 如果有跳过的场景,添加提示信息 if len(skippedScenes) > 0 { result["skipped_scenes"] = skippedScenes result["warning"] = fmt.Sprintf("已跳过 %d 个未生成视频的场景(场景编号:%v)", len(skippedScenes), skippedScenes) } return result, nil } ================================================ FILE: cmd/migrate/main.go ================================================ package main import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/infrastructure/database" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "gorm.io/gorm" ) // DataMigrationService 数据迁移服务 type DataMigrationService struct { db *gorm.DB log *logger.Logger storageRoot string urlMapping map[string]string // 原始URL -> 本地路径的映射 } // MigrationStats 迁移统计信息 type MigrationStats struct { AssetsSuccess int AssetsFailed int CharacterLibrariesSuccess int CharacterLibrariesFailed int CharactersSuccess int CharactersFailed int ImageGenerationsSuccess int ImageGenerationsFailed int ScenesSuccess int ScenesFailed int VideosSuccess int VideosFailed int } func main() { fmt.Println("=== 数据清洗工具:迁移 local_path ===") fmt.Println("开始时间:", time.Now().Format("2006-01-02 15:04:05")) fmt.Println() // 1. 初始化日志 logr := logger.NewLogger(false) logr.Info("初始化日志系统...") // 2. 加载配置 cfg, err := config.LoadConfig() if err != nil { logr.Fatalw("加载配置失败", "error", err) } logr.Info("配置加载成功") // 3. 连接数据库 db, err := database.NewDatabase(cfg.Database) if err != nil { logr.Fatalw("数据库连接失败", "error", err) } logr.Info("数据库连接成功") // 4. 创建迁移服务 service := &DataMigrationService{ db: db, log: logr, storageRoot: "data/storage", urlMapping: make(map[string]string), } // 5. 执行迁移 if err := service.MigrateLocalPaths(); err != nil { logr.Fatalw("数据清洗失败", "error", err) } fmt.Println() fmt.Println("=== 数据清洗完成 ===") fmt.Println("结束时间:", time.Now().Format("2006-01-02 15:04:05")) } // MigrateLocalPaths 迁移所有表中 local_path 为空的数据 func (s *DataMigrationService) MigrateLocalPaths() error { s.log.Info("开始数据清洗:迁移 local_path 为空的数据") startTime := time.Now() // 确保存储目录存在 if err := s.ensureStorageDirectories(); err != nil { return fmt.Errorf("创建存储目录失败: %w", err) } // 迁移各个表的数据(按指定顺序) stats := &MigrationStats{} // 1. 迁移 assets 表 if err := s.migrateAssets(stats); err != nil { s.log.Errorw("迁移 assets 数据失败", "error", err) } // 2. 迁移 character_libraries 表 if err := s.migrateCharacterLibraries(stats); err != nil { s.log.Errorw("迁移 character_libraries 数据失败", "error", err) } // 3. 迁移 characters 表 if err := s.migrateCharacters(stats); err != nil { s.log.Errorw("迁移 characters 数据失败", "error", err) } // 4. 迁移 image_generations 表 if err := s.migrateImageGenerations(stats); err != nil { s.log.Errorw("迁移 image_generations 数据失败", "error", err) } // 5. 迁移 scenes 表 if err := s.migrateScenes(stats); err != nil { s.log.Errorw("迁移 scenes 数据失败", "error", err) } // 6. 迁移 video_generations 表 if err := s.migrateVideoGenerations(stats); err != nil { s.log.Errorw("迁移 video_generations 数据失败", "error", err) } duration := time.Since(startTime) s.log.Infow("数据清洗完成", "总耗时", duration.String(), "URL映射缓存数", len(s.urlMapping), "Assets成功", stats.AssetsSuccess, "Assets失败", stats.AssetsFailed, "角色库成功", stats.CharacterLibrariesSuccess, "角色库失败", stats.CharacterLibrariesFailed, "角色成功", stats.CharactersSuccess, "角色失败", stats.CharactersFailed, "图片生成成功", stats.ImageGenerationsSuccess, "图片生成失败", stats.ImageGenerationsFailed, "场景成功", stats.ScenesSuccess, "场景失败", stats.ScenesFailed, "视频成功", stats.VideosSuccess, "视频失败", stats.VideosFailed, ) return nil } // ensureStorageDirectories 确保存储目录存在 func (s *DataMigrationService) ensureStorageDirectories() error { dirs := []string{ filepath.Join(s.storageRoot, "images"), filepath.Join(s.storageRoot, "characters"), filepath.Join(s.storageRoot, "videos"), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("创建目录 %s 失败: %w", dir, err) } } s.log.Infow("存储目录创建成功", "root", s.storageRoot) return nil } // migrateAssets 迁移 assets 表数据 func (s *DataMigrationService) migrateAssets(stats *MigrationStats) error { s.log.Info("开始迁移 assets 数据...") var assets []models.Asset if err := s.db.Where("(local_path IS NULL OR local_path = '') AND url IS NOT NULL AND url != ''").Find(&assets).Error; err != nil { return fmt.Errorf("查询 assets 数据失败: %w", err) } s.log.Infow("找到需要迁移的 assets", "数量", len(assets)) for _, asset := range assets { s.log.Infow("处理 asset", "id", asset.ID, "name", asset.Name, "type", asset.Type, "url", asset.URL) subDir := "images" if asset.Type == models.AssetTypeVideo { subDir = "videos" } localPath, err := s.downloadOrGetCached(asset.URL, subDir, fmt.Sprintf("asset_%d", asset.ID)) if err != nil { s.log.Errorw("下载 asset 失败", "asset_id", asset.ID, "error", err) stats.AssetsFailed++ continue } if err := s.db.Model(&asset).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 asset local_path 失败", "asset_id", asset.ID, "error", err) stats.AssetsFailed++ continue } s.log.Infow("asset 迁移成功", "asset_id", asset.ID, "local_path", localPath) stats.AssetsSuccess++ } return nil } // migrateCharacterLibraries 迁移 character_libraries 表数据 func (s *DataMigrationService) migrateCharacterLibraries(stats *MigrationStats) error { s.log.Info("开始迁移 character_libraries 数据...") var charLibs []models.CharacterLibrary if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&charLibs).Error; err != nil { return fmt.Errorf("查询 character_libraries 数据失败: %w", err) } s.log.Infow("找到需要迁移的 character_libraries", "数量", len(charLibs)) for _, charLib := range charLibs { s.log.Infow("处理 character_library", "id", charLib.ID, "name", charLib.Name, "image_url", charLib.ImageURL) localPath, err := s.downloadOrGetCached(charLib.ImageURL, "characters", fmt.Sprintf("charlib_%d", charLib.ID)) if err != nil { s.log.Errorw("下载 character_library 图片失败", "charlib_id", charLib.ID, "error", err) stats.CharacterLibrariesFailed++ continue } if err := s.db.Model(&charLib).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 character_library local_path 失败", "charlib_id", charLib.ID, "error", err) stats.CharacterLibrariesFailed++ continue } s.log.Infow("character_library 迁移成功", "charlib_id", charLib.ID, "local_path", localPath) stats.CharacterLibrariesSuccess++ } return nil } // migrateCharacters 迁移角色数据 func (s *DataMigrationService) migrateCharacters(stats *MigrationStats) error { s.log.Info("开始迁移角色数据...") var characters []models.Character if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&characters).Error; err != nil { return fmt.Errorf("查询角色数据失败: %w", err) } s.log.Infow("找到需要迁移的角色", "数量", len(characters)) for _, character := range characters { if character.ImageURL == nil { continue } s.log.Infow("处理角色", "id", character.ID, "name", character.Name, "image_url", *character.ImageURL) localPath, err := s.downloadOrGetCached(*character.ImageURL, "characters", fmt.Sprintf("character_%d", character.ID)) if err != nil { s.log.Errorw("下载角色图片失败", "character_id", character.ID, "error", err) stats.CharactersFailed++ continue } if err := s.db.Model(&character).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新角色 local_path 失败", "character_id", character.ID, "error", err) stats.CharactersFailed++ continue } s.log.Infow("角色迁移成功", "character_id", character.ID, "local_path", localPath) stats.CharactersSuccess++ } return nil } // migrateImageGenerations 迁移 image_generations 表数据 func (s *DataMigrationService) migrateImageGenerations(stats *MigrationStats) error { s.log.Info("开始迁移 image_generations 数据...") var imageGens []models.ImageGeneration if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&imageGens).Error; err != nil { return fmt.Errorf("查询 image_generations 数据失败: %w", err) } s.log.Infow("找到需要迁移的 image_generations", "数量", len(imageGens)) for _, imageGen := range imageGens { if imageGen.ImageURL == nil { continue } imageTypeStr := string(imageGen.ImageType) s.log.Infow("处理 image_generation", "id", imageGen.ID, "image_type", imageTypeStr, "image_url", *imageGen.ImageURL) subDir := "images" if imageGen.ImageType == "character" { subDir = "characters" } localPath, err := s.downloadOrGetCached(*imageGen.ImageURL, subDir, fmt.Sprintf("imggen_%d", imageGen.ID)) if err != nil { s.log.Errorw("下载 image_generation 图片失败", "imggen_id", imageGen.ID, "error", err) stats.ImageGenerationsFailed++ continue } if err := s.db.Model(&imageGen).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新 image_generation local_path 失败", "imggen_id", imageGen.ID, "error", err) stats.ImageGenerationsFailed++ continue } s.log.Infow("image_generation 迁移成功", "imggen_id", imageGen.ID, "local_path", localPath) stats.ImageGenerationsSuccess++ } return nil } // migrateScenes 迁移场景数据 func (s *DataMigrationService) migrateScenes(stats *MigrationStats) error { s.log.Info("开始迁移场景数据...") var scenes []models.Scene if err := s.db.Where("(local_path IS NULL OR local_path = '') AND image_url IS NOT NULL AND image_url != ''").Find(&scenes).Error; err != nil { return fmt.Errorf("查询场景数据失败: %w", err) } s.log.Infow("找到需要迁移的场景", "数量", len(scenes)) for _, scene := range scenes { if scene.ImageURL == nil { continue } s.log.Infow("处理场景", "id", scene.ID, "location", scene.Location, "image_url", *scene.ImageURL) localPath, err := s.downloadOrGetCached(*scene.ImageURL, "images", fmt.Sprintf("scene_%d", scene.ID)) if err != nil { s.log.Errorw("下载场景图片失败", "scene_id", scene.ID, "error", err) stats.ScenesFailed++ continue } if err := s.db.Model(&scene).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新场景 local_path 失败", "scene_id", scene.ID, "error", err) stats.ScenesFailed++ continue } s.log.Infow("场景迁移成功", "scene_id", scene.ID, "local_path", localPath) stats.ScenesSuccess++ } return nil } // migrateVideoGenerations 迁移视频生成数据 func (s *DataMigrationService) migrateVideoGenerations(stats *MigrationStats) error { s.log.Info("开始迁移视频生成数据...") var videoGens []models.VideoGeneration if err := s.db.Where("(local_path IS NULL OR local_path = '') AND video_url IS NOT NULL AND video_url != ''").Find(&videoGens).Error; err != nil { return fmt.Errorf("查询视频生成数据失败: %w", err) } s.log.Infow("找到需要迁移的视频", "数量", len(videoGens)) for _, videoGen := range videoGens { if videoGen.VideoURL == nil { continue } s.log.Infow("处理视频", "id", videoGen.ID, "video_url", *videoGen.VideoURL) localPath, err := s.downloadOrGetCached(*videoGen.VideoURL, "videos", fmt.Sprintf("video_%d", videoGen.ID)) if err != nil { s.log.Errorw("下载视频失败", "video_gen_id", videoGen.ID, "error", err) stats.VideosFailed++ continue } if err := s.db.Model(&videoGen).Update("local_path", localPath).Error; err != nil { s.log.Errorw("更新视频 local_path 失败", "video_gen_id", videoGen.ID, "error", err) stats.VideosFailed++ continue } s.log.Infow("视频迁移成功", "video_gen_id", videoGen.ID, "local_path", localPath) stats.VideosSuccess++ } return nil } // downloadOrGetCached 下载文件或从缓存获取本地路径 func (s *DataMigrationService) downloadOrGetCached(url, subDir, prefix string) (string, error) { // 1. 检查 URL 映射缓存 if localPath, exists := s.urlMapping[url]; exists { s.log.Infow("使用缓存的本地路径", "url", url, "local_path", localPath) return localPath, nil } // 2. 如果缓存中没有,则下载文件 var localPath string var err error // 根据子目录判断是图片还是视频 if subDir == "videos" { localPath, err = s.downloadAndSaveVideo(url, subDir, prefix) } else { localPath, err = s.downloadAndSaveImage(url, subDir, prefix) } if err != nil { return "", err } // 3. 将 URL 和本地路径的映射关系存入缓存 s.urlMapping[url] = localPath s.log.Infow("已缓存 URL 映射", "url", url, "local_path", localPath) return localPath, nil } // downloadAndSaveImage 下载并保存图片 func (s *DataMigrationService) downloadAndSaveImage(imageURL, subDir, prefix string) (string, error) { if imageURL == "" { return "", fmt.Errorf("图片 URL 为空") } // 如果已经是本地路径,直接返回 if strings.HasPrefix(imageURL, "/static/") || strings.HasPrefix(imageURL, "data/") { return imageURL, nil } // 从 URL 中提取文件扩展名(去掉查询参数) ext := s.extractFileExtension(imageURL) // 生成文件名 timestamp := time.Now().Unix() filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext) relativePath := filepath.Join(subDir, filename) fullPath := filepath.Join(s.storageRoot, relativePath) // 下载文件 if err := s.downloadFile(imageURL, fullPath); err != nil { return "", fmt.Errorf("下载文件失败: %w", err) } // 返回相对路径(用于存储到数据库) return relativePath, nil } // downloadAndSaveVideo 下载并保存视频 func (s *DataMigrationService) downloadAndSaveVideo(videoURL, subDir, prefix string) (string, error) { if videoURL == "" { return "", fmt.Errorf("视频 URL 为空") } // 如果已经是本地路径,直接返回 if strings.HasPrefix(videoURL, "/static/") || strings.HasPrefix(videoURL, "data/") { return videoURL, nil } // 从 URL 中提取文件扩展名(去掉查询参数) ext := s.extractFileExtension(videoURL) if ext == "" || ext == ".jpeg" || ext == ".jpg" || ext == ".png" { ext = ".mp4" // 视频默认扩展名 } // 生成文件名 timestamp := time.Now().Unix() filename := fmt.Sprintf("%s_%d%s", prefix, timestamp, ext) relativePath := filepath.Join(subDir, filename) fullPath := filepath.Join(s.storageRoot, relativePath) // 下载文件 if err := s.downloadFile(videoURL, fullPath); err != nil { return "", fmt.Errorf("下载文件失败: %w", err) } // 返回相对路径(用于存储到数据库) return relativePath, nil } // extractFileExtension 从 URL 中提取文件扩展名(去掉查询参数) func (s *DataMigrationService) extractFileExtension(url string) string { // 去掉查询参数 if idx := strings.Index(url, "?"); idx != -1 { url = url[:idx] } // 去掉 fragment if idx := strings.Index(url, "#"); idx != -1 { url = url[:idx] } // 获取文件扩展名 ext := filepath.Ext(url) if ext == "" { // 如果没有扩展名,默认返回 .jpg return ".jpg" } // 转换为小写 ext = strings.ToLower(ext) // 验证扩展名是否合理(限制长度) if len(ext) > 10 { return ".jpg" } return ext } // downloadFile 下载文件到指定路径 func (s *DataMigrationService) downloadFile(url, filepath string) error { s.log.Infow("开始下载文件", "url", url, "filepath", filepath) // 创建 HTTP 请求 client := &http.Client{ Timeout: 60 * time.Second, } resp, err := client.Get(url) if err != nil { return fmt.Errorf("HTTP 请求失败: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP 状态码错误: %d", resp.StatusCode) } // 创建文件 out, err := os.Create(filepath) if err != nil { return fmt.Errorf("创建文件失败: %w", err) } defer out.Close() // 写入文件 size, err := io.Copy(out, resp.Body) if err != nil { return fmt.Errorf("写入文件失败: %w", err) } s.log.Infow("文件下载成功", "filepath", filepath, "size", size) return nil } ================================================ FILE: configs/config.example.yaml ================================================ app: name: "Huobao Drama API" version: "1.0.0" debug: true language: "zh" # 系统语言:zh(中文) 或 en(英文) server: port: 5678 host: "0.0.0.0" cors_origins: - "http://localhost:3012" read_timeout: 600 write_timeout: 600 database: type: "sqlite" path: "./data/drama_generator.db" max_idle: 10 max_open: 100 storage: type: "local" local_path: "./data/storage" base_url: "http://localhost:5678/static" ai: default_text_provider: "openai" default_image_provider: "openai" default_video_provider: "doubao" ================================================ FILE: docker-compose.yml ================================================ services: huobao-drama: # image: huobao-drama:latest container_name: huobao-drama build: context: . dockerfile: Dockerfile args: # Docker Hub 镜像源(注意末尾斜杠) DOCKER_REGISTRY: ${DOCKER_REGISTRY:-} # npm 镜像源 NPM_REGISTRY: ${NPM_REGISTRY:-} # Go 代理 GO_PROXY: ${GO_PROXY:-} # Alpine apk 镜像源 ALPINE_MIRROR: ${ALPINE_MIRROR:-} ports: - "5678:5678" volumes: # 持久化数据目录(使用命名卷,容器内以 root 运行) - huobao-data:/app/data # 挂载配置文件(可选,如需自定义配置请取消注释) # - ./configs/config.yaml:/app/configs/config.yaml:ro # 注意:如果使用本地目录挂载,需要确保目录权限正确 # 例如:- ./data:/app/data (需要 chmod 777 ./data) environment: - TZ=Asia/Shanghai # 访问宿主机服务说明: # 使用 host.docker.internal 代替 127.0.0.1 # 例如:http://host.docker.internal:11434 (Ollama) extra_hosts: - "host.docker.internal:host-gateway" # 统一支持所有平台 restart: unless-stopped healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5678/health"] interval: 30s timeout: 3s retries: 3 start_period: 10s networks: - huobao-network volumes: huobao-data: driver: local networks: huobao-network: driver: bridge ================================================ FILE: docs/DATA_MIGRATION.md ================================================ # 数据清洗服务文档 ## 概述 数据清洗服务(Data Migration Service)用于自动下载并迁移数据库中 `local_path` 字段为空的数据。该服务会在应用启动时自动执行,将远程 URL 的文件下载到本地存储,并更新数据库中的 `local_path` 字段。 ## 功能特性 - ✅ **自动执行**:服务启动时自动运行,无需手动干预 - ✅ **异步处理**:后台异步执行,不阻塞服务启动 - ✅ **多表支持**:支持场景、角色、视频、分镜等多个表 - ✅ **智能分类**:根据数据类型自动分类存储到不同目录 - ✅ **错误容忍**:单个文件下载失败不影响其他文件的处理 - ✅ **详细日志**:提供完整的执行日志和统计信息 ## 处理的数据表 ### 1. 场景表(scenes) - **字段**:`image_url` → `local_path` - **存储目录**:`data/storage/images/` - **文件命名**:`scene_{id}_{timestamp}.{ext}` ### 2. 角色表(characters) - **字段**:`image_url` → `local_path` - **存储目录**:`data/storage/characters/` - **文件命名**:`character_{id}_{timestamp}.{ext}` ### 3. 视频生成表(video_generations) - **字段**:`video_url` → `local_path` - **存储目录**:`data/storage/videos/` - **文件命名**:`video_{id}_{timestamp}.{ext}` ### 4. 分镜表(storyboards) - **字段**:`image_url` → `local_path` - **存储目录**:`data/storage/images/` - **文件命名**:`storyboard_{id}_{timestamp}.{ext}` ## 执行流程 ``` 1. 服务启动 ↓ 2. 数据库连接和迁移 ↓ 3. 启动数据清洗任务(异步) ↓ 4. 创建存储目录 ↓ 5. 查询各表中 local_path 为空的数据 ↓ 6. 遍历每条记录 ├─ 下载文件到本地 ├─ 更新 local_path 字段 └─ 记录成功/失败统计 ↓ 7. 输出执行统计 ``` ## 日志示例 ### 启动日志 ``` INFO 启动数据清洗任务... INFO 开始数据清洗:迁移 local_path 为空的数据 INFO 存储目录创建成功 root=data/storage ``` ### 处理日志 ``` INFO 开始迁移场景数据... INFO 找到需要迁移的场景 数量=5 INFO 处理场景 id=1 location=大型超市 image_url=https://... INFO 开始下载文件 url=https://... filepath=data/storage/images/scene_1_1706345678.jpg INFO 文件下载成功 filepath=data/storage/images/scene_1_1706345678.jpg size=245678 INFO 场景迁移成功 scene_id=1 local_path=images/scene_1_1706345678.jpg ``` ### 完成日志 ``` INFO 数据清洗完成 总耗时=15.234s 场景成功=5 场景失败=0 角色成功=3 角色失败=1 视频成功=2 视频失败=0 分镜成功=4 分镜失败=0 ``` ### 错误日志 ``` ERROR 下载场景图片失败 scene_id=10 error=HTTP 状态码错误: 404 ERROR 更新角色 local_path 失败 character_id=5 error=database connection lost ``` ## 配置说明 ### 存储根目录 默认存储根目录为 `data/storage`,可在代码中修改: ```go storageRoot: "data/storage" // 可自定义路径 ``` ### 下载超时设置 默认 HTTP 请求超时为 60 秒: ```go client := &http.Client{ Timeout: 60 * time.Second, // 可根据需要调整 } ``` ## 错误处理 ### 跳过的情况 - URL 为空 - URL 已经是本地路径(以 `/static/` 或 `data/` 开头) - HTTP 请求失败(404、超时等) - 文件写入失败 - 数据库更新失败 ### 错误不会导致 - ❌ 服务启动失败 - ❌ 其他数据处理中断 - ❌ 数据库回滚 ## 手动触发 如果需要手动触发数据清洗(例如在运行时),可以通过以下方式: ```go // 创建服务实例 migrationService := services.NewDataMigrationService(db, logger) // 执行迁移 if err := migrationService.MigrateLocalPaths(); err != nil { log.Printf("数据清洗失败: %v", err) } ``` ## 性能考虑 ### 异步执行 数据清洗任务在后台异步执行,不会阻塞服务启动。服务可以立即开始处理用户请求。 ### 网络带宽 - 大量文件下载可能占用网络带宽 - 建议在低峰期执行或限制并发下载数 ### 存储空间 - 确保服务器有足够的磁盘空间 - 定期清理不再使用的文件 ## 监控建议 ### 关键指标 - 成功迁移数量 - 失败迁移数量 - 总执行时间 - 磁盘使用率 ### 告警条件 - 失败率 > 10% - 执行时间 > 5 分钟 - 磁盘使用率 > 90% ## 故障排查 ### 问题:所有下载都失败 **可能原因**: - 网络连接问题 - 防火墙阻止外部请求 - 源服务器不可用 **解决方案**: - 检查网络连接 - 检查防火墙配置 - 验证源 URL 是否可访问 ### 问题:部分下载失败 **可能原因**: - 特定 URL 无效或过期 - 文件格式不支持 - 临时网络波动 **解决方案**: - 查看错误日志定位具体 URL - 手动验证 URL 有效性 - 重启服务重试 ### 问题:数据库更新失败 **可能原因**: - 数据库连接断开 - 权限不足 - 字段约束冲突 **解决方案**: - 检查数据库连接 - 验证数据库用户权限 - 检查表结构和约束 ## 代码位置 - **服务实现**:`application/services/data_migration_service.go` - **集成代码**:`main.go`(第 45-55 行) - **文档**:`docs/DATA_MIGRATION.md` ## 版本历史 - **v1.0.0** (2026-01-27) - 初始版本 - 支持场景、角色、视频、分镜数据迁移 - 异步执行,详细日志 ================================================ FILE: domain/models/ai_config.go ================================================ package models import ( "database/sql/driver" "encoding/json" "errors" "time" ) type AIServiceConfig struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"` // text, image, video Provider string `gorm:"type:varchar(50)" json:"provider"` // openai, gemini, volcengine, etc. Name string `gorm:"type:varchar(100);not null" json:"name"` BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"` APIKey string `gorm:"type:varchar(255);not null" json:"api_key"` Model ModelField `gorm:"type:text" json:"model"` Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` QueryEndpoint string `gorm:"type:varchar(255)" json:"query_endpoint"` Priority int `gorm:"default:0" json:"priority"` // 优先级,数值越大优先级越高 IsDefault bool `gorm:"default:false" json:"is_default"` IsActive bool `gorm:"default:true" json:"is_active"` Settings string `gorm:"type:text" json:"settings"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` } func (c *AIServiceConfig) TableName() string { return "ai_service_configs" } type AIServiceProvider struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Name string `gorm:"type:varchar(100);not null;uniqueIndex" json:"name"` DisplayName string `gorm:"type:varchar(100);not null" json:"display_name"` ServiceType string `gorm:"type:varchar(50);not null" json:"service_type"` DefaultURL string `gorm:"type:varchar(255)" json:"default_url"` Description string `gorm:"type:text" json:"description"` IsActive bool `gorm:"default:true" json:"is_active"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` } func (p *AIServiceProvider) TableName() string { return "ai_service_providers" } // ModelField 自定义类型,支持字符串或字符串数组 type ModelField []string // Value 实现 driver.Valuer 接口,用于存储到数据库 func (m ModelField) Value() (driver.Value, error) { if len(m) == 0 { return nil, nil } data, err := json.Marshal(m) if err != nil { return nil, err } return string(data), nil } // Scan 实现 sql.Scanner 接口,用于从数据库读取 func (m *ModelField) Scan(value interface{}) error { if value == nil { *m = []string{} return nil } var data []byte switch v := value.(type) { case []byte: data = v case string: data = []byte(v) default: return errors.New("unsupported type for ModelField") } // 尝试解析为数组 var arr []string if err := json.Unmarshal(data, &arr); err == nil { *m = arr return nil } // 如果解析失败,尝试作为单个字符串处理 var str string if err := json.Unmarshal(data, &str); err == nil { *m = []string{str} return nil } // 兼容旧数据:直接作为字符串 *m = []string{string(data)} return nil } // MarshalJSON 实现 json.Marshaler 接口 func (m ModelField) MarshalJSON() ([]byte, error) { if len(m) == 0 { return json.Marshal([]string{}) } return json.Marshal([]string(m)) } // UnmarshalJSON 实现 json.Unmarshaler 接口,支持字符串或数组 func (m *ModelField) UnmarshalJSON(data []byte) error { // 尝试解析为数组 var arr []string if err := json.Unmarshal(data, &arr); err == nil { *m = arr return nil } // 尝试解析为单个字符串 var str string if err := json.Unmarshal(data, &str); err == nil { *m = []string{str} return nil } return errors.New("model field must be string or array of strings") } ================================================ FILE: domain/models/asset.go ================================================ package models import ( "time" "gorm.io/gorm" ) type Asset struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DramaID *uint `gorm:"index" json:"drama_id,omitempty"` Drama *Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"` StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` StoryboardNum *int `json:"storyboard_num,omitempty"` Name string `gorm:"type:varchar(200);not null" json:"name"` Description *string `gorm:"type:text" json:"description,omitempty"` Type AssetType `gorm:"type:varchar(20);not null;index" json:"type"` Category *string `gorm:"type:varchar(50);index" json:"category,omitempty"` URL string `gorm:"type:varchar(1000);not null" json:"url"` ThumbnailURL *string `gorm:"type:varchar(1000)" json:"thumbnail_url,omitempty"` LocalPath *string `gorm:"type:varchar(500)" json:"local_path"` FileSize *int64 `json:"file_size,omitempty"` MimeType *string `gorm:"type:varchar(100)" json:"mime_type,omitempty"` Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` Duration *int `json:"duration,omitempty"` Format *string `gorm:"type:varchar(50)" json:"format,omitempty"` ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"` ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"` VideoGenID *uint `gorm:"index" json:"video_gen_id,omitempty"` VideoGen VideoGeneration `gorm:"foreignKey:VideoGenID" json:"video_gen,omitempty"` IsFavorite bool `gorm:"default:false" json:"is_favorite"` ViewCount int `gorm:"default:0" json:"view_count"` } type AssetType string const ( AssetTypeImage AssetType = "image" AssetTypeVideo AssetType = "video" AssetTypeAudio AssetType = "audio" ) func (Asset) TableName() string { return "assets" } ================================================ FILE: domain/models/character_library.go ================================================ package models import ( "time" "gorm.io/gorm" ) // CharacterLibrary 角色库模型 type CharacterLibrary struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Name string `gorm:"type:varchar(100);not null" json:"name"` Category *string `gorm:"type:varchar(50)" json:"category"` ImageURL string `gorm:"type:varchar(500);not null" json:"image_url"` LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"` Description *string `gorm:"type:text" json:"description"` Tags *string `gorm:"type:varchar(500)" json:"tags"` SourceType string `gorm:"type:varchar(20);default:'generated'" json:"source_type"` // generated, uploaded CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } func (c *CharacterLibrary) TableName() string { return "character_libraries" } ================================================ FILE: domain/models/drama.go ================================================ package models import ( "time" "gorm.io/datatypes" "gorm.io/gorm" ) type Drama struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Title string `gorm:"type:varchar(200);not null" json:"title"` Description *string `gorm:"type:text" json:"description"` Genre *string `gorm:"type:varchar(50)" json:"genre"` Style string `gorm:"type:varchar(50);default:'realistic'" json:"style"` TotalEpisodes int `gorm:"default:1" json:"total_episodes"` TotalDuration int `gorm:"default:0" json:"total_duration"` Status string `gorm:"type:varchar(20);default:'draft';not null" json:"status"` Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"` Tags datatypes.JSON `gorm:"type:json" json:"tags"` Metadata datatypes.JSON `gorm:"type:json" json:"metadata"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Episodes []Episode `gorm:"foreignKey:DramaID" json:"episodes,omitempty"` Characters []Character `gorm:"foreignKey:DramaID" json:"characters,omitempty"` Scenes []Scene `gorm:"foreignKey:DramaID" json:"scenes,omitempty"` Props []Prop `gorm:"foreignKey:DramaID" json:"props,omitempty"` } func (d *Drama) TableName() string { return "dramas" } type Character struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` DramaID uint `gorm:"not null;index" json:"drama_id"` Name string `gorm:"type:varchar(100);not null" json:"name"` Role *string `gorm:"type:varchar(50)" json:"role"` Description *string `gorm:"type:text" json:"description"` Appearance *string `gorm:"type:text" json:"appearance"` Personality *string `gorm:"type:text" json:"personality"` VoiceStyle *string `gorm:"type:varchar(200)" json:"voice_style"` ImageURL *string `gorm:"type:varchar(500)" json:"image_url"` LocalPath *string `gorm:"type:text" json:"local_path,omitempty"` ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images"` SeedValue *string `gorm:"type:varchar(100)" json:"seed_value"` SortOrder int `gorm:"default:0" json:"sort_order"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 多对多关系:角色可以属于多个章节 Episodes []Episode `gorm:"many2many:episode_characters;" json:"episodes,omitempty"` // 运行时字段(不存储到数据库) ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"` ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"` } func (c *Character) TableName() string { return "characters" } type Episode struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` DramaID uint `gorm:"not null;index" json:"drama_id"` EpisodeNum int `gorm:"column:episode_number;not null" json:"episode_number"` Title string `gorm:"type:varchar(200);not null" json:"title"` ScriptContent *string `gorm:"type:longtext" json:"script_content"` Description *string `gorm:"type:text" json:"description"` Duration int `gorm:"default:0" json:"duration"` // 总时长(秒) Status string `gorm:"type:varchar(20);default:'draft'" json:"status"` VideoURL *string `gorm:"type:varchar(500)" json:"video_url"` Thumbnail *string `gorm:"type:varchar(500)" json:"thumbnail"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 关联 Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` Storyboards []Storyboard `gorm:"foreignKey:EpisodeID" json:"storyboards,omitempty"` Characters []Character `gorm:"many2many:episode_characters;" json:"characters,omitempty"` Scenes []Scene `gorm:"foreignKey:EpisodeID" json:"scenes,omitempty"` } func (e *Episode) TableName() string { return "episodes" } type Storyboard struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` EpisodeID uint `gorm:"not null;index:idx_storyboards_episode_id" json:"episode_id"` SceneID *uint `gorm:"index:idx_storyboards_scene_id;column:scene_id" json:"scene_id"` StoryboardNumber int `gorm:"not null;column:storyboard_number" json:"storyboard_number"` Title *string `gorm:"size:255" json:"title"` Location *string `gorm:"size:255" json:"location"` Time *string `gorm:"size:255" json:"time"` ShotType *string `gorm:"size:100" json:"shot_type"` Angle *string `gorm:"size:100" json:"angle"` Movement *string `gorm:"size:100" json:"movement"` Action *string `gorm:"type:text" json:"action"` Result *string `gorm:"type:text" json:"result"` Atmosphere *string `gorm:"type:text" json:"atmosphere"` ImagePrompt *string `gorm:"type:text" json:"image_prompt"` VideoPrompt *string `gorm:"type:text" json:"video_prompt"` BgmPrompt *string `gorm:"type:text" json:"bgm_prompt"` SoundEffect *string `gorm:"size:255" json:"sound_effect"` Dialogue *string `gorm:"type:text" json:"dialogue"` Description *string `gorm:"type:text" json:"description"` Duration int `gorm:"default:5" json:"duration"` ComposedImage *string `gorm:"type:text" json:"composed_image"` VideoURL *string `gorm:"type:text" json:"video_url"` Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Episode Episode `gorm:"foreignKey:EpisodeID;constraint:OnDelete:CASCADE" json:"episode,omitempty"` Background *Scene `gorm:"foreignKey:SceneID" json:"background,omitempty"` Characters []Character `gorm:"many2many:storyboard_characters;" json:"characters,omitempty"` Props []Prop `gorm:"many2many:storyboard_props;" json:"props,omitempty"` } func (s *Storyboard) TableName() string { return "storyboards" } type Scene struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` DramaID uint `gorm:"not null;index:idx_scenes_drama_id" json:"drama_id"` EpisodeID *uint `gorm:"index:idx_scenes_episode_id" json:"episode_id"` // 场景所属章节 Location string `gorm:"type:varchar(200);not null" json:"location"` Time string `gorm:"type:varchar(100);not null" json:"time"` Prompt string `gorm:"type:text;not null" json:"prompt"` StoryboardCount int `gorm:"default:1" json:"storyboard_count"` ImageURL *string `gorm:"type:varchar(500)" json:"image_url"` LocalPath *string `gorm:"type:text" json:"local_path"` Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending, generated, failed CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 运行时字段(不存储到数据库) ImageGenerationStatus *string `gorm:"-" json:"image_generation_status,omitempty"` ImageGenerationError *string `gorm:"-" json:"image_generation_error,omitempty"` } func (s *Scene) TableName() string { return "scenes" } type Prop struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` DramaID uint `gorm:"not null;index" json:"drama_id"` Name string `gorm:"type:varchar(100);not null" json:"name"` Type *string `gorm:"type:varchar(50)" json:"type"` // e.g., "weapon", "daily", "vehicle" Description *string `gorm:"type:text" json:"description"` Prompt *string `gorm:"type:text" json:"prompt"` // AI Image prompt ImageURL *string `gorm:"type:varchar(500)" json:"image_url"` LocalPath *string `gorm:"type:text" json:"local_path,omitempty"` ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"not null;autoUpdateTime" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // Relationships Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` Storyboards []Storyboard `gorm:"many2many:storyboard_props;" json:"storyboards,omitempty"` } func (p *Prop) TableName() string { return "props" } ================================================ FILE: domain/models/frame_prompt.go ================================================ package models import "time" // FramePrompt 帧提示词存储表 type FramePrompt struct { ID uint `gorm:"primarykey" json:"id"` StoryboardID uint `gorm:"not null;index:idx_frame_prompts_storyboard" json:"storyboard_id"` FrameType string `gorm:"size:20;not null;index:idx_frame_prompts_type" json:"frame_type"` // first, key, last, panel, action Prompt string `gorm:"type:text;not null" json:"prompt"` Description *string `gorm:"type:text" json:"description,omitempty"` Layout *string `gorm:"size:50" json:"layout,omitempty"` // 仅用于panel/action类型,如 horizontal_3 CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } func (FramePrompt) TableName() string { return "frame_prompts" } // FrameType 帧类型常量 const ( FrameTypeFirst = "first" FrameTypeKey = "key" FrameTypeLast = "last" FrameTypePanel = "panel" FrameTypeAction = "action" ) ================================================ FILE: domain/models/image_generation.go ================================================ package models import ( "time" "gorm.io/datatypes" ) type ImageGeneration struct { ID uint `gorm:"primarykey" json:"id"` StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` DramaID uint `gorm:"not null;index" json:"drama_id"` SceneID *uint `gorm:"index" json:"scene_id,omitempty"` CharacterID *uint `gorm:"index" json:"character_id,omitempty"` PropID *uint `gorm:"index" json:"prop_id,omitempty"` ImageType string `gorm:"size:20;index;default:'storyboard'" json:"image_type"` FrameType *string `gorm:"size:20" json:"frame_type,omitempty"` Provider string `gorm:"size:50;not null" json:"provider"` Prompt string `gorm:"type:text;not null" json:"prompt"` NegPrompt *string `gorm:"column:negative_prompt;type:text" json:"negative_prompt,omitempty"` Model string `gorm:"size:100" json:"model"` Size string `gorm:"size:20" json:"size"` Quality string `gorm:"size:20" json:"quality"` Style *string `gorm:"size:50" json:"style,omitempty"` Steps *int `json:"steps,omitempty"` CfgScale *float64 `json:"cfg_scale,omitempty"` Seed *int64 `json:"seed,omitempty"` ImageURL *string `gorm:"type:text" json:"image_url,omitempty"` MinioURL *string `gorm:"type:text" json:"minio_url,omitempty"` LocalPath *string `gorm:"type:text" json:"local_path,omitempty"` Status ImageGenerationStatus `gorm:"size:20;not null;default:'pending'" json:"status"` TaskID *string `gorm:"size:200" json:"task_id,omitempty"` ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` ReferenceImages datatypes.JSON `gorm:"type:json" json:"reference_images,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` Scene *Scene `gorm:"foreignKey:SceneID" json:"scene,omitempty"` Character *Character `gorm:"foreignKey:CharacterID" json:"character,omitempty"` Prop *Prop `gorm:"foreignKey:PropID" json:"prop,omitempty"` } func (ImageGeneration) TableName() string { return "image_generations" } type ImageGenerationStatus string const ( ImageStatusPending ImageGenerationStatus = "pending" ImageStatusProcessing ImageGenerationStatus = "processing" ImageStatusCompleted ImageGenerationStatus = "completed" ImageStatusFailed ImageGenerationStatus = "failed" ) type ImageProvider string const ( ProviderOpenAI ImageProvider = "openai" ProviderMidjourney ImageProvider = "midjourney" ProviderStableDiffusion ImageProvider = "stable_diffusion" ProviderDALLE ImageProvider = "dalle" ) // ImageType 图片类型 type ImageType string const ( ImageTypeCharacter ImageType = "character" // 角色图片 ImageTypeScene ImageType = "scene" // 场景图片 ImageTypeProp ImageType = "prop" // 道具图片 ImageTypeStoryboard ImageType = "storyboard" // 分镜图片 ) ================================================ FILE: domain/models/task.go ================================================ package models import ( "time" "gorm.io/gorm" ) // AsyncTask 异步任务模型 type AsyncTask struct { ID string `gorm:"primaryKey;size:36" json:"id"` Type string `gorm:"size:50;not null;index" json:"type"` // 任务类型:storyboard_generation Status string `gorm:"size:20;not null;index" json:"status"` // pending, processing, completed, failed Progress int `gorm:"default:0" json:"progress"` // 0-100 Message string `gorm:"size:500" json:"message,omitempty"` // 当前状态消息 Error string `gorm:"type:text" json:"error,omitempty"` // 错误信息 Result string `gorm:"type:text" json:"result,omitempty"` // JSON格式的结果数据 ResourceID string `gorm:"size:36;index" json:"resource_id"` // 关联资源ID(如episode_id) CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } ================================================ FILE: domain/models/timeline.go ================================================ package models import ( "time" "gorm.io/gorm" ) type Timeline struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DramaID uint `gorm:"not null;index" json:"drama_id"` Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` EpisodeID *uint `gorm:"index" json:"episode_id,omitempty"` Episode *Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"` Name string `gorm:"type:varchar(200);not null" json:"name"` Description *string `gorm:"type:text" json:"description,omitempty"` Duration int `gorm:"default:0" json:"duration"` FPS int `gorm:"default:30" json:"fps"` Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"` Status TimelineStatus `gorm:"type:varchar(20);not null;default:'draft';index" json:"status"` Tracks []TimelineTrack `gorm:"foreignKey:TimelineID" json:"tracks,omitempty"` } type TimelineStatus string const ( TimelineStatusDraft TimelineStatus = "draft" TimelineStatusEditing TimelineStatus = "editing" TimelineStatusCompleted TimelineStatus = "completed" TimelineStatusExporting TimelineStatus = "exporting" ) func (Timeline) TableName() string { return "timelines" } type TimelineTrack struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` TimelineID uint `gorm:"not null;index" json:"timeline_id"` Timeline Timeline `gorm:"foreignKey:TimelineID" json:"-"` Name string `gorm:"type:varchar(100);not null" json:"name"` Type TrackType `gorm:"type:varchar(20);not null" json:"type"` Order int `gorm:"not null;default:0" json:"order"` IsLocked bool `gorm:"default:false" json:"is_locked"` IsMuted bool `gorm:"default:false" json:"is_muted"` Volume *int `gorm:"default:100" json:"volume,omitempty"` Clips []TimelineClip `gorm:"foreignKey:TrackID" json:"clips,omitempty"` } type TrackType string const ( TrackTypeVideo TrackType = "video" TrackTypeAudio TrackType = "audio" TrackTypeText TrackType = "text" ) func (TimelineTrack) TableName() string { return "timeline_tracks" } type TimelineClip struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` TrackID uint `gorm:"not null;index" json:"track_id"` Track TimelineTrack `gorm:"foreignKey:TrackID" json:"-"` AssetID *uint `gorm:"index" json:"asset_id,omitempty"` Asset Asset `gorm:"foreignKey:AssetID" json:"asset,omitempty"` StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` Name string `gorm:"type:varchar(200)" json:"name"` StartTime int `gorm:"not null" json:"start_time"` EndTime int `gorm:"not null" json:"end_time"` Duration int `gorm:"not null" json:"duration"` TrimStart *int `json:"trim_start,omitempty"` TrimEnd *int `json:"trim_end,omitempty"` Speed *float64 `gorm:"default:1.0" json:"speed,omitempty"` Volume *int `json:"volume,omitempty"` IsMuted bool `gorm:"default:false" json:"is_muted"` FadeIn *int `json:"fade_in,omitempty"` FadeOut *int `json:"fade_out,omitempty"` TransitionIn *uint `gorm:"index" json:"transition_in_id,omitempty"` TransitionOut *uint `gorm:"index" json:"transition_out_id,omitempty"` InTransition ClipTransition `gorm:"foreignKey:TransitionIn" json:"in_transition,omitempty"` OutTransition ClipTransition `gorm:"foreignKey:TransitionOut" json:"out_transition,omitempty"` Effects []ClipEffect `gorm:"foreignKey:ClipID" json:"effects,omitempty"` } func (TimelineClip) TableName() string { return "timeline_clips" } type ClipTransition struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Type TransitionType `gorm:"type:varchar(50);not null" json:"type"` Duration int `gorm:"not null;default:500" json:"duration"` Easing *string `gorm:"type:varchar(50)" json:"easing,omitempty"` Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"` } type TransitionType string const ( TransitionTypeFade TransitionType = "fade" TransitionTypeCrossFade TransitionType = "crossfade" TransitionTypeSlide TransitionType = "slide" TransitionTypeWipe TransitionType = "wipe" TransitionTypeZoom TransitionType = "zoom" TransitionTypeDissolve TransitionType = "dissolve" ) func (ClipTransition) TableName() string { return "clip_transitions" } type ClipEffect struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` ClipID uint `gorm:"not null;index" json:"clip_id"` Clip TimelineClip `gorm:"foreignKey:ClipID" json:"-"` Type EffectType `gorm:"type:varchar(50);not null" json:"type"` Name string `gorm:"type:varchar(100)" json:"name"` IsEnabled bool `gorm:"default:true" json:"is_enabled"` Order int `gorm:"default:0" json:"order"` Config map[string]interface{} `gorm:"serializer:json" json:"config,omitempty"` } type EffectType string const ( EffectTypeFilter EffectType = "filter" EffectTypeColor EffectType = "color" EffectTypeBlur EffectType = "blur" EffectTypeBrightness EffectType = "brightness" EffectTypeContrast EffectType = "contrast" EffectTypeSaturation EffectType = "saturation" ) func (ClipEffect) TableName() string { return "clip_effects" } ================================================ FILE: domain/models/video_generation.go ================================================ package models import ( "time" "gorm.io/gorm" ) type VideoGeneration struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` StoryboardID *uint `gorm:"index" json:"storyboard_id,omitempty"` Storyboard *Storyboard `gorm:"foreignKey:StoryboardID" json:"storyboard,omitempty"` DramaID uint `gorm:"not null;index" json:"drama_id"` Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` Provider string `gorm:"type:varchar(50);not null;index" json:"provider"` Prompt string `gorm:"type:text;not null" json:"prompt"` Model string `gorm:"type:varchar(100)" json:"model,omitempty"` ImageGenID *uint `gorm:"index" json:"image_gen_id,omitempty"` ImageGen ImageGeneration `gorm:"foreignKey:ImageGenID" json:"image_gen,omitempty"` // 参考图模式:single(单图), first_last(首尾帧), multiple(多图), none(无) ReferenceMode *string `gorm:"type:varchar(20)" json:"reference_mode,omitempty"` ImageURL *string `gorm:"type:varchar(1000)" json:"image_url,omitempty"` FirstFrameURL *string `gorm:"type:varchar(1000)" json:"first_frame_url,omitempty"` LastFrameURL *string `gorm:"type:varchar(1000)" json:"last_frame_url,omitempty"` ReferenceImageURLs *string `gorm:"type:text" json:"reference_image_urls,omitempty"` // JSON数组存储多张参考图 Duration *int `json:"duration,omitempty"` FPS *int `json:"fps,omitempty"` Resolution *string `gorm:"type:varchar(50)" json:"resolution,omitempty"` AspectRatio *string `gorm:"type:varchar(20)" json:"aspect_ratio,omitempty"` Style *string `gorm:"type:varchar(100)" json:"style,omitempty"` MotionLevel *int `json:"motion_level,omitempty"` CameraMotion *string `gorm:"type:varchar(100)" json:"camera_motion,omitempty"` Seed *int64 `json:"seed,omitempty"` VideoURL *string `gorm:"type:varchar(1000)" json:"video_url,omitempty"` MinioURL *string `gorm:"type:varchar(1000)" json:"minio_url,omitempty"` LocalPath *string `gorm:"type:varchar(500)" json:"local_path,omitempty"` Status VideoStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"` TaskID *string `gorm:"type:varchar(200);index" json:"task_id,omitempty"` ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` Width *int `json:"width,omitempty"` Height *int `json:"height,omitempty"` } type VideoStatus string const ( VideoStatusPending VideoStatus = "pending" VideoStatusProcessing VideoStatus = "processing" VideoStatusCompleted VideoStatus = "completed" VideoStatusFailed VideoStatus = "failed" ) type VideoProvider string const ( VideoProviderRunway VideoProvider = "runway" VideoProviderPika VideoProvider = "pika" VideoProviderDoubao VideoProvider = "doubao" VideoProviderOpenAI VideoProvider = "openai" ) func (VideoGeneration) TableName() string { return "video_generations" } ================================================ FILE: domain/models/video_merge.go ================================================ package models import ( "time" "gorm.io/datatypes" "gorm.io/gorm" ) type VideoMergeStatus string const ( VideoMergeStatusPending VideoMergeStatus = "pending" VideoMergeStatusProcessing VideoMergeStatus = "processing" VideoMergeStatusCompleted VideoMergeStatus = "completed" VideoMergeStatusFailed VideoMergeStatus = "failed" ) type VideoMerge struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` EpisodeID uint `gorm:"not null;index" json:"episode_id"` DramaID uint `gorm:"not null;index" json:"drama_id"` Title string `gorm:"type:varchar(200)" json:"title"` Provider string `gorm:"type:varchar(50);not null" json:"provider"` Model *string `gorm:"type:varchar(100)" json:"model,omitempty"` Status VideoMergeStatus `gorm:"type:varchar(20);not null;default:'pending'" json:"status"` Scenes datatypes.JSON `gorm:"type:json;not null" json:"scenes"` MergedURL *string `gorm:"type:varchar(500)" json:"merged_url,omitempty"` Duration *int `gorm:"type:int" json:"duration,omitempty"` TaskID *string `gorm:"type:varchar(100)" json:"task_id,omitempty"` ErrorMsg *string `gorm:"type:text" json:"error_msg,omitempty"` CreatedAt time.Time `gorm:"not null;autoCreateTime" json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Episode Episode `gorm:"foreignKey:EpisodeID" json:"episode,omitempty"` Drama Drama `gorm:"foreignKey:DramaID" json:"drama,omitempty"` } type SceneClip struct { SceneID uint `json:"scene_id"` VideoURL string `json:"video_url"` StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` Duration float64 `json:"duration"` Order int `json:"order"` Transition map[string]interface{} `json:"transition"` } func (v *VideoMerge) TableName() string { return "video_merges" } ================================================ FILE: go.mod ================================================ module github.com/drama-generator/backend go 1.23.0 replace github.com/drama-generator/backend => ./ require ( github.com/gin-gonic/gin v1.9.1 github.com/google/uuid v1.6.0 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/viper v1.17.0 go.uber.org/zap v1.26.0 gorm.io/datatypes v1.2.0 gorm.io/driver/mysql v1.5.2 gorm.io/driver/postgres v1.5.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 modernc.org/sqlite v1.34.4 ) require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.9.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect go.uber.org/goleak v1.2.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.34.0 // indirect golang.org/x/text v0.26.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= ================================================ FILE: infrastructure/database/custom_logger.go ================================================ package database import ( "context" "strings" "time" "gorm.io/gorm/logger" ) // CustomLogger 自定义 GORM logger,截断过长的 SQL 参数(如 base64 数据) type CustomLogger struct { logger.Interface } // NewCustomLogger 创建自定义 logger func NewCustomLogger() logger.Interface { return &CustomLogger{ Interface: logger.Default.LogMode(logger.Silent), } } // Trace 重写 Trace 方法,禁用 SQL 日志输出 func (l *CustomLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { // 不输出任何 SQL 日志 // 如果需要调试,可以临时取消注释下面的代码 /* sql, rows := fc() sql = truncateLongValues(sql) elapsed := time.Since(begin) if err != nil { l.Interface.Error(ctx, "SQL error: %v [%v] %s", err, elapsed, sql) } else { l.Interface.Info(ctx, "[%.3fms] [rows:%d] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql) } */ } // truncateLongValues 截断 SQL 中的长字符串值 func truncateLongValues(sql string) string { // 查找 base64 格式的数据 (data:image/...;base64,...) if strings.Contains(sql, "data:image/") && strings.Contains(sql, ";base64,") { parts := strings.Split(sql, "\"") for i, part := range parts { if strings.HasPrefix(part, "data:image/") && strings.Contains(part, ";base64,") { if len(part) > 100 { // 保留前50字符,添加截断标记 parts[i] = part[:50] + "...[base64 data truncated]" } } } sql = strings.Join(parts, "\"") } // 截断其他过长的值 if len(sql) > 5000 { // 查找 VALUES 或 SET 后的内容 if idx := strings.Index(sql, " VALUES "); idx > 0 && len(sql) > idx+5000 { sql = sql[:idx+5000] + "...[truncated]" } else if idx := strings.Index(sql, " SET "); idx > 0 && len(sql) > idx+3000 { sql = sql[:idx+3000] + "...[truncated]" } else if len(sql) > 5000 { sql = sql[:5000] + "...[truncated]" } } return sql } // Info 实现 Info 方法 func (l *CustomLogger) Info(ctx context.Context, msg string, data ...interface{}) { l.Interface.Info(ctx, msg, data...) } // Warn 实现 Warn 方法 func (l *CustomLogger) Warn(ctx context.Context, msg string, data ...interface{}) { l.Interface.Warn(ctx, msg, data...) } // Error 实现 Error 方法 func (l *CustomLogger) Error(ctx context.Context, msg string, data ...interface{}) { // 检查并截断 data 中的长字符串 truncatedData := make([]interface{}, len(data)) for i, d := range data { if str, ok := d.(string); ok && len(str) > 200 { if strings.HasPrefix(str, "data:image/") { truncatedData[i] = str[:50] + "...[base64 data]" } else { truncatedData[i] = str[:200] + "..." } } else { truncatedData[i] = d } } l.Interface.Error(ctx, msg, truncatedData...) } // LogMode 实现 LogMode 方法 func (l *CustomLogger) LogMode(level logger.LogLevel) logger.Interface { newLogger := *l newLogger.Interface = l.Interface.LogMode(level) return &newLogger } ================================================ FILE: infrastructure/database/database.go ================================================ package database import ( "fmt" "os" "path/filepath" "time" "github.com/drama-generator/backend/domain/models" "github.com/drama-generator/backend/pkg/config" "gorm.io/driver/mysql" "gorm.io/driver/sqlite" "gorm.io/gorm" _ "modernc.org/sqlite" ) func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) { dsn := cfg.DSN() if cfg.Type == "sqlite" { dbDir := filepath.Dir(dsn) if err := os.MkdirAll(dbDir, 0755); err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) } } gormConfig := &gorm.Config{ Logger: NewCustomLogger(), } var db *gorm.DB var err error if cfg.Type == "sqlite" { // 使用 modernc.org/sqlite 纯 Go 驱动(无需 CGO) // 添加并发优化参数:WAL 模式、busy_timeout、cache dsnWithParams := dsn + "?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&cache=shared" db, err = gorm.Open(sqlite.Dialector{ DriverName: "sqlite", DSN: dsnWithParams, }, gormConfig) } else { db, err = gorm.Open(mysql.Open(dsn), gormConfig) } if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to get database instance: %w", err) } // SQLite 连接池配置(限制并发连接数) if cfg.Type == "sqlite" { sqlDB.SetMaxIdleConns(1) sqlDB.SetMaxOpenConns(1) // SQLite 单写入,限制为 1 } else { sqlDB.SetMaxIdleConns(cfg.MaxIdle) sqlDB.SetMaxOpenConns(cfg.MaxOpen) } sqlDB.SetConnMaxLifetime(time.Hour) if err := sqlDB.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } return db, nil } func AutoMigrate(db *gorm.DB) error { return db.AutoMigrate( // 核心模型 &models.Drama{}, &models.Episode{}, &models.Character{}, &models.Scene{}, &models.Storyboard{}, &models.FramePrompt{}, &models.Prop{}, // 生成相关 &models.ImageGeneration{}, &models.VideoGeneration{}, &models.VideoMerge{}, // AI配置 &models.AIServiceConfig{}, &models.AIServiceProvider{}, // 资源管理 &models.Asset{}, &models.CharacterLibrary{}, // 任务管理 &models.AsyncTask{}, ) } ================================================ FILE: infrastructure/external/ffmpeg/ffmpeg.go ================================================ package ffmpeg import ( "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "github.com/drama-generator/backend/pkg/logger" ) type FFmpeg struct { log *logger.Logger tempDir string } func NewFFmpeg(log *logger.Logger) *FFmpeg { tempDir := filepath.Join(os.TempDir(), "drama-video-merge") os.MkdirAll(tempDir, 0755) return &FFmpeg{ log: log, tempDir: tempDir, } } type VideoClip struct { URL string Duration float64 StartTime float64 EndTime float64 Transition map[string]interface{} } type MergeOptions struct { OutputPath string Clips []VideoClip } func (f *FFmpeg) MergeVideos(opts *MergeOptions) (string, error) { if len(opts.Clips) == 0 { return "", fmt.Errorf("no video clips to merge") } f.log.Infow("Starting video merge with trimming", "clips_count", len(opts.Clips)) // 下载并裁剪所有视频片段 trimmedPaths := make([]string, 0, len(opts.Clips)) downloadedPaths := make([]string, 0, len(opts.Clips)) for i, clip := range opts.Clips { // 下载原始视频 downloadPath := filepath.Join(f.tempDir, fmt.Sprintf("download_%d_%d.mp4", time.Now().Unix(), i)) localPath, err := f.downloadVideo(clip.URL, downloadPath) if err != nil { f.cleanup(downloadedPaths) f.cleanup(trimmedPaths) return "", fmt.Errorf("failed to download clip %d: %w", i, err) } downloadedPaths = append(downloadedPaths, localPath) // 裁剪视频片段(根据StartTime和EndTime) trimmedPath := filepath.Join(f.tempDir, fmt.Sprintf("trimmed_%d_%d.mp4", time.Now().Unix(), i)) err = f.trimVideo(localPath, trimmedPath, clip.StartTime, clip.EndTime) if err != nil { f.cleanup(downloadedPaths) f.cleanup(trimmedPaths) return "", fmt.Errorf("failed to trim clip %d: %w", i, err) } trimmedPaths = append(trimmedPaths, trimmedPath) f.log.Infow("Clip trimmed", "index", i, "start", clip.StartTime, "end", clip.EndTime, "duration", clip.EndTime-clip.StartTime) } // 清理下载的原始文件 f.cleanup(downloadedPaths) // 确保输出目录存在 outputDir := filepath.Dir(opts.OutputPath) if err := os.MkdirAll(outputDir, 0755); err != nil { f.cleanup(trimmedPaths) return "", fmt.Errorf("failed to create output directory: %w", err) } // 合并裁剪后的视频片段(支持转场效果) err := f.concatenateVideosWithTransitions(trimmedPaths, opts.Clips, opts.OutputPath) // 清理裁剪后的临时文件 f.cleanup(trimmedPaths) if err != nil { return "", fmt.Errorf("failed to concatenate videos: %w", err) } f.log.Infow("Video merge completed", "output", opts.OutputPath) return opts.OutputPath, nil } func (f *FFmpeg) downloadVideo(url, destPath string) (string, error) { // 检查是否是本地文件路径 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { // 这是本地文件路径,检查文件是否存在 if _, err := os.Stat(url); err == nil { f.log.Infow("Copying local video file to temp", "source", url, "dest", destPath) // 复制本地文件到临时目录,避免删除原始文件 sourceFile, err := os.Open(url) if err != nil { return "", fmt.Errorf("failed to open source file: %w", err) } defer sourceFile.Close() destFile, err := os.Create(destPath) if err != nil { return "", fmt.Errorf("failed to create dest file: %w", err) } defer destFile.Close() _, err = io.Copy(destFile, sourceFile) if err != nil { return "", fmt.Errorf("failed to copy file: %w", err) } return destPath, nil } else { return "", fmt.Errorf("local file not found: %s", url) } } // 远程 URL,需要下载 f.log.Infow("Downloading video", "url", url, "dest", destPath) resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("bad status: %s", resp.Status) } out, err := os.Create(destPath) if err != nil { return "", fmt.Errorf("failed to create file: %w", err) } defer out.Close() _, err = io.Copy(out, resp.Body) if err != nil { return "", fmt.Errorf("failed to save file: %w", err) } return destPath, nil } func (f *FFmpeg) trimVideo(inputPath, outputPath string, startTime, endTime float64) error { f.log.Infow("Trimming video", "input", inputPath, "output", outputPath, "start", startTime, "end", endTime) // 如果startTime和endTime都为0,或者endTime <= startTime,复制整个视频 // 使用重新编码而非-c copy以确保输出文件完整性 if (startTime == 0 && endTime == 0) || endTime <= startTime { f.log.Infow("No valid trim range, re-encoding entire video") cmd := exec.Command("ffmpeg", "-i", inputPath, "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", outputPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg re-encode failed", "error", err, "output", string(output)) return fmt.Errorf("ffmpeg re-encode failed: %w, output: %s", err, string(output)) } f.log.Infow("Video re-encoded successfully", "output", outputPath) return nil } // 使用FFmpeg裁剪视频 // -ss: 开始时间(秒) // -to/-t: 结束时间或持续时间 // 使用重新编码而非-c copy以确保输出文件完整性,避免Windows环境下流信息丢失 var cmd *exec.Cmd if endTime > 0 { // 有明确的结束时间 cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.2f", startTime), "-to", fmt.Sprintf("%.2f", endTime), "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", outputPath, ) } else { // 只有开始时间,裁剪到视频末尾 cmd = exec.Command("ffmpeg", "-i", inputPath, "-ss", fmt.Sprintf("%.2f", startTime), "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-y", outputPath, ) } output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg trim failed", "error", err, "output", string(output)) return fmt.Errorf("ffmpeg trim failed: %w, output: %s", err, string(output)) } f.log.Infow("Video trimmed successfully", "output", outputPath) return nil } func (f *FFmpeg) concatenateVideosWithTransitions(inputPaths []string, clips []VideoClip, outputPath string) error { if len(inputPaths) == 0 { return fmt.Errorf("no input paths") } // 如果只有一个视频,直接复制 if len(inputPaths) == 1 { f.log.Infow("Only one clip, copying directly") return f.copyFile(inputPaths[0], outputPath) } // 检查是否有转场效果 hasTransitions := false for _, clip := range clips { if clip.Transition != nil && len(clip.Transition) > 0 { hasTransitions = true break } } // 如果没有转场效果,使用简单拼接 if !hasTransitions { f.log.Infow("No transitions, using simple concatenation") return f.concatenateVideos(inputPaths, outputPath) } // 使用xfade滤镜添加转场效果 f.log.Infow("Merging with transitions", "clips_count", len(inputPaths)) return f.mergeWithXfade(inputPaths, clips, outputPath) } func (f *FFmpeg) concatenateVideos(inputPaths []string, outputPath string) error { // 创建文件列表 listFile := filepath.Join(f.tempDir, fmt.Sprintf("filelist_%d.txt", time.Now().Unix())) defer os.Remove(listFile) var content strings.Builder for _, path := range inputPaths { content.WriteString(fmt.Sprintf("file '%s'\n", path)) } if err := os.WriteFile(listFile, []byte(content.String()), 0644); err != nil { return fmt.Errorf("failed to create file list: %w", err) } // 使用FFmpeg合并视频 // -f concat: 使用concat demuxer // -safe 0: 允许不安全的文件路径 // -i: 输入文件列表 // -c copy: 直接复制流,不重新编码(速度快) cmd := exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", "-y", // 覆盖输出文件 outputPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg failed", "error", err, "output", string(output)) return fmt.Errorf("ffmpeg execution failed: %w, output: %s", err, string(output)) } f.log.Infow("FFmpeg concatenation completed", "output", outputPath) return nil } func (f *FFmpeg) mergeWithXfade(inputPaths []string, clips []VideoClip, outputPath string) error { // 使用xfade滤镜进行转场 // 构建输入参数 args := []string{} for _, path := range inputPaths { args = append(args, "-i", path) } // 检测每个视频是否有音频流 audioStreams := make([]bool, len(inputPaths)) hasAnyAudio := false for i, path := range inputPaths { audioStreams[i] = f.hasAudioStream(path) if audioStreams[i] { hasAnyAudio = true } f.log.Infow("Audio stream detection", "index", i, "path", path, "has_audio", audioStreams[i]) } f.log.Infow("Overall audio detection", "has_any_audio", hasAnyAudio, "audio_streams", audioStreams) // 检测视频分辨率,找到最大分辨率作为目标分辨率 maxWidth := 0 maxHeight := 0 for i, path := range inputPaths { width, height := f.getVideoResolution(path) if width > maxWidth { maxWidth = width } if height > maxHeight { maxHeight = height } f.log.Infow("Video resolution detection", "index", i, "width", width, "height", height) } f.log.Infow("Target resolution", "width", maxWidth, "height", maxHeight) // 为每个视频流添加缩放滤镜,统一分辨率 // 同时为有转场的视频添加 tpad 延长(freeze 最后一帧) var scaleFilters []string for i := 0; i < len(inputPaths); i++ { // 检查当前视频是否需要转场到下一个视频 var tpadDuration float64 = 0 if i < len(clips)-1 && clips[i].Transition != nil { // 检查转场类型 if tType, ok := clips[i].Transition["type"].(string); ok { // none 转场不需要 tpad if strings.ToLower(tType) != "none" && tType != "" { if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 { tpadDuration = tDuration } else { tpadDuration = 1.0 // 默认1秒 } } } else { // 没有指定类型,默认需要转场 if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 { tpadDuration = tDuration } else { tpadDuration = 1.0 } } } // 使用scale滤镜缩放到目标分辨率,pad添加黑边保持长宽比 // 如果需要转场,使用 tpad 延长视频(freeze最后一帧) if tpadDuration > 0 { scaleFilters = append(scaleFilters, fmt.Sprintf("[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,tpad=stop_mode=clone:stop_duration=%.2f[v%d]", i, maxWidth, maxHeight, maxWidth, maxHeight, tpadDuration, i)) f.log.Infow("Adding tpad to video", "index", i, "duration", tpadDuration) } else { scaleFilters = append(scaleFilters, fmt.Sprintf("[%d:v]scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2[v%d]", i, maxWidth, maxHeight, maxWidth, maxHeight, i)) } } // 构建filter_complex // 检查是否有任何转场效果 hasAnyTransition := false for i := 0; i < len(inputPaths)-1; i++ { if clips[i].Transition != nil { if tType, ok := clips[i].Transition["type"].(string); ok { if strings.ToLower(tType) != "none" && tType != "" { hasAnyTransition = true break } } } } // 如果没有任何转场,使用简单拼接 if !hasAnyTransition { f.log.Infow("No transitions detected, using simple concatenation") return f.concatenateVideos(inputPaths, outputPath) } // 构建转场滤镜,使用缩放后的视频流 // 对所有相邻视频都应用 xfade,type=none 时使用 0 秒时长实现无缝拼接 var transitionFilters []string var offset float64 = 0 for i := 0; i < len(inputPaths)-1; i++ { // 获取当前片段的时长 clipDuration := clips[i].Duration if clips[i].EndTime > 0 && clips[i].StartTime >= 0 { clipDuration = clips[i].EndTime - clips[i].StartTime } // 默认转场参数 transitionType := "fade" transitionDuration := 1.0 if clips[i].Transition != nil { if tType, ok := clips[i].Transition["type"].(string); ok { if strings.ToLower(tType) == "none" || tType == "" { // none 转场使用 0 秒时长,实现无缝拼接 transitionDuration = 0.0 f.log.Infow("Using no transition (0s xfade)", "clip_index", i) } else { transitionType = f.mapTransitionType(tType) f.log.Infow("Using transition type", "type", tType, "mapped", transitionType) } } // 只有非 none 转场才读取时长 if transitionDuration > 0 { if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 { transitionDuration = tDuration } } } // 计算转场开始的时间点 offset += clipDuration if offset < 0 { offset = 0 } f.log.Infow("Transition settings", "clip_index", i, "type", transitionType, "duration", transitionDuration, "offset", offset, "clip_duration", clipDuration) var inputLabel, outputLabel string if i == 0 { inputLabel = fmt.Sprintf("[v0][v1]") } else { inputLabel = fmt.Sprintf("[vx%02d][v%d]", i-1, i+1) } if i == len(inputPaths)-2 { outputLabel = "[outv]" } else { outputLabel = fmt.Sprintf("[vx%02d]", i) } filterPart := fmt.Sprintf("%sxfade=transition=%s:duration=%.1f:offset=%.1f%s", inputLabel, transitionType, transitionDuration, offset, outputLabel) transitionFilters = append(transitionFilters, filterPart) } // 合并缩放和转场滤镜 var videoFilters []string videoFilters = append(videoFilters, scaleFilters...) videoFilters = append(videoFilters, transitionFilters...) filterComplex := strings.Join(videoFilters, ";") // 音频处理:如果有任何视频包含音频流,则处理音频 var fullFilter string if hasAnyAudio { // 为音频流添加处理:生成静音流或延长音频 var audioFilters []string for i := 0; i < len(inputPaths); i++ { // 计算该视频的时长 clipDuration := clips[i].Duration if clips[i].EndTime > 0 && clips[i].StartTime >= 0 { clipDuration = clips[i].EndTime - clips[i].StartTime } // 检查是否需要为转场延长音频 var padDuration float64 = 0 if i < len(clips)-1 && clips[i].Transition != nil { // 检查转场类型 needTransition := true if tType, ok := clips[i].Transition["type"].(string); ok { if strings.ToLower(tType) == "none" || tType == "" { needTransition = false } } // 只有需要转场时才延长音频 if needTransition { if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 { padDuration = tDuration } else { padDuration = 1.0 } } } if !audioStreams[i] { // 没有音频的视频:生成静音轨道(包括转场延长) totalDuration := clipDuration + padDuration audioFilters = append(audioFilters, fmt.Sprintf("anullsrc=channel_layout=stereo:sample_rate=44100:duration=%.2f[a%d]", totalDuration, i)) f.log.Infow("Generated silence for audio", "index", i, "duration", totalDuration) } else if padDuration > 0 { // 有音频且需要延长:使用apad添加静音延长(稍后会用acrossfade处理) audioFilters = append(audioFilters, fmt.Sprintf("[%d:a]apad=pad_dur=%.2f[a%d]", i, padDuration, i)) f.log.Infow("Padding audio with silence", "index", i, "pad_duration", padDuration) } else { // 有音频但不需要延长:直接标记 audioFilters = append(audioFilters, fmt.Sprintf("[%d:a]acopy[a%d]", i, i)) } } // 音频交叉淡入淡出(避免转场时静音) // 对所有相邻音频都应用 acrossfade,type=none 时使用 0 秒时长 var audioCrossfades []string for i := 0; i < len(inputPaths)-1; i++ { // 默认转场时长 transitionDuration := 1.0 if clips[i].Transition != nil { if tType, ok := clips[i].Transition["type"].(string); ok { if strings.ToLower(tType) == "none" || tType == "" { // none 转场使用 0 秒 transitionDuration = 0.0 } } // 只有非 none 转场才读取自定义时长 if transitionDuration > 0 { if tDuration, ok := clips[i].Transition["duration"].(float64); ok && tDuration > 0 { transitionDuration = tDuration } } } var inputLabel, outputLabel string if i == 0 { inputLabel = "[a0][a1]" } else { inputLabel = fmt.Sprintf("[ax%02d][a%d]", i-1, i+1) } if i == len(inputPaths)-2 { outputLabel = "[outa]" } else { outputLabel = fmt.Sprintf("[ax%02d]", i) } // acrossfade: d=转场时长,c1=第一个音频淡出曲线,c2=第二个音频淡入曲线 // 0 秒时长实现无缝音频拼接 audioCrossfades = append(audioCrossfades, fmt.Sprintf("%sacrossfade=d=%.2f:c1=tri:c2=tri%s", inputLabel, transitionDuration, outputLabel)) f.log.Infow("Audio crossfade", "clip_index", i, "duration", transitionDuration) } // 构建完整滤镜:音频处理 + 音频交叉淡入淡出 var allAudioFilters []string allAudioFilters = append(allAudioFilters, audioFilters...) allAudioFilters = append(allAudioFilters, audioCrossfades...) fullFilter = filterComplex + ";" + strings.Join(allAudioFilters, ";") } else { // 所有视频都无音频流,只处理视频 fullFilter = filterComplex } // 构建完整命令 args = append(args, "-filter_complex", fullFilter, "-map", "[outv]", ) // 仅在有任何音频时映射音频输出 if hasAnyAudio { args = append(args, "-map", "[outa]") } args = append(args, "-c:v", "libx264", "-preset", "medium", "-crf", "23", ) // 仅在有任何音频时设置音频编码参数 if hasAnyAudio { args = append(args, "-c:a", "aac", "-b:a", "128k", ) } args = append(args, "-y", outputPath, ) f.log.Infow("Running FFmpeg with transitions", "filter", fullFilter, "has_any_audio", hasAnyAudio) cmd := exec.Command("ffmpeg", args...) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg xfade failed", "error", err, "output", string(output)) return fmt.Errorf("ffmpeg xfade failed: %w, output: %s", err, string(output)) } f.log.Infow("Video merged with transitions successfully") return nil } func (f *FFmpeg) mapTransitionType(transType string) string { // 将前端传入的转场类型映射为FFmpeg xfade支持的类型 // FFmpeg xfade支持的完整转场列表: https://ffmpeg.org/ffmpeg-filters.html#xfade switch strings.ToLower(transType) { // 淡入淡出类 case "fade", "fadein", "fadeout": return "fade" case "fadeblack": return "fadeblack" case "fadewhite": return "fadewhite" case "fadegrays": return "fadegrays" // 滑动类 case "slideleft": return "slideleft" case "slideright": return "slideright" case "slideup": return "slideup" case "slidedown": return "slidedown" // 擦除类 case "wipeleft": return "wipeleft" case "wiperight": return "wiperight" case "wipeup": return "wipeup" case "wipedown": return "wipedown" // 圆形类 case "circleopen": return "circleopen" case "circleclose": return "circleclose" // 矩形打开/关闭类 case "horzopen": return "horzopen" case "horzclose": return "horzclose" case "vertopen": return "vertopen" case "vertclose": return "vertclose" // 其他特效 case "dissolve": return "dissolve" case "distance": return "distance" case "pixelize": return "pixelize" default: return "fade" // 默认淡入淡出 } } func (f *FFmpeg) hasAudioStream(videoPath string) bool { cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", videoPath, ) output, err := cmd.CombinedOutput() if err != nil { return false } result := strings.TrimSpace(string(output)) return result == "audio" } func (f *FFmpeg) getVideoResolution(videoPath string) (int, int) { cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", videoPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Warnw("Failed to get video resolution", "path", videoPath, "error", err) return 1920, 1080 // 默认分辨率 } result := strings.TrimSpace(string(output)) parts := strings.Split(result, ",") if len(parts) != 2 { f.log.Warnw("Invalid resolution format", "output", result) return 1920, 1080 } var width, height int fmt.Sscanf(parts[0], "%d", &width) fmt.Sscanf(parts[1], "%d", &height) if width <= 0 || height <= 0 { return 1920, 1080 } return width, height } // GetVideoDuration 获取视频时长(秒) func (f *FFmpeg) GetVideoDuration(videoPath string) (float64, error) { cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("Failed to get video duration", "path", videoPath, "error", err) return 0, fmt.Errorf("ffprobe failed: %w", err) } result := strings.TrimSpace(string(output)) var duration float64 _, err = fmt.Sscanf(result, "%f", &duration) if err != nil { f.log.Errorw("Failed to parse duration", "output", result, "error", err) return 0, fmt.Errorf("parse duration failed: %w", err) } if duration <= 0 { return 0, fmt.Errorf("invalid duration: %f", duration) } return duration, nil } func (f *FFmpeg) copyFile(src, dst string) error { cmd := exec.Command("cp", src, dst) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("File copy failed", "error", err, "output", string(output)) return fmt.Errorf("copy failed: %w", err) } return nil } func (f *FFmpeg) cleanup(paths []string) { for _, path := range paths { if err := os.Remove(path); err != nil { f.log.Warnw("Failed to cleanup file", "path", path, "error", err) } } } func (f *FFmpeg) CleanupTempDir() error { return os.RemoveAll(f.tempDir) } // ExtractAudio 从视频文件中提取音频轨道 // 返回提取的音频文件路径 func (f *FFmpeg) ExtractAudio(videoURL, outputPath string) (string, error) { f.log.Infow("Extracting audio from video", "url", videoURL, "output", outputPath) // 下载视频文件 downloadPath := filepath.Join(f.tempDir, fmt.Sprintf("video_%d.mp4", time.Now().Unix())) localVideoPath, err := f.downloadVideo(videoURL, downloadPath) if err != nil { return "", fmt.Errorf("failed to download video: %w", err) } defer os.Remove(localVideoPath) // 检查视频是否有音频流 if !f.hasAudioStream(localVideoPath) { f.log.Warnw("Video has no audio stream, generating silence", "video", videoURL) // 获取视频时长 duration, err := f.GetVideoDuration(localVideoPath) if err != nil { return "", fmt.Errorf("failed to get video duration: %w", err) } // 生成静音音频文件 return f.generateSilence(outputPath, duration) } // 确保输出目录存在 outputDir := filepath.Dir(outputPath) if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("failed to create output directory: %w", err) } // 使用FFmpeg提取音频 // -vn: 禁用视频 // -acodec: 音频编码器 // -ar: 音频采样率 // -ac: 音频声道数 // -ab: 音频比特率 cmd := exec.Command("ffmpeg", "-i", localVideoPath, "-vn", "-acodec", "aac", "-ar", "44100", "-ac", "2", "-ab", "128k", "-y", outputPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg audio extraction failed", "error", err, "output", string(output)) return "", fmt.Errorf("ffmpeg audio extraction failed: %w, output: %s", err, string(output)) } f.log.Infow("Audio extracted successfully", "output", outputPath) return outputPath, nil } // generateSilence 生成指定时长的静音音频文件 func (f *FFmpeg) generateSilence(outputPath string, duration float64) (string, error) { f.log.Infow("Generating silence audio", "duration", duration, "output", outputPath) // 确保输出目录存在 outputDir := filepath.Dir(outputPath) if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("failed to create output directory: %w", err) } // 使用FFmpeg生成静音 // -f lavfi: 使用lavfi(libavfilter)输入 // -i anullsrc: 生成静音音频源 cmd := exec.Command("ffmpeg", "-f", "lavfi", "-i", fmt.Sprintf("anullsrc=channel_layout=stereo:sample_rate=44100"), "-t", fmt.Sprintf("%.2f", duration), "-acodec", "aac", "-ab", "128k", "-y", outputPath, ) output, err := cmd.CombinedOutput() if err != nil { f.log.Errorw("FFmpeg silence generation failed", "error", err, "output", string(output)) return "", fmt.Errorf("ffmpeg silence generation failed: %w, output: %s", err, string(output)) } f.log.Infow("Silence audio generated successfully", "output", outputPath) return outputPath, nil } ================================================ FILE: infrastructure/scheduler/resource_transfer_scheduler.go ================================================ package scheduler import ( "time" "github.com/drama-generator/backend/application/services" "github.com/drama-generator/backend/pkg/logger" "github.com/robfig/cron/v3" "gorm.io/gorm" ) type ResourceTransferScheduler struct { cron *cron.Cron transferService *services.ResourceTransferService db *gorm.DB log *logger.Logger running bool } func NewResourceTransferScheduler( transferService *services.ResourceTransferService, db *gorm.DB, log *logger.Logger, ) *ResourceTransferScheduler { return &ResourceTransferScheduler{ cron: cron.New(cron.WithSeconds()), transferService: transferService, db: db, log: log, running: false, } } // Start 启动定时任务 func (s *ResourceTransferScheduler) Start() error { if s.running { s.log.Warn("Resource transfer scheduler already running") return nil } s.log.Info("Starting resource transfer scheduler...") // 每小时执行一次资源转存任务 _, err := s.cron.AddFunc("0 0 * * * *", func() { s.log.Info("Starting scheduled resource transfer task") s.transferPendingResources() }) if err != nil { return err } // 每天凌晨2点执行完整扫描 _, err = s.cron.AddFunc("0 0 2 * * *", func() { s.log.Info("Starting daily full resource scan and transfer") s.transferAllPendingResources() }) if err != nil { return err } s.cron.Start() s.running = true s.log.Info("Resource transfer scheduler started successfully") return nil } // Stop 停止定时任务 func (s *ResourceTransferScheduler) Stop() { if !s.running { return } s.log.Info("Stopping resource transfer scheduler...") ctx := s.cron.Stop() <-ctx.Done() s.running = false s.log.Info("Resource transfer scheduler stopped") } // transferPendingResources 转存最近生成的待转存资源(最近24小时) func (s *ResourceTransferScheduler) transferPendingResources() { s.log.Info("Scanning for pending resources to transfer (last 24 hours)...") // 查找最近24小时内完成的、还未转存的图片和视频 type DramaCount struct { DramaID string Count int64 } // 统计每个剧本的待转存图片数量 var imageDramas []DramaCount s.db.Raw(` SELECT drama_id, COUNT(*) as count FROM image_generations WHERE status = 'completed' AND image_url IS NOT NULL AND image_url != '' AND (minio_url IS NULL OR minio_url = '') AND completed_at >= ? GROUP BY drama_id `, time.Now().Add(-24*time.Hour)).Scan(&imageDramas) // 转存图片 imageCount := 0 for _, drama := range imageDramas { count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 50) // 每个剧本最多转50个 if err != nil { s.log.Errorw("Failed to transfer images for drama", "drama_id", drama.DramaID, "error", err) continue } imageCount += count s.log.Infow("Transferred images for drama", "drama_id", drama.DramaID, "count", count) } // 统计每个剧本的待转存视频数量 var videoDramas []DramaCount s.db.Raw(` SELECT drama_id, COUNT(*) as count FROM video_generations WHERE status = 'completed' AND video_url IS NOT NULL AND video_url != '' AND (minio_url IS NULL OR minio_url = '') AND completed_at >= ? GROUP BY drama_id `, time.Now().Add(-24*time.Hour)).Scan(&videoDramas) // 转存视频 videoCount := 0 for _, drama := range videoDramas { count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 50) // 每个剧本最多转50个 if err != nil { s.log.Errorw("Failed to transfer videos for drama", "drama_id", drama.DramaID, "error", err) continue } videoCount += count s.log.Infow("Transferred videos for drama", "drama_id", drama.DramaID, "count", count) } s.log.Infow("Scheduled resource transfer task completed", "images", imageCount, "videos", videoCount) } // transferAllPendingResources 转存所有待转存的资源(全量扫描) func (s *ResourceTransferScheduler) transferAllPendingResources() { s.log.Info("Starting full scan for all pending resources...") // 查找所有待转存的资源 type DramaCount struct { DramaID string Count int64 } // 统计所有剧本的待转存图片 var imageDramas []DramaCount s.db.Raw(` SELECT drama_id, COUNT(*) as count FROM image_generations WHERE status = 'completed' AND image_url IS NOT NULL AND image_url != '' AND (minio_url IS NULL OR minio_url = '') GROUP BY drama_id `).Scan(&imageDramas) s.log.Infow("Found dramas with pending images", "count", len(imageDramas)) // 转存所有待转存图片 totalImageCount := 0 for _, drama := range imageDramas { count, err := s.transferService.BatchTransferImagesToMinio(drama.DramaID, 0) // 0表示全部转存 if err != nil { s.log.Errorw("Failed to transfer images for drama", "drama_id", drama.DramaID, "error", err) continue } totalImageCount += count s.log.Infow("Transferred all images for drama", "drama_id", drama.DramaID, "count", count) } // 统计所有剧本的待转存视频 var videoDramas []DramaCount s.db.Raw(` SELECT drama_id, COUNT(*) as count FROM video_generations WHERE status = 'completed' AND video_url IS NOT NULL AND video_url != '' AND (minio_url IS NULL OR minio_url = '') GROUP BY drama_id `).Scan(&videoDramas) s.log.Infow("Found dramas with pending videos", "count", len(videoDramas)) // 转存所有待转存视频 totalVideoCount := 0 for _, drama := range videoDramas { count, err := s.transferService.BatchTransferVideosToMinio(drama.DramaID, 0) // 0表示全部转存 if err != nil { s.log.Errorw("Failed to transfer videos for drama", "drama_id", drama.DramaID, "error", err) continue } totalVideoCount += count s.log.Infow("Transferred all videos for drama", "drama_id", drama.DramaID, "count", count) } s.log.Infow("Full resource scan and transfer completed", "total_images", totalImageCount, "total_videos", totalVideoCount, "drama_count", len(imageDramas)+len(videoDramas)) } // RunNow 立即执行一次转存任务(用于手动触发) func (s *ResourceTransferScheduler) RunNow() { s.log.Info("Manually triggering resource transfer task...") go s.transferPendingResources() } // RunFullScan 立即执行一次全量扫描(用于手动触发) func (s *ResourceTransferScheduler) RunFullScan() { s.log.Info("Manually triggering full resource scan...") go s.transferAllPendingResources() } ================================================ FILE: infrastructure/storage/local_storage.go ================================================ package storage import ( "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/google/uuid" ) type LocalStorage struct { basePath string baseURL string } func NewLocalStorage(basePath, baseURL string) (*LocalStorage, error) { if err := os.MkdirAll(basePath, 0755); err != nil { return nil, fmt.Errorf("failed to create storage directory: %w", err) } return &LocalStorage{ basePath: basePath, baseURL: baseURL, }, nil } func (s *LocalStorage) Upload(file io.Reader, filename string, category string) (string, error) { dir := filepath.Join(s.basePath, category) if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("failed to create category directory: %w", err) } timestamp := time.Now().Format("20060102_150405") newFilename := fmt.Sprintf("%s_%s", timestamp, filename) filePath := filepath.Join(dir, newFilename) dst, err := os.Create(filePath) if err != nil { return "", fmt.Errorf("failed to create file: %w", err) } defer dst.Close() if _, err := io.Copy(dst, file); err != nil { return "", fmt.Errorf("failed to save file: %w", err) } url := fmt.Sprintf("%s/%s/%s", s.baseURL, category, newFilename) return url, nil } func (s *LocalStorage) Delete(url string) error { return nil } func (s *LocalStorage) GetURL(path string) string { return fmt.Sprintf("%s/%s", s.baseURL, path) } // DownloadResult 下载结果,包含URL和相对路径 type DownloadResult struct { URL string // 完整的访问URL RelativePath string // 相对于basePath的路径,用于保存到数据库 AbsolutePath string // 绝对文件路径 } // DownloadFromURL 从远程URL下载文件到本地存储 func (s *LocalStorage) DownloadFromURL(url, category string) (string, error) { result, err := s.DownloadFromURLWithPath(url, category) if err != nil { return "", err } return result.URL, nil } // DownloadFromURLWithPath 从远程URL下载文件到本地存储,返回详细信息 func (s *LocalStorage) DownloadFromURLWithPath(url, category string) (*DownloadResult, error) { // CRITICAL FIX: Add HTTP client with timeout to prevent hanging indefinitely // Without timeout, the download can hang forever if the remote server is unresponsive // 5 minute timeout is reasonable for large video/image files client := &http.Client{ Timeout: 5 * time.Minute, } // 发送HTTP请求下载文件 resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("failed to download file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download file: HTTP %d", resp.StatusCode) } // 从URL或Content-Type推断文件扩展名 ext := getFileExtension(url, resp.Header.Get("Content-Type")) // 创建目录 dir := filepath.Join(s.basePath, category) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create category directory: %w", err) } // 生成唯一文件名(时间戳 + UUID 前8位) timestamp := time.Now().Format("20060102_150405") uniqueID := uuid.New().String()[:8] filename := fmt.Sprintf("%s_%s%s", timestamp, uniqueID, ext) filePath := filepath.Join(dir, filename) // 保存文件 dst, err := os.Create(filePath) if err != nil { return nil, fmt.Errorf("failed to create file: %w", err) } defer dst.Close() if _, err := io.Copy(dst, resp.Body); err != nil { return nil, fmt.Errorf("failed to save file: %w", err) } // 返回详细信息 relativePath := filepath.Join(category, filename) localURL := fmt.Sprintf("%s/%s/%s", s.baseURL, category, filename) return &DownloadResult{ URL: localURL, RelativePath: relativePath, AbsolutePath: filePath, }, nil } // GetAbsolutePath 根据相对路径获取绝对路径 func (s *LocalStorage) GetAbsolutePath(relativePath string) string { return filepath.Join(s.basePath, relativePath) } // getFileExtension 从URL或Content-Type推断文件扩展名 func getFileExtension(url, contentType string) string { // 首先尝试从URL获取扩展名 if idx := strings.LastIndex(url, "."); idx != -1 { ext := url[idx:] // 只取扩展名部分,忽略查询参数 if qIdx := strings.Index(ext, "?"); qIdx != -1 { ext = ext[:qIdx] } if len(ext) <= 5 { // 合理的扩展名长度 return ext } } // 根据Content-Type推断扩展名 switch { case strings.Contains(contentType, "image/jpeg"): return ".jpg" case strings.Contains(contentType, "image/png"): return ".png" case strings.Contains(contentType, "image/gif"): return ".gif" case strings.Contains(contentType, "image/webp"): return ".webp" case strings.Contains(contentType, "video/mp4"): return ".mp4" case strings.Contains(contentType, "video/webm"): return ".webm" case strings.Contains(contentType, "video/quicktime"): return ".mov" default: return ".bin" } } ================================================ FILE: main.go ================================================ package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/drama-generator/backend/api/routes" "github.com/drama-generator/backend/infrastructure/database" "github.com/drama-generator/backend/infrastructure/storage" "github.com/drama-generator/backend/pkg/config" "github.com/drama-generator/backend/pkg/logger" "github.com/gin-gonic/gin" ) func main() { cfg, err := config.LoadConfig() if err != nil { log.Fatalf("Failed to load config: %v", err) } logr := logger.NewLogger(cfg.App.Debug) defer logr.Sync() logr.Info("Starting Drama Generator API Server...") db, err := database.NewDatabase(cfg.Database) if err != nil { logr.Fatal("Failed to connect to database", "error", err) } logr.Info("Database connected successfully") // 自动迁移数据库表结构 if err := database.AutoMigrate(db); err != nil { logr.Fatal("Failed to migrate database", "error", err) } logr.Info("Database tables migrated successfully") // 初始化本地存储 var localStorage *storage.LocalStorage if cfg.Storage.Type == "local" { localStorage, err = storage.NewLocalStorage(cfg.Storage.LocalPath, cfg.Storage.BaseURL) if err != nil { logr.Fatal("Failed to initialize local storage", "error", err) } logr.Info("Local storage initialized successfully", "path", cfg.Storage.LocalPath) } if cfg.App.Debug { gin.SetMode(gin.DebugMode) } else { gin.SetMode(gin.ReleaseMode) } router := routes.SetupRouter(cfg, db, logr, localStorage) srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), Handler: router, ReadTimeout: 10 * time.Minute, WriteTimeout: 10 * time.Minute, } go func() { logr.Infow("🚀 Server starting...", "port", cfg.Server.Port, "mode", gin.Mode()) logr.Info("📍 Access URLs:") logr.Info(fmt.Sprintf(" Frontend: http://localhost:%d", cfg.Server.Port)) logr.Info(fmt.Sprintf(" API: http://localhost:%d/api/v1", cfg.Server.Port)) logr.Info(fmt.Sprintf(" Health: http://localhost:%d/health", cfg.Server.Port)) logr.Info("📁 Static files:") logr.Info(fmt.Sprintf(" Uploads: http://localhost:%d/static", cfg.Server.Port)) logr.Info(fmt.Sprintf(" Assets: http://localhost:%d/assets", cfg.Server.Port)) logr.Info("✅ Server is ready!") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { logr.Fatal("Failed to start server", "error", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logr.Info("Shutting down server...") // 清理资源 // CRITICAL FIX: Properly close database connection to prevent resource leaks // SQLite connections should be closed gracefully to avoid database lock issues sqlDB, err := db.DB() if err == nil { if err := sqlDB.Close(); err != nil { logr.Warnw("Failed to close database connection", "error", err) } else { logr.Info("Database connection closed") } } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { logr.Fatal("Server forced to shutdown", "error", err) } logr.Info("Server exited") } ================================================ FILE: migrations/20260126_add_local_path.sql ================================================ -- 添加 local_path 字段到相关表 -- 创建时间: 2026-01-26 -- 说明: 为 characters, scenes, props, character_libraries 表添加 local_path 字段以支持本地存储路径 -- 为 characters 表添加 local_path 字段 ALTER TABLE characters ADD COLUMN local_path TEXT; -- 为 scenes 表添加 local_path 字段 ALTER TABLE scenes ADD COLUMN local_path TEXT; -- 为 props 表添加 local_path 字段 ALTER TABLE props ADD COLUMN local_path TEXT; -- 为 character_libraries 表添加 local_path 字段 ALTER TABLE character_libraries ADD COLUMN local_path TEXT; ================================================ FILE: migrations/init.sql ================================================ -- AI短剧生成平台 - SQLite数据库初始化脚本 (开源版本 - 无用户认证) -- 创建时间: 2026-01-07 -- 说明: 此版本适配SQLite,移除外键约束,适合单机部署 -- ====================================== -- 1. 剧本相关表 -- ====================================== -- 剧本表 CREATE TABLE IF NOT EXISTS dramas ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, genre TEXT, style TEXT NOT NULL DEFAULT 'realistic', total_episodes INTEGER NOT NULL DEFAULT 1, total_duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒) status TEXT NOT NULL DEFAULT 'draft', -- draft, in_progress, completed thumbnail TEXT, tags TEXT, -- JSON存储 metadata TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_dramas_status ON dramas(status); CREATE INDEX IF NOT EXISTS idx_dramas_deleted_at ON dramas(deleted_at); -- 章节表 CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER NOT NULL, episode_number INTEGER NOT NULL, title TEXT NOT NULL, script_content TEXT, description TEXT, duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒) status TEXT NOT NULL DEFAULT 'draft', video_url TEXT, thumbnail TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_episodes_drama_id ON episodes(drama_id); CREATE INDEX IF NOT EXISTS idx_episodes_status ON episodes(status); CREATE INDEX IF NOT EXISTS idx_episodes_deleted_at ON episodes(deleted_at); -- 角色表 CREATE TABLE IF NOT EXISTS characters ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER NOT NULL, name TEXT NOT NULL, role TEXT, description TEXT, appearance TEXT, personality TEXT, voice_style TEXT, image_url TEXT, local_path TEXT, reference_images TEXT, -- JSON存储 seed_value TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_characters_drama_id ON characters(drama_id); CREATE INDEX IF NOT EXISTS idx_characters_deleted_at ON characters(deleted_at); -- 场景表 CREATE TABLE IF NOT EXISTS scenes ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER NOT NULL, location TEXT NOT NULL, time TEXT NOT NULL, prompt TEXT NOT NULL, storyboard_count INTEGER NOT NULL DEFAULT 1, image_url TEXT, local_path TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, generated, failed created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_scenes_drama_id ON scenes(drama_id); CREATE INDEX IF NOT EXISTS idx_scenes_status ON scenes(status); CREATE INDEX IF NOT EXISTS idx_scenes_deleted_at ON scenes(deleted_at); -- 道具表 CREATE TABLE IF NOT EXISTS props ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER NOT NULL, name TEXT NOT NULL, type TEXT, description TEXT, prompt TEXT, image_url TEXT, local_path TEXT, reference_images TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_props_drama_id ON props(drama_id); CREATE INDEX IF NOT EXISTS idx_props_deleted_at ON props(deleted_at); -- 分镜表 CREATE TABLE IF NOT EXISTS storyboards ( id INTEGER PRIMARY KEY AUTOINCREMENT, episode_id INTEGER NOT NULL, scene_id INTEGER, storyboard_number INTEGER NOT NULL, title TEXT, description TEXT, location TEXT, time TEXT, duration INTEGER NOT NULL DEFAULT 0, -- 时长(秒) dialogue TEXT, action TEXT, atmosphere TEXT, image_prompt TEXT, video_prompt TEXT, characters TEXT, -- JSON存储 composed_image TEXT, video_url TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_storyboards_episode_id ON storyboards(episode_id); CREATE INDEX IF NOT EXISTS idx_storyboards_scene_id ON storyboards(scene_id); CREATE INDEX IF NOT EXISTS idx_storyboards_storyboard_number ON storyboards(storyboard_number); CREATE INDEX IF NOT EXISTS idx_storyboards_status ON storyboards(status); CREATE INDEX IF NOT EXISTS idx_storyboards_deleted_at ON storyboards(deleted_at); -- ====================================== -- 2. AI生成相关表 -- ====================================== -- 图片生成记录表 CREATE TABLE IF NOT EXISTS image_generations ( id INTEGER PRIMARY KEY AUTOINCREMENT, storyboard_id INTEGER, -- 修正:引用storyboards表 drama_id INTEGER NOT NULL, provider TEXT NOT NULL, -- openai, midjourney, stable_diffusion prompt TEXT NOT NULL, negative_prompt TEXT, model TEXT, size TEXT, quality TEXT, style TEXT, steps INTEGER, cfg_scale REAL, seed INTEGER, image_url TEXT, minio_url TEXT, local_path TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed task_id TEXT, error_msg TEXT, width INTEGER, height INTEGER, reference_images TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_image_generations_storyboard_id ON image_generations(storyboard_id); CREATE INDEX IF NOT EXISTS idx_image_generations_drama_id ON image_generations(drama_id); CREATE INDEX IF NOT EXISTS idx_image_generations_status ON image_generations(status); CREATE INDEX IF NOT EXISTS idx_image_generations_task_id ON image_generations(task_id); CREATE INDEX IF NOT EXISTS idx_image_generations_deleted_at ON image_generations(deleted_at); -- 视频生成记录表 CREATE TABLE IF NOT EXISTS video_generations ( id INTEGER PRIMARY KEY AUTOINCREMENT, storyboard_id INTEGER, -- 修正:引用storyboards表 drama_id INTEGER NOT NULL, provider TEXT NOT NULL, -- runway, pika, doubao, openai prompt TEXT NOT NULL, model TEXT, image_gen_id INTEGER, image_url TEXT, first_frame_url TEXT, duration INTEGER, -- 时长(秒) fps INTEGER, resolution TEXT, aspect_ratio TEXT, style TEXT, motion_level INTEGER, camera_motion TEXT, seed INTEGER, video_url TEXT, minio_url TEXT, local_path TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed task_id TEXT, error_msg TEXT, completed_at DATETIME, width INTEGER, height INTEGER, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_video_generations_storyboard_id ON video_generations(storyboard_id); CREATE INDEX IF NOT EXISTS idx_video_generations_drama_id ON video_generations(drama_id); CREATE INDEX IF NOT EXISTS idx_video_generations_provider ON video_generations(provider); CREATE INDEX IF NOT EXISTS idx_video_generations_status ON video_generations(status); CREATE INDEX IF NOT EXISTS idx_video_generations_task_id ON video_generations(task_id); CREATE INDEX IF NOT EXISTS idx_video_generations_image_gen_id ON video_generations(image_gen_id); CREATE INDEX IF NOT EXISTS idx_video_generations_deleted_at ON video_generations(deleted_at); -- 视频合成记录表 CREATE TABLE IF NOT EXISTS video_merges ( id INTEGER PRIMARY KEY AUTOINCREMENT, episode_id INTEGER NOT NULL, drama_id INTEGER NOT NULL, title TEXT, provider TEXT NOT NULL, model TEXT, status TEXT NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed scenes TEXT NOT NULL, -- JSON存储:场景片段列表 merged_url TEXT, duration INTEGER, -- 总时长(秒) task_id TEXT, error_msg TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_video_merges_episode_id ON video_merges(episode_id); CREATE INDEX IF NOT EXISTS idx_video_merges_drama_id ON video_merges(drama_id); CREATE INDEX IF NOT EXISTS idx_video_merges_status ON video_merges(status); CREATE INDEX IF NOT EXISTS idx_video_merges_deleted_at ON video_merges(deleted_at); -- ====================================== -- 3. 角色库表 -- ====================================== -- 角色库表 (开源版本 - 全局共享) CREATE TABLE IF NOT EXISTS character_libraries ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT, image_url TEXT NOT NULL, local_path TEXT, description TEXT, tags TEXT, source_type TEXT NOT NULL DEFAULT 'generated', -- generated, uploaded created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_character_libraries_category ON character_libraries(category); CREATE INDEX IF NOT EXISTS idx_character_libraries_deleted_at ON character_libraries(deleted_at); -- ====================================== -- 4. 时间线相关表 -- ====================================== -- 时间线表 CREATE TABLE IF NOT EXISTS timelines ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER NOT NULL, episode_id INTEGER, name TEXT NOT NULL, description TEXT, duration INTEGER NOT NULL DEFAULT 0, -- 总时长(秒) fps INTEGER NOT NULL DEFAULT 30, resolution TEXT, status TEXT NOT NULL DEFAULT 'draft', -- draft, editing, completed, exporting created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_timelines_drama_id ON timelines(drama_id); CREATE INDEX IF NOT EXISTS idx_timelines_episode_id ON timelines(episode_id); CREATE INDEX IF NOT EXISTS idx_timelines_status ON timelines(status); CREATE INDEX IF NOT EXISTS idx_timelines_deleted_at ON timelines(deleted_at); -- 时间线轨道表 CREATE TABLE IF NOT EXISTS timeline_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, timeline_id INTEGER NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, -- video, audio, text track_order INTEGER NOT NULL DEFAULT 0, is_locked INTEGER NOT NULL DEFAULT 0, is_muted INTEGER NOT NULL DEFAULT 0, volume INTEGER DEFAULT 100, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_timeline_tracks_timeline_id ON timeline_tracks(timeline_id); CREATE INDEX IF NOT EXISTS idx_timeline_tracks_type ON timeline_tracks(type); CREATE INDEX IF NOT EXISTS idx_timeline_tracks_deleted_at ON timeline_tracks(deleted_at); -- 时间线片段表 CREATE TABLE IF NOT EXISTS timeline_clips ( id INTEGER PRIMARY KEY AUTOINCREMENT, track_id INTEGER NOT NULL, asset_id INTEGER, storyboard_id INTEGER, -- 修正:引用storyboards而非scenes name TEXT, start_time INTEGER NOT NULL, -- 开始时间(毫秒) end_time INTEGER NOT NULL, -- 结束时间(毫秒) duration INTEGER NOT NULL, -- 时长(毫秒) trim_start INTEGER, -- 裁剪开始(毫秒) trim_end INTEGER, -- 裁剪结束(毫秒) speed REAL DEFAULT 1.0, volume INTEGER, is_muted INTEGER NOT NULL DEFAULT 0, fade_in INTEGER, -- 淡入时长(毫秒) fade_out INTEGER, -- 淡出时长(毫秒) transition_in_id INTEGER, transition_out_id INTEGER, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_timeline_clips_track_id ON timeline_clips(track_id); CREATE INDEX IF NOT EXISTS idx_timeline_clips_asset_id ON timeline_clips(asset_id); CREATE INDEX IF NOT EXISTS idx_timeline_clips_storyboard_id ON timeline_clips(storyboard_id); CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_in ON timeline_clips(transition_in_id); CREATE INDEX IF NOT EXISTS idx_timeline_clips_transition_out ON timeline_clips(transition_out_id); CREATE INDEX IF NOT EXISTS idx_timeline_clips_deleted_at ON timeline_clips(deleted_at); -- 片段转场表 CREATE TABLE IF NOT EXISTS clip_transitions ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, -- fade, crossfade, slide, wipe, zoom, dissolve duration INTEGER NOT NULL DEFAULT 500, -- 转场时长(毫秒) easing TEXT, config TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_clip_transitions_type ON clip_transitions(type); CREATE INDEX IF NOT EXISTS idx_clip_transitions_deleted_at ON clip_transitions(deleted_at); -- 片段效果表 CREATE TABLE IF NOT EXISTS clip_effects ( id INTEGER PRIMARY KEY AUTOINCREMENT, clip_id INTEGER NOT NULL, type TEXT NOT NULL, -- filter, color, blur, brightness, contrast, saturation name TEXT, is_enabled INTEGER NOT NULL DEFAULT 1, effect_order INTEGER NOT NULL DEFAULT 0, config TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_clip_effects_clip_id ON clip_effects(clip_id); CREATE INDEX IF NOT EXISTS idx_clip_effects_type ON clip_effects(type); CREATE INDEX IF NOT EXISTS idx_clip_effects_deleted_at ON clip_effects(deleted_at); -- ====================================== -- 5. 资源管理相关表 -- ====================================== -- 资源表 CREATE TABLE IF NOT EXISTS assets ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER, name TEXT NOT NULL, description TEXT, type TEXT NOT NULL, -- image, video, audio category TEXT, url TEXT NOT NULL, thumbnail_url TEXT, local_path TEXT, file_size INTEGER, mime_type TEXT, width INTEGER, height INTEGER, duration INTEGER, -- 时长(秒) format TEXT, image_gen_id INTEGER, video_gen_id INTEGER, is_favorite INTEGER NOT NULL DEFAULT 0, view_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_assets_drama_id ON assets(drama_id); CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(type); CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category); CREATE INDEX IF NOT EXISTS idx_assets_image_gen_id ON assets(image_gen_id); CREATE INDEX IF NOT EXISTS idx_assets_video_gen_id ON assets(video_gen_id); CREATE INDEX IF NOT EXISTS idx_assets_deleted_at ON assets(deleted_at); -- 资源标签表 CREATE TABLE IF NOT EXISTS asset_tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_asset_tags_deleted_at ON asset_tags(deleted_at); -- 资源集合表 CREATE TABLE IF NOT EXISTS asset_collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, drama_id INTEGER, name TEXT NOT NULL, description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_asset_collections_drama_id ON asset_collections(drama_id); CREATE INDEX IF NOT EXISTS idx_asset_collections_deleted_at ON asset_collections(deleted_at); -- 资源标签关系表(多对多) CREATE TABLE IF NOT EXISTS asset_tag_relations ( asset_id INTEGER NOT NULL, asset_tag_id INTEGER NOT NULL, PRIMARY KEY (asset_id, asset_tag_id) ); CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_asset_id ON asset_tag_relations(asset_id); CREATE INDEX IF NOT EXISTS idx_asset_tag_relations_tag_id ON asset_tag_relations(asset_tag_id); -- 资源集合关系表(多对多) CREATE TABLE IF NOT EXISTS asset_collection_relations ( asset_id INTEGER NOT NULL, asset_collection_id INTEGER NOT NULL, PRIMARY KEY (asset_id, asset_collection_id) ); CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_asset_id ON asset_collection_relations(asset_id); CREATE INDEX IF NOT EXISTS idx_asset_collection_relations_collection_id ON asset_collection_relations(asset_collection_id); -- ====================================== -- 6. AI服务配置表 (开源版本 - 全局配置) -- ====================================== -- AI服务配置表 (全局配置,无用户隔离) CREATE TABLE IF NOT EXISTS ai_service_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, service_type TEXT NOT NULL, -- text, image, video provider TEXT, -- openai, gemini, volcengine, etc. name TEXT NOT NULL, base_url TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, endpoint TEXT, query_endpoint TEXT, priority INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, settings TEXT, -- JSON存储 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_ai_service_configs_service_type ON ai_service_configs(service_type); CREATE INDEX IF NOT EXISTS idx_ai_service_configs_deleted_at ON ai_service_configs(deleted_at); -- AI服务提供商表 CREATE TABLE IF NOT EXISTS ai_service_providers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, service_type TEXT NOT NULL, -- text, image, video default_url TEXT, description TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME ); CREATE INDEX IF NOT EXISTS idx_ai_service_providers_service_type ON ai_service_providers(service_type); CREATE INDEX IF NOT EXISTS idx_ai_service_providers_deleted_at ON ai_service_providers(deleted_at); -- ====================================== -- 7. 初始数据 -- ====================================== -- 插入默认AI服务提供商 INSERT OR IGNORE INTO ai_service_providers (name, display_name, service_type, default_url, description) VALUES ('openai', 'OpenAI', 'text', 'https://api.openai.com/v1', 'OpenAI GPT模型'), ('openai-dalle', 'OpenAI DALL-E', 'image', 'https://api.openai.com/v1', 'OpenAI DALL-E图片生成'), ('openai-sora', 'OpenAI Sora', 'video', 'https://api.openai.com/v1', 'OpenAI Sora视频生成'), ('midjourney', 'Midjourney', 'image', '', 'Midjourney图片生成'), ('doubao-image', '豆包(火山引擎)', 'image', 'https://ark.cn-beijing.volces.com', '火山引擎豆包图片生成'), ('gemini-image', 'Google Gemini', 'image', 'https://generativelanguage.googleapis.com', 'Google Gemini原生图片生成(base64)'), ('runway', 'Runway', 'video', '', 'Runway视频生成'), ('pika', 'Pika Labs', 'video', '', 'Pika视频生成'), ('doubao', '豆包(火山引擎)', 'video', 'https://ark.cn-beijing.volces.com', '火山引擎豆包视频生成'), ('minimax', 'MiniMax', 'video', '', 'MiniMax视频生成'); ================================================ FILE: pkg/ai/client.go ================================================ package ai // AIClient 定义文本生成客户端接口 type AIClient interface { GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) GenerateImage(prompt string, size string, n int) ([]string, error) TestConnection() error } ================================================ FILE: pkg/ai/gemini_client.go ================================================ package ai import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type GeminiClient struct { BaseURL string APIKey string Model string Endpoint string HTTPClient *http.Client } type GeminiTextRequest struct { Contents []GeminiContent `json:"contents"` SystemInstruction *GeminiInstruction `json:"systemInstruction,omitempty"` } type GeminiContent struct { Parts []GeminiPart `json:"parts"` Role string `json:"role,omitempty"` } type GeminiPart struct { Text string `json:"text"` } type GeminiInstruction struct { Parts []GeminiPart `json:"parts"` } type GeminiTextResponse struct { Candidates []struct { Content struct { Parts []struct { Text string `json:"text"` } `json:"parts"` Role string `json:"role"` } `json:"content"` FinishReason string `json:"finishReason"` Index int `json:"index"` SafetyRatings []struct { Category string `json:"category"` Probability string `json:"probability"` } `json:"safetyRatings"` } `json:"candidates"` UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` } `json:"usageMetadata"` } func NewGeminiClient(baseURL, apiKey, model, endpoint string) *GeminiClient { if baseURL == "" { baseURL = "https://generativelanguage.googleapis.com" } if endpoint == "" { endpoint = "/v1beta/models/{model}:generateContent" } if model == "" { model = "gemini-3-pro" } return &GeminiClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, HTTPClient: &http.Client{ Timeout: 10 * time.Minute, }, } } func (c *GeminiClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) { model := c.Model // 构建请求体 reqBody := GeminiTextRequest{ Contents: []GeminiContent{ { Parts: []GeminiPart{{Text: prompt}}, Role: "user", }, }, } // 使用 systemInstruction 字段处理系统提示 if systemPrompt != "" { reqBody.SystemInstruction = &GeminiInstruction{ Parts: []GeminiPart{{Text: systemPrompt}}, } } jsonData, err := json.Marshal(reqBody) if err != nil { fmt.Printf("Gemini: Failed to marshal request: %v\n", err) return "", fmt.Errorf("marshal request: %w", err) } // 替换端点中的 {model} 占位符 endpoint := c.BaseURL + c.Endpoint endpoint = strings.ReplaceAll(endpoint, "{model}", model) url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey) // 打印请求信息(隐藏 API Key) safeURL := strings.Replace(url, c.APIKey, "***", 1) fmt.Printf("Gemini: Sending request to: %s\n", safeURL) requestPreview := string(jsonData) if len(jsonData) > 300 { requestPreview = string(jsonData[:300]) + "..." } fmt.Printf("Gemini: Request body: %s\n", requestPreview) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { fmt.Printf("Gemini: Failed to create request: %v\n", err) return "", fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") fmt.Printf("Gemini: Executing HTTP request...\n") resp, err := c.HTTPClient.Do(req) if err != nil { fmt.Printf("Gemini: HTTP request failed: %v\n", err) return "", fmt.Errorf("send request: %w", err) } defer resp.Body.Close() fmt.Printf("Gemini: Received response with status: %d\n", resp.StatusCode) body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("Gemini: Failed to read response body: %v\n", err) return "", fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { fmt.Printf("Gemini: API error (status %d): %s\n", resp.StatusCode, string(body)) return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } // 打印响应体用于调试 bodyPreview := string(body) if len(body) > 500 { bodyPreview = string(body[:500]) + "..." } fmt.Printf("Gemini: Response body: %s\n", bodyPreview) var result GeminiTextResponse if err := json.Unmarshal(body, &result); err != nil { errorPreview := string(body) if len(body) > 200 { errorPreview = string(body[:200]) } fmt.Printf("Gemini: Failed to parse response: %v\n", err) return "", fmt.Errorf("parse response: %w, body preview: %s", err, errorPreview) } fmt.Printf("Gemini: Successfully parsed response, candidates count: %d\n", len(result.Candidates)) if len(result.Candidates) == 0 { fmt.Printf("Gemini: No candidates in response\n") return "", fmt.Errorf("no candidates in response") } if len(result.Candidates[0].Content.Parts) == 0 { fmt.Printf("Gemini: No parts in first candidate\n") return "", fmt.Errorf("no parts in response") } responseText := result.Candidates[0].Content.Parts[0].Text fmt.Printf("Gemini: Generated text: %s\n", responseText) return responseText, nil } func (c *GeminiClient) GenerateImage(prompt string, size string, n int) ([]string, error) { return nil, fmt.Errorf("GenerateImage not implemented for Gemini client") } func (c *GeminiClient) TestConnection() error { fmt.Printf("Gemini: TestConnection called with BaseURL=%s, Model=%s, Endpoint=%s\n", c.BaseURL, c.Model, c.Endpoint) _, err := c.GenerateText("Hello", "") if err != nil { fmt.Printf("Gemini: TestConnection failed: %v\n", err) } else { fmt.Printf("Gemini: TestConnection succeeded\n") } return err } ================================================ FILE: pkg/ai/openai_client.go ================================================ package ai import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type OpenAIClient struct { BaseURL string APIKey string Model string Endpoint string HTTPClient *http.Client } type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type ChatCompletionRequest struct { Model string `json:"model"` Messages []ChatMessage `json:"messages"` Temperature float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` MaxCompletionTokens *int `json:"max_completion_tokens,omitempty"` TopP float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` } type ChatCompletionResponse struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []struct { Index int `json:"index"` Message struct { Role string `json:"role"` Content string `json:"content"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` } type ImageGenerationRequest struct { Model string `json:"model,omitempty"` Prompt string `json:"prompt"` N int `json:"n,omitempty"` Size string `json:"size,omitempty"` } type ImageGenerationResponse struct { Created int64 `json:"created"` Data []struct { URL string `json:"url"` B64JSON string `json:"b64_json"` } `json:"data"` } type ErrorResponse struct { Error struct { Message string `json:"message"` Type string `json:"type"` Code string `json:"code"` } `json:"error"` } func NewOpenAIClient(baseURL, apiKey, model, endpoint string) *OpenAIClient { if endpoint == "" { endpoint = "/v1/chat/completions" } return &OpenAIClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, HTTPClient: &http.Client{ Timeout: 10 * time.Minute, }, } } func (c *OpenAIClient) ChatCompletion(messages []ChatMessage, options ...func(*ChatCompletionRequest)) (*ChatCompletionResponse, error) { req := &ChatCompletionRequest{ Model: c.Model, Messages: messages, } for _, option := range options { option(req) } return c.sendChatRequest(req) } func (c *OpenAIClient) sendChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) { resp, err := c.doChatRequest(req) if err == nil { return resp, nil } if shouldRetryWithMaxCompletionTokens(err, req) { tokens := *req.MaxTokens retryReq := *req retryReq.MaxTokens = nil retryReq.MaxCompletionTokens = &tokens fmt.Printf("OpenAI: retrying with max_completion_tokens=%d\n", tokens) return c.doChatRequest(&retryReq) } return nil, err } func (c *OpenAIClient) doChatRequest(req *ChatCompletionRequest) (*ChatCompletionResponse, error) { jsonData, err := json.Marshal(req) if err != nil { fmt.Printf("OpenAI: Failed to marshal request: %v\n", err) return nil, fmt.Errorf("failed to marshal request: %w", err) } url := c.BaseURL + c.Endpoint // 打印请求信息 fmt.Printf("OpenAI: Sending request to: %s\n", url) fmt.Printf("OpenAI: BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model) requestPreview := string(jsonData) if len(jsonData) > 300 { requestPreview = string(jsonData[:300]) + "..." } fmt.Printf("OpenAI: Request body: %s\n", requestPreview) httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { fmt.Printf("OpenAI: Failed to create request: %v\n", err) return nil, fmt.Errorf("failed to create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) fmt.Printf("OpenAI: Executing HTTP request...\n") resp, err := c.HTTPClient.Do(httpReq) if err != nil { fmt.Printf("OpenAI: HTTP request failed: %v\n", err) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() fmt.Printf("OpenAI: Received response with status: %d\n", resp.StatusCode) body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("OpenAI: Failed to read response body: %v\n", err) return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { fmt.Printf("OpenAI: API error (status %d): %s\n", resp.StatusCode, string(body)) var errResp ErrorResponse if err := json.Unmarshal(body, &errResp); err != nil { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } return nil, fmt.Errorf("API error: %s", errResp.Error.Message) } // 打印响应体用于调试 bodyPreview := string(body) if len(body) > 500 { bodyPreview = string(body[:500]) + "..." } fmt.Printf("OpenAI: Response body: %s\n", bodyPreview) var chatResp ChatCompletionResponse if err := json.Unmarshal(body, &chatResp); err != nil { errorPreview := string(body) if len(body) > 200 { errorPreview = string(body[:200]) } fmt.Printf("OpenAI: Failed to parse response: %v\n", err) return nil, fmt.Errorf("failed to unmarshal response: %w, body preview: %s", err, errorPreview) } fmt.Printf("OpenAI: Successfully parsed response, choices count: %d\n", len(chatResp.Choices)) if len(chatResp.Choices) == 0 { fmt.Printf("OpenAI: No choices in response\n") return nil, fmt.Errorf("no choices in response") } // 检查 finish_reason,处理内容过滤的情况 if len(chatResp.Choices) > 0 { finishReason := chatResp.Choices[0].FinishReason content := chatResp.Choices[0].Message.Content usage := chatResp.Usage fmt.Printf("OpenAI: finish_reason=%s, content_length=%d\n", finishReason, len(content)) if finishReason == "content_filter" { return nil, fmt.Errorf("AI内容被安全过滤器拦截,可能因为:\n1. 请求内容触发了安全策略\n2. 生成的内容包含敏感信息\n3. 建议:调整输入内容或联系API提供商调整过滤策略") } if usage.TotalTokens == 0 && finishReason != "stop" { return nil, fmt.Errorf("AI返回内容为空 (finish_reason: %s),可能的原因:\n1. 内容被过滤\n2. Token限制\n3. API异常", finishReason) } } return &chatResp, nil } func WithTemperature(temp float64) func(*ChatCompletionRequest) { return func(req *ChatCompletionRequest) { req.Temperature = temp } } func WithMaxTokens(tokens int) func(*ChatCompletionRequest) { return func(req *ChatCompletionRequest) { req.MaxTokens = &tokens } } func WithTopP(topP float64) func(*ChatCompletionRequest) { return func(req *ChatCompletionRequest) { req.TopP = topP } } func (c *OpenAIClient) GenerateText(prompt string, systemPrompt string, options ...func(*ChatCompletionRequest)) (string, error) { messages := []ChatMessage{} if systemPrompt != "" { messages = append(messages, ChatMessage{ Role: "system", Content: systemPrompt, }) } messages = append(messages, ChatMessage{ Role: "user", Content: prompt, }) resp, err := c.ChatCompletion(messages, options...) if err != nil { return "", err } if len(resp.Choices) == 0 { return "", fmt.Errorf("no response from API") } return resp.Choices[0].Message.Content, nil } func (c *OpenAIClient) GenerateImage(prompt string, size string, n int) ([]string, error) { // 图片生成端点通常是 /v1/images/generations // 如果 c.Endpoint 是 chat 端点,我们需要将其替换 // 这是一个简单的处理逻辑,实际可能需要更复杂的配置 imageEndpoint := "/v1/images/generations" // 如果 BaseURL 是类似 api.openai.com,那么直接拼接 url := c.BaseURL + imageEndpoint reqBody := ImageGenerationRequest{ Prompt: prompt, N: n, Size: size, Model: c.Model, // 如果是DALL-E 3,模型名很重要 } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, err } httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { var errResp ErrorResponse if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" { return nil, fmt.Errorf("API error: %s", errResp.Error.Message) } return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var imgResp ImageGenerationResponse if err := json.Unmarshal(body, &imgResp); err != nil { return nil, err } var urls []string for _, data := range imgResp.Data { if data.URL != "" { urls = append(urls, data.URL) } else if data.B64JSON != "" { // 如果返回的是base64,添加前缀 urls = append(urls, "data:image/png;base64,"+data.B64JSON) } } return urls, nil } func (c *OpenAIClient) TestConnection() error { fmt.Printf("OpenAI: TestConnection called with BaseURL=%s, Endpoint=%s, Model=%s\n", c.BaseURL, c.Endpoint, c.Model) messages := []ChatMessage{ { Role: "user", Content: "Hello", }, } _, err := c.ChatCompletion(messages, WithMaxTokens(50)) if err != nil { fmt.Printf("OpenAI: TestConnection failed: %v\n", err) } else { fmt.Printf("OpenAI: TestConnection succeeded\n") } return err } func shouldRetryWithMaxCompletionTokens(err error, req *ChatCompletionRequest) bool { if err == nil || req == nil || req.MaxTokens == nil || req.MaxCompletionTokens != nil { return false } msg := err.Error() if strings.Contains(msg, "Unsupported parameter: 'max_tokens'") { return true } if strings.Contains(msg, "max_tokens is not supported") { return true } if strings.Contains(msg, "max_completion_tokens") { return true } return false } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "fmt" "github.com/spf13/viper" ) type Config struct { App AppConfig `mapstructure:"app"` Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Storage StorageConfig `mapstructure:"storage"` AI AIConfig `mapstructure:"ai"` } type AppConfig struct { Name string `mapstructure:"name"` Version string `mapstructure:"version"` Debug bool `mapstructure:"debug"` Language string `mapstructure:"language"` // zh 或 en } type ServerConfig struct { Port int `mapstructure:"port"` Host string `mapstructure:"host"` CORSOrigins []string `mapstructure:"cors_origins"` ReadTimeout int `mapstructure:"read_timeout"` WriteTimeout int `mapstructure:"write_timeout"` } type DatabaseConfig struct { Type string `mapstructure:"type"` // sqlite, mysql Path string `mapstructure:"path"` // SQLite数据库文件路径 Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` Database string `mapstructure:"database"` Charset string `mapstructure:"charset"` MaxIdle int `mapstructure:"max_idle"` MaxOpen int `mapstructure:"max_open"` } type StorageConfig struct { Type string `mapstructure:"type"` // local, minio LocalPath string `mapstructure:"local_path"` // 本地存储路径 BaseURL string `mapstructure:"base_url"` // 访问URL前缀 } type AIConfig struct { DefaultTextProvider string `mapstructure:"default_text_provider"` DefaultImageProvider string `mapstructure:"default_image_provider"` DefaultVideoProvider string `mapstructure:"default_video_provider"` } func LoadConfig() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath("./configs") viper.AddConfigPath(".") viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config: %w", err) } var config Config if err := viper.Unmarshal(&config); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } return &config, nil } func (c *DatabaseConfig) DSN() string { if c.Type == "sqlite" { return c.Path } // MySQL DSN return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", c.User, c.Password, c.Host, c.Port, c.Database, c.Charset, ) } ================================================ FILE: pkg/image/gemini_image_client.go ================================================ package image import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type GeminiImageClient struct { BaseURL string APIKey string Model string Endpoint string HTTPClient *http.Client } type GeminiImageRequest struct { Contents []struct { Parts []GeminiPart `json:"parts"` } `json:"contents"` GenerationConfig struct { ResponseModalities []string `json:"responseModalities"` } `json:"generationConfig"` } type GeminiPart struct { Text string `json:"text,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"` } type GeminiInlineData struct { MimeType string `json:"mimeType"` Data string `json:"data"` // base64 编码的图片数据 } type GeminiImageResponse struct { Candidates []struct { Content struct { Parts []struct { InlineData struct { MimeType string `json:"mimeType"` Data string `json:"data"` } `json:"inlineData,omitempty"` Text string `json:"text,omitempty"` } `json:"parts"` } `json:"content"` } `json:"candidates"` UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` } `json:"usageMetadata"` } // downloadImageToBase64 下载图片 URL 并转换为 base64 func downloadImageToBase64(imageURL string) (string, string, error) { resp, err := http.Get(imageURL) if err != nil { return "", "", fmt.Errorf("download image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", fmt.Errorf("download image failed with status: %d", resp.StatusCode) } imageData, err := io.ReadAll(resp.Body) if err != nil { return "", "", fmt.Errorf("read image data: %w", err) } // 根据 Content-Type 确定 mimeType mimeType := resp.Header.Get("Content-Type") if mimeType == "" { mimeType = "image/jpeg" } base64Data := base64.StdEncoding.EncodeToString(imageData) return base64Data, mimeType, nil } func NewGeminiImageClient(baseURL, apiKey, model, endpoint string) *GeminiImageClient { if baseURL == "" { baseURL = "https://generativelanguage.googleapis.com" } if endpoint == "" { endpoint = "/v1beta/models/{model}:generateContent" } if model == "" { model = "gemini-3-pro-image-preview" } return &GeminiImageClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, HTTPClient: &http.Client{ Timeout: 10 * time.Minute, }, } } func (c *GeminiImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) { options := &ImageOptions{ Size: "1920x1920", Quality: "standard", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } promptText := prompt if options.NegativePrompt != "" { promptText += fmt.Sprintf("\n\nNegative prompt: %s", options.NegativePrompt) } if options.Size != "" { promptText += fmt.Sprintf("\n\nImage size: %s", options.Size) } // 构建请求的 parts,支持参考图 parts := []GeminiPart{} // 如果有参考图,先添加参考图 if len(options.ReferenceImages) > 0 { for _, refImg := range options.ReferenceImages { var base64Data string var mimeType string var err error // 检查是否是 HTTP/HTTPS URL if strings.HasPrefix(refImg, "http://") || strings.HasPrefix(refImg, "https://") { // 下载图片并转换为 base64 base64Data, mimeType, err = downloadImageToBase64(refImg) if err != nil { continue } } else if strings.HasPrefix(refImg, "data:") { // 如果是 data URI 格式,需要解析 // 格式: data:image/jpeg;base64,xxxxx mimeType = "image/jpeg" parts := []byte(refImg) for i := 0; i < len(parts); i++ { if parts[i] == ',' { base64Data = refImg[i+1:] // 提取 mime type if i > 11 { mimeTypeEnd := i for j := 5; j < i; j++ { if parts[j] == ';' { mimeTypeEnd = j break } } mimeType = refImg[5:mimeTypeEnd] } break } } } else { // 假设已经是 base64 编码 base64Data = refImg mimeType = "image/jpeg" } if base64Data != "" { parts = append(parts, GeminiPart{ InlineData: &GeminiInlineData{ MimeType: mimeType, Data: base64Data, }, }) } } } // 添加文本提示词 parts = append(parts, GeminiPart{ Text: promptText, }) reqBody := GeminiImageRequest{ Contents: []struct { Parts []GeminiPart `json:"parts"` }{ { Parts: parts, }, }, GenerationConfig: struct { ResponseModalities []string `json:"responseModalities"` }{ ResponseModalities: []string{"IMAGE"}, }, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } endpoint := c.BaseURL + c.Endpoint endpoint = replaceModelPlaceholder(endpoint, model) url := fmt.Sprintf("%s?key=%s", endpoint, c.APIKey) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { bodyStr := string(body) if len(bodyStr) > 1000 { bodyStr = fmt.Sprintf("%s ... %s", bodyStr[:500], bodyStr[len(bodyStr)-500:]) } return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, bodyStr) } var result GeminiImageResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if len(result.Candidates) == 0 || len(result.Candidates[0].Content.Parts) == 0 { return nil, fmt.Errorf("no image generated in response") } base64Data := result.Candidates[0].Content.Parts[0].InlineData.Data if base64Data == "" { return nil, fmt.Errorf("no base64 image data in response") } dataURI := fmt.Sprintf("data:image/jpeg;base64,%s", base64Data) return &ImageResult{ Status: "completed", ImageURL: dataURI, Completed: true, Width: 1024, Height: 1024, }, nil } func (c *GeminiImageClient) GetTaskStatus(taskID string) (*ImageResult, error) { return nil, fmt.Errorf("not supported for Gemini (synchronous generation)") } func replaceModelPlaceholder(endpoint, model string) string { result := endpoint if bytes.Contains([]byte(result), []byte("{model}")) { result = string(bytes.ReplaceAll([]byte(result), []byte("{model}"), []byte(model))) } return result } ================================================ FILE: pkg/image/image_client.go ================================================ package image type ImageClient interface { GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) GetTaskStatus(taskID string) (*ImageResult, error) } type ImageResult struct { TaskID string Status string ImageURL string Width int Height int Error string Completed bool } type ImageOptions struct { NegativePrompt string Size string Quality string Style string Steps int CfgScale float64 Seed int64 Model string Width int Height int ReferenceImages []string // 参考图片URL列表 } type ImageOption func(*ImageOptions) func WithNegativePrompt(prompt string) ImageOption { return func(o *ImageOptions) { o.NegativePrompt = prompt } } func WithSize(size string) ImageOption { return func(o *ImageOptions) { o.Size = size } } func WithQuality(quality string) ImageOption { return func(o *ImageOptions) { o.Quality = quality } } func WithStyle(style string) ImageOption { return func(o *ImageOptions) { o.Style = style } } func WithSteps(steps int) ImageOption { return func(o *ImageOptions) { o.Steps = steps } } func WithCfgScale(scale float64) ImageOption { return func(o *ImageOptions) { o.CfgScale = scale } } func WithSeed(seed int64) ImageOption { return func(o *ImageOptions) { o.Seed = seed } } func WithModel(model string) ImageOption { return func(o *ImageOptions) { o.Model = model } } func WithDimensions(width, height int) ImageOption { return func(o *ImageOptions) { o.Width = width o.Height = height } } func WithReferenceImages(images []string) ImageOption { return func(o *ImageOptions) { o.ReferenceImages = images } } ================================================ FILE: pkg/image/openai_image_client.go ================================================ package image import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) type OpenAIImageClient struct { BaseURL string APIKey string Model string Endpoint string HTTPClient *http.Client } type DALLERequest struct { Model string `json:"model"` Prompt string `json:"prompt"` Size string `json:"size,omitempty"` Quality string `json:"quality,omitempty"` N int `json:"n"` Image []string `json:"image,omitempty"` } type DALLEResponse struct { Created int64 `json:"created"` Data []struct { URL string `json:"url"` RevisedPrompt string `json:"revised_prompt,omitempty"` } `json:"data"` } func NewOpenAIImageClient(baseURL, apiKey, model, endpoint string) *OpenAIImageClient { if endpoint == "" { endpoint = "/v1/images/generations" } return &OpenAIImageClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, HTTPClient: &http.Client{ Timeout: 10 * time.Minute, }, } } func (c *OpenAIImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) { options := &ImageOptions{ Size: "1920x1920", Quality: "standard", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } reqBody := DALLERequest{ Model: model, Prompt: prompt, Size: options.Size, Quality: options.Quality, N: 1, Image: options.ReferenceImages, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } url := c.BaseURL + c.Endpoint fmt.Printf("[OpenAI Image] Request URL: %s\n", url) fmt.Printf("[OpenAI Image] Request Body: %s\n", string(jsonData)) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } fmt.Printf("OpenAI API Response: %s\n", string(body)) var result DALLEResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body)) } if len(result.Data) == 0 { return nil, fmt.Errorf("no image generated, response: %s", string(body)) } return &ImageResult{ Status: "completed", ImageURL: result.Data[0].URL, Completed: true, }, nil } func (c *OpenAIImageClient) GetTaskStatus(taskID string) (*ImageResult, error) { return nil, fmt.Errorf("not supported for OpenAI/DALL-E") } ================================================ FILE: pkg/image/volcengine_image_client.go ================================================ package image import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) type VolcEngineImageClient struct { BaseURL string APIKey string Model string Endpoint string QueryEndpoint string HTTPClient *http.Client } type VolcEngineImageRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` Image []string `json:"image,omitempty"` SequentialImageGeneration string `json:"sequential_image_generation,omitempty"` Size string `json:"size,omitempty"` Watermark bool `json:"watermark,omitempty"` } type VolcEngineImageResponse struct { Model string `json:"model"` Created int64 `json:"created"` Data []struct { URL string `json:"url"` Size string `json:"size"` } `json:"data"` Usage struct { GeneratedImages int `json:"generated_images"` OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` Error interface{} `json:"error,omitempty"` } func NewVolcEngineImageClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcEngineImageClient { if endpoint == "" { endpoint = "/api/v3/images/generations" } if queryEndpoint == "" { queryEndpoint = endpoint } return &VolcEngineImageClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, QueryEndpoint: queryEndpoint, HTTPClient: &http.Client{ Timeout: 10 * time.Minute, }, } } func (c *VolcEngineImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) { options := &ImageOptions{ Size: "1920x1920", Quality: "standard", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } promptText := prompt if options.NegativePrompt != "" { promptText += fmt.Sprintf(". Negative: %s", options.NegativePrompt) } size := options.Size if size == "" { if model == "doubao-seedream-4-5-251128" { size = "2K" } else { size = "1K" } } reqBody := VolcEngineImageRequest{ Model: model, Prompt: promptText, Image: options.ReferenceImages, SequentialImageGeneration: "disabled", Size: size, Watermark: false, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } url := c.BaseURL + c.Endpoint fmt.Printf("[VolcEngine Image] Request URL: %s\n", url) fmt.Printf("[VolcEngine Image] Request Body: %s\n", string(jsonData)) req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } fmt.Printf("VolcEngine Image API Response: %s\n", string(body)) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var result VolcEngineImageResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if result.Error != nil { return nil, fmt.Errorf("volcengine error: %v", result.Error) } if len(result.Data) == 0 { return nil, fmt.Errorf("no image generated") } return &ImageResult{ Status: "completed", ImageURL: result.Data[0].URL, Completed: true, }, nil } func (c *VolcEngineImageClient) GetTaskStatus(taskID string) (*ImageResult, error) { return nil, fmt.Errorf("not supported for VolcEngine Seedream (synchronous generation)") } ================================================ FILE: pkg/logger/logger.go ================================================ package logger import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" ) type Logger struct { *zap.SugaredLogger } func NewLogger(debug bool) *Logger { var config zap.Config if debug { config = zap.NewDevelopmentConfig() config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder // 在开发模式下,禁用时间戳和调用者信息,使输出更简洁 config.EncoderConfig.TimeKey = "" config.EncoderConfig.CallerKey = "" } else { config = zap.NewProductionConfig() config.EncoderConfig.TimeKey = "timestamp" config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder } logger, err := config.Build() if err != nil { panic(err) } return &Logger{ SugaredLogger: logger.Sugar(), } } ================================================ FILE: pkg/response/response.go ================================================ package response import ( "net/http" "time" "github.com/gin-gonic/gin" ) type Response struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error *ErrorInfo `json:"error,omitempty"` Message string `json:"message,omitempty"` Timestamp string `json:"timestamp"` } type ErrorInfo struct { Code string `json:"code"` Message string `json:"message"` Details interface{} `json:"details,omitempty"` } type PaginationData struct { Items interface{} `json:"items"` Pagination Pagination `json:"pagination"` } type Pagination struct { Page int `json:"page"` PageSize int `json:"page_size"` Total int64 `json:"total"` TotalPages int64 `json:"total_pages"` } func Success(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, Response{ Success: true, Data: data, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func SuccessWithMessage(c *gin.Context, message string, data interface{}) { c.JSON(http.StatusOK, Response{ Success: true, Data: data, Message: message, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func Created(c *gin.Context, data interface{}) { c.JSON(http.StatusCreated, Response{ Success: true, Data: data, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func SuccessWithPagination(c *gin.Context, items interface{}, total int64, page int, pageSize int) { totalPages := (total + int64(pageSize) - 1) / int64(pageSize) c.JSON(http.StatusOK, Response{ Success: true, Data: PaginationData{ Items: items, Pagination: Pagination{ Page: page, PageSize: pageSize, Total: total, TotalPages: totalPages, }, }, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func Error(c *gin.Context, statusCode int, errCode string, message string) { c.JSON(statusCode, Response{ Success: false, Error: &ErrorInfo{ Code: errCode, Message: message, }, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func ErrorWithDetails(c *gin.Context, statusCode int, errCode string, message string, details interface{}) { c.JSON(statusCode, Response{ Success: false, Error: &ErrorInfo{ Code: errCode, Message: message, Details: details, }, Timestamp: time.Now().UTC().Format(time.RFC3339), }) } func BadRequest(c *gin.Context, message string) { Error(c, http.StatusBadRequest, "BAD_REQUEST", message) } func Unauthorized(c *gin.Context, message string) { Error(c, http.StatusUnauthorized, "UNAUTHORIZED", message) } func Forbidden(c *gin.Context, message string) { Error(c, http.StatusForbidden, "FORBIDDEN", message) } func NotFound(c *gin.Context, message string) { Error(c, http.StatusNotFound, "NOT_FOUND", message) } func InternalError(c *gin.Context, message string) { Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", message) } ================================================ FILE: pkg/utils/image_utils.go ================================================ package utils import ( "encoding/base64" "fmt" "io" "net/http" "os" "strings" ) // ImageToBase64 将图片转换为 base64 编码 // 支持本地文件路径和 HTTP/HTTPS URL func ImageToBase64(imagePath string) (string, error) { var data []byte var err error if strings.HasPrefix(imagePath, "http://") || strings.HasPrefix(imagePath, "https://") { // 从 URL 下载图片 data, err = downloadImageFromURL(imagePath) if err != nil { return "", fmt.Errorf("failed to download image from URL: %w", err) } } else { // 从本地文件读取 data, err = os.ReadFile(imagePath) if err != nil { return "", fmt.Errorf("failed to read local image file: %w", err) } } // 转换为 base64 base64Str := base64.StdEncoding.EncodeToString(data) // 检测 MIME 类型 mimeType := detectImageMimeType(data) // 返回 data URI 格式 return fmt.Sprintf("data:%s;base64,%s", mimeType, base64Str), nil } // downloadImageFromURL 从 URL 下载图片数据 func downloadImageFromURL(url string) ([]byte, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP error: %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // detectImageMimeType 检测图片的 MIME 类型 func detectImageMimeType(data []byte) string { if len(data) < 12 { return "image/jpeg" // 默认 } // PNG: 89 50 4E 47 if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { return "image/png" } // JPEG: FF D8 FF if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { return "image/jpeg" } // GIF: 47 49 46 if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 { return "image/gif" } // WebP: 52 49 46 46 ... 57 45 42 50 if data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { return "image/webp" } return "image/jpeg" // 默认 } ================================================ FILE: pkg/utils/json_parser.go ================================================ package utils import ( "encoding/json" "fmt" "regexp" "strings" ) // SafeParseAIJSON 安全地解析AI返回的JSON,处理常见的格式问题 // 包括: // 1. 移除Markdown代码块标记 // 2. 提取JSON对象 // 3. 清理多余的空白和换行 // 4. 尝试修复截断的JSON // 5. 提供详细的错误信息 func SafeParseAIJSON(aiResponse string, v interface{}) error { if aiResponse == "" { return fmt.Errorf("AI返回内容为空") } // 1. 移除可能的Markdown代码块标记 cleaned := strings.TrimSpace(aiResponse) // 移除开头的 ```json 或 ``` cleaned = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(cleaned, "") cleaned = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(cleaned, "") // 移除结尾的 ``` cleaned = regexp.MustCompile("(?m)```\\s*$").ReplaceAllString(cleaned, "") cleaned = strings.TrimSpace(cleaned) // 2. 提取JSON (支持对象 {} 和数组 []) var jsonMatch string // 优先尝试提取完整的JSON(对象或数组) // 先尝试对象格式 if strings.HasPrefix(cleaned, "{") { jsonRegex := regexp.MustCompile(`(?s)\{.*\}`) jsonMatch = jsonRegex.FindString(cleaned) } // 如果没找到对象,尝试数组格式 if jsonMatch == "" && strings.HasPrefix(cleaned, "[") { jsonRegex := regexp.MustCompile(`(?s)\[.*\]`) jsonMatch = jsonRegex.FindString(cleaned) } // 如果还是没找到,尝试从中间提取 if jsonMatch == "" { // 尝试对象 objRegex := regexp.MustCompile(`(?s)\{.*\}`) jsonMatch = objRegex.FindString(cleaned) // 如果对象没找到,尝试数组 if jsonMatch == "" { arrRegex := regexp.MustCompile(`(?s)\[.*\]`) jsonMatch = arrRegex.FindString(cleaned) } } if jsonMatch == "" { return fmt.Errorf("响应中未找到有效的JSON对象或数组,原始响应: %s", truncateString(aiResponse, 200)) } // 3. 尝试解析JSON err := json.Unmarshal([]byte(jsonMatch), v) if err == nil { return nil // 解析成功 } // 4. 如果解析失败,尝试修复截断的JSON fixedJSON := attemptJSONRepair(jsonMatch) if fixedJSON != jsonMatch { if err := json.Unmarshal([]byte(fixedJSON), v); err == nil { return nil // 修复后解析成功 } } // 5. 检测是否是响应被截断导致的问题 if isTruncated(jsonMatch) { return fmt.Errorf( "AI响应可能被截断,导致JSON不完整。\n请尝试:\n1. 增加maxTokens参数\n2. 简化输入内容\n3. 使用更强大的模型\n\n原始错误: %s\n响应长度: %d\n响应末尾: %s", err.Error(), len(jsonMatch), truncateString(jsonMatch[maxInt(0, len(jsonMatch)-200):], 200), ) } // 6. 提供详细的错误上下文 if jsonErr, ok := err.(*json.SyntaxError); ok { errorPos := int(jsonErr.Offset) start := maxInt(0, errorPos-100) end := minInt(len(jsonMatch), errorPos+100) context := jsonMatch[start:end] marker := strings.Repeat(" ", errorPos-start) + "^" return fmt.Errorf( "JSON解析失败: %s\n错误位置附近:\n%s\n%s", jsonErr.Error(), context, marker, ) } return fmt.Errorf("JSON解析失败: %w\n原始响应: %s", err, truncateString(jsonMatch, 300)) } // attemptJSONRepair 尝试修复常见的JSON问题 func attemptJSONRepair(jsonStr string) string { // 1. 处理未闭合的字符串 // 如果最后一个字符不是 },尝试补全 trimmed := strings.TrimSpace(jsonStr) // 2. 检查是否有未闭合的引号 if strings.Count(trimmed, `"`)%2 != 0 { // 有奇数个引号,尝试补全最后一个引号 trimmed += `"` } // 3. 统计括号 openBraces := strings.Count(trimmed, "{") closeBraces := strings.Count(trimmed, "}") openBrackets := strings.Count(trimmed, "[") closeBrackets := strings.Count(trimmed, "]") // 4. 处理多余的闭合括号(从末尾移除) // 这是 AI 生成 JSON 时常见的问题 for closeBrackets > openBrackets && len(trimmed) > 0 { // 从末尾向前查找多余的 ] lastIdx := strings.LastIndex(trimmed, "]") if lastIdx >= 0 { trimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:] closeBrackets-- } else { break } } for closeBraces > openBraces && len(trimmed) > 0 { // 从末尾向前查找多余的 } lastIdx := strings.LastIndex(trimmed, "}") if lastIdx >= 0 { trimmed = trimmed[:lastIdx] + trimmed[lastIdx+1:] closeBraces-- } else { break } } // 重新统计括号(因为可能已修改) openBraces = strings.Count(trimmed, "{") closeBraces = strings.Count(trimmed, "}") openBrackets = strings.Count(trimmed, "[") closeBrackets = strings.Count(trimmed, "]") // 5. 补全未闭合的数组 for i := 0; i < openBrackets-closeBrackets; i++ { trimmed += "]" } // 6. 补全未闭合的对象 for i := 0; i < openBraces-closeBraces; i++ { trimmed += "}" } return trimmed } // ExtractJSONFromText 从文本中提取JSON对象或数组 func ExtractJSONFromText(text string) string { text = strings.TrimSpace(text) // 移除Markdown代码块 text = regexp.MustCompile("(?m)^```json\\s*").ReplaceAllString(text, "") text = regexp.MustCompile("(?m)^```\\s*").ReplaceAllString(text, "") text = strings.TrimSpace(text) // 查找JSON对象 if idx := strings.Index(text, "{"); idx != -1 { if lastIdx := strings.LastIndex(text, "}"); lastIdx != -1 && lastIdx > idx { return text[idx : lastIdx+1] } } // 查找JSON数组 if idx := strings.Index(text, "["); idx != -1 { if lastIdx := strings.LastIndex(text, "]"); lastIdx != -1 && lastIdx > idx { return text[idx : lastIdx+1] } } return text } // ValidateJSON 验证JSON字符串是否有效 func ValidateJSON(jsonStr string) error { var js json.RawMessage return json.Unmarshal([]byte(jsonStr), &js) } // isTruncated 检测JSON字符串是否可能被截断 func isTruncated(jsonStr string) bool { trimmed := strings.TrimSpace(jsonStr) if len(trimmed) == 0 { return false } // 检查是否以不完整的字符串结尾(引号未闭合) lastChar := trimmed[len(trimmed)-1] if lastChar != '}' && lastChar != ']' { return true } // 检查括号是否匹配 openBraces := strings.Count(trimmed, "{") closeBraces := strings.Count(trimmed, "}") openBrackets := strings.Count(trimmed, "[") closeBrackets := strings.Count(trimmed, "]") if openBraces != closeBraces || openBrackets != closeBrackets { return true } // 检查引号是否匹配(简化检查,不考虑转义) quoteCount := strings.Count(trimmed, `"`) if quoteCount%2 != 0 { return true } return false } // Helper functions func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } func maxInt(a, b int) int { if a > b { return a } return b } func minInt(a, b int) int { if a < b { return a } return b } ================================================ FILE: pkg/utils/json_parser_test.go ================================================ package utils import ( "encoding/json" "testing" ) // TestAttemptJSONRepairExcessBraces tests fixing JSON with excess closing braces // This is the fix for issue #28: AI sometimes returns JSON with extra closing braces func TestAttemptJSONRepairExcessBraces(t *testing.T) { tests := []struct { name string input string wantErr bool }{ { name: "normal JSON", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}`, wantErr: false, }, { name: "extra closing brace - issue #28 case", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}}`, wantErr: false, }, { name: "extra closing bracket", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]]}`, wantErr: false, }, { name: "multiple extra closing braces", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]}}}`, wantErr: false, }, { name: "missing closing brace", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}]`, wantErr: false, }, { name: "missing closing bracket", input: `{"backgrounds": [{"location": "test", "prompt": "hello"}`, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var result struct { Backgrounds []struct { Location string `json:"location"` Prompt string `json:"prompt"` } `json:"backgrounds"` } err := SafeParseAIJSON(tt.input, &result) if (err != nil) != tt.wantErr { t.Errorf("SafeParseAIJSON() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr { // Verify the parsed result if len(result.Backgrounds) != 1 { t.Errorf("Expected 1 background, got %d", len(result.Backgrounds)) return } if result.Backgrounds[0].Location != "test" { t.Errorf("Expected location 'test', got '%s'", result.Backgrounds[0].Location) } if result.Backgrounds[0].Prompt != "hello" { t.Errorf("Expected prompt 'hello', got '%s'", result.Backgrounds[0].Prompt) } } }) } } // TestAttemptJSONRepairFunction tests the attemptJSONRepair function directly func TestAttemptJSONRepairFunction(t *testing.T) { tests := []struct { name string input string valid bool }{ { name: "fix extra closing brace", input: `{"key": "value"}}`, valid: true, }, { name: "fix extra closing bracket", input: `["item1", "item2"]]`, valid: true, }, { name: "fix missing closing brace", input: `{"key": "value"`, valid: true, }, { name: "fix missing closing bracket", input: `["item1", "item2"`, valid: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repaired := attemptJSONRepair(tt.input) var js json.RawMessage err := json.Unmarshal([]byte(repaired), &js) if tt.valid && err != nil { t.Errorf("attemptJSONRepair() failed to produce valid JSON: %v\nInput: %s\nOutput: %s", err, tt.input, repaired) } }) } } ================================================ FILE: pkg/video/chatfire_client.go ================================================ package video import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // ChatfireClient Chatfire 视频生成客户端 type ChatfireClient struct { BaseURL string APIKey string Model string Endpoint string QueryEndpoint string HTTPClient *http.Client } type ChatfireRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` ImageURL string `json:"image_url,omitempty"` Duration int `json:"duration,omitempty"` Size string `json:"size,omitempty"` } // ChatfireSoraRequest Sora 模型请求格式 type ChatfireSoraRequest struct { Model string `json:"model"` Prompt string `json:"prompt"` Seconds string `json:"seconds,omitempty"` Size string `json:"size,omitempty"` InputReference string `json:"input_reference,omitempty"` } // ChatfireDoubaoRequest 豆包/火山模型请求格式 type ChatfireDoubaoRequest struct { Model string `json:"model"` Content []struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` } `json:"content"` } type ChatfireResponse struct { ID string `json:"id"` TaskID string `json:"task_id,omitempty"` Status string `json:"status,omitempty"` Error json.RawMessage `json:"error,omitempty"` Data struct { ID string `json:"id,omitempty"` Status string `json:"status,omitempty"` VideoURL string `json:"video_url,omitempty"` } `json:"data,omitempty"` } type ChatfireTaskResponse struct { ID string `json:"id,omitempty"` TaskID string `json:"task_id,omitempty"` Status string `json:"status,omitempty"` VideoURL string `json:"video_url,omitempty"` Error json.RawMessage `json:"error,omitempty"` Data struct { ID string `json:"id,omitempty"` Status string `json:"status,omitempty"` VideoURL string `json:"video_url,omitempty"` } `json:"data,omitempty"` Content struct { VideoURL string `json:"video_url,omitempty"` } `json:"content,omitempty"` } // getErrorMessage 从 error 字段提取错误信息(支持字符串或对象) func getErrorMessage(errorData json.RawMessage) string { if len(errorData) == 0 { return "" } // 尝试解析为字符串 var errStr string if err := json.Unmarshal(errorData, &errStr); err == nil { return errStr } // 尝试解析为对象 var errObj struct { Message string `json:"message"` Code string `json:"code"` } if err := json.Unmarshal(errorData, &errObj); err == nil { if errObj.Message != "" { return errObj.Message } } // 返回原始 JSON 字符串 return string(errorData) } func NewChatfireClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *ChatfireClient { if endpoint == "" { endpoint = "/video/generations" } if queryEndpoint == "" { queryEndpoint = "/video/task/{taskId}" } return &ChatfireClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, QueryEndpoint: queryEndpoint, HTTPClient: &http.Client{ Timeout: 300 * time.Second, }, } } func (c *ChatfireClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 5, AspectRatio: "16:9", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } // 根据模型名称选择请求格式 var jsonData []byte var err error if strings.Contains(model, "doubao") || strings.Contains(model, "seedance") { // 豆包/火山格式 reqBody := ChatfireDoubaoRequest{ Model: model, } // 构建prompt文本(包含duration和ratio参数) promptText := prompt if options.AspectRatio != "" { promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio) } if options.Duration > 0 { promptText += fmt.Sprintf(" --dur %d", options.Duration) } // 添加文本内容 reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{Type: "text", Text: promptText}) // 处理不同的图片模式 // 1. 组图模式(多个reference_image) if len(options.ReferenceImageURLs) > 0 { for _, refURL := range options.ReferenceImageURLs { reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{ Type: "image_url", ImageURL: map[string]interface{}{ "url": refURL, }, Role: "reference_image", }) } } else if options.FirstFrameURL != "" && options.LastFrameURL != "" { // 2. 首尾帧模式 reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.FirstFrameURL, }, Role: "first_frame", }) reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.LastFrameURL, }, Role: "last_frame", }) } else if imageURL != "" { // 3. 单图模式(默认) reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{ Type: "image_url", ImageURL: map[string]interface{}{ "url": imageURL, }, // 单图模式不需要role }) } else if options.FirstFrameURL != "" { // 4. 只有首帧 reqBody.Content = append(reqBody.Content, struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` }{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.FirstFrameURL, }, Role: "first_frame", }) } jsonData, err = json.Marshal(reqBody) } else if strings.Contains(model, "sora") { // Sora 格式 seconds := fmt.Sprintf("%d", options.Duration) size := options.AspectRatio if size == "16:9" { size = "1280x720" } else if size == "9:16" { size = "720x1280" } reqBody := ChatfireSoraRequest{ Model: model, Prompt: prompt, Seconds: seconds, Size: size, InputReference: imageURL, } jsonData, err = json.Marshal(reqBody) } else { // 默认格式 reqBody := ChatfireRequest{ Model: model, Prompt: prompt, ImageURL: imageURL, Duration: options.Duration, Size: options.AspectRatio, } jsonData, err = json.Marshal(reqBody) } if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } endpoint := c.BaseURL + c.Endpoint req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } // 调试日志:打印响应内容 fmt.Printf("[Chatfire] Response body: %s\n", string(body)) var result ChatfireResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body)) } // 优先使用 id 字段,其次使用 task_id taskID := result.ID if taskID == "" { taskID = result.TaskID } // 如果有 data 嵌套,优先使用 data 中的值 if result.Data.ID != "" { taskID = result.Data.ID } status := result.Status if status == "" && result.Data.Status != "" { status = result.Data.Status } fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s\n", taskID, status) if errMsg := getErrorMessage(result.Error); errMsg != "" { return nil, fmt.Errorf("chatfire error: %s", errMsg) } videoResult := &VideoResult{ TaskID: taskID, Status: status, Completed: status == "completed" || status == "succeeded", Duration: options.Duration, } return videoResult, nil } func (c *ChatfireClient) GetTaskStatus(taskID string) (*VideoResult, error) { queryPath := c.QueryEndpoint if strings.Contains(queryPath, "{taskId}") { queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID) } else if strings.Contains(queryPath, "{task_id}") { queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID) } else { queryPath = queryPath + "/" + taskID } endpoint := c.BaseURL + queryPath req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } // 调试日志:打印响应内容 fmt.Printf("[Chatfire] GetTaskStatus Response body: %s\n", string(body)) var result ChatfireTaskResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w, body: %s", err, string(body)) } // 优先使用 id 字段,其次使用 task_id responseTaskID := result.ID if responseTaskID == "" { responseTaskID = result.TaskID } // 如果有 data 嵌套,优先使用 data 中的值 if result.Data.ID != "" { responseTaskID = result.Data.ID } status := result.Status if status == "" && result.Data.Status != "" { status = result.Data.Status } // 按优先级获取 video_url:VideoURL -> Data.VideoURL -> Content.VideoURL videoURL := result.VideoURL if videoURL == "" && result.Data.VideoURL != "" { videoURL = result.Data.VideoURL } if videoURL == "" && result.Content.VideoURL != "" { videoURL = result.Content.VideoURL } fmt.Printf("[Chatfire] Parsed result - TaskID: %s, Status: %s, VideoURL: %s\n", responseTaskID, status, videoURL) videoResult := &VideoResult{ TaskID: responseTaskID, Status: status, Completed: status == "completed" || status == "succeeded", } if errMsg := getErrorMessage(result.Error); errMsg != "" { videoResult.Error = errMsg } if videoURL != "" { videoResult.VideoURL = videoURL videoResult.Completed = true } return videoResult, nil } ================================================ FILE: pkg/video/minimax_client.go ================================================ package video import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) // MiniMax Hailuo 支持的模型 const ( // ModelHailuo23 全新视频生成模型,肢体动作、面部表情、物理表现与指令遵循再度突破 // 支持:文生视频、图生视频 // 时长:768P(6s/10s), 1080P(6s) ModelHailuo23 = "MiniMax-Hailuo-2.3" // ModelHailuo23Fast 全新图生视频模型,物理表现与指令遵循具佳,更快更优惠 // 支持:图生视频 // 时长:768P(6s/10s), 1080P(6s) ModelHailuo23Fast = "MiniMax-Hailuo-2.3-Fast" // ModelHailuo02 新一代视频生成模型,1080p 原生,SOTA 指令遵循,极致物理表现 // 支持:文生视频、图生视频、首尾帧模式 // 时长:768P(6s/10s), 1080P(6s) ModelHailuo02 = "MiniMax-Hailuo-02" ) // MiniMax Hailuo 支持的分辨率 const ( Resolution768P = "768P" Resolution1080P = "1080P" ) // MiniMax Hailuo 支持的时长(秒) const ( Duration6s = 6 Duration10s = 10 ) // MinimaxClient Minimax视频生成客户端 type MinimaxClient struct { BaseURL string APIKey string Model string HTTPClient *http.Client } type MinimaxSubjectReference struct { Type string `json:"type"` Image []string `json:"image"` } type MinimaxRequest struct { Prompt string `json:"prompt"` FirstFrameImage string `json:"first_frame_image,omitempty"` LastFrameImage string `json:"last_frame_image,omitempty"` SubjectReference []MinimaxSubjectReference `json:"subject_reference,omitempty"` Model string `json:"model"` Duration int `json:"duration,omitempty"` Resolution string `json:"resolution,omitempty"` } // MinimaxCreateResponse 创建任务的响应 type MinimaxCreateResponse struct { TaskID string `json:"task_id"` BaseResp struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` } `json:"base_resp"` } // MinimaxQueryResponse 查询任务状态的响应 type MinimaxQueryResponse struct { TaskID string `json:"task_id"` Status string `json:"status"` // Processing, Success, Failed FileID string `json:"file_id"` VideoWidth int `json:"video_width"` VideoHeight int `json:"video_height"` BaseResp struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` } `json:"base_resp"` } // MinimaxFileResponse 获取文件信息的响应 type MinimaxFileResponse struct { File struct { FileID interface{} `json:"file_id"` // 可能是 string 或 number Bytes int `json:"bytes"` CreatedAt int64 `json:"created_at"` Filename string `json:"filename"` Purpose string `json:"purpose"` DownloadURL string `json:"download_url"` } `json:"file"` BaseResp struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` } `json:"base_resp"` } func NewMinimaxClient(baseURL, apiKey, model string) *MinimaxClient { return &MinimaxClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, HTTPClient: &http.Client{ Timeout: 300 * time.Second, }, } } // GenerateVideo 生成视频(支持首尾帧和主体参考) // 步骤1:创建任务,返回 task_id func (c *MinimaxClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 6, Resolution: "1080P", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } reqBody := MinimaxRequest{ Prompt: prompt, Model: model, Duration: options.Duration, } // 设置分辨率 if options.Resolution != "" { reqBody.Resolution = options.Resolution } // 支持首帧图片 if options.FirstFrameURL != "" { reqBody.FirstFrameImage = options.FirstFrameURL } else if imageURL != "" { reqBody.FirstFrameImage = imageURL } // 支持尾帧图片 if options.LastFrameURL != "" { reqBody.LastFrameImage = options.LastFrameURL } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } // 步骤1:创建任务,POST 请求 // 注意:BaseURL 应该已包含 /v1,例如 https://api.minimaxi.com/v1 endpoint := c.BaseURL + "/video_generation" req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var result MinimaxCreateResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if result.BaseResp.StatusCode != 0 { return nil, fmt.Errorf("minimax error: %s", result.BaseResp.StatusMsg) } // 第一步只返回 task_id,状态为 Processing videoResult := &VideoResult{ TaskID: result.TaskID, Status: "Processing", Completed: false, } return videoResult, nil } // GetTaskStatus 查询任务状态 // 步骤2:查询任务状态,如果成功则进入步骤3获取文件下载地址 func (c *MinimaxClient) GetTaskStatus(taskID string) (*VideoResult, error) { // 步骤2:查询任务状态 // 注意:BaseURL 应该已包含 /v1 endpoint := fmt.Sprintf("%s/query/video_generation?task_id=%s", c.BaseURL, taskID) req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var queryResult MinimaxQueryResponse if err := json.Unmarshal(body, &queryResult); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if queryResult.BaseResp.StatusCode != 0 { return nil, fmt.Errorf("minimax error: %s", queryResult.BaseResp.StatusMsg) } videoResult := &VideoResult{ TaskID: queryResult.TaskID, Status: queryResult.Status, Width: queryResult.VideoWidth, Height: queryResult.VideoHeight, Completed: false, } // 如果状态是 Success 且有 file_id,则获取文件下载地址 if queryResult.Status == "Success" && queryResult.FileID != "" { downloadURL, err := c.getFileDownloadURL(queryResult.FileID) if err != nil { return nil, fmt.Errorf("failed to get download URL: %w", err) } videoResult.VideoURL = downloadURL videoResult.Completed = true } else if queryResult.Status == "Failed" { videoResult.Error = "Video generation failed" videoResult.Completed = true } return videoResult, nil } // getFileDownloadURL 步骤3:根据 file_id 获取文件下载地址 func (c *MinimaxClient) getFileDownloadURL(fileID string) (string, error) { // 注意:BaseURL 应该已包含 /v1 endpoint := fmt.Sprintf("%s/files/retrieve?file_id=%s", c.BaseURL, fileID) req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return "", fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return "", fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var fileResult MinimaxFileResponse if err := json.Unmarshal(body, &fileResult); err != nil { return "", fmt.Errorf("parse response: %w", err) } if fileResult.BaseResp.StatusCode != 0 { return "", fmt.Errorf("minimax error: %s", fileResult.BaseResp.StatusMsg) } return fileResult.File.DownloadURL, nil } ================================================ FILE: pkg/video/openai_sora_client.go ================================================ package video import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/textproto" // Added for explicit MIME header control "path/filepath" "strings" "time" ) type OpenAISoraClient struct { BaseURL string APIKey string Model string HTTPClient *http.Client } type OpenAISoraResponse struct { ID string `json:"id"` Object string `json:"object"` Model string `json:"model"` Status string `json:"status"` Progress int `json:"progress"` CreatedAt int64 `json:"created_at"` CompletedAt int64 `json:"completed_at"` Size string `json:"size"` Seconds string `json:"seconds"` Quality string `json:"quality"` VideoURL string `json:"video_url"` // 直接的video_url字段 Video struct { URL string `json:"url"` } `json:"video"` // 嵌套的video.url字段(兼容) Error struct { Message string `json:"message"` Type string `json:"type"` } `json:"error"` } func NewOpenAISoraClient(baseURL, apiKey, model string) *OpenAISoraClient { return &OpenAISoraClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, HTTPClient: &http.Client{ Timeout: 300 * time.Second, }, } } func (c *OpenAISoraClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 4, } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } body := &bytes.Buffer{} writer := multipart.NewWriter(body) // Add basic fields writer.WriteField("model", model) writer.WriteField("prompt", prompt) if options.Duration > 0 { writer.WriteField("seconds", fmt.Sprintf("%d", options.Duration)) } if options.Resolution != "" { writer.WriteField("size", options.Resolution) } // [PR FIX START] // The OpenAI Sora API requires 'input_reference' to be a file upload (binary), not a URL string // set the Content-Type header (e.g., image/png) or the API returns 400 if imageURL != "" { var imageData []byte var mimeType string var filename string = "reference_image.png" if strings.HasPrefix(imageURL, "data:") { // Case A: Handle Base64 Data URI (often stored in DB) parts := strings.Split(imageURL, ",") if len(parts) != 2 { return nil, fmt.Errorf("invalid data URI format") } // Extract mime type from header (e.g., "data:image/jpeg;base64") header := parts[0] if strings.Contains(header, "image/jpeg") || strings.Contains(header, "image/jpg") { mimeType = "image/jpeg" filename = "reference.jpg" } else if strings.Contains(header, "image/png") { mimeType = "image/png" filename = "reference.png" } else if strings.Contains(header, "image/webp") { mimeType = "image/webp" filename = "reference.webp" } else { mimeType = "image/png" // Default fallback } decoded, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return nil, fmt.Errorf("failed to decode base64 image: %w", err) } imageData = decoded } else { // Case B: Handle Standard HTTP/HTTPS URL resp, err := http.Get(imageURL) if err != nil { return nil, fmt.Errorf("failed to download reference image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download reference image, status: %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read downloaded image: %w", err) } imageData = data // Use the Content-Type header from the response mimeType = resp.Header.Get("Content-Type") // Fallback/Correction if server sends bad headers if mimeType == "" || mimeType == "application/octet-stream" { ext := filepath.Ext(imageURL) switch strings.ToLower(ext) { case ".jpg", ".jpeg": mimeType = "image/jpeg" case ".png": mimeType = "image/png" case ".webp": mimeType = "image/webp" default: mimeType = "image/png" } } // Ensure filename has extension base := filepath.Base(imageURL) if base != "" && base != "." { if idx := strings.Index(base, "?"); idx != -1 { base = base[:idx] } filename = base } } // Create the MIME Header manually to force the Content-Type. // Standard writer.CreateFormFile does not set Content-Type, causing "unsupported mimetype" errors. h := make(textproto.MIMEHeader) h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="input_reference"; filename="%s"`, filename)) h.Set("Content-Type", mimeType) part, err := writer.CreatePart(h) if err != nil { return nil, fmt.Errorf("create part: %w", err) } if _, err := part.Write(imageData); err != nil { return nil, fmt.Errorf("write image data: %w", err) } } // [PR FIX END] writer.Close() endpoint := c.BaseURL + "/videos" req, err := http.NewRequest("POST", endpoint, body) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) } var result OpenAISoraResponse if err := json.Unmarshal(respBody, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if result.Error.Message != "" { return nil, fmt.Errorf("openai error: %s", result.Error.Message) } videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "completed", } // 优先使用video_url字段,兼容video.url嵌套结构 if result.VideoURL != "" { videoResult.VideoURL = result.VideoURL } else if result.Video.URL != "" { videoResult.VideoURL = result.Video.URL } return videoResult, nil } func (c *OpenAISoraClient) GetTaskStatus(taskID string) (*VideoResult, error) { endpoint := c.BaseURL + "/videos/" + taskID req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } var result OpenAISoraResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "completed", } if result.Error.Message != "" { videoResult.Error = result.Error.Message } // 优先使用video_url字段,兼容video.url嵌套结构 if result.VideoURL != "" { videoResult.VideoURL = result.VideoURL } else if result.Video.URL != "" { videoResult.VideoURL = result.Video.URL } return videoResult, nil } ================================================ FILE: pkg/video/video_client.go ================================================ package video import ( "bytes" "encoding/json" "fmt" "io" "net/http" "time" ) type VideoClient interface { GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) GetTaskStatus(taskID string) (*VideoResult, error) } type VideoResult struct { TaskID string Status string VideoURL string ThumbnailURL string Duration int Width int Height int Error string Completed bool } type VideoOptions struct { Model string Duration int FPS int Resolution string AspectRatio string Style string MotionLevel int CameraMotion string Seed int64 FirstFrameURL string LastFrameURL string ReferenceImageURLs []string } type VideoOption func(*VideoOptions) func WithModel(model string) VideoOption { return func(o *VideoOptions) { o.Model = model } } func WithDuration(duration int) VideoOption { return func(o *VideoOptions) { o.Duration = duration } } func WithFPS(fps int) VideoOption { return func(o *VideoOptions) { o.FPS = fps } } func WithResolution(resolution string) VideoOption { return func(o *VideoOptions) { o.Resolution = resolution } } func WithAspectRatio(ratio string) VideoOption { return func(o *VideoOptions) { o.AspectRatio = ratio } } func WithStyle(style string) VideoOption { return func(o *VideoOptions) { o.Style = style } } func WithMotionLevel(level int) VideoOption { return func(o *VideoOptions) { o.MotionLevel = level } } func WithCameraMotion(motion string) VideoOption { return func(o *VideoOptions) { o.CameraMotion = motion } } func WithSeed(seed int64) VideoOption { return func(o *VideoOptions) { o.Seed = seed } } func WithFirstFrame(url string) VideoOption { return func(o *VideoOptions) { o.FirstFrameURL = url } } func WithLastFrame(url string) VideoOption { return func(o *VideoOptions) { o.LastFrameURL = url } } func WithReferenceImages(urls []string) VideoOption { return func(o *VideoOptions) { o.ReferenceImageURLs = urls } } type RunwayClient struct { BaseURL string APIKey string Model string HTTPClient *http.Client } type RunwayRequest struct { Model string `json:"model"` PromptImage string `json:"prompt_image"` PromptText string `json:"prompt_text"` Duration int `json:"duration,omitempty"` AspectRatio string `json:"aspect_ratio,omitempty"` Seed int64 `json:"seed,omitempty"` } type RunwayResponse struct { ID string `json:"id"` Status string `json:"status"` Output struct { URL string `json:"url"` } `json:"output"` Error string `json:"error,omitempty"` } func NewRunwayClient(baseURL, apiKey, model string) *RunwayClient { return &RunwayClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, HTTPClient: &http.Client{ Timeout: 180 * time.Second, }, } } func (c *RunwayClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 5, AspectRatio: "16:9", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } reqBody := RunwayRequest{ Model: model, PromptImage: imageURL, PromptText: prompt, Duration: options.Duration, AspectRatio: options.AspectRatio, Seed: options.Seed, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } endpoint := c.BaseURL + "/v1/video/generate" req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var result RunwayResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if result.Error != "" { return nil, fmt.Errorf("runway error: %s", result.Error) } videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "succeeded", } if result.Output.URL != "" { videoResult.VideoURL = result.Output.URL } return videoResult, nil } func (c *RunwayClient) GetTaskStatus(taskID string) (*VideoResult, error) { endpoint := c.BaseURL + "/v1/video/status/" + taskID req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } var result RunwayResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "succeeded", } if result.Error != "" { videoResult.Error = result.Error } if result.Output.URL != "" { videoResult.VideoURL = result.Output.URL } return videoResult, nil } type PikaClient struct { BaseURL string APIKey string Model string HTTPClient *http.Client } type PikaRequest struct { Model string `json:"model"` Image string `json:"image"` Prompt string `json:"prompt"` Duration int `json:"duration,omitempty"` AspectRatio string `json:"aspect_ratio,omitempty"` Motion int `json:"motion,omitempty"` CameraMotion string `json:"camera_motion,omitempty"` Seed int64 `json:"seed,omitempty"` } type PikaResponse struct { JobID string `json:"job_id"` Status string `json:"status"` Result struct { VideoURL string `json:"video_url"` } `json:"result"` Error string `json:"error,omitempty"` } func NewPikaClient(baseURL, apiKey, model string) *PikaClient { return &PikaClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, HTTPClient: &http.Client{ Timeout: 180 * time.Second, }, } } func (c *PikaClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 3, AspectRatio: "16:9", MotionLevel: 50, } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } reqBody := PikaRequest{ Model: model, Image: imageURL, Prompt: prompt, Duration: options.Duration, AspectRatio: options.AspectRatio, Motion: options.MotionLevel, CameraMotion: options.CameraMotion, Seed: options.Seed, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } endpoint := c.BaseURL + "/v1/video/generate" req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var result PikaResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } if result.Error != "" { return nil, fmt.Errorf("pika error: %s", result.Error) } videoResult := &VideoResult{ TaskID: result.JobID, Status: result.Status, Completed: result.Status == "completed", } if result.Result.VideoURL != "" { videoResult.VideoURL = result.Result.VideoURL } return videoResult, nil } func (c *PikaClient) GetTaskStatus(taskID string) (*VideoResult, error) { endpoint := c.BaseURL + "/v1/video/status/" + taskID req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } var result PikaResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } videoResult := &VideoResult{ TaskID: result.JobID, Status: result.Status, Completed: result.Status == "completed", } if result.Error != "" { videoResult.Error = result.Error } if result.Result.VideoURL != "" { videoResult.VideoURL = result.Result.VideoURL } return videoResult, nil } ================================================ FILE: pkg/video/volces_ark_client.go ================================================ package video import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // VolcesArkClient 火山引擎ARK视频生成客户端 type VolcesArkClient struct { BaseURL string APIKey string Model string Endpoint string QueryEndpoint string HTTPClient *http.Client } type VolcesArkContent struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL map[string]interface{} `json:"image_url,omitempty"` Role string `json:"role,omitempty"` } type VolcesArkRequest struct { Model string `json:"model"` Content []VolcesArkContent `json:"content"` GenerateAudio bool `json:"generate_audio,omitempty"` } type VolcesArkResponse struct { ID string `json:"id"` Model string `json:"model"` Status string `json:"status"` Content struct { VideoURL string `json:"video_url"` } `json:"content"` Usage struct { CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` Seed int `json:"seed"` Resolution string `json:"resolution"` Ratio string `json:"ratio"` Duration int `json:"duration"` FramesPerSecond int `json:"framespersecond"` ServiceTier string `json:"service_tier"` ExecutionExpiresAfter int `json:"execution_expires_after"` GenerateAudio bool `json:"generate_audio"` Error interface{} `json:"error,omitempty"` } func NewVolcesArkClient(baseURL, apiKey, model, endpoint, queryEndpoint string) *VolcesArkClient { if endpoint == "" { endpoint = "/api/v3/contents/generations/tasks" } if queryEndpoint == "" { queryEndpoint = endpoint } return &VolcesArkClient{ BaseURL: baseURL, APIKey: apiKey, Model: model, Endpoint: endpoint, QueryEndpoint: queryEndpoint, HTTPClient: &http.Client{ Timeout: 300 * time.Second, }, } } // GenerateVideo 生成视频(支持首帧、首尾帧、参考图等多种模式) func (c *VolcesArkClient) GenerateVideo(imageURL, prompt string, opts ...VideoOption) (*VideoResult, error) { options := &VideoOptions{ Duration: 5, AspectRatio: "adaptive", } for _, opt := range opts { opt(options) } model := c.Model if options.Model != "" { model = options.Model } // 构建prompt文本(包含duration和ratio参数) promptText := prompt if options.AspectRatio != "" { promptText += fmt.Sprintf(" --ratio %s", options.AspectRatio) } if options.Duration > 0 { promptText += fmt.Sprintf(" --dur %d", options.Duration) } content := []VolcesArkContent{ { Type: "text", Text: promptText, }, } // 处理不同的图片模式 // 1. 组图模式(多个reference_image) if len(options.ReferenceImageURLs) > 0 { for _, refURL := range options.ReferenceImageURLs { content = append(content, VolcesArkContent{ Type: "image_url", ImageURL: map[string]interface{}{ "url": refURL, }, Role: "reference_image", }) } } else if options.FirstFrameURL != "" && options.LastFrameURL != "" { // 2. 首尾帧模式 content = append(content, VolcesArkContent{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.FirstFrameURL, }, Role: "first_frame", }) content = append(content, VolcesArkContent{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.LastFrameURL, }, Role: "last_frame", }) } else if imageURL != "" { // 3. 单图模式(默认) content = append(content, VolcesArkContent{ Type: "image_url", ImageURL: map[string]interface{}{ "url": imageURL, }, // 单图模式不需要role }) } else if options.FirstFrameURL != "" { // 4. 只有首帧 content = append(content, VolcesArkContent{ Type: "image_url", ImageURL: map[string]interface{}{ "url": options.FirstFrameURL, }, Role: "first_frame", }) } // 只有 seedance-1-5-pro 模型支持 generate_audio 参数 generateAudio := false if strings.Contains(strings.ToLower(model), "seedance-1-5-pro") { generateAudio = true } reqBody := VolcesArkRequest{ Model: model, Content: content, GenerateAudio: generateAudio, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } endpoint := c.BaseURL + c.Endpoint fmt.Printf("[VolcesARK] Generating video - Endpoint: %s, FullURL: %s, Model: %s\n", c.Endpoint, endpoint, model) fmt.Printf("[VolcesARK] Request body: %s\n", string(jsonData)) req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } fmt.Printf("[VolcesARK] Response status: %d, body: %s\n", resp.StatusCode, string(body)) if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } var result VolcesArkResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } fmt.Printf("[VolcesARK] Video generation initiated - TaskID: %s, Status: %s\n", result.ID, result.Status) if result.Error != nil { errorMsg := fmt.Sprintf("%v", result.Error) return nil, fmt.Errorf("volces error: %s", errorMsg) } videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "completed" || result.Status == "succeeded", Duration: result.Duration, } if result.Content.VideoURL != "" { videoResult.VideoURL = result.Content.VideoURL videoResult.Completed = true } return videoResult, nil } func (c *VolcesArkClient) GetTaskStatus(taskID string) (*VideoResult, error) { // 替换占位符{taskId}、{task_id}或直接拼接 queryPath := c.QueryEndpoint if strings.Contains(queryPath, "{taskId}") { queryPath = strings.ReplaceAll(queryPath, "{taskId}", taskID) } else if strings.Contains(queryPath, "{task_id}") { queryPath = strings.ReplaceAll(queryPath, "{task_id}", taskID) } else { queryPath = queryPath + "/" + taskID } endpoint := c.BaseURL + queryPath fmt.Printf("[VolcesARK] Querying task status - TaskID: %s, QueryEndpoint: %s, FullURL: %s\n", taskID, c.QueryEndpoint, endpoint) req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } fmt.Printf("[VolcesARK] Response body: %s\n", string(body)) var result VolcesArkResponse if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } fmt.Printf("[VolcesARK] Parsed result - ID: %s, Status: %s, VideoURL: %s\n", result.ID, result.Status, result.Content.VideoURL) videoResult := &VideoResult{ TaskID: result.ID, Status: result.Status, Completed: result.Status == "completed" || result.Status == "succeeded", Duration: result.Duration, } if result.Error != nil { videoResult.Error = fmt.Sprintf("%v", result.Error) } if result.Content.VideoURL != "" { videoResult.VideoURL = result.Content.VideoURL videoResult.Completed = true } return videoResult, nil } ================================================ FILE: web/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? # Environment .env .env.local .env.*.local ================================================ FILE: web/index.html ================================================ Drama Generator - AI 短剧生成平台
================================================ FILE: web/nginx.conf ================================================ server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_comp_level 6; gzip_min_length 1000; } ================================================ FILE: web/package.json ================================================ { "name": "drama-generator-frontend", "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "build:check": "vue-tsc --noEmit --skipLibCheck && vite build", "build:skip": "vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { "@element-plus/icons-vue": "^2.3.0", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "axios": "^1.6.0", "cropperjs": "^2.1.0", "dayjs": "^1.11.10", "element-plus": "^2.5.0", "lodash-es": "^4.17.22", "pinia": "^2.1.0", "vue": "^3.4.0", "vue-i18n": "^9.14.5", "vue-router": "^4.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.0", "@types/node": "^20.10.0", "@vitejs/plugin-vue": "^5.0.0", "@vue/tsconfig": "^0.5.0", "autoprefixer": "^10.4.0", "postcss": "^8.4.0", "sass-embedded": "^1.97.1", "tailwindcss": "^4.1.0", "typescript": "^5.3.0", "vite": "^5.0.0", "vue-tsc": "^2.2.12" } } ================================================ FILE: web/public/ffmpeg/ffmpeg-core.js ================================================ var createFFmpegCore = (() => { var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined; return ( function(createFFmpegCore = {}) { var Module=typeof createFFmpegCore!="undefined"?createFFmpegCore:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=["./ffmpeg","-nostdin","-y"];const DEFAULT_ARGS_FFPROBE=["./ffprobe"];Module["NULL"]=NULL;Module["SIZE_I32"]=SIZE_I32;Module["DEFAULT_ARGS"]=DEFAULT_ARGS;Module["DEFAULT_ARGS_FFPROBE"]=DEFAULT_ARGS_FFPROBE;Module["ret"]=-1;Module["timeout"]=-1;Module["logger"]=()=>{};Module["progress"]=()=>{};function stringToPtr(str){const len=Module["lengthBytesUTF8"](str)+1;const ptr=Module["_malloc"](len);Module["stringToUTF8"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module["_malloc"](len*SIZE_I32);for(let i=0;i{throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var ENVIRONMENT_IS_NODE=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.init.initialized)FS.init();FS.ignorePermissions=false;TTY.init();SOCKFS.root=FS.mount(SOCKFS,{},null);callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="ffmpeg-core.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}catch(err){abort(err)}}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinary(binaryFile))}}return Promise.resolve().then(()=>getBinary(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>{return WebAssembly.instantiate(binary,imports)}).then(instance=>{return instance}).then(receiver,reason=>{err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}else{return instantiateArrayBuffer(binaryFile,imports,callback)}}function createWasm(){var info={"a":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["ra"];updateMemoryViews();wasmTable=Module["asm"]["ua"];addOnInit(Module["asm"]["sa"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err("Module.instantiateWasm callback failed with error: "+e);readyPromiseReject(e)}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var ASM_CONSTS={6077464:$0=>{Module.ret=$0}};function send_progress(progress,time){Module.receiveProgress(progress,time)}function is_timeout(diff){if(Module.timeout===-1)return 0;else{return Module.timeout<=diff}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){callbacks.shift()(Module)}}var wasmTableMirror=[];function getWasmTableEntry(funcPtr){var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func}function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr>>0];case"i8":return HEAP8[ptr>>0];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr>>0]=value;break;case"i8":HEAP8[ptr>>0]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heapOrArray,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function ___assert_fail(condition,filename,line,func){abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])}function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;function ___cxa_throw(ptr,type,destructor){var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast}var dlopenMissingError="To use dlopen, you need enable dynamic linking, see https://emscripten.org/docs/compiling/Dynamic-Linking.html";function ___dlsym(handle,symbol){abort(dlopenMissingError)}var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:function(){var paths=Array.prototype.slice.call(arguments);return PATH.normalize(paths.join("/"))},join2:(l,r)=>{return PATH.normalize(l+"/"+r)}};function initRandomFill(){if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else abort("initRandomDevice")}function randomFill(view){return(randomFill=initRandomFill())(view)}var PATH_FS={resolve:function(){var resolvedPath="",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var TTY={ttys:[],init:function(){},shutdown:function(){},register:function(dev,ops){TTY.ttys[dev]={input:[],output:[],ops:ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open:function(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close:function(stream){stream.tty.ops.fsync(stream.tty)},fsync:function(stream){stream.tty.ops.fsync(stream.tty)},read:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output,0));tty.output=[]}}},default_tty1_ops:{put_char:function(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output,0));tty.output=[]}}}};function zeroMemory(address,size){HEAPU8.fill(0,address,address+size);return address}function alignMemory(size,alignment){return Math.ceil(size/alignment)*alignment}function mmapAlloc(size){size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(!ptr)return 0;return zeroMemory(ptr,size)}var MEMFS={ops_table:null,mount:function(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode:function(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}if(!MEMFS.ops_table){MEMFS.ops_table={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}}}var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray:function(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage:function(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage:function(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr:function(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup:function(parent,name){throw FS.genericErrors[44]},mknod:function(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename:function(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp;old_node.parent=new_dir},unlink:function(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir:function(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink:function(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read:function(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i0||position+length{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},event=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)}var preloadPlugins=Module["preloadPlugins"]||[];function FS_handledByPreloadPlugin(byteArray,fullname,finish,onerror){if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(function(plugin){if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled}function FS_createPreloadedFile(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish){var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){if(preFinish)preFinish();if(!dontCreateFile){FS.createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}if(onload)onload();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{if(onerror)onerror();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,byteArray=>processData(byteArray),onerror)}else{processData(url)}}function FS_modeStringToFlags(str){var flagModes={"r":0,"r+":2,"w":512|64|1,"w+":512|64|2,"a":1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags}function FS_getMode(canRead,canWrite){var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode}var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount:function(mount){assert(ENVIRONMENT_IS_WORKER);if(!WORKERFS.reader)WORKERFS.reader=new FileReaderSync;var root=WORKERFS.createNode(null,"/",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split("/");var parent=root;for(var i=0;i=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write:function(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:(path,opts={})=>{path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath:node=>{var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName:(parentid,name)=>{var hash=0;for(var i=0;i>>0)%FS.nameTable.length},hashAddNode:node=>{var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode:node=>{var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode:(parent,name)=>{var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode,parent)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode:(parent,name,mode,rdev)=>{var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode:node=>{FS.hashRemoveNode(node)},isRoot:node=>{return node===node.parent},isMountpoint:node=>{return!!node.mounted},isFile:mode=>{return(mode&61440)===32768},isDir:mode=>{return(mode&61440)===16384},isLink:mode=>{return(mode&61440)===40960},isChrdev:mode=>{return(mode&61440)===8192},isBlkdev:mode=>{return(mode&61440)===24576},isFIFO:mode=>{return(mode&61440)===4096},isSocket:mode=>{return(mode&49152)===49152},flagsToPermissionString:flag=>{var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions:(node,perms)=>{if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup:dir=>{var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate:(dir,name)=>{try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete:(dir,name,isdir)=>{var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen:(node,flags)=>{if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd:()=>{for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStream:fd=>FS.streams[fd],createStream:(stream,fd=-1)=>{if(!FS.FSStream){FS.FSStream=function(){this.shared={}};FS.FSStream.prototype={};Object.defineProperties(FS.FSStream.prototype,{object:{get:function(){return this.node},set:function(val){this.node=val}},isRead:{get:function(){return(this.flags&2097155)!==1}},isWrite:{get:function(){return(this.flags&2097155)!==0}},isAppend:{get:function(){return this.flags&1024}},flags:{get:function(){return this.shared.flags},set:function(val){this.shared.flags=val}},position:{get:function(){return this.shared.position},set:function(val){this.shared.position=val}}})}stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream:fd=>{FS.streams[fd]=null},chrdev_stream_ops:{open:stream=>{var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;if(stream.stream_ops.open){stream.stream_ops.open(stream)}},llseek:()=>{throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice:(dev,ops)=>{FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts:mount=>{var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push.apply(check,m.mounts)}return mounts},syncfs:(populate,callback)=>{if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount:(type,opts,mountpoint)=>{var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type:type,opts:opts,mountpoint:mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount:mountpoint=>{var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup:(parent,name)=>{return parent.node_ops.lookup(parent,name)},mknod:(path,mode,dev)=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create:(path,mode)=>{mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir:(path,mode)=>{mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree:(path,mode)=>{var dirs=path.split("/");var d="";for(var i=0;i{if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink:(oldpath,newpath)=>{if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename:(old_path,new_path)=>{var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name)}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir:path=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node.node_ops.readdir){throw new FS.ErrnoError(54)}return node.node_ops.readdir(node)},unlink:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink:path=>{var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return PATH_FS.resolve(FS.getPath(link.parent),link.node_ops.readlink(link))},stat:(path,dontFollow)=>{var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;if(!node){throw new FS.ErrnoError(44)}if(!node.node_ops.getattr){throw new FS.ErrnoError(63)}return node.node_ops.getattr(node)},lstat:path=>{return FS.stat(path,true)},chmod:(path,mode,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{mode:mode&4095|node.mode&~4095,timestamp:Date.now()})},lchmod:(path,mode)=>{FS.chmod(path,mode,true)},fchmod:(fd,mode)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chmod(stream.node,mode)},chown:(path,uid,gid,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{timestamp:Date.now()})},lchown:(path,uid,gid)=>{FS.chown(path,uid,gid,true)},fchown:(fd,uid,gid)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chown(stream.node,uid,gid)},truncate:(path,len)=>{if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}node.node_ops.setattr(node,{size:len,timestamp:Date.now()})},ftruncate:(fd,len)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.truncate(stream.node,len)},utime:(path,atime,mtime)=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;node.node_ops.setattr(node,{timestamp:Math.max(atime,mtime)})},open:(path,flags,mode)=>{if(path===""){throw new FS.ErrnoError(44)}flags=typeof flags=="string"?FS_modeStringToFlags(flags):flags;mode=typeof mode=="undefined"?438:mode;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;if(typeof path=="object"){node=path}else{path=PATH.normalize(path);try{var lookup=FS.lookupPath(path,{follow:!(flags&131072)});node=lookup.node}catch(e){}}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else{node=FS.mknod(path,mode,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node:node,path:FS.getPath(node),flags:flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(Module["logReadFiles"]&&!(flags&1)){if(!FS.readFiles)FS.readFiles={};if(!(path in FS.readFiles)){FS.readFiles[path]=1}}return stream},close:stream=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed:stream=>{return stream.fd===null},llseek:(stream,offset,whence)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read:(stream,buffer,offset,length,position)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write:(stream,buffer,offset,length,position,canOwn)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate:(stream,offset,length)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap:(stream,length,position,prot,flags)=>{if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync:(stream,buffer,offset,length,mmapFlags)=>{if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},munmap:stream=>0,ioctl:(stream,cmd,arg)=>{if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile:(path,opts={})=>{opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf,0)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile:(path,data,opts={})=>{opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir:path=>{var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories:()=>{FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices:()=>{FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories:()=>{FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount:()=>{var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup:(parent,name)=>{var fd=+name;var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams:()=>{if(Module["stdin"]){FS.createDevice("/dev","stdin",Module["stdin"])}else{FS.symlink("/dev/tty","/dev/stdin")}if(Module["stdout"]){FS.createDevice("/dev","stdout",null,Module["stdout"])}else{FS.symlink("/dev/tty","/dev/stdout")}if(Module["stderr"]){FS.createDevice("/dev","stderr",null,Module["stderr"])}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},ensureErrnoError:()=>{if(FS.ErrnoError)return;FS.ErrnoError=function ErrnoError(errno,node){this.name="ErrnoError";this.node=node;this.setErrno=function(errno){this.errno=errno};this.setErrno(errno);this.message="FS error"};FS.ErrnoError.prototype=new Error;FS.ErrnoError.prototype.constructor=FS.ErrnoError;[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack=""})},staticInit:()=>{FS.ensureErrnoError();FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={"MEMFS":MEMFS,"WORKERFS":WORKERFS}},init:(input,output,error)=>{FS.init.initialized=true;FS.ensureErrnoError();Module["stdin"]=input||Module["stdin"];Module["stdout"]=output||Module["stdout"];Module["stderr"]=error||Module["stderr"];FS.createStandardStreams()},quit:()=>{FS.init.initialized=false;for(var i=0;i{var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath:(path,dontResolveLastLink)=>{try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath:(parent,path,canRead,canWrite)=>{parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){}parent=current}return current},createFile:(parent,name,properties,canRead,canWrite)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile:(parent,name,data,canRead,canWrite,canOwn)=>{var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data=="string"){var arr=new Array(data.length);for(var i=0,len=data.length;i{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);if(!FS.createDevice.major)FS.createDevice.major=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open:stream=>{stream.seekable=false},close:stream=>{if(output&&output.buffer&&output.buffer.length){output(10)}},read:(stream,buffer,offset,length,pos)=>{var bytesRead=0;for(var i=0;i{for(var i=0;i{if(obj.isDevice||obj.isFolder||obj.link||obj.contents)return true;if(typeof XMLHttpRequest!="undefined"){throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.")}else if(read_){try{obj.contents=intArrayFromString(read_(obj.url),true);obj.usedBytes=obj.contents.length}catch(e){throw new FS.ErrnoError(29)}}else{throw new Error("Cannot load without read() or XMLHttpRequest.")}},createLazyFile:(parent,name,url,canRead,canWrite)=>{function LazyUint8Array(){this.lengthKnown=false;this.chunks=[]}LazyUint8Array.prototype.get=function LazyUint8Array_get(idx){if(idx>this.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]};LazyUint8Array.prototype.setDataGetter=function LazyUint8Array_setDataGetter(getter){this.getter=getter};LazyUint8Array.prototype.cacheLength=function LazyUint8Array_cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true};if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;Object.defineProperties(lazyArray,{length:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._length}},chunkSize:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}});var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url:url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=function forceLoadLazyFile(){FS.forceLoadFile(node);return fn.apply(null,arguments)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr:ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt:function(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat:function(func,path,buf){try{var stat=func(path)}catch(e){if(e&&e.node&&PATH.normalize(path)!==PATH.normalize(FS.getPath(e.node))){return-54}throw e}HEAP32[buf>>2]=stat.dev;HEAP32[buf+8>>2]=stat.ino;HEAP32[buf+12>>2]=stat.mode;HEAPU32[buf+16>>2]=stat.nlink;HEAP32[buf+20>>2]=stat.uid;HEAP32[buf+24>>2]=stat.gid;HEAP32[buf+28>>2]=stat.rdev;HEAP64[buf+40>>3]=BigInt(stat.size);HEAP32[buf+48>>2]=4096;HEAP32[buf+52>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+56>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+64>>2]=atime%1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+80>>2]=mtime%1e3*1e3;HEAP64[buf+88>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+96>>2]=ctime%1e3*1e3;HEAP64[buf+104>>3]=BigInt(stat.ino);return 0},doMsync:function(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},getStreamFromFD:function(fd){var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);return stream}};function ___syscall__newselect(nfds,readfds,writefds,exceptfds,timeout){try{var total=0;var srcReadLow=readfds?HEAP32[readfds>>2]:0,srcReadHigh=readfds?HEAP32[readfds+4>>2]:0;var srcWriteLow=writefds?HEAP32[writefds>>2]:0,srcWriteHigh=writefds?HEAP32[writefds+4>>2]:0;var srcExceptLow=exceptfds?HEAP32[exceptfds>>2]:0,srcExceptHigh=exceptfds?HEAP32[exceptfds+4>>2]:0;var dstReadLow=0,dstReadHigh=0;var dstWriteLow=0,dstWriteHigh=0;var dstExceptLow=0,dstExceptHigh=0;var allLow=(readfds?HEAP32[readfds>>2]:0)|(writefds?HEAP32[writefds>>2]:0)|(exceptfds?HEAP32[exceptfds>>2]:0);var allHigh=(readfds?HEAP32[readfds+4>>2]:0)|(writefds?HEAP32[writefds+4>>2]:0)|(exceptfds?HEAP32[exceptfds+4>>2]:0);var check=function(fd,low,high,val){return fd<32?low&val:high&val};for(var fd=0;fd>2]=dstReadLow;HEAP32[readfds+4>>2]=dstReadHigh}if(writefds){HEAP32[writefds>>2]=dstWriteLow;HEAP32[writefds+4>>2]=dstWriteHigh}if(exceptfds){HEAP32[exceptfds>>2]=dstExceptLow;HEAP32[exceptfds+4>>2]=dstExceptHigh}return total}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var SOCKFS={mount:function(mount){Module["websocket"]=Module["websocket"]&&"object"===typeof Module["websocket"]?Module["websocket"]:{};Module["websocket"]._callbacks={};Module["websocket"]["on"]=function(event,callback){if("function"===typeof callback){this._callbacks[event]=callback}return this};Module["websocket"].emit=function(event,param){if("function"===typeof this._callbacks[event]){this._callbacks[event].call(this,param)}};return FS.createNode(null,"/",16384|511,0)},createSocket:function(family,type,protocol){type&=~526336;var streaming=type==1;if(streaming&&protocol&&protocol!=6){throw new FS.ErrnoError(66)}var sock={family:family,type:type,protocol:protocol,server:null,error:null,peers:{},pending:[],recv_queue:[],sock_ops:SOCKFS.websocket_sock_ops};var name=SOCKFS.nextname();var node=FS.createNode(SOCKFS.root,name,49152,0);node.sock=sock;var stream=FS.createStream({path:name,node:node,flags:2,seekable:false,stream_ops:SOCKFS.stream_ops});sock.stream=stream;return sock},getSocket:function(fd){var stream=FS.getStream(fd);if(!stream||!FS.isSocket(stream.node.mode)){return null}return stream.node.sock},stream_ops:{poll:function(stream){var sock=stream.node.sock;return sock.sock_ops.poll(sock)},ioctl:function(stream,request,varargs){var sock=stream.node.sock;return sock.sock_ops.ioctl(sock,request,varargs)},read:function(stream,buffer,offset,length,position){var sock=stream.node.sock;var msg=sock.sock_ops.recvmsg(sock,length);if(!msg){return 0}buffer.set(msg.buffer,offset);return msg.buffer.length},write:function(stream,buffer,offset,length,position){var sock=stream.node.sock;return sock.sock_ops.sendmsg(sock,buffer,offset,length)},close:function(stream){var sock=stream.node.sock;sock.sock_ops.close(sock)}},nextname:function(){if(!SOCKFS.nextname.current){SOCKFS.nextname.current=0}return"socket["+SOCKFS.nextname.current+++"]"},websocket_sock_ops:{createPeer:function(sock,addr,port){var ws;if(typeof addr=="object"){ws=addr;addr=null;port=null}if(ws){if(ws._socket){addr=ws._socket.remoteAddress;port=ws._socket.remotePort}else{var result=/ws[s]?:\/\/([^:]+):(\d+)/.exec(ws.url);if(!result){throw new Error("WebSocket URL must be in the format ws(s)://address:port")}addr=result[1];port=parseInt(result[2],10)}}else{try{var runtimeConfig=Module["websocket"]&&"object"===typeof Module["websocket"];var url="ws:#".replace("#","//");if(runtimeConfig){if("string"===typeof Module["websocket"]["url"]){url=Module["websocket"]["url"]}}if(url==="ws://"||url==="wss://"){var parts=addr.split("/");url=url+parts[0]+":"+port+"/"+parts.slice(1).join("/")}var subProtocols="binary";if(runtimeConfig){if("string"===typeof Module["websocket"]["subprotocol"]){subProtocols=Module["websocket"]["subprotocol"]}}var opts=undefined;if(subProtocols!=="null"){subProtocols=subProtocols.replace(/^ +| +$/g,"").split(/ *, */);opts=subProtocols}if(runtimeConfig&&null===Module["websocket"]["subprotocol"]){subProtocols="null";opts=undefined}var WebSocketConstructor;{WebSocketConstructor=WebSocket}ws=new WebSocketConstructor(url,opts);ws.binaryType="arraybuffer"}catch(e){throw new FS.ErrnoError(23)}}var peer={addr:addr,port:port,socket:ws,dgram_send_queue:[]};SOCKFS.websocket_sock_ops.addPeer(sock,peer);SOCKFS.websocket_sock_ops.handlePeerEvents(sock,peer);if(sock.type===2&&typeof sock.sport!="undefined"){peer.dgram_send_queue.push(new Uint8Array([255,255,255,255,"p".charCodeAt(0),"o".charCodeAt(0),"r".charCodeAt(0),"t".charCodeAt(0),(sock.sport&65280)>>8,sock.sport&255]))}return peer},getPeer:function(sock,addr,port){return sock.peers[addr+":"+port]},addPeer:function(sock,peer){sock.peers[peer.addr+":"+peer.port]=peer},removePeer:function(sock,peer){delete sock.peers[peer.addr+":"+peer.port]},handlePeerEvents:function(sock,peer){var first=true;var handleOpen=function(){Module["websocket"].emit("open",sock.stream.fd);try{var queued=peer.dgram_send_queue.shift();while(queued){peer.socket.send(queued);queued=peer.dgram_send_queue.shift()}}catch(e){peer.socket.close()}};function handleMessage(data){if(typeof data=="string"){var encoder=new TextEncoder;data=encoder.encode(data)}else{assert(data.byteLength!==undefined);if(data.byteLength==0){return}data=new Uint8Array(data)}var wasfirst=first;first=false;if(wasfirst&&data.length===10&&data[0]===255&&data[1]===255&&data[2]===255&&data[3]===255&&data[4]==="p".charCodeAt(0)&&data[5]==="o".charCodeAt(0)&&data[6]==="r".charCodeAt(0)&&data[7]==="t".charCodeAt(0)){var newport=data[8]<<8|data[9];SOCKFS.websocket_sock_ops.removePeer(sock,peer);peer.port=newport;SOCKFS.websocket_sock_ops.addPeer(sock,peer);return}sock.recv_queue.push({addr:peer.addr,port:peer.port,data:data});Module["websocket"].emit("message",sock.stream.fd)}if(ENVIRONMENT_IS_NODE){peer.socket.on("open",handleOpen);peer.socket.on("message",function(data,isBinary){if(!isBinary){return}handleMessage(new Uint8Array(data).buffer)});peer.socket.on("close",function(){Module["websocket"].emit("close",sock.stream.fd)});peer.socket.on("error",function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])})}else{peer.socket.onopen=handleOpen;peer.socket.onclose=function(){Module["websocket"].emit("close",sock.stream.fd)};peer.socket.onmessage=function peer_socket_onmessage(event){handleMessage(event.data)};peer.socket.onerror=function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])}}},poll:function(sock){if(sock.type===1&&sock.server){return sock.pending.length?64|1:0}var mask=0;var dest=sock.type===1?SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport):null;if(sock.recv_queue.length||!dest||dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=64|1}if(!dest||dest&&dest.socket.readyState===dest.socket.OPEN){mask|=4}if(dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=16}return mask},ioctl:function(sock,request,arg){switch(request){case 21531:var bytes=0;if(sock.recv_queue.length){bytes=sock.recv_queue[0].data.length}HEAP32[arg>>2]=bytes;return 0;default:return 28}},close:function(sock){if(sock.server){try{sock.server.close()}catch(e){}sock.server=null}var peers=Object.keys(sock.peers);for(var i=0;i>2]=value;return value}function inetPton4(str){var b=str.split(".");for(var i=0;i<4;i++){var tmp=Number(b[i]);if(isNaN(tmp))return null;b[i]=tmp}return(b[0]|b[1]<<8|b[2]<<16|b[3]<<24)>>>0}function jstoi_q(str){return parseInt(str)}function inetPton6(str){var words;var w,offset,z;var valid6regx=/^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i;var parts=[];if(!valid6regx.test(str)){return null}if(str==="::"){return[0,0,0,0,0,0,0,0]}if(str.startsWith("::")){str=str.replace("::","Z:")}else{str=str.replace("::",":Z:")}if(str.indexOf(".")>0){str=str.replace(new RegExp("[.]","g"),":");words=str.split(":");words[words.length-4]=jstoi_q(words[words.length-4])+jstoi_q(words[words.length-3])*256;words[words.length-3]=jstoi_q(words[words.length-2])+jstoi_q(words[words.length-1])*256;words=words.slice(0,words.length-2)}else{words=str.split(":")}offset=0;z=0;for(w=0;w>2]=16}HEAP16[sa>>1]=family;HEAP32[sa+4>>2]=addr;HEAP16[sa+2>>1]=_htons(port);break;case 10:addr=inetPton6(addr);zeroMemory(sa,28);if(addrlen){HEAP32[addrlen>>2]=28}HEAP32[sa>>2]=family;HEAP32[sa+8>>2]=addr[0];HEAP32[sa+12>>2]=addr[1];HEAP32[sa+16>>2]=addr[2];HEAP32[sa+20>>2]=addr[3];HEAP16[sa+2>>1]=_htons(port);break;default:return 5}return 0}var DNS={address_map:{id:1,addrs:{},names:{}},lookup_name:function(name){var res=inetPton4(name);if(res!==null){return name}res=inetPton6(name);if(res!==null){return name}var addr;if(DNS.address_map.addrs[name]){addr=DNS.address_map.addrs[name]}else{var id=DNS.address_map.id++;assert(id<65535,"exceeded max address mappings of 65535");addr="172.29."+(id&255)+"."+(id&65280);DNS.address_map.names[addr]=name;DNS.address_map.addrs[name]=addr}return addr},lookup_addr:function(addr){if(DNS.address_map.names[addr]){return DNS.address_map.names[addr]}return null}};function ___syscall_accept4(fd,addr,addrlen,flags,d1,d2){try{var sock=getSocketFromFD(fd);var newsock=sock.sock_ops.accept(sock);if(addr){var errno=writeSockaddr(addr,newsock.family,DNS.lookup_name(newsock.daddr),newsock.dport,addrlen)}return newsock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function inetNtop4(addr){return(addr&255)+"."+(addr>>8&255)+"."+(addr>>16&255)+"."+(addr>>24&255)}function inetNtop6(ints){var str="";var word=0;var longest=0;var lastzero=0;var zstart=0;var len=0;var i=0;var parts=[ints[0]&65535,ints[0]>>16,ints[1]&65535,ints[1]>>16,ints[2]&65535,ints[2]>>16,ints[3]&65535,ints[3]>>16];var hasipv4=true;var v4part="";for(i=0;i<5;i++){if(parts[i]!==0){hasipv4=false;break}}if(hasipv4){v4part=inetNtop4(parts[6]|parts[7]<<16);if(parts[5]===-1){str="::ffff:";str+=v4part;return str}if(parts[5]===0){str="::";if(v4part==="0.0.0.0")v4part="";if(v4part==="0.0.0.1")v4part="1";str+=v4part;return str}}for(word=0;word<8;word++){if(parts[word]===0){if(word-lastzero>1){len=0}lastzero=word;len++}if(len>longest){longest=len;zstart=word-longest+1}}for(word=0;word<8;word++){if(longest>1){if(parts[word]===0&&word>=zstart&&word>1];var port=_ntohs(HEAPU16[sa+2>>1]);var addr;switch(family){case 2:if(salen!==16){return{errno:28}}addr=HEAP32[sa+4>>2];addr=inetNtop4(addr);break;case 10:if(salen!==28){return{errno:28}}addr=[HEAP32[sa+8>>2],HEAP32[sa+12>>2],HEAP32[sa+16>>2],HEAP32[sa+20>>2]];addr=inetNtop6(addr);break;default:return{errno:5}}return{family:family,addr:addr,port:port}}function getSocketAddress(addrp,addrlen,allowNull){if(allowNull&&addrp===0)return null;var info=readSockaddr(addrp,addrlen);if(info.errno)throw new FS.ErrnoError(info.errno);info.addr=DNS.lookup_addr(info.addr)||info.addr;return info}function ___syscall_bind(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.bind(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_connect(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.connect(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=SYSCALLS.get();if(arg<0){return-28}var newStream;newStream=FS.createStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=SYSCALLS.get();stream.flags|=arg;return 0}case 5:{var arg=SYSCALLS.get();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 6:case 7:return 0;case 16:case 8:return-28;case 9:setErrNo(28);return-1;default:{return-28}}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);if(!stream.getdents){stream.getdents=FS.readdir(stream.path)}var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18>>0]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getpeername(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);if(!sock.daddr){return-53}var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.daddr),sock.dport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockname(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.saddr||"0.0.0.0"),sock.sport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockopt(fd,level,optname,optval,optlen,d1){try{var sock=getSocketFromFD(fd);if(level===1){if(optname===4){HEAP32[optval>>2]=sock.error;HEAP32[optlen>>2]=4;sock.error=null;return 0}}return-50}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:case 21505:{if(!stream.tty)return-59;return 0}case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:{if(!stream.tty)return-59;return 0}case 21519:{if(!stream.tty)return-59;var argp=SYSCALLS.get();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=SYSCALLS.get();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;return 0}case 21524:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_listen(fd,backlog){try{var sock=getSocketFromFD(fd);sock.sock_ops.listen(sock,backlog);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?SYSCALLS.get():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_poll(fds,nfds,timeout){try{var nonzero=0;for(var i=0;i>2];var events=HEAP16[pollfd+4>>1];var mask=32;var stream=FS.getStream(fd);if(stream){mask=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){mask=stream.stream_ops.poll(stream)}}mask&=events|8|16;if(mask)nonzero++;HEAP16[pollfd+6>>1]=mask}return nonzero}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_recvfrom(fd,buf,len,flags,addr,addrlen){try{var sock=getSocketFromFD(fd);var msg=sock.sock_ops.recvmsg(sock,len);if(!msg)return 0;if(addr){var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(msg.addr),msg.port,addrlen)}HEAPU8.set(msg.buffer,buf);return msg.buffer.byteLength}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_sendto(fd,message,length,flags,addr,addr_len){try{var sock=getSocketFromFD(fd);var dest=getSocketAddress(addr,addr_len,true);if(!dest){return FS.write(sock.stream,HEAP8,message,length)}return sock.sock_ops.sendmsg(sock,HEAP8,message,length,dest.addr,dest.port)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_socket(domain,type,protocol){try{var sock=SOCKFS.createSocket(domain,type,protocol);return sock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var nowIsMonotonic=true;function __emscripten_get_now_is_monotonic(){return nowIsMonotonic}function __emscripten_throw_longjmp(){throw Infinity}function readI53FromI64(ptr){return HEAPU32[ptr>>2]+HEAP32[ptr+4>>2]*4294967296}function __gmtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}function isLeapYear(year){return year%4===0&&(year%100!==0||year%400===0)}var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];function ydayFromDate(date){var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday}function __localtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mktime_js(tmPtr){var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3|0}function __mmap_js(len,prot,flags,fd,off,allocated,addr){try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,off,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}FS.munmap(stream)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToNewUTF8(str){var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret}function __tzset_js(timezone,daylight,tzname){var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}}function _abort(){abort("")}Module["_abort"]=_abort;function _dlopen(handle){abort(dlopenMissingError)}var readEmAsmArgsArray=[];function readEmAsmArgs(sigPtr,buf){readEmAsmArgsArray.length=0;var ch;buf>>=2;while(ch=HEAPU8[sigPtr++]){buf+=ch!=105&buf;readEmAsmArgsArray.push(ch==105?HEAP32[buf]:(ch==106?HEAP64:HEAPF64)[buf++>>1]);++buf}return readEmAsmArgsArray}function runEmAsmFunction(code,sigPtr,argbuf){var args=readEmAsmArgs(sigPtr,argbuf);return ASM_CONSTS[code].apply(null,args)}function _emscripten_asm_const_int(code,sigPtr,argbuf){return runEmAsmFunction(code,sigPtr,argbuf)}function _emscripten_date_now(){return Date.now()}function getHeapMax(){return 2147483648}function _emscripten_get_heap_max(){return getHeapMax()}var _emscripten_get_now;_emscripten_get_now=()=>performance.now();function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){var b=wasmMemory.buffer;try{wasmMemory.grow(size-b.byteLength+65535>>>16);updateMemoryViews();return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||"./this.program"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings}function stringToAscii(str,buffer){for(var i=0;i>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0}function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAPU32[penviron_buf_size>>2]=bufSize;return 0}function _proc_exit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}function exitJS(status,implicit){EXITSTATUS=status;_proc_exit(status)}var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf>>0]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doReadv(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var MAX_INT53=9007199254740992;var MIN_INT53=-9007199254740992;function bigintToI53Checked(num){return numMAX_INT53?NaN:Number(num)}function _fd_seek(fd,offset,whence,newOffset){try{offset=bigintToI53Checked(offset);if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doWritev(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _getaddrinfo(node,service,hint,out){var addr=0;var port=0;var flags=0;var family=0;var type=0;var proto=0;var ai;function allocaddrinfo(family,type,proto,canon,addr,port){var sa,salen,ai;var errno;salen=family===10?28:16;addr=family===10?inetNtop6(addr):inetNtop4(addr);sa=_malloc(salen);errno=writeSockaddr(sa,family,addr,port);assert(!errno);ai=_malloc(32);HEAP32[ai+4>>2]=family;HEAP32[ai+8>>2]=type;HEAP32[ai+12>>2]=proto;HEAPU32[ai+24>>2]=canon;HEAPU32[ai+20>>2]=sa;if(family===10){HEAP32[ai+16>>2]=28}else{HEAP32[ai+16>>2]=16}HEAP32[ai+28>>2]=0;return ai}if(hint){flags=HEAP32[hint>>2];family=HEAP32[hint+4>>2];type=HEAP32[hint+8>>2];proto=HEAP32[hint+12>>2]}if(type&&!proto){proto=type===2?17:6}if(!type&&proto){type=proto===17?2:1}if(proto===0){proto=6}if(type===0){type=1}if(!node&&!service){return-2}if(flags&~(1|2|4|1024|8|16|32)){return-1}if(hint!==0&&HEAP32[hint>>2]&2&&!node){return-1}if(flags&32){return-2}if(type!==0&&type!==1&&type!==2){return-7}if(family!==0&&family!==2&&family!==10){return-6}if(service){service=UTF8ToString(service);port=parseInt(service,10);if(isNaN(port)){if(flags&1024){return-2}return-8}}if(!node){if(family===0){family=2}if((flags&1)===0){if(family===2){addr=_htonl(2130706433)}else{addr=[0,0,0,1]}}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}node=UTF8ToString(node);addr=inetPton4(node);if(addr!==null){if(family===0||family===2){family=2}else if(family===10&&flags&8){addr=[0,0,_htonl(65535),addr];family=10}else{return-2}}else{addr=inetPton6(node);if(addr!==null){if(family===0||family===10){family=10}else{return-2}}}if(addr!=null){ai=allocaddrinfo(family,type,proto,node,addr,port);HEAPU32[out>>2]=ai;return 0}if(flags&4){return-2}node=DNS.lookup_name(node);addr=inetPton4(node);if(family===0){family=2}else if(family===10){addr=[0,0,_htonl(65535),addr]}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}function _getnameinfo(sa,salen,node,nodelen,serv,servlen,flags){var info=readSockaddr(sa,salen);if(info.errno){return-6}var port=info.port;var addr=info.addr;var overflowed=false;if(node&&nodelen){var lookup;if(flags&1||!(lookup=DNS.lookup_addr(addr))){if(flags&8){return-2}}else{addr=lookup}var numBytesWrittenExclNull=stringToUTF8(addr,node,nodelen);if(numBytesWrittenExclNull+1>=nodelen){overflowed=true}}if(serv&&servlen){port=""+port;var numBytesWrittenExclNull=stringToUTF8(port,serv,servlen);if(numBytesWrittenExclNull+1>=servlen){overflowed=true}}if(overflowed){return-12}return 0}function arraySum(array,index){var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum}var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];function addDays(date,days){var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate}function writeArrayToMemory(array,buffer){HEAP8.set(array,buffer)}function _strftime(s,maxsize,format,tm){var tm_zone=HEAP32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":function(date){return WEEKDAYS[date.tm_wday].substring(0,3)},"%A":function(date){return WEEKDAYS[date.tm_wday]},"%b":function(date){return MONTHS[date.tm_mon].substring(0,3)},"%B":function(date){return MONTHS[date.tm_mon]},"%C":function(date){var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":function(date){return leadingNulls(date.tm_mday,2)},"%e":function(date){return leadingSomething(date.tm_mday,2," ")},"%g":function(date){return getWeekBasedYear(date).toString().substring(2)},"%G":function(date){return getWeekBasedYear(date)},"%H":function(date){return leadingNulls(date.tm_hour,2)},"%I":function(date){var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":function(date){return leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3)},"%m":function(date){return leadingNulls(date.tm_mon+1,2)},"%M":function(date){return leadingNulls(date.tm_min,2)},"%n":function(){return"\n"},"%p":function(date){if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":function(date){return leadingNulls(date.tm_sec,2)},"%t":function(){return"\t"},"%u":function(date){return date.tm_wday||7},"%U":function(date){var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":function(date){var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":function(date){return date.tm_wday},"%W":function(date){var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":function(date){return(date.tm_year+1900).toString().substring(2)},"%Y":function(date){return date.tm_year+1900},"%z":function(date){var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":function(date){return date.tm_zone},"%%":function(){return"%"}};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1}var FSNode=function(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev};var readMode=292|73;var writeMode=146;Object.defineProperties(FSNode.prototype,{read:{get:function(){return(this.mode&readMode)===readMode},set:function(val){val?this.mode|=readMode:this.mode&=~readMode}},write:{get:function(){return(this.mode&writeMode)===writeMode},set:function(val){val?this.mode|=writeMode:this.mode&=~writeMode}},isFolder:{get:function(){return FS.isDir(this.mode)}},isDevice:{get:function(){return FS.isChrdev(this.mode)}}});FS.FSNode=FSNode;FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();var wasmImports={"b":___assert_fail,"f":___cxa_throw,"ka":___dlsym,"R":___syscall__newselect,"L":___syscall_accept4,"K":___syscall_bind,"J":___syscall_connect,"la":___syscall_faccessat,"g":___syscall_fcntl64,"ha":___syscall_fstat64,"U":___syscall_getdents64,"I":___syscall_getpeername,"H":___syscall_getsockname,"G":___syscall_getsockopt,"y":___syscall_ioctl,"F":___syscall_listen,"ea":___syscall_lstat64,"$":___syscall_mkdirat,"fa":___syscall_newfstatat,"w":___syscall_openat,"V":___syscall_poll,"E":___syscall_recvfrom,"T":___syscall_renameat,"S":___syscall_rmdir,"D":___syscall_sendto,"v":___syscall_socket,"ga":___syscall_stat64,"O":___syscall_unlinkat,"ia":__emscripten_get_now_is_monotonic,"M":__emscripten_throw_longjmp,"Y":__gmtime_js,"Z":__localtime_js,"_":__mktime_js,"W":__mmap_js,"X":__munmap_js,"P":__tzset_js,"a":_abort,"t":_dlopen,"oa":_emscripten_asm_const_int,"m":_emscripten_date_now,"Q":_emscripten_get_heap_max,"p":_emscripten_get_now,"ja":_emscripten_memcpy_big,"N":_emscripten_resize_heap,"ca":_environ_get,"da":_environ_sizes_get,"l":_exit,"n":_fd_close,"ba":_fd_fdstat_get,"x":_fd_read,"aa":_fd_seek,"q":_fd_write,"k":_getaddrinfo,"i":_getnameinfo,"pa":invoke_i,"na":invoke_ii,"c":invoke_iii,"o":invoke_iiii,"s":invoke_iiiii,"z":invoke_iiiiii,"r":invoke_iiiiiiiii,"B":invoke_iiiijj,"qa":invoke_iij,"h":invoke_vi,"j":invoke_vii,"d":invoke_viiii,"ma":invoke_viiiiii,"A":invoke_viiiiiiii,"C":is_timeout,"u":send_progress,"e":_strftime};var asm=createWasm();var ___wasm_call_ctors=function(){return(___wasm_call_ctors=Module["asm"]["sa"]).apply(null,arguments)};var _malloc=Module["_malloc"]=function(){return(_malloc=Module["_malloc"]=Module["asm"]["ta"]).apply(null,arguments)};var ___errno_location=function(){return(___errno_location=Module["asm"]["va"]).apply(null,arguments)};var _ntohs=function(){return(_ntohs=Module["asm"]["wa"]).apply(null,arguments)};var _htons=function(){return(_htons=Module["asm"]["xa"]).apply(null,arguments)};var _ffmpeg=Module["_ffmpeg"]=function(){return(_ffmpeg=Module["_ffmpeg"]=Module["asm"]["ya"]).apply(null,arguments)};var _ffprobe=Module["_ffprobe"]=function(){return(_ffprobe=Module["_ffprobe"]=Module["asm"]["za"]).apply(null,arguments)};var _htonl=function(){return(_htonl=Module["asm"]["Aa"]).apply(null,arguments)};var _emscripten_builtin_memalign=function(){return(_emscripten_builtin_memalign=Module["asm"]["Ba"]).apply(null,arguments)};var _setThrew=function(){return(_setThrew=Module["asm"]["Ca"]).apply(null,arguments)};var stackSave=function(){return(stackSave=Module["asm"]["Da"]).apply(null,arguments)};var stackRestore=function(){return(stackRestore=Module["asm"]["Ea"]).apply(null,arguments)};var ___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module["asm"]["Fa"]).apply(null,arguments)};var _ff_h264_cabac_tables=Module["_ff_h264_cabac_tables"]=1546732;var ___start_em_js=Module["___start_em_js"]=6077485;var ___stop_em_js=Module["___stop_em_js"]=6077662;function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["setValue"]=setValue;Module["getValue"]=getValue;Module["UTF8ToString"]=UTF8ToString;Module["stringToUTF8"]=stringToUTF8;Module["lengthBytesUTF8"]=lengthBytesUTF8;Module["FS"]=FS;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); return createFFmpegCore.ready } ); })(); if (typeof exports === 'object' && typeof module === 'object') module.exports = createFFmpegCore; else if (typeof define === 'function' && define['amd']) define([], function() { return createFFmpegCore; }); else if (typeof exports === 'object') exports["createFFmpegCore"] = createFFmpegCore; ================================================ FILE: web/public/ffmpeg/ffmpeg-core.wasm ================================================ [File too large to display: 30.7 MB] ================================================ FILE: web/src/App.vue ================================================ ================================================ FILE: web/src/api/ai.ts ================================================ import type { AIServiceConfig, AIServiceType, CreateAIConfigRequest, TestConnectionRequest, UpdateAIConfigRequest } from '../types/ai' import request from '../utils/request' export const aiAPI = { list(serviceType?: AIServiceType) { return request.get('/ai-configs', { params: { service_type: serviceType } }) }, create(data: CreateAIConfigRequest) { return request.post('/ai-configs', data) }, get(id: number) { return request.get(`/ai-configs/${id}`) }, update(id: number, data: UpdateAIConfigRequest) { return request.put(`/ai-configs/${id}`, data) }, delete(id: number) { return request.delete(`/ai-configs/${id}`) }, testConnection(data: TestConnectionRequest) { return request.post('/ai-configs/test', data) } } ================================================ FILE: web/src/api/asset.ts ================================================ import type { Asset, AssetCollection, AssetTag, CreateAssetRequest, ListAssetsParams, UpdateAssetRequest } from '../types/asset' import request from '../utils/request' export const assetAPI = { createAsset(data: CreateAssetRequest) { return request.post('/assets', data) }, updateAsset(id: number, data: UpdateAssetRequest) { return request.put(`/assets/${id}`, data) }, getAsset(id: number) { return request.get(`/assets/${id}`) }, listAssets(params: ListAssetsParams) { return request.get<{ items: Asset[] pagination: { page: number page_size: number total: number total_pages: number } }>('/assets', { params }) }, deleteAsset(id: number) { return request.delete(`/assets/${id}`) }, importFromImage(imageGenId: number) { return request.post(`/assets/import/image/${imageGenId}`) }, importFromVideo(videoGenId: number) { return request.post(`/assets/import/video/${videoGenId}`) } } ================================================ FILE: web/src/api/audio.ts ================================================ import axios from 'axios' const API_BASE_URL = '/api/v1' export interface ExtractAudioRequest { video_url: string } export interface ExtractAudioResponse { audio_url: string duration: number } export interface BatchExtractAudioRequest { video_urls: string[] } export interface BatchExtractAudioResponse { results: ExtractAudioResponse[] total: number } export const audioAPI = { /** * 从视频URL提取音频 */ extractAudio: async (videoUrl: string): Promise => { const response = await axios.post( `${API_BASE_URL}/audio/extract`, { video_url: videoUrl } ) return response.data }, /** * 批量从视频URL提取音频 */ batchExtractAudio: async (videoUrls: string[]): Promise => { const response = await axios.post( `${API_BASE_URL}/audio/extract/batch`, { video_urls: videoUrls } ) return response.data } } ================================================ FILE: web/src/api/character-library.ts ================================================ import request from '../utils/request' export interface CharacterLibraryItem { id: string name: string category?: string image_url: string description?: string tags?: string source_type: string created_at: string updated_at: string } export interface CreateLibraryItemRequest { name: string category?: string image_url: string description?: string tags?: string source_type?: string } export interface CharacterLibraryQuery { page?: number page_size?: number category?: string source_type?: string keyword?: string } export const characterLibraryAPI = { // 获取角色库列表 list(params?: CharacterLibraryQuery) { return request.get<{ items: CharacterLibraryItem[] pagination: { page: number page_size: number total: number total_pages: number } }>('/character-library', { params }) }, // 创建角色库项 create(data: CreateLibraryItemRequest) { return request.post('/character-library', data) }, // 获取角色库项详情 get(id: string) { return request.get(`/character-library/${id}`) }, // 删除角色库项 delete(id: string) { return request.delete(`/character-library/${id}`) }, // 上传角色图片 uploadCharacterImage(characterId: string, imageUrl: string) { return request.put(`/characters/${characterId}/image`, { image_url: imageUrl }) }, // 从角色库应用形象 applyFromLibrary(characterId: string, libraryItemId: string) { return request.put(`/characters/${characterId}/image-from-library`, { library_item_id: libraryItemId }) }, // 将角色添加到角色库 addCharacterToLibrary(characterId: string, category?: string) { return request.post(`/characters/${characterId}/add-to-library`, { category }) }, // AI生成角色形象 generateCharacterImage(characterId: string, model?: string) { return request.post<{ image_url: string }>(`/characters/${characterId}/generate-image`, { model }) }, // 批量生成角色形象 batchGenerateCharacterImages(characterIds: string[], model?: string) { return request.post<{ message: string; count: number }>('/characters/batch-generate-images', { character_ids: characterIds, model }) }, // 更新角色信息 updateCharacter(characterId: number, data: { name?: string appearance?: string personality?: string description?: string image_url?: string local_path?: string }) { return request.put(`/characters/${characterId}`, data) }, // 删除角色 deleteCharacter(characterId: number) { return request.delete(`/characters/${characterId}`) }, // 从剧本提取角色 extractFromEpisode(episodeId: number) { return request.post<{ task_id: string; message: string }>(`/episodes/${episodeId}/characters/extract`) } } ================================================ FILE: web/src/api/drama.ts ================================================ import type { CreateDramaRequest, Drama, DramaListQuery, DramaStats, UpdateDramaRequest } from '../types/drama' import request from '../utils/request' export const dramaAPI = { list(params?: DramaListQuery) { return request.get<{ items: Drama[] pagination: { page: number page_size: number total: number total_pages: number } }>('/dramas', { params }) }, create(data: CreateDramaRequest) { return request.post('/dramas', data) }, get(id: string) { return request.get(`/dramas/${id}`) }, update(id: string, data: UpdateDramaRequest) { return request.put(`/dramas/${id}`, data) }, delete(id: string) { return request.delete(`/dramas/${id}`) }, getStats() { return request.get('/dramas/stats') }, saveOutline(id: string, data: { title: string; summary: string; genre?: string; tags?: string[] }) { return request.put(`/dramas/${id}/outline`, data) }, getCharacters(dramaId: string) { return request.get(`/dramas/${dramaId}/characters`) }, saveCharacters(id: string, data: any[], episodeId?: string) { return request.put(`/dramas/${id}/characters`, { characters: data, episode_id: episodeId ? parseInt(episodeId) : undefined }) }, updateCharacter(id: number, data: any) { return request.put(`/characters/${id}`, data) }, saveEpisodes(id: string, data: any[]) { return request.put(`/dramas/${id}/episodes`, { episodes: data }) }, saveProgress(id: string, data: { current_step: string; step_data?: any }) { return request.put(`/dramas/${id}/progress`, data) }, generateStoryboard(episodeId: string) { return request.post(`/episodes/${episodeId}/storyboards`) }, getBackgrounds(episodeId: string) { return request.get(`/images/episode/${episodeId}/backgrounds`) }, extractBackgrounds(episodeId: string, model?: string) { return request.post<{ task_id: string; status: string; message: string }>(`/images/episode/${episodeId}/backgrounds/extract`, { model }) }, batchGenerateBackgrounds(episodeId: string) { return request.post(`/images/episode/${episodeId}/batch`) }, generateSingleBackground(backgroundId: number, dramaId: string, prompt: string) { return request.post('/images', { background_id: backgroundId, drama_id: dramaId, prompt: prompt }) }, getStoryboards(episodeId: string) { return request.get(`/episodes/${episodeId}/storyboards`) }, updateStoryboard(storyboardId: string, data: any) { return request.put(`/storyboards/${storyboardId}`, data) }, updateScene(sceneId: string, data: { background_id?: string; characters?: string[]; location?: string; time?: string; prompt?: string; action?: string; dialogue?: string; description?: string; duration?: number; image_url?: string; local_path?: string; }) { return request.put(`/scenes/${sceneId}`, data) }, createScene(data: { drama_id: number; episode_id?: number; location: string; time?: string; prompt?: string; description?: string; image_url?: string; local_path?: string; }) { return request.post('/scenes', data) }, generateSceneImage(data: { scene_id: number; prompt?: string; model?: string }) { return request.post<{ image_generation: { id: number } }>('/scenes/generate-image', data) }, updateScenePrompt(sceneId: string, prompt: string) { return request.put(`/scenes/${sceneId}/prompt`, { prompt }) }, deleteScene(sceneId: string) { return request.delete(`/scenes/${sceneId}`) }, // 完成集数制作(触发视频合成) finalizeEpisode(episodeId: string, timelineData?: any) { return request.post(`/episodes/${episodeId}/finalize`, timelineData || {}) }, createStoryboard(data: { episode_id: number; storyboard_number: number; title?: string; description?: string; action?: string; dialogue?: string; scene_id?: number; duration: number; }) { return request.post('/storyboards', data) }, deleteStoryboard(storyboardId: number) { return request.delete(`/storyboards/${storyboardId}`) } } ================================================ FILE: web/src/api/frame.ts ================================================ import request from '../utils/request' // 帧类型 export type FrameType = 'first' | 'key' | 'last' | 'panel' | 'action' // 单帧提示词 export interface SingleFramePrompt { prompt: string description: string } // 多帧提示词 export interface MultiFramePrompt { layout: string // horizontal_3, grid_2x2 等 frames: SingleFramePrompt[] } // 生成帧提示词响应 (异步任务) export interface GenerateFramePromptResponse { task_id: string status: string message: string } // 生成帧提示词请求 export interface GenerateFramePromptRequest { frame_type: FrameType panel_count?: number // 分镜板格数,默认3 } /** * 生成指定类型的帧提示词 */ export function generateFramePrompt( storyboardId: number, data: GenerateFramePromptRequest ): Promise { return request.post(`/storyboards/${storyboardId}/frame-prompt`, data) } /** * 生成首帧提示词 */ export function generateFirstFrame(storyboardId: number): Promise { return generateFramePrompt(storyboardId, { frame_type: 'first' }) } /** * 生成关键帧提示词 */ export function generateKeyFrame(storyboardId: number): Promise { return generateFramePrompt(storyboardId, { frame_type: 'key' }) } /** * 生成尾帧提示词 */ export function generateLastFrame(storyboardId: number): Promise { return generateFramePrompt(storyboardId, { frame_type: 'last' }) } /** * 生成分镜板(3格组合) */ export function generatePanelFrames( storyboardId: number, panelCount: number = 3 ): Promise { return generateFramePrompt(storyboardId, { frame_type: 'panel', panel_count: panelCount }) } /** * 生成动作序列(5格) */ export function generateActionSequence(storyboardId: number): Promise { return generateFramePrompt(storyboardId, { frame_type: 'action' }) } // 帧提示词记录(从数据库查询) export interface FramePromptRecord { id: number storyboard_id: number frame_type: FrameType prompt: string description?: string layout?: string created_at: string updated_at: string } /** * 查询镜头的所有已生成帧提示词 */ export function getStoryboardFramePrompts(storyboardId: number): Promise<{ frame_prompts: FramePromptRecord[] }> { return request.get<{ frame_prompts: FramePromptRecord[] }>(`/storyboards/${storyboardId}/frame-prompts`) } ================================================ FILE: web/src/api/generation.ts ================================================ import type { GenerateCharactersRequest } from '../types/generation' import request from '../utils/request' export const generationAPI = { generateCharacters(data: GenerateCharactersRequest) { return request.post<{ task_id: string; status: string; message: string }>('/generation/characters', data) }, generateStoryboard(episodeId: string, model?: string) { return request.post<{ task_id: string; status: string; message: string }>(`/episodes/${episodeId}/storyboards`, { model }) }, getTaskStatus(taskId: string) { return request.get<{ id: string type: string status: string progress: number message?: string error?: string result?: string created_at: string updated_at: string completed_at?: string }>(`/tasks/${taskId}`) } } ================================================ FILE: web/src/api/image.ts ================================================ import type { GenerateImageRequest, ImageGeneration, ImageGenerationListParams } from '../types/image' import request from '../utils/request' export const imageAPI = { generateImage(data: GenerateImageRequest) { return request.post('/images', data) }, generateForScene(sceneId: number) { return request.post(`/images/scene/${sceneId}`) }, batchGenerateForEpisode(episodeId: number) { return request.post(`/images/episode/${episodeId}/batch`) }, getImage(id: number) { return request.get(`/images/${id}`) }, listImages(params: ImageGenerationListParams) { return request.get<{ items: ImageGeneration[] pagination: { page: number page_size: number total: number total_pages: number } }>('/images', { params }) }, deleteImage(id: number) { return request.delete(`/images/${id}`) }, // 上传图片并创建图片生成记录 uploadImage(data: { storyboard_id: number drama_id: number frame_type: string image_url: string prompt?: string }) { return request.post('/images/upload', data) } } ================================================ FILE: web/src/api/prop.ts ================================================ import request from '../utils/request' import type { Prop, CreatePropRequest, UpdatePropRequest } from '../types/prop' export const propAPI = { list(dramaId: string | number) { return request.get('/dramas/' + dramaId + '/props') }, create(data: CreatePropRequest) { return request.post('/props', data) }, update(id: number, data: UpdatePropRequest) { return request.put('/props/' + id, data) }, delete(id: number) { return request.delete('/props/' + id) }, extractFromScript(episodeId: number) { return request.post<{ task_id: string }>(`/episodes/${episodeId}/props/extract`) }, generateImage(id: number) { return request.post<{ task_id: string }>(`/props/${id}/generate`) }, associateWithStoryboard(storyboardId: number, propIds: number[]) { return request.post(`/storyboards/${storyboardId}/props`, { prop_ids: propIds }) } } ================================================ FILE: web/src/api/settings.ts ================================================ import request from '../utils/request' export const settingsAPI = { // 获取系统语言 getLanguage() { return request.get<{ language: string }>('/settings/language') }, // 更新系统语言 updateLanguage(language: 'zh' | 'en') { return request.put<{ message: string; language: string }>('/settings/language', { language }) } } ================================================ FILE: web/src/api/task.ts ================================================ import request from '../utils/request' export interface AsyncTask { id: string type: string status: 'pending' | 'processing' | 'completed' | 'failed' progress: number message: string result?: any error?: string created_at: string } export const taskAPI = { getStatus(taskId: string) { return request.get(`/tasks/${taskId}`) } } ================================================ FILE: web/src/api/video.ts ================================================ import type { GenerateVideoRequest, VideoGeneration, VideoGenerationListParams } from '../types/video' import request from '../utils/request' export const videoAPI = { generateVideo(data: GenerateVideoRequest) { return request.post('/videos', data) }, generateFromImage(imageGenId: number) { return request.post(`/videos/image/${imageGenId}`) }, batchGenerateForEpisode(episodeId: number) { return request.post(`/videos/episode/${episodeId}/batch`) }, getVideoGeneration(id: number) { return request.get(`/videos/${id}`) }, getVideo(id: number) { return request.get(`/videos/${id}`) }, listVideos(params: VideoGenerationListParams) { return request.get<{ items: VideoGeneration[] pagination: { page: number page_size: number total: number total_pages: number } }>('/videos', { params }) }, deleteVideo(id: number) { return request.delete(`/videos/${id}`) } } ================================================ FILE: web/src/api/videoMerge.ts ================================================ import request from '../utils/request' export interface SceneClip { scene_id: string video_url: string start_time: number end_time: number duration: number order: number } export interface MergeVideoRequest { episode_id: string drama_id: string title: string scenes: SceneClip[] provider?: string model?: string } export interface VideoMerge { id: number episode_id: string drama_id: string title: string provider: string model?: string status: 'pending' | 'processing' | 'completed' | 'failed' scenes: SceneClip[] merged_url?: string duration?: number task_id?: string error_msg?: string created_at: string completed_at?: string } export const videoMergeAPI = { async mergeVideos(data: MergeVideoRequest): Promise { const response = await request.post<{ merge: VideoMerge }>('/video-merges', data) return response.merge }, async getMerge(mergeId: number): Promise { const response = await request.get<{ merge: VideoMerge }>(`/video-merges/${mergeId}`) return response.merge }, async listMerges(params: { episode_id?: string status?: string page?: number page_size?: number }): Promise<{ merges: VideoMerge[]; total: number }> { const response = await request.get<{ merges: VideoMerge[]; total: number }>('/video-merges', { params }) return { merges: response.merges || [], total: response.total || 0 } }, async deleteMerge(mergeId: number): Promise { await request.delete(`/video-merges/${mergeId}`) } } ================================================ FILE: web/src/assets/styles/element/index.scss ================================================ /*just override what you need*/ @forward 'element-plus/theme-chalk/src/dark/var.scss' with ( $bg-color: ( 'page': #0a0a0a, '': #141414, 'overlay': #1d1e1f, ), $fill-color: ( '': #262727, 'light': #1d1e1f, 'lighter': #141414, 'extra-light': #191919, 'dark': #3a3a3a, 'darker': #4a4a4a, 'blank': #1a1a1a, ) ); ================================================ FILE: web/src/assets/styles/main.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; /* ======================================== CSS Variables for Theme / 主题 CSS 变量 Modern minimalist design system ======================================== */ :root { /* Background colors / 背景色 */ --bg-primary: #f8fafc; --bg-secondary: #ffffff; --bg-card: #ffffff; --bg-card-hover: #f1f5f9; --bg-elevated: #ffffff; --bg-overlay: rgba(15, 23, 42, 0.5); /* Text colors / 文字色 */ --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; --text-inverse: #ffffff; /* Border colors / 边框色 */ --border-primary: #e2e8f0; --border-secondary: #cbd5e1; --border-focus: #0ea5e9; /* Primary accent / 主强调色 */ --accent: #0ea5e9; --accent-hover: #0284c7; --accent-light: #e0f2fe; --accent-dark: #0369a1; /* Status colors / 状态色 */ --success: #10b981; --success-light: #d1fae5; --warning: #f59e0b; --warning-light: #fef3c7; --error: #ef4444; --error-light: #fee2e2; --info: #3b82f6; --info-light: #dbeafe; /* Shadows / 阴影 - refined for depth */ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.03); --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.05); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.05); --shadow-glow: 0 0 20px rgba(14, 165, 233, 0.15); --shadow-card: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04); --shadow-card-hover: 0 8px 16px -4px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05); /* Transition / 过渡 */ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); --transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); --transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55); /* Border radius / 圆角 */ --radius-xs: 0.25rem; --radius-sm: 0.375rem; --radius-md: 0.5rem; --radius-lg: 0.75rem; --radius-xl: 1rem; --radius-2xl: 1.25rem; --radius-full: 9999px; /* Spacing scale / 间距比例 */ --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-5: 1.25rem; --space-6: 1.5rem; --space-8: 2rem; --space-10: 2.5rem; --space-12: 3rem; } /* Dark mode theme / 深色模式主题 - 参考深色UI设计 */ .dark { /* Background - 深邃的蓝黑色调 */ --bg-primary: #0c1015; --bg-secondary: #12181f; --bg-card: #181f28; --bg-card-hover: #1e2730; --bg-elevated: #1a2129; --bg-overlay: rgba(0, 0, 0, 0.8); /* Text - 清晰的层次对比 */ --text-primary: #e8edf3; --text-secondary: #8b9bb0; --text-muted: #5a6a7e; --text-inverse: #0c1015; /* Border - 微妙的边框 */ --border-primary: #252d38; --border-secondary: #323d4d; --border-focus: #22d3ee; /* Accent - 青色强调色 */ --accent: #22d3ee; --accent-hover: #06b6d4; --accent-light: rgba(34, 211, 238, 0.12); --accent-dark: #67e8f9; /* Status colors / 状态色 */ --success: #34d399; --success-light: rgba(52, 211, 153, 0.12); --warning: #fbbf24; --warning-light: rgba(251, 191, 36, 0.12); --error: #f87171; --error-light: rgba(248, 113, 113, 0.12); --info: #60a5fa; --info-light: rgba(96, 165, 250, 0.12); /* Shadows - 更深的阴影 */ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.4); --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.5), 0 1px 2px -1px rgb(0 0 0 / 0.5); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.5); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.7), 0 4px 6px -4px rgb(0 0 0 / 0.6); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.8), 0 8px 10px -6px rgb(0 0 0 / 0.7); --shadow-glow: 0 0 20px rgba(34, 211, 238, 0.25); --shadow-card: 0 2px 4px 0 rgb(0 0 0 / 0.3); --shadow-card-hover: 0 8px 20px -4px rgb(0 0 0 / 0.5), 0 0 0 1px rgba(34, 211, 238, 0.15); --el-fill-color-blank: #181f28; --el-border-color: #4d4d4d; --el-border-color-light: #2a333d; --el-fill-color-light: #2a333d; --el-bg-color-overlay: #181f28; --el-text-color-regular: #e8edf3; --el-descriptions-table-border: #4d4d4d; --el-border-color-lighter: #4d4d4d; --el-text-color-primary: #e8edf3; } /* ======================================== Base Styles / 基础样式 ======================================== */ * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: var(--bg-primary); color: var(--text-primary); transition: background-color var(--transition-normal), color var(--transition-normal); } #app { width: 100%; height: 100%; } /* ======================================== Element Plus Overrides / Element Plus 样式覆盖 Modern minimalist design overrides ======================================== */ /* 单行打点 */ .overflow-tooltip { display: inline-block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; cursor: pointer; } .el-select-dropdown__item { line-height: unset; } /* Button overrides / 按钮样式覆盖 */ .el-button { --el-button-border-radius: var(--radius-lg); font-weight: 500; transition: all var(--transition-fast); border: none; letter-spacing: -0.01em; } .el-button--default { background: var(--bg-card); border: 1px solid var(--border-primary); color: var(--text-primary); } .el-button--default:hover { background: var(--bg-card-hover); border-color: var(--border-secondary); color: var(--text-primary); } .el-button--primary { --el-button-bg-color: var(--accent); --el-button-border-color: var(--accent); --el-button-hover-bg-color: var(--accent-hover); --el-button-hover-border-color: var(--accent-hover); background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%); box-shadow: 0 2px 8px rgba(14, 165, 233, 0.25); } .el-button--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(14, 165, 233, 0.35); } .el-button--primary:active { transform: translateY(0); } .el-button--danger { background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%); box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25); } .el-button--danger:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35); } .el-button--success { background: linear-gradient(135deg, var(--success) 0%, #059669 100%); box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25); } .el-button.is-text { color: var(--text-secondary); } .el-button.is-text:hover { color: var(--accent); background: var(--accent-light); } .el-button.is-circle { background: var(--bg-card); border: 1px solid var(--border-primary); color: var(--text-secondary); } .el-button.is-circle:hover { background: var(--bg-card-hover); border-color: var(--border-secondary); color: var(--text-primary); } /* Back button / 返回按钮 */ .back-btn { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.5rem 0.875rem; background: var(--bg-card); border: 1px solid var(--border-primary); border-radius: var(--radius-lg); color: var(--text-secondary); font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); white-space: nowrap; } .back-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); border-color: var(--border-secondary); } .back-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* Card overrides / 卡片样式覆盖 */ .el-card { --el-card-bg-color: var(--bg-card); --el-card-border-color: var(--border-primary); --el-card-border-radius: var(--radius-xl); border: 1px solid var(--border-primary); box-shadow: var(--shadow-card); transition: all var(--transition-normal); } .el-card:hover { box-shadow: var(--shadow-card-hover); } .el-card__header { border-bottom: 1px solid var(--border-primary); padding: var(--space-4) var(--space-5); } .el-card__body { padding: var(--space-5); } .dark .el-card { --el-card-bg-color: var(--bg-card); --el-card-border-color: var(--border-primary); } /* Dialog overrides / 对话框样式覆盖 */ .el-dialog { --el-dialog-bg-color: var(--bg-card); --el-dialog-border-radius: var(--radius-2xl); box-shadow: var(--shadow-xl); border: 1px solid var(--border-primary); } .el-dialog__header { padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--border-primary); margin-right: 0; } .el-dialog__title { font-size: 1.125rem; font-weight: 600; color: var(--text-primary); letter-spacing: -0.02em; } .el-dialog__body { padding: var(--space-6); } .el-dialog__footer { padding: var(--space-4) var(--space-6); border-top: 1px solid var(--border-primary); } .dark .el-dialog { --el-dialog-bg-color: var(--bg-card); } .dark .el-dialog__title { color: var(--text-primary); } /* Input overrides / 输入框样式覆盖 */ .el-input__wrapper { --el-input-bg-color: var(--bg-secondary); --el-input-border-color: var(--border-primary); border-radius: var(--radius-lg) !important; box-shadow: 0 0 0 1px var(--border-primary) inset !important; transition: all var(--transition-fast); padding: 0 var(--space-3); } .el-input__wrapper:hover { box-shadow: 0 0 0 1px var(--border-secondary) inset !important; } .el-input__wrapper.is-focus { box-shadow: 0 0 0 2px var(--accent) inset !important; } .el-input__inner { color: var(--text-primary); font-size: 0.875rem; } .el-input__inner::placeholder { color: var(--text-muted); } .el-textarea__inner { --el-input-bg-color: var(--bg-secondary); border-radius: var(--radius-lg) !important; box-shadow: 0 0 0 1px var(--border-primary) inset; transition: all var(--transition-fast); padding: var(--space-3); color: var(--text-primary); font-size: 0.875rem; } .el-textarea__inner:hover { box-shadow: 0 0 0 1px var(--border-secondary) inset; } .el-textarea__inner:focus { box-shadow: 0 0 0 2px var(--accent) inset; } .el-textarea__inner::placeholder { color: var(--text-muted); } .dark .el-input__wrapper { background-color: var(--bg-secondary); } .dark .el-input__inner { color: var(--text-primary); } .dark .el-textarea__inner { background-color: var(--bg-secondary); color: var(--text-primary); } /* Select overrides / 选择器样式覆盖 */ .el-select .el-input__wrapper { background: var(--bg-secondary); } .el-select-dropdown { border-radius: var(--radius-lg); border: 1px solid var(--border-primary); box-shadow: var(--shadow-lg); } .el-select-dropdown__item { font-size: 0.875rem; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); margin: 2px var(--space-1); } .el-select-dropdown__item.is-selected { background: var(--accent-light); color: var(--accent); font-weight: 500; } .el-select-dropdown__item:hover { background: var(--bg-card-hover); } .dark .el-select-dropdown { background: var(--bg-elevated); border-color: var(--border-primary); } .dark .el-select-dropdown__item:hover { background: var(--bg-card-hover); } /* Tag overrides / 标签样式覆盖 */ .el-tag { --el-tag-border-radius: var(--radius-md); font-weight: 500; font-size: 0.75rem; padding: 0 var(--space-2); height: 1.5rem; line-height: 1.5rem; border: none; } .el-tag--info { background: var(--bg-card-hover); color: var(--text-secondary); } .el-tag--primary { background: var(--accent-light); color: var(--accent); } .el-tag--success { background: var(--success-light); color: var(--success); } .el-tag--warning { background: var(--warning-light); color: var(--warning); } .el-tag--danger { background: var(--error-light); color: var(--error); } /* Tabs overrides / 标签页样式覆盖 */ .el-tabs__header { margin-bottom: var(--space-6); } .el-tabs__nav-wrap::after { display: none; } .el-tabs__item { font-weight: 500; font-size: 0.875rem; color: var(--text-secondary); padding: 0 var(--space-5); height: 2.5rem; line-height: 2.5rem; transition: color var(--transition-fast); } .el-tabs__item:hover { color: var(--text-primary); } .el-tabs__item.is-active { color: var(--accent); font-weight: 600; } .el-tabs__active-bar { background: var(--accent); height: 2px; border-radius: var(--radius-full); } .dark .el-tabs__item { color: var(--text-secondary); } .dark .el-tabs__item.is-active { color: var(--accent); } /* Table overrides / 表格样式覆盖 */ .el-table { --el-table-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-secondary); --el-table-tr-bg-color: var(--bg-card); --el-table-row-hover-bg-color: var(--bg-card-hover); --el-table-border-color: var(--border-primary); border-radius: var(--radius-lg); overflow: hidden; } .el-table th.el-table__cell { font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } .el-table td.el-table__cell { font-size: 0.875rem; color: var(--text-primary); } .dark .el-table { --el-table-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-secondary); --el-table-tr-bg-color: var(--bg-card); --el-table-row-hover-bg-color: var(--bg-card-hover); --el-fill-color-lighter: var(--bg-secondary); } .dark .el-table th.el-table__cell, .dark .el-table td.el-table__cell { border-color: var(--border-primary); } .dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell { background-color: var(--bg-secondary); } /* Pagination overrides / 分页样式覆盖 */ .el-pagination { --el-pagination-bg-color: transparent; --el-pagination-button-bg-color: var(--bg-card); gap: var(--space-1); } .el-pager li { min-width: 2rem; height: 2rem; line-height: 2rem; font-weight: 500; font-size: 0.8125rem; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); transition: all var(--transition-fast); } .el-pager li:hover { color: var(--accent); background: var(--accent-light); } .el-pager li.is-active { background: var(--accent); color: white; font-weight: 600; } .el-pagination button { min-width: 2rem; height: 2rem; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); transition: all var(--transition-fast); } .el-pagination button:hover:not(:disabled) { color: var(--accent); background: var(--accent-light); } .el-pagination button:disabled { opacity: 0.4; } .dark .el-pagination { --el-pagination-text-color: var(--text-secondary); --el-pagination-button-color: var(--text-primary); } .dark .el-pagination button, .dark .el-pager li { background-color: transparent; color: var(--text-secondary); } .dark .el-pager li:hover { background: var(--accent-light); } .dark .el-pager li.is-active { background: var(--accent); color: var(--text-inverse); } /* Empty state overrides / 空状态样式覆盖 */ .el-empty { padding: var(--space-12) var(--space-6); } .el-empty__description p { color: var(--text-muted); font-size: 0.875rem; } .dark .el-empty__description p { color: var(--text-muted); } /* Alert overrides / 提示框样式覆盖 */ .el-alert { border-radius: var(--radius-lg); border: none; padding: var(--space-4); } .el-alert--info { background: var(--info-light); } .el-alert--success { background: var(--success-light); } .el-alert--warning { background: var(--warning-light); } .el-alert--error { background: var(--error-light); } .dark .el-alert--info { --el-alert-bg-color: var(--info-light); } /* Form overrides / 表单样式覆盖 */ .el-form-item__label { font-weight: 500; font-size: 0.875rem; color: var(--text-primary); margin-bottom: var(--space-2); } .dark .el-form-item__label { color: var(--text-primary); } /* Descriptions overrides / 描述列表样式覆盖 */ .el-descriptions { --el-descriptions-item-bordered-label-background: var(--bg-secondary); } .el-descriptions__label { font-weight: 500; color: var(--text-secondary); } .el-descriptions__content { color: var(--text-primary); } .dark .el-descriptions__label, .dark .el-descriptions__content { background: var(--bg-secondary); } /* Message Box overrides / 消息框样式覆盖 */ .el-message-box { border-radius: var(--radius-xl); border: 1px solid var(--border-primary); box-shadow: var(--shadow-xl); } .dark .el-message-box { background: var(--bg-card); } /* Popconfirm overrides / 确认弹窗样式覆盖 */ .el-popconfirm { border-radius: var(--radius-lg); } .dark .el-popconfirm { --el-popconfirm-bg-color: var(--bg-card); } /* Loading overrides / 加载样式覆盖 */ .el-loading-mask { background: var(--bg-overlay); backdrop-filter: blur(4px); } .el-loading-spinner .circular { width: 32px; height: 32px; } .el-loading-spinner .path { stroke: var(--accent); } /* Switch overrides / 开关样式覆盖 */ .el-switch { --el-switch-on-color: var(--accent); } .dark .el-switch__core { background: var(--bg-secondary); border-color: var(--border-primary); } /* Tooltip overrides / 提示框样式覆盖 */ .el-tooltip__trigger { outline: none; } /* Avatar overrides / 头像样式覆盖 */ .el-avatar { --el-avatar-bg-color: var(--accent); } /* Scrollbar overrides / 滚动条样式覆盖 */ .el-scrollbar__thumb { background: var(--border-secondary); border-radius: var(--radius-full); } .el-scrollbar__thumb:hover { background: var(--text-muted); } /* 图片样式 */ .el-image-viewer__close { background-color: var(--text-muted); /* border: 1px solid var(--border-primary); */ } .el-image-viewer__actions { background-color: #0f172a; /* border: 1px solid var(--border-primary); */ } /* ======================================== Utility Classes / 工具类 ======================================== */ .page-container { min-height: 100vh; background-color: var(--bg-primary); /* padding: var(--space-2) var(--space-3); */ transition: background-color var(--transition-normal); } /* @media (min-width: 768px) { .page-container { padding: var(--space-3) var(--space-4); } } */ /* @media (min-width: 1024px) { .page-container { padding: var(--space-4) var(--space-5); } } */ .content-wrapper { margin: 0 auto; width: 100%; } /* ======================================== Layout Components / 布局组件 ======================================== */ /* Glass morphism card / 玻璃态卡片 */ .glass-card { background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(12px); border: 1px solid var(--border-primary); border-radius: var(--radius-xl); box-shadow: var(--shadow-card); transition: all var(--transition-normal); } .dark .glass-card { background: rgba(26, 35, 50, 0.8); } .glass-card:hover { box-shadow: var(--shadow-card-hover); } /* Gradient backgrounds / 渐变背景 */ .gradient-primary { background: linear-gradient(135deg, var(--accent) 0%, #0284c7 100%); } .gradient-success { background: linear-gradient(135deg, var(--success) 0%, #059669 100%); } .gradient-warning { background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%); } .gradient-error { background: linear-gradient(135deg, var(--error) 0%, #dc2626 100%); } /* Animated gradient / 动画渐变 */ .gradient-animated { background: linear-gradient(-45deg, #0ea5e9, #06b6d4, #8b5cf6, #ec4899); background-size: 400% 400%; animation: gradient-shift 15s ease infinite; } @keyframes gradient-shift { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } /* Glow effect / 发光效果 */ .glow-primary { box-shadow: 0 0 20px rgba(14, 165, 233, 0.3); } .glow-success { box-shadow: 0 0 20px rgba(16, 185, 129, 0.3); } /* Skeleton loading / 骨架屏加载 */ .skeleton { background: linear-gradient(90deg, var(--bg-card-hover) 25%, var(--bg-secondary) 50%, var(--bg-card-hover) 75%); background-size: 200% 100%; animation: skeleton-loading 1.5s infinite; border-radius: var(--radius-md); } @keyframes skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* Hover lift effect / 悬停提升效果 */ .hover-lift { transition: transform var(--transition-normal), box-shadow var(--transition-normal); } .hover-lift:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); } /* Interactive scale / 交互缩放 */ .interactive-scale { transition: transform var(--transition-fast); } .interactive-scale:hover { transform: scale(1.02); } .interactive-scale:active { transform: scale(0.98); } /* Focus ring / 焦点环 */ .focus-ring:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* Text truncation / 文本截断 */ .truncate-1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .truncate-2 { display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .truncate-3 { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } /* Scrollbar styling / 滚动条样式 */ /* 全局滚动条样式 */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--bg-secondary); border-radius: var(--radius-full); } ::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: var(--radius-full); transition: background var(--transition-fast); } ::-webkit-scrollbar-thumb:hover { background: var(--accent); } /* Firefox 滚动条样式 */ * { scrollbar-width: thin; scrollbar-color: var(--border-secondary) var(--bg-secondary); } /* 自定义滚动条类 */ .custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; } .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } .custom-scrollbar::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: var(--radius-full); } .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: var(--accent); } /* Hide scrollbar / 隐藏滚动条 */ .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } .hide-scrollbar::-webkit-scrollbar { display: none; } /* ======================================== Animation Utilities / 动画工具 ======================================== */ /* Fade in / 淡入 */ .animate-fade-in { animation: fade-in 0.3s ease-out; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } /* Slide up / 向上滑入 */ .animate-slide-up { animation: slide-up 0.3s ease-out; } @keyframes slide-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* Scale in / 缩放进入 */ .animate-scale-in { animation: scale-in 0.2s ease-out; } @keyframes scale-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } /* Pulse / 脉冲 */ .animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Spin / 旋转 */ .animate-spin { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Bounce / 弹跳 */ .animate-bounce { animation: bounce 1s infinite; } @keyframes bounce { 0%, 100% { transform: translateY(-5%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: translateY(0); animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } } /* ======================================== Status Indicators / 状态指示器 ======================================== */ .status-dot { width: 8px; height: 8px; border-radius: var(--radius-full); flex-shrink: 0; } .status-dot.success { background: var(--success); } .status-dot.warning { background: var(--warning); } .status-dot.error { background: var(--error); } .status-dot.info { background: var(--info); } .status-dot.muted { background: var(--text-muted); } .status-dot.pulse { animation: status-pulse 2s infinite; } @keyframes status-pulse { 0%, 100% { box-shadow: 0 0 0 0 currentColor; opacity: 1; } 50% { box-shadow: 0 0 0 4px currentColor; opacity: 0.5; } } /* ======================================== Typography Utilities / 排版工具 ======================================== */ .text-gradient { background: linear-gradient(135deg, var(--accent) 0%, #06b6d4 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .font-display { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 700; letter-spacing: -0.02em; } .font-mono { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; } ================================================ FILE: web/src/components/LanguageSwitcher.vue ================================================ ================================================ FILE: web/src/components/common/AIConfigDialog.vue ================================================ ================================================ FILE: web/src/components/common/ActionButton.vue ================================================ ================================================ FILE: web/src/components/common/AppHeader.vue ================================================ ================================================ FILE: web/src/components/common/AppLayout.vue ================================================ ================================================ FILE: web/src/components/common/BaseCard.vue ================================================ ================================================ FILE: web/src/components/common/CreateDramaDialog.vue ================================================ ================================================ FILE: web/src/components/common/EmptyState.vue ================================================ ================================================ FILE: web/src/components/common/ImageCropDialog.vue ================================================ ================================================ FILE: web/src/components/common/ImagePreview.vue ================================================ ================================================ FILE: web/src/components/common/PageHeader.vue ================================================ ================================================ FILE: web/src/components/common/ProjectCard.vue ================================================ ================================================ FILE: web/src/components/common/StatCard.vue ================================================ ================================================ FILE: web/src/components/common/ThemeToggle.vue ================================================ ================================================ FILE: web/src/components/common/index.ts ================================================ /** * Common UI Components barrel export * 通用 UI 组件统一导出 */ // Layout Components / 布局组件 export { default as PageHeader } from './PageHeader.vue' export { default as BaseCard } from './BaseCard.vue' export { default as StatCard } from './StatCard.vue' export { default as EmptyState } from './EmptyState.vue' // Interactive Components / 交互组件 export { default as ProjectCard } from './ProjectCard.vue' export { default as ThemeToggle } from './ThemeToggle.vue' export { default as ActionButton } from './ActionButton.vue' export { default as ImagePreview } from "./ImagePreview.vue"; export { default as ImageCropDialog } from "./ImageCropDialog.vue"; // Dialog Components / 弹窗组件 export { default as CreateDramaDialog } from './CreateDramaDialog.vue' export { default as AIConfigDialog } from './AIConfigDialog.vue' // Layout Components / 布局组件 export { default as AppLayout } from './AppLayout.vue' export { default as AppHeader } from './AppHeader.vue' ================================================ FILE: web/src/components/editor/GridImageEditor.vue ================================================ ================================================ FILE: web/src/components/editor/StoryboardEditor.vue ================================================ ================================================ FILE: web/src/components/editor/VideoTimelineEditor.vue ================================================ ================================================ FILE: web/src/locales/en-US.ts ================================================ export default { nav: { home: 'Home', characters: 'Characters', storyboard: 'Storyboard', videos: 'Videos', assets: 'Assets', settings: 'Settings', dramas: 'Drama Projects' }, dashboard: { title: '🎬 Drama Generator', welcome: 'Welcome to AI Drama Generation Platform', subtitle: 'One-stop drama creation tool from script to video', stats: { projects: 'Drama Projects', images: 'Generated Images', videos: 'Generated Videos', tasks: 'Processing Tasks' }, quickStart: 'Quick Start', actions: { newProject: 'Create New Project', newProjectDesc: 'Start a brand new drama project', myProjects: 'My Projects', myProjectsDesc: 'View and manage existing projects' } }, common: { create: 'Create', edit: 'Edit', delete: 'Delete', save: 'Save', cancel: 'Cancel', confirm: 'Confirm', search: 'Search', filter: 'Filter', reset: 'Reset', submit: 'Submit', close: 'Close', back: 'Back', next: 'Next', previous: 'Previous', selectAll: 'Select All', loading: 'Loading...', success: 'Success', error: 'Error', warning: 'Warning', info: 'Info', actions: 'Actions', status: 'Status', name: 'Name', description: 'Description', createdAt: 'Created At', updatedAt: 'Updated At', perPage: 'Per Page', image:"Image", }, settings: { title: 'Settings', aiConfig: 'AI Configuration', general: 'General', language: 'Language', systemLanguage: 'System Language', currentLanguage: 'Current Language', languageSwitchNotice: 'Language Switch Notice', languageSwitchDesc: 'After switching system language, the following will be affected:', languageSwitchItem1: 'All prompts generated by backend (storyboard descriptions, character descriptions, scene descriptions, etc.) will use the selected language', languageSwitchItem2: 'Conversations with AI models will use the selected language', languageSwitchItem3: 'Already generated content will not be automatically updated and needs to be regenerated', theme: 'Theme' }, aiConfig: { title: 'AI Service Configuration', addConfig: 'Add Configuration', editConfig: 'Edit Configuration', back: 'Back', empty: 'No configurations yet, click Add Configuration to get started', enabled: 'Enabled', disabled: 'Disabled', enable: 'Enable', disable: 'Disable', endpoint: 'Endpoint', queryEndpoint: 'Query Endpoint', tabs: { text: 'Text Generation', image: 'Image Generation', video: 'Video Generation' }, form: { name: 'Configuration Name', namePlaceholder: 'e.g., OpenAI GPT-4', provider: 'Provider', providerPlaceholder: 'Select a provider', providerTip: 'Select AI service provider', priority: 'Priority', priorityTip: 'Higher values have higher priority. For the same model, higher priority configurations are used first', model: 'Model', modelPlaceholder: 'Enter or select model name', modelTip: 'Enter model name directly or select from list, supports multiple models', baseUrl: 'Base URL', baseUrlPlaceholder: 'https://api.openai.com', baseUrlTip: 'API service base address, e.g., Chatfire: https://api.chatfire.site/v1, Gemini: https://generativelanguage.googleapis.com (no /v1 needed)', fullEndpoint: 'Full endpoint path', apiKey: 'API Key', apiKeyPlaceholder: 'sk-...', apiKeyTip: 'Your API key', isActive: 'Active Status' }, actions: { test: 'Test Connection', delete: 'Delete', edit: 'Edit' }, messages: { deleteConfirm: 'Are you sure to delete this configuration?', testSuccess: 'Connection test successful!', testFailed: 'Connection test failed' } }, drama: { title: 'My Drama Projects', create: 'Create Project', totalProjects: 'Total {count} projects', createNew: 'Create Project', createDesc: 'Fill in basic information to create your drama project', aiConfig: 'AI Config', aiConfigTip: 'Please configure AI service before creating a project', empty: 'No projects yet', emptyHint: 'Click "Create Project" button above to start your first drama', editProject: 'Edit Project', projectName: 'Project Name', projectNamePlaceholder: 'Enter project name', projectDesc: 'Project Description', projectDescPlaceholder: 'Enter project description (optional)', deleteConfirm: 'Are you sure you want to delete this project?', noCover: 'No cover', noDescription: 'No description', status: { draft: 'Draft', production: 'In Production', completed: 'Completed' }, actions: { edit: 'Edit', view: 'View', delete: 'Delete' }, management: { overview: 'Project Overview', episodes: 'Episode Management', characters: 'Character Management', scenes: 'Scene Management', projectInfo: 'Project Information', projectName: 'Project Name', projectDesc: 'Project Description', noDescription: 'No description', episodeStats: 'Episode Statistics', characterStats: 'Character Statistics', sceneStats: 'Scene Statistics', episodesCreated: 'Episodes Created', charactersCreated: 'Characters Created', sceneLibraryCount: 'Scene Library Count', startFirstEpisode: 'Start creating your first episode!', noEpisodesYet: 'Your project has no episodes yet. Please create an episode to start production.', createFirstEpisode: 'Create First Episode Now', episodeList: 'Episode List', createNewEpisode: 'Create New Episode', noEpisodes: 'No episodes yet', clickToCreate: 'Click the button above to create your first episode', episodeNumber: 'Episode {number}', goToEdit: 'Go to Edit', characterList: 'Character List', noCharacters: 'No characters yet', charactersTip: 'Characters will be automatically created during script generation', sceneList: 'Scene List', noScenes: 'No scenes yet', scenesTip: 'Scenes will be automatically created during storyboard generation', propList: 'Prop List', noProps: 'No props yet', propStats: 'Prop Statistics', propsCreated: 'Props Created' } }, character: { title: 'Character Management', create: 'Create Character', edit: 'Edit Character', add: 'Add Character', list: 'Character List', name: 'Character Name', role: 'Role', personality: 'Personality', appearance: 'Appearance', background: 'Background', description: 'Description', image: 'Character Image', generate: 'Generate Character Image', extracting: 'Extracting...', generateImage: 'Generate Image', batch: 'Batch Operations', empty: 'Characters were created during script generation. You can view and edit them here', backToProject: 'Back to Project', saveChanges: 'Save Changes', nextStep: 'Next Step: Generate Character Images' }, prop: { title: 'Prop Management', add: 'Add Prop', edit: 'Edit Prop', delete: 'Delete Prop', create: 'Create Prop', name: 'Prop Name', type: 'Type', typePlaceholder: 'e.g., Weapon, Daily Item', description: 'Description', prompt: 'Image Prompt', promptPlaceholder: 'English prompt for AI image generation', extract: 'Extract from Script', extractTitle: 'Extract Props from Script', selectEpisode: 'Select Episode', extractTip: 'AI will analyze the script to automatically extract key props and generate descriptions', startExtract: 'Start Extracting', extractSuccess: 'Prop extraction task submitted, AI analysis will take about 1 minute', generateImage: 'Generate Image' }, scriptGenerationPage: { prevStep: 'Previous', characterList: 'Character List', characterName: 'Character Name', position: 'Position', appearanceDesc: 'Appearance Description', personality: 'Personality', uploadScript: 'Upload Script', uploadContent: 'Upload Content', aiParse: 'AI Parse', confirmSave: 'Confirm & Save', uploadNotice: 'Paste or upload your script file, the system will automatically identify and split into episodes and scenes', uploadMethod: 'Upload Method', dragFilesHere: 'Drag files here or', clickUpload: 'click to upload', supportedFormats: 'Supports .txt, .md, .doc, .docx formats', characterListEditable: 'Character List (Editable)', addCharacter: '+ Add Character', characterType: 'Character Type', mainCharacter: 'Main Character', supportingCharacter: 'Supporting Character', minorCharacter: 'Minor Character', characterDesc: 'Character Description', appearanceFeatures: 'Appearance Features', operations: 'Operations', delete: 'Delete', episodeCount: 'Episode Count', generateFullScript: 'Generate complete episode scripts based on outline', outlineCreatedEpisodes: 'The outline has created {count} episodes, but you can reset the episode count and regenerate', episodePreview: 'Episode Preview (Total {count} episodes)', regenerate: 'Regenerate', episodeNumber: 'Episode', title: 'Title', summary: 'Summary', durationSeconds: 'Duration (seconds)', autoGenerateCharacters: 'Auto-generate character list from outline', charactersCreatedInOutline: 'Characters have been created during outline generation, click "Next" to view and edit' }, script: { title: 'Script Generation', backToProject: 'Back to Project', aiGenerate: 'AI Generate Script', uploadScript: 'Upload Script', steps: { outline: 'Generate Outline', characters: 'Generate Characters', episodes: 'Generate Episodes' }, form: { theme: 'Creative Theme', themePlaceholder: 'Describe the theme and story concept of the drama you want to create', genre: 'Genre Preference', genrePlaceholder: 'Select a genre', style: 'Style Requirements', stylePlaceholder: 'e.g., Light and humorous, Tense and thrilling, Warm and healing', episodeCount: 'Episode Count', randomGenerate: 'Random Generate', title: 'Title', titlePlaceholder: 'Enter script title', summary: 'Summary', summaryPlaceholder: 'Enter script summary', genreExample: 'e.g., Urban, Costume', tags: 'Tags', newTag: 'New Tag' }, notice: 'Please enter the creative theme and requirements, AI will generate a script outline for you', generateFailed: 'Generation Failed', generating: 'Generating...', nextStep: 'Next Step', prevStep: 'Previous Step', complete: 'Complete', regenerate: 'Regenerate', regenerateOutline: 'Regenerate Outline', outlinePreview: 'Outline Preview (Editable)' }, imageDialog: { title: 'AI Image Generation', selectDrama: 'Select Drama', selectScene: 'Select Scene', selectSceneOptional: 'Select Scene (Optional)', sceneLabel: 'Scene {number}: {title}', prompt: 'Prompt', promptPlaceholder: 'Describe the image you want to generate\nFor example: A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed', negativePrompt: 'Negative Prompt', negativePromptPlaceholder: 'Describe elements you don\'t want (optional)\nFor example: blurry, low quality, watermark', aiService: 'AI Service', selectService: 'Select service', imageSize: 'Image Size', selectSize: 'Select size', square: 'Square', landscape: 'Landscape', portrait: 'Portrait', imageQuality: 'Image Quality', standard: 'Standard', hd: 'HD', style: 'Style', vivid: 'Vivid', natural: 'Natural', advancedSettings: 'Advanced Settings', samplingSteps: 'Sampling Steps', promptRelevance: 'Prompt Relevance', randomSeed: 'Random Seed', leaveBlankRandom: 'Leave blank for random', seedTip: 'Set the same seed to reproduce the image', generate: 'Generate Image', pleaseSelectDrama: 'Please select a drama', pleaseEnterPrompt: 'Please enter a prompt', promptMinLength: 'Prompt must be at least 5 characters', taskSubmitted: 'Image generation task submitted, please check results later', generateFailed: 'Generation failed', weak: 'Weak', moderate: 'Moderate', strong: 'Strong', veryStrong: 'Very Strong' }, image: { title: 'AI Image Generation', generate: 'Generate Image', loadFailed: 'Load Failed', generating: 'Generating...', generateFailed: 'Generation Failed' }, dramaWorkflow: { returnToList: 'Back', episodeScript: 'Episode {number} Script', storyboardBreakdown: 'Storyboard Breakdown', characterImages: 'Character Images', createChapterPrompt: 'Please create the first chapter to start production', createChapter: 'Create Chapter {number}', nextStepCharacterImages: 'Next: Character Images', nextStep: 'Next', reGenerateShots: 'Re-split', reGenerateShotsConfirm: 'Re-splitting will overwrite existing shots, are you sure?', pleaseWriteScript: 'Please write script content first', splitStoryboardFirst: 'Please split storyboard first', aiSplitting: 'AI Splitting...', aiAutoSplit: 'AI Auto Split', selected: 'Selected', characterCount: 'Characters', generated: 'Generated', batchGenerate: 'Batch Generate' }, workflow: { backToProject: 'Back to Project', episodeProduction: 'Episode {number} Production', steps: { content: 'Episode Content', generateImages: 'Generate Images', splitStoryboard: 'Split Storyboard' }, scriptPlaceholder: 'Enter episode content...', saveChapter: 'Save Chapter', chapterContent: 'Chapter {number} Content', saved: 'Saved', extractedData: 'Extracted Data', characters: 'Characters', scenes: 'Scenes', extractedCharacters: 'Extracted Characters (This Episode)', extractedScenes: 'Extracted Scenes (This Episode)', extractCharactersAndScenes: 'Extract Characters and Scenes', reExtract: 'Re-extract Characters and Scenes', nextStepGenerateImages: 'Next Step: Generate Images', extractWarning: 'Please click "Extract Characters and Scenes" first, then you can generate images after extraction is complete', characterImages: 'Character Images', sceneImages: 'Scene Images', characterCount: '{count} characters need to generate images', sceneCount: '{count} scenes need to generate images', selectAll: 'Select All', batchGenerate: 'Batch Generate', modelConfig: 'AI Model Configuration', editPrompt: 'Edit Prompt', aiGenerate: 'AI Generate', uploadImage: 'Upload Image', selectFromLibrary: 'Select from Library', shotList: 'Shot List', dragFilesHere: 'Drop files here, or', clickToUpload: 'Click to Upload', prevStep: 'Previous Step', nextStepSplitShots: 'Next Step: Split Shots', reExtractConfirmTitle: 'Re-extract Confirmation', reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?', startReExtracting: 'Starting re-extraction, please wait...', regenerateShots: 'Regenerate Shots', batchGenerateSelected: 'Batch Generate Selected Scenes', generateAllImagesFirst: 'Please generate all character and scene images before splitting shots', sceneImageGenerating: 'Scene image generating, please wait...', sceneImageComplete: 'Scene image generation completed!', sceneImageStarted: 'Scene image generation started', reSplitShots: 'Re-split Shots', enterProfessional: 'Enter Professional Production', editShot: 'Edit Shot', splitSuccess: 'Shot splitting successful! Entering professional production interface...', reSplitConfirm: 'Are you sure you want to re-split the shots?', deleteCharacter: 'Delete Character', splitStoryboardFirst: 'Please split the storyboard first', aiSplitting: 'AI Splitting...', aiAutoSplit: 'AI Auto Split', batchTaskSubmitted: 'Batch generation task submitted!', batchGenerateFailed: 'Batch generation failed', batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes', batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed', addToLibrary: 'Add to Character Library', addToLibraryConfirm: 'Are you sure you want to add character "{name}" to the global character library? Once added, this character can be used in all projects.', addedToLibrary: 'Added to character library!', addFailed: 'Add failed', shotTitle: 'Shot Title', shotTitlePlaceholder: 'Enter shot title', shotType: 'Shot Type', selectShotType: 'Select shot type', longShot: 'Long Shot', fullShot: 'Full Shot', mediumShot: 'Medium Shot', closeUp: 'Close-up', extremeCloseUp: 'Extreme Close-up', cameraAngle: 'Camera Angle', selectAngle: 'Select angle', eyeLevel: 'Eye Level', lowAngle: 'Low Angle', highAngle: 'High Angle', location: 'Location', locationPlaceholder: 'Scene location', shotDescription: 'Shot Description', shotDescriptionPlaceholder: 'Overall shot description', cameraMovement: 'Camera Movement', selectMovement: 'Select movement', staticShot: 'Static Shot', pushIn: 'Push In', pullOut: 'Pull Out', followShot: 'Follow Shot', sideView: 'Side View', time: 'Time', timeSetting: 'Time Setting', actionDescription: 'Action Description', detailedAction: 'Detailed action description', dialogue: 'Dialogue', characterDialogue: 'Character dialogue', generateImageFirst: 'Please generate character images first', saveAndGenerate: 'Save and Generate', saveConfig: 'Save Configuration', play: 'Play', pause: 'Pause', addAll: 'Add All', addToTimeline: 'Add to Timeline', deleteAsset: 'Delete Asset', confirmDelete: 'Confirm Delete', tip: 'Tip', edit: 'Edit' }, tooltip: { editPrompt: 'Edit Prompt', aiGenerate: 'AI Generate', uploadImage: 'Upload Image', selectFromLibrary: 'Select from Library', shotList: 'Shot List', dragFilesHere: 'Drop files here, or', prevStep: 'Previous Step', nextStepSplitShots: 'Next Step: Split Shots', reExtractConfirmTitle: 'Re-extract Confirmation', reExtractConfirmMessage: 'Re-extraction will overwrite extracted characters and scenes (including generated images). Continue?', startReExtracting: 'Starting re-extraction, please wait...', regenerateShots: 'Regenerate Shots', batchGenerateSelected: 'Batch Generate Selected Scenes', generateAllImagesFirst: 'Please generate all character and scene images before splitting shots', sceneImageGenerating: 'Scene image generating, please wait...', sceneImageComplete: 'Scene image generation completed!', sceneImageStarted: 'Scene image generation started', reSplitShots: 'Re-split Shots', editShot: 'Edit Shot', splitSuccess: 'Shot splitting successful! Entering professional production interface...', reSplitConfirm: 'Are you sure you want to re-split the shots?', deleteCharacter: 'Delete Character', splitStoryboardFirst: 'Please split the storyboard first', aiSplitting: 'AI Splitting...', aiAutoSplit: 'AI Auto Split', batchTaskSubmitted: 'Batch generation task submitted!', batchGenerateFailed: 'Batch generation failed', batchCompleteSuccess: 'Batch generation completed! Successfully generated {count} scenes', batchCompletePartial: 'Generation completed: {success} succeeded, {fail} failed', addToLibrary: 'Add to Character Library', addToLibraryConfirm: 'Are you sure you want to add character "{name}" to the global character library? Once added, this character can be used in all projects.', addedToLibrary: 'Added to character library!', addFailed: 'Add failed', shotTitle: 'Shot Title', shotTitlePlaceholder: 'Enter shot title', shotType: 'Shot Type', selectShotType: 'Select shot type', longShot: 'Long Shot', fullShot: 'Full Shot', mediumShot: 'Medium Shot', closeUp: 'Close-up', extremeCloseUp: 'Extreme Close-up', cameraAngle: 'Camera Angle', selectAngle: 'Select angle', eyeLevel: 'Eye Level', lowAngle: 'Low Angle', highAngle: 'High Angle', location: 'Location', locationPlaceholder: 'Scene location', shotDescription: 'Shot Description', shotDescriptionPlaceholder: 'Overall shot description', cameraMovement: 'Camera Movement', selectMovement: 'Select movement', staticShot: 'Static Shot', pushIn: 'Push In', pullOut: 'Pull Out', followShot: 'Follow Shot', sideView: 'Side View', time: 'Time', timeSetting: 'Time setting', actionDescription: 'Action Description', detailedAction: 'Detailed action description', dialogue: 'Dialogue', characterDialogue: 'Character dialogue', generateImageFirst: 'Please generate character image first', result: 'Result', actionResult: 'Action result', atmosphere: 'Atmosphere', atmosphereDescription: 'Atmosphere description', loadLibraryFailed: 'Failed to load character library', imagePrompt: 'Image Prompt', imagePromptPlaceholder: 'Prompt for AI image generation', videoPrompt: 'Video Prompt', videoPromptPlaceholder: 'Prompt for AI video generation', bgmHint: 'BGM Hint', bgmAtmosphere: 'BGM atmosphere description', soundEffect: 'Sound Effect', soundEffectDescription: 'Sound effect description', durationSeconds: 'Duration (seconds)', emptyLibrary: 'Character library is empty, please generate or upload character images first', textModelTip: 'Used to generate episode content, characters, scenes and other text', uploadFormatTip: 'Supports jpg/png formats, file size should not exceed 10MB', aiModelConfig: 'AI Model Configuration', textGenModel: 'Text Generation Model', imageGenModel: 'Image Generation Model', selectTextModel: 'Select text generation model', selectImageModel: 'Select image generation model', modelConfigTip: 'For generating character and scene images', modelConfigSaved: 'Model configuration saved', pleaseSelectModels: 'Please select text and image generation models' }, professionalEditor: { duration: 'Duration', seconds: 's', videoDuration: 'Video Duration', downloadVideo: 'Download Video' }, storyboard: { title: 'Storyboard', edit: 'Storyboard Edit', create: 'Create Storyboard', script: 'Script', scene: 'Scene', shot: 'Shot', shotNumber: 'Shot {number}', untitled: 'Untitled Shot', scriptStructure: 'Script Structure', add: 'Add', noStoryboard: 'No Storyboards', shotProperties: 'Shot Properties', selectScene: 'Select Scene', inDevelopment: 'Feature under development...', generateScript: 'Generate Script', generateImage: 'Generate Image', generateVideo: 'Generate Video', table: { number: 'No.', title: 'Title', shotType: 'Shot Type', movement: 'Movement', location: 'Location', character: 'Character', dialogue: 'Dialogue', action: 'Action', duration: 'Duration', operations: 'Operations' } }, timeline: { title: 'Timeline Editor', backToEditor: 'Back', noScenes: 'No available scenes', loadFailed: 'Failed to load storyboards' }, editor: { backToEpisode: 'Back to Episode Edit', episode: 'Episode {number}', settings: 'Settings', basicInfo: 'Basic Info', sceneProduction: 'Scene Production', sceneId: 'Scene ID', sceneGenerating: 'Scene image generating...', noBackground: 'No background linked', cast: 'Cast', addCharacter: 'Add Character', removeCharacter: 'Remove Character', noCharacters: 'No characters specified', visualSettings: 'Visual Settings', shotType: 'Shot Type', shotTypePlaceholder: 'Select shot type', movement: 'Camera Movement', movementPlaceholder: 'Camera movement', angle: 'Camera Angle', anglePlaceholder: 'Camera angle', action: 'Action', actionPlaceholder: 'Describe the action...', result: 'Result', resultPlaceholder: 'Describe the result...', dialogue: 'Dialogue', dialoguePlaceholder: 'Enter dialogue...', soundEffects: 'Sound Effects', soundEffectsPlaceholder: 'Describe sound effects...', transitions: 'Transitions', transitionsPlaceholder: 'Select transition', duration: 'Duration', seconds: 's', description: 'Description', descriptionPlaceholder: 'Overall shot description...', bgmPrompt: 'BGM Prompt', bgmPromptPlaceholder: 'Describe BGM atmosphere, e.g., Intense background music', atmosphere: 'Atmosphere', atmospherePlaceholder: 'Describe environment atmosphere, e.g., Dark and oppressive, Bright and warm', lightingEffect: 'Lighting Effect', specialEffects: 'Special Effects', props: 'Props', addProp: 'Add Prop', addPropToShot: 'Add Prop to Shot', removeProp: 'Remove Prop', noProps: 'No Props', noPropsAvailable: 'No props available, please add props in drama management first', updatePropFailed: 'Failed to update prop', emotionalTone: 'Emotional Tone', shotImage: 'Shot Image', noShotSelected: 'No shot selected', selectFrameType: 'Select Frame Type', firstFrame: 'First Frame', lastFrame: 'Last Frame', panelFrame: 'Panel', actionSequence: 'Action Sequence', keyFrame: 'Key Frame', panelCount: 'Panel Count', prompt: 'Prompt', extractPrompt: 'Extract Prompt', promptPlaceholder: 'Click Extract Prompt button, the system will generate image prompts based on storyboard content...', generating: 'Generating...', generateImage: 'Generate Image', uploadImage: 'Upload Image', generationResult: 'Generation Result' }, video: { title: 'AI Video Generation', generate: 'Generate Video', merge: 'Merge Video', mediaLibrary: 'Video Media Library', videoCount: '{count} videos', dragToTimeline: 'Drag scenes to timeline to start editing', videoTrack: 'Video Track', audioTrack: 'Audio Track', clearTrack: 'Clear Track', soundAndMusic: 'Sound & Music', soundMusicInDev: 'Sound & Music feature in development', noMergeYet: 'No videos merged yet', mergeInstructions: 'Arrange videos in the timeline editor and click "Merge Video" to proceed', selectVideoModel: 'Please select a video model', mergeComplete: 'Video merge completed and downloaded!', mergeTaskSubmitted: 'Video merge task submitted, processing in background...', audio: 'Audio', extractAudio: 'Extract audio from all video clips', model: 'Model', videoGeneration: 'Video Generation', soundAndMusicTab: 'Sound & Music', videoMerge: 'Video Merge', noMergeRecords: 'No merge records', transitionType: 'Transition Type', transitionDuration: 'Transition Duration', selectTransition: 'Select transition', filter: { drama: 'Script', allDramas: 'All Scripts', status: 'Status', allStatus: 'All Status', query: 'Query', reset: 'Reset' }, status: { pending: 'Pending', processing: 'Processing', completed: 'Completed', failed: 'Failed' }, prompt: 'Prompt', duration: 'Duration', createdAt: 'Created At', actions: { view: 'View Details', download: 'Download', delete: 'Delete' } }, asset: { title: 'Asset Library', type: 'Asset Type', upload: 'Upload', import: 'Import', export: 'Export' }, genres: { urban: 'Urban', costume: 'Costume', mystery: 'Mystery', romance: 'Romance', comedy: 'Comedy' }, message: { deleteConfirm: 'Are you sure to delete?', deleteSuccess: 'Deleted successfully', createSuccess: 'Created successfully', updateSuccess: 'Updated successfully', operationSuccess: 'Operation successful', operationFailed: 'Operation failed', loadingFailed: 'Loading failed', networkError: 'Network error' } } ================================================ FILE: web/src/locales/index.ts ================================================ import { createI18n } from 'vue-i18n' import zhCN from './zh-CN' import enUS from './en-US' // 从 localStorage 获取保存的语言,默认为中文 const getStoredLanguage = (): string => { const stored = localStorage.getItem('language') if (stored) return stored // 自动检测浏览器语言 const browserLang = navigator.language.toLowerCase() if (browserLang.startsWith('zh')) return 'zh-CN' return 'en-US' } const i18n = createI18n({ legacy: false, // 使用 Composition API 模式 locale: getStoredLanguage(), fallbackLocale: 'zh-CN', messages: { 'zh-CN': zhCN, 'en-US': enUS } }) export default i18n // 导出语言切换函数 export const setLanguage = (lang: string) => { i18n.global.locale.value = lang as any localStorage.setItem('language', lang) } export const getCurrentLanguage = () => { return i18n.global.locale.value } ================================================ FILE: web/src/locales/zh-CN.ts ================================================ export default { nav: { home: '首页', characters: '角色管理', storyboard: '分镜制作', videos: '视频管理', assets: '资源库', settings: '设置', dramas: '短剧项目' }, dashboard: { title: '🎬 Drama Generator', welcome: '欢迎使用 AI 短剧生成平台', subtitle: '从剧本到视频,一站式短剧创作工具', stats: { projects: '短剧项目', images: '生成图片', videos: '生成视频', tasks: '处理中任务' }, quickStart: '快速开始', actions: { newProject: '创建新项目', newProjectDesc: '开始一个全新的短剧项目', myProjects: '我的项目', myProjectsDesc: '查看和管理已有项目' } }, common: { create: '创建', edit: '编辑', delete: '删除', save: '保存', cancel: '取消', confirm: '确定', search: '搜索', filter: '筛选', reset: '重置', submit: '提交', close: '关闭', back: '返回', next: '下一步', previous: '上一步', selectAll: '全选', loading: '加载中...', success: '成功', failed: '失败', noData: '暂无数据', pleaseSelect: '请选择', add: '添加', view: '查看', upload: '上传', download: '下载', generating: '生成中...', notGenerated: '未生成', generateFailed: '生成失败', clickToRegenerate: '点击重新生成', queuing: '排队中', processing: '处理中', saveAndGenerate: '保存并生成', saveConfig: '保存配置', play: '播放', pause: '暂停', addAll: '一键添加全部', addToTimeline: '添加到时间线', deleteAsset: '删除素材', confirmDelete: '确认删除', tip: '提示', status: '状态', createdAt: '创建时间', updatedAt: '更新时间', name: '名称', description: '描述', image: '图片', perPage: '每页' }, settings: { title: '设置', aiConfig: 'AI配置', general: '通用设置', systemLanguage: '系统语言', currentLanguage: '当前语言', languageSwitchNotice: '语言切换提醒', languageSwitchDesc: '切换系统语言后,以下内容将受到影响:', languageSwitchItem1: '后端生成的所有提示词(分镜描述、角色描述、场景描述等)将使用所选语言', languageSwitchItem2: '与AI模型的对话将使用所选语言', languageSwitchItem3: '已生成的内容不会自动更新,需要重新生成', language: '语言', theme: '主题' }, aiConfig: { title: 'AI 服务配置', addConfig: '添加配置', editConfig: '编辑配置', back: '返回', empty: '暂无配置,点击添加配置开始使用', enabled: '已启用', disabled: '已禁用', enable: '启用', disable: '禁用', endpoint: '端点', queryEndpoint: '查询端点', tabs: { text: '文本生成', image: '图片生成', video: '视频生成' }, form: { name: '配置名称', namePlaceholder: '例如:OpenAI GPT-4', provider: '厂商', providerPlaceholder: '请选择厂商', providerTip: '选择AI服务提供商', priority: '优先级', priorityTip: '数值越大优先级越高,相同模型时优先使用高优先级配置', model: '模型', modelPlaceholder: '输入或选择模型名称', modelTip: '可直接输入模型名称或从列表选择,支持多个模型', baseUrl: 'Base URL', baseUrlPlaceholder: 'https://api.openai.com', baseUrlTip: 'API 服务的基础地址,如 Chatfire: https://api.chatfire.site/v1,Gemini: https://generativelanguage.googleapis.com(无需 /v1)', fullEndpoint: '完整调用路径', apiKey: 'API Key', apiKeyPlaceholder: 'sk-...', apiKeyTip: '您的 API 密钥', isActive: '启用状态' }, actions: { test: '测试连接', delete: '删除', edit: '编辑' }, messages: { deleteConfirm: '确定要删除此配置吗?', testSuccess: '连接测试成功!', testFailed: '连接测试失败' } }, drama: { title: '短剧管理', create: '创建项目', totalProjects: '共 {count} 个项目', createNew: '创建新项目', createDesc: '开始创作您的短剧项目', aiConfig: 'AI配置', aiConfigTip: '请先配置 AI 服务后再创建项目', empty: '暂无项目,点击上方按钮创建新项目', emptyHint: '点击上方"创建新项目"按钮开始您的第一部短剧', editProject: '编辑项目', projectName: '项目名称', projectNamePlaceholder: '请输入项目名称', projectDesc: '项目描述', projectDescPlaceholder: '请输入项目描述(可选)', style: '风格', stylePlaceholder: '请选择风格', styles: { ghibli: '吉卜力', guoman: '国漫', wasteland: '末日废土', nostalgia: '怀旧', pixel: '像素艺术', voxel: '方块世界', urban: '都市', guoman3d: '国漫3D', chibi3d: 'Q版3D' }, deleteConfirm: '确定要删除这个项目吗?', noCover: '暂无封面', noDescription: '暂无描述', status: { draft: '草稿', production: '制作中', completed: '已完成' }, actions: { edit: '编辑', view: '查看', delete: '删除' }, management: { overview: '项目概览', episodes: '章节管理', characters: '角色管理', scenes: '场景管理', projectInfo: '项目信息', projectName: '项目名称', projectDesc: '项目描述', noDescription: '暂无描述', episodeStats: '章节统计', characterStats: '角色统计', sceneStats: '场景统计', episodesCreated: '已创建章节', charactersCreated: '已创建角色', sceneLibraryCount: '场景库数量', startFirstEpisode: '开始创作您的第一个章节!', noEpisodesYet: '您的项目还没有章节。请先创建一个章节开始制作。', createFirstEpisode: '立即创建第一个章节', episodeList: '章节列表', createNewEpisode: '创建新章节', noEpisodes: '还没有章节', clickToCreate: '点击上方按钮创建第一个章节', episodeNumber: '第 {number} 章', goToEdit: '进入编辑', characterList: '角色列表', noCharacters: '还没有角色', charactersTip: '角色将在剧本生成阶段自动创建', sceneList: '场景列表', noScenes: '还没有场景', scenesTip: '场景将在分镜生成阶段自动创建', propList: '道具列表', noProps: '还没有道具', propStats: '道具统计', propsCreated: '已创建道具' } }, character: { title: '角色管理', create: '创建角色', edit: '编辑角色', add: '添加角色', list: '角色列表', name: '角色名称', role: '角色', personality: '性格', appearance: '外貌', background: '背景', description: '角色描述', image: '角色形象', generate: '生成角色形象', extracting: '提取中...', generateImage: '生成形象', batch: '批量操作', empty: '角色已在剧本生成阶段创建,您可以在此查看和编辑', backToProject: '返回项目', saveChanges: '保存修改', nextStep: '下一步:生成角色图片' }, prop: { title: '道具管理', add: '添加道具', edit: '编辑道具', delete: '删除道具', create: '创建道具', name: '道具名称', type: '类型', typePlaceholder: '如:武器、日常用品', description: '道具描述', prompt: '图片提示词', promptPlaceholder: 'AI生成图片的英文提示词', extract: '从剧本提取', extractTitle: '从剧本提取道具', selectEpisode: '选择章节', extractTip: 'AI将分析剧本内容,自动提取关键道具并生成描述', startExtract: '开始提取', extractSuccess: '道具提取任务已提交,AI分析大约需要1分钟', generateImage: '生成图片' }, script: { title: '剧本生成', backToProject: '返回项目', aiGenerate: 'AI 生成剧本', uploadScript: '上传剧本', steps: { outline: '生成大纲', characters: '生成角色', episodes: '生成剧集' }, form: { theme: '创作主题', themePlaceholder: '描述你想创作的短剧主题和故事概念', genre: '类型偏好', genrePlaceholder: '选择类型', style: '风格要求', stylePlaceholder: '例如:轻松幽默、紧张刺激、温馨治愈', episodeCount: '剧集数量', randomGenerate: '随机生成', title: '标题', titlePlaceholder: '请输入剧本标题', summary: '概要', summaryPlaceholder: '请输入剧本概要', genreExample: '例如:都市、古装', tags: '标签', newTag: '新标签' }, notice: '请输入创作主题和相关要求,AI将为您生成剧本大纲', generateFailed: '生成失败', generating: '生成中...', nextStep: '下一步', prevStep: '上一步', complete: '完成', regenerate: '重新生成', regenerateOutline: '重新生成大纲', outlinePreview: '大纲预览(可编辑)' }, imageDialog: { title: 'AI 图片生成', selectDrama: '选择剧本', selectScene: '选择场景', selectSceneOptional: '选择场景(可选)', sceneLabel: '场景{number}: {title}', prompt: '提示词', promptPlaceholder: '描述你想生成的图片\n例如:A beautiful landscape with mountains and rivers at sunset, cinematic lighting, highly detailed', negativePrompt: '反向提示词', negativePromptPlaceholder: '描述不希望出现的元素(可选)\n例如:blurry, low quality, watermark', aiService: 'AI 服务', selectService: '选择服务', imageSize: '图片尺寸', selectSize: '选择尺寸', square: '正方形', landscape: '横向', portrait: '纵向', imageQuality: '图片质量', standard: '标准', hd: '高清', style: '风格', vivid: '鲜艳', natural: '自然', advancedSettings: '高级设置', samplingSteps: '采样步数', promptRelevance: '提示词相关性', randomSeed: '随机种子', leaveBlankRandom: '留空随机', seedTip: '设置相同种子可复现图片', generate: '生成图片', pleaseSelectDrama: '请选择剧本', pleaseEnterPrompt: '请输入提示词', promptMinLength: '提示词至少5个字符', taskSubmitted: '图片生成任务已提交,请稍后查看结果', generateFailed: '生成失败', weak: '弱', moderate: '适中', strong: '强', veryStrong: '很强' }, image: { title: 'AI 图片生成', generate: '生成图片', loadFailed: '加载失败', generating: '生成中...', generateFailed: '生成失败' }, dramaWorkflow: { returnToList: '返回', episodeScript: '第{number}集剧本', storyboardBreakdown: '分镜拆解', characterImages: '角色图片', createChapterPrompt: '请创建第一章开始制作', createChapter: '创建第{number}章', nextStepCharacterImages: '下一步:角色图片', nextStep: '下一步', reGenerateShots: '重新拆分', reGenerateShotsConfirm: '重新拂分将覆盖现有镜头,确定继续吗?', pleaseWriteScript: '请先创作剧本内容', splitStoryboardFirst: '请先对剧本进行分镜拆解', aiSplitting: 'AI拆分中...', aiAutoSplit: 'AI自动拆分', selected: '已选', characterCount: '角色数', generated: '已生成', batchGenerate: '批量生成' }, workflow: { backToProject: '返回项目', episodeProduction: '第{number}章制作', steps: { content: '章节内容', generateImages: '生成图片', splitStoryboard: '拆分分镜' }, scriptPlaceholder: '请输入章节内容...', saveChapter: '保存章节', chapterContent: '第{number}章内容', saved: '已保存', extractedData: '已提取数据', characters: '角色', scenes: '场景', extractedCharacters: '提取的角色(本集)', extractedScenes: '提取的场景(本集)', extractCharactersAndScenes: '提取角色和场景', reExtract: '重新提取角色和场景', nextStepGenerateImages: '下一步:生成图片', extractWarning: '请先点击“提取角色和场景”按钮,完成提取后才能生成图片', characterImages: '角色图片', sceneImages: '场景图片', characterCount: '共 {count} 个角色需要生成图片', sceneCount: '共 {count} 个场景需要生成图片', selectAll: '全选', batchGenerate: '批量生成', modelConfig: 'AI模型配置', editPrompt: '修改提示词', aiGenerate: 'AI生成', uploadImage: '上传图片', selectFromLibrary: '从角色库选择', shotList: '镜头列表', dragFilesHere: '将文件拖到此处,或', clickToUpload: '点击上传', prevStep: '上一步', nextStepSplitShots: '下一步:拆分分镜', reExtractConfirmTitle: '重新提取确认', reExtractConfirmMessage: '重新提取将覆盖已提取的角色和场景(包括已生成的图片),确定继续吗?', startReExtracting: '开始重新提取,请稍候...', regenerateShots: '重新生成分镜', batchGenerateSelected: '批量生成选中场景', generateAllImagesFirst: '请先生成所有角色和场景图片后再进行分镜拆分', sceneImageGenerating: '场景图片生成中,请稍候...', sceneImageComplete: '场景图片生成完成!', sceneImageStarted: '场景图片生成已启动', reSplitShots: '重新拆分', enterProfessional: '进入专业制作', editShot: '编辑镜头', splitSuccess: '分镜拆分成功!正在进入专业制作界面...', reSplitConfirm: '确定要重新拂分分镜吗?', deleteCharacter: '删除角色', splitStoryboardFirst: '请先对章节进行分镜拆解', aiSplitting: 'AI拆分中...', aiAutoSplit: 'AI自动拆分', batchTaskSubmitted: '批量生成任务已提交!', batchGenerateFailed: '批量生成失败', batchCompleteSuccess: '批量生成完成!成功生成 {count} 个场景', batchCompletePartial: '生成完成:成功 {success} 个,失败 {fail} 个', addToLibrary: '添加到角色库', addToLibraryConfirm: '确定要将角色“{name}”添加到全局角色库吗?添加后可以在所有项目中使用该角色形象。', addedToLibrary: '已添加到角色库!', addFailed: '添加失败', shotTitle: '镜头标题', shotTitlePlaceholder: '请输入镜头标题', shotType: '景别', selectShotType: '选择景别', longShot: '远景', fullShot: '全景', mediumShot: '中景', closeUp: '近景', extremeCloseUp: '特写', cameraAngle: '镜头角度', selectAngle: '选择角度', eyeLevel: '平视', lowAngle: '仰视', highAngle: '俯视', location: '地点', locationPlaceholder: '场景地点', shotDescription: '镜头描述', shotDescriptionPlaceholder: '镜头整体描述', cameraMovement: '运镜方式', selectMovement: '选择运镜', staticShot: '固定镜头', pushIn: '推镜', pullOut: '拉镜', followShot: '跟镜', sideView: '侧面', time: '时间', timeSetting: '时间设定', actionDescription: '动作描述', detailedAction: '详细动作描述', dialogue: '对白', characterDialogue: '角色对白', generateImageFirst: '请先生成角色图片', result: '画面结果', actionResult: '动作结果', atmosphere: '环境氛围', atmosphereDescription: '环境氛围描述', loadLibraryFailed: '获取角色库失败', imagePrompt: '图片提示词', imagePromptPlaceholder: '用于AI生成图片的提示词', videoPrompt: '视频提示词', videoPromptPlaceholder: '用于AI生成视频的提示词', bgmHint: '配乐提示', bgmAtmosphere: '配乐氛围描述', soundEffect: '音效', soundEffectDescription: '音效描述', durationSeconds: '时长(秒)', emptyLibrary: '角色库为空,请先生成或上传角色图片', textModelTip: '用于生成章节内容、角色、场景等文本', uploadFormatTip: '支持 jpg/png 格式,文件大小不超过 10MB', aiModelConfig: 'AI模型配置', textGenModel: '文本生成模型', imageGenModel: '图片生成模型', selectTextModel: '选择文本生成模型', selectImageModel: '选择图片生成模型', modelConfigTip: '用于生成角色和场景图片', modelConfigSaved: '模型配置已保存', pleaseSelectModels: '请选择文本和图片生成模型', addScene: '添加场景', extractFromScript: '从剧本提取', sceneImage: '场景图片', sceneName: '场景名称', sceneNamePlaceholder: '请输入场景名称,如:大型商场内部通道', timePlaceholder: '请输入时间,如:下午14:00', sceneDescription: '场景描述', sceneDescriptionPlaceholder: '请输入场景描述', extractSceneDialogTitle: '从剧本提取场景', extractSceneDialogTip: '将从当前章节的剧本内容中自动提取场景信息', startExtract: '开始提取', sceneAddSuccess: '场景添加成功', sceneAddFailed: '场景添加失败', pleaseEnterSceneName: '请输入场景名称', chapterInfoNotExist: '章节信息不存在', sceneExtractSubmitted: '场景提取任务已提交,请稍后刷新查看结果', sceneExtractFailed: '场景提取失败', imageUploadSuccess: '图片上传成功', imageUploadSuccessNoUrl: '图片上传成功,但未获取到图片地址', sceneImageUploadSuccess: '场景图片上传成功!', sceneImageGenerateComplete: '场景图片生成完成!', sceneImageGenerateStarted: '场景图片生成已启动', deleteCharacterConfirm: '确定要删除该角色吗?删除后将无法恢复。', deleteConfirmTitle: '删除确认', confirmButtonText: '确定', cancelButtonText: '取消', extractCancelled: '已取消提取', charactersAndScenesExtractSuccess: '角色和场景提取成功!', charactersAndScenesExtractFailed: '角色和场景提取失败', characterGenerationFailed: '角色生成失败', sceneExtractionFailed: '场景提取失败', characterGenerationTimeout: '角色生成超时', sceneExtractionTimeout: '场景提取超时' }, professionalEditor: { duration: '时长', seconds: '秒', videoDuration: '视频时长', downloadVideo: '下载视频' }, storyboard: { title: '分镜制作', edit: '分镜编辑', create: '创建分镜', script: '剧本', scene: '场景', shot: '镜头', shotNumber: '镜头 {number}', untitled: '未命名镜头', scriptStructure: '剧本结构', add: '添加', noStoryboard: '暂无分镜', shotProperties: '镜头属性', selectScene: '选择场景', inDevelopment: '功能开发中...', generateScript: '生成分镜脚本', generateImage: '生成分镜图片', generateVideo: '生成视频', table: { number: '编号', title: '标题', shotType: '景别', movement: '运镜', location: '地点', character: '角色', dialogue: '对白', action: '动作', duration: '时长', operations: '操作' } }, timeline: { title: '时间线编辑器', backToEditor: '返回', noScenes: '暂无可用场景', loadFailed: '加载分镜失败' }, editor: { backToEpisode: '返回剧集编辑', episode: '第{number}集', settings: '设置', basicInfo: '基础信息', sceneProduction: '场景制作', sceneId: '场景ID', sceneGenerating: '场景图片生成中...', noBackground: '未关联背景', cast: '登场角色', addCharacter: '添加角色', removeCharacter: '移除角色', noCharacters: '未指定角色', visualSettings: '视效设置', shotType: '景别', shotTypePlaceholder: '选择景别', movement: '运镜方式', movementPlaceholder: '运镜方式', angle: '镜头角度', anglePlaceholder: '镜头角度', action: '动作描述', actionPlaceholder: '描述角色的动作过程...', result: '动作结果', resultPlaceholder: '描述动作的结果...', dialogue: '对白', dialoguePlaceholder: '输入角色对白...', soundEffects: '音效', soundEffectsPlaceholder: '描述音效...', transitions: '转场效果', transitionsPlaceholder: '选择转场', duration: '时长', seconds: '秒', description: '镜头描述', descriptionPlaceholder: '整体镜头描述...', bgmPrompt: '配乐提示', bgmPromptPlaceholder: '描述配乐氛围,如:紧张激烈的背景音乐', atmosphere: '环境氛围', atmospherePlaceholder: '描述环境氛围,如:昱暗压抑、明亮温馨', lightingEffect: '光照效果', specialEffects: '特效', props: '道具', addProp: '添加道具', addPropToShot: '添加道具到镜头', removeProp: '移除道具', noProps: '无道具', noPropsAvailable: '暂无道具,请先在剧本管理中添加道具', updatePropFailed: '更新道具失败', emotionalTone: '情绪色调', shotImage: '镜头图片', noShotSelected: '未选择镜头', selectFrameType: '选择帧类型', firstFrame: '首帧', lastFrame: '尾帧', panelFrame: '分镜板', actionSequence: '动作序列', keyFrame: '关键帧', panelCount: '格数', prompt: '提示词', extractPrompt: '提取提示词', promptPlaceholder: '点击提取提示词按钮,系统将根据分镜内容生成图片提示词...', generating: '生成中...', generateImage: '生成图片', uploadImage: '上传图片', generationResult: '生成结果', createGridImage: '制作宫格图片', gridImageEditor: '宫格图片编辑器', gridType: '宫格类型', fourGrid: '四宫格', sixGrid: '六宫格', nineGrid: '九宫格', editGridImage: '编辑宫格图片', selectImage: '选择图片', existingImages: '已有图片', uploadNewImage: '上传图片', preview: '预览', replace: '替换', clear: '清空', creating: '制作中...', createSuccess: '宫格图片制作成功', createFailed: '制作失败', allCellsRequired: '请填充所有宫格', replaceImage: '替换图片', noImagesAvailable: '暂无图片' }, video: { title: 'AI 视频生成', generate: '生成视频', merge: '合成视频', mediaLibrary: '视频素材库', videoCount: '{count} 个视频', dragToTimeline: '将场景拖拽到时间线开始编辑', videoTrack: '视频轨道', audioTrack: '音频轨道', clearTrack: '清空轨道', soundAndMusic: '音效与配乐', soundMusicInDev: '音效与配乐功能开发中', noMergeYet: '还没有合成过视频', mergeInstructions: '在时间线编辑器中排列好视频后点击“合成视频”即可', selectVideoModel: '请选择视频模型', mergeComplete: '视频合成完成并已下载!', mergeTaskSubmitted: '视频合成任务已提交,正在后台处理...', audio: '音频', extractAudio: '从所有视频片段提取音频', model: '模型', videoGeneration: '视频生成', soundAndMusicTab: '音效与配乐', videoMerge: '视频合成', noMergeRecords: '暂无视频合成记录', transitionType: '转场类型', transitionDuration: '转场时长', selectTransition: '选择转场效果', filter: { drama: '剧本', allDramas: '全部剧本', status: '状态', allStatus: '全部状态', query: '查询', reset: '重置' }, status: { pending: '等待中', processing: '生成中', completed: '已完成', failed: '失败' }, prompt: '提示词', duration: '时长', createdAt: '创建时间', actions: { view: '查看详情', download: '下载', delete: '删除' } }, asset: { title: '资源库', type: '资源类型', upload: '上传', import: '导入', export: '导出' }, genres: { urban: '都市', costume: '古装', mystery: '悬疑', romance: '爱情', comedy: '喜剧' }, tooltip: { editPrompt: '修改提示词', aiGenerate: 'AI生成', uploadImage: '上传图片', selectFromLibrary: '从角色库选择' }, message: { deleteConfirm: '确定要删除吗?', deleteSuccess: '删除成功', createSuccess: '创建成功', updateSuccess: '更新成功', operationSuccess: '操作成功', operationFailed: '操作失败', loadingFailed: '加载失败', networkError: '网络错误' } } ================================================ FILE: web/src/main.ts ================================================ import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import './assets/styles/element/index.scss' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import App from './App.vue' import router from './router' import i18n from './locales' import './assets/styles/main.css' // Apply theme before app mounts to prevent flash // 在应用挂载前应用主题,防止闪烁 const savedTheme = localStorage.getItem('theme') if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark') } const app = createApp(App) app.use(createPinia()) app.use(router) app.use(i18n) app.use(ElementPlus) for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.mount('#app') ================================================ FILE: web/src/router/index.ts ================================================ import type { RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router' const routes: RouteRecordRaw[] = [ { path: '/', name: 'DramaList', component: () => import('../views/drama/DramaList.vue') }, { path: '/dramas/create', name: 'DramaCreate', component: () => import('../views/drama/DramaCreate.vue') }, { path: '/dramas/:id', name: 'DramaManagement', component: () => import('../views/drama/DramaManagement.vue') }, { path: '/dramas/:id/episode/:episodeNumber', name: 'EpisodeWorkflowNew', component: () => import('../views/drama/EpisodeWorkflow.vue') }, { path: '/dramas/:id/characters', name: 'CharacterExtraction', component: () => import('../views/workflow/CharacterExtraction.vue') }, { path: '/dramas/:id/images/characters', name: 'CharacterImages', component: () => import('../views/workflow/CharacterImages.vue') }, { path: '/dramas/:id/settings', name: 'DramaSettings', component: () => import('../views/workflow/DramaSettings.vue') }, { path: '/episodes/:id/edit', name: 'ScriptEdit', component: () => import('../views/script/ScriptEdit.vue') }, { path: '/episodes/:id/storyboard', name: 'StoryboardEdit', component: () => import('../views/storyboard/StoryboardEdit.vue') }, { path: '/episodes/:id/generate', name: 'Generation', component: () => import('../views/generation/ImageGeneration.vue') }, { path: '/timeline/:id', name: 'TimelineEditor', component: () => import('../views/editor/TimelineEditor.vue') }, { path: '/dramas/:dramaId/episode/:episodeNumber/professional', name: 'ProfessionalEditor', component: () => import('../views/drama/ProfessionalEditor.vue') }, { path: '/settings/ai-config', name: 'AIConfig', component: () => import('../views/settings/AIConfig.vue') } ] const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) // 开源版本 - 无需认证 export default router ================================================ FILE: web/src/stores/episode.ts ================================================ import { ref, computed, reactive } from 'vue' import { defineStore } from 'pinia' import { dramaAPI } from '@/api/drama' import type { Episode, Character, Scene } from '@/types/drama' interface EpisodeCache { data: Episode loading: boolean error: string | null lastFetch: number } interface EpisodeOperations { refresh: () => Promise set: (params: SetOperationParams) => Promise del: (params: DeleteOperationParams) => Promise saveScript: (content: string) => Promise extractData: () => Promise generateImages: (options?: GenerateImageOptions) => Promise generateStoryboards: () => Promise } interface SetOperationParams { type: 'character' | 'scene' | 'storyboard' data: any } interface DeleteOperationParams { type: 'character' | 'scene' | 'storyboard' id: string | number } interface GenerateImageOptions { characterIds?: number[] sceneIds?: string[] } export interface CachedEpisode { value: Episode loading: boolean error: string | null refresh: () => Promise set: (params: SetOperationParams) => Promise del: (params: DeleteOperationParams) => Promise saveScript: (content: string) => Promise extractData: () => Promise generateImages: (options?: GenerateImageOptions) => Promise generateStoryboards: () => Promise } export const useEpisodeStore = defineStore('episode', () => { const caches = reactive>(new Map()) const getCacheByEpisodeId = (episodeId: string): CachedEpisode => { if (!caches.has(episodeId)) { caches.set(episodeId, { data: {} as Episode, loading: false, error: null, lastFetch: 0 }) fetchEpisode(episodeId) } const cache = caches.get(episodeId)! const operations: EpisodeOperations = { async refresh() { await fetchEpisode(episodeId, true) }, async set(params: SetOperationParams) { const { type, data } = params switch (type) { case 'character': await dramaAPI.saveCharacters(cache.data.drama_id, [data], episodeId) await fetchEpisode(episodeId, true) break case 'scene': await dramaAPI.updateScene(data.id, data) await fetchEpisode(episodeId, true) break case 'storyboard': await dramaAPI.updateStoryboard(data.id, data) await fetchEpisode(episodeId, true) break } }, async del(params: DeleteOperationParams) { const { type, id } = params switch (type) { case 'character': const characters = cache.data.characters?.filter(c => c.id !== id) || [] await dramaAPI.saveCharacters(cache.data.drama_id, characters, episodeId) await fetchEpisode(episodeId, true) break case 'scene': break case 'storyboard': break } }, async saveScript(content: string) { const parts = episodeId.split('-') const dramaId = parts[0] const episodeNumber = parseInt(parts.length > 1 ? parts[1] : cache.data.episode_number?.toString() || '1') await dramaAPI.saveEpisodes(dramaId, [{ episode_number: episodeNumber, script_content: content }]) await fetchEpisode(episodeId, true) }, async extractData() { await dramaAPI.extractBackgrounds(episodeId) await fetchEpisode(episodeId, true) }, async generateImages(options?: GenerateImageOptions) { const promises: Promise[] = [] if (options?.characterIds && options.characterIds.length > 0) { options.characterIds.forEach(id => { const character = cache.data.characters?.find(c => c.id === id) if (character) { promises.push( dramaAPI.generateSceneImage({ scene_id: character.id.toString(), prompt: character.appearance || character.description || character.name, model: undefined }) ) } }) } if (options?.sceneIds && options.sceneIds.length > 0) { options.sceneIds.forEach(sceneId => { promises.push( dramaAPI.generateSceneImage({ scene_id: sceneId, model: undefined }) ) }) } if (promises.length > 0) { await Promise.allSettled(promises) } await fetchEpisode(episodeId, true) }, async generateStoryboards() { await dramaAPI.generateStoryboard(episodeId) await fetchEpisode(episodeId, true) } } return { get value() { return cache.data }, get loading() { return cache.loading }, get error() { return cache.error }, ...operations } } const fetchEpisode = async (episodeId: string, force = false) => { const cache = caches.get(episodeId) if (!cache) return const now = Date.now() if (!force && cache.lastFetch && (now - cache.lastFetch) < 3000) { return } cache.loading = true cache.error = null try { const parts = episodeId.split('-') const dramaId = parts[0] const episodeNumber = parts.length > 1 ? parseInt(parts[1]) : null const drama = await dramaAPI.get(dramaId) let episode: Episode | undefined if (episodeNumber !== null) { episode = drama.episodes?.find(e => e.episode_number === episodeNumber) } else { episode = drama.episodes?.find(e => e.id === episodeId) } if (episode) { cache.data = episode cache.lastFetch = now } else { cache.error = '未找到章节数据' } } catch (error: any) { cache.error = error.message || '加载章节数据失败' console.error('Failed to fetch episode:', error) } finally { cache.loading = false } } const clearCache = (episodeId?: string) => { if (episodeId) { caches.delete(episodeId) } else { caches.clear() } } return { getCacheByEpisodeId, clearCache } }) ================================================ FILE: web/src/types/ai.ts ================================================ export interface AIServiceConfig { id: number service_type: AIServiceType provider?: string // 厂商标识 name: string base_url: string api_key: string model: string | string[] // 支持单个或多个模型 endpoint: string query_endpoint?: string // 异步查询端点(用于视频等异步任务) priority: number // 优先级,数值越大优先级越高 is_active: boolean settings?: string created_at: string updated_at: string } export type AIServiceType = 'text' | 'image' | 'video' export interface CreateAIConfigRequest { service_type: AIServiceType provider?: string // 厂商标识 name: string base_url: string api_key: string model: string | string[] // 支持单个或多个模型 endpoint?: string query_endpoint?: string // 异步查询端点(用于视频等异步任务) priority?: number // 优先级,数值越大优先级越高 settings?: string } export interface UpdateAIConfigRequest { name?: string provider?: string // 厂商标识 base_url?: string api_key?: string model?: string | string[] // 支持单个或多个模型 endpoint?: string query_endpoint?: string // 异步查询端点(用于视频等异步任务) priority?: number // 优先级,数值越大优先级越高 is_active?: boolean settings?: string } export interface TestConnectionRequest { base_url: string api_key: string model: string | string[] // 支持单个或多个模型 provider?: string // 厂商标识 endpoint?: string query_endpoint?: string // 异步查询端点(用于视频等异步任务) } export interface AIServiceProvider { id: number name: string display_name: string service_type: AIServiceType default_url: string description: string is_active: boolean created_at: string updated_at: string } ================================================ FILE: web/src/types/asset.ts ================================================ export interface Asset { id: number drama_id?: number episode_id?: number storyboard_id?: number storyboard_num?: number name: string description?: string type: AssetType category?: string url: string thumbnail_url?: string local_path?: string file_size?: number mime_type?: string width?: number height?: number duration?: number format?: string image_gen_id?: number video_gen_id?: number tags?: AssetTag[] collections?: AssetCollection[] is_favorite: boolean view_count: number created_at: string updated_at: string } export type AssetType = 'image' | 'video' | 'audio' export interface AssetTag { id: number name: string color?: string created_at: string } export interface AssetCollection { id: number drama_id?: number name: string description?: string assets?: Asset[] created_at: string } export interface CreateAssetRequest { drama_id?: number name: string description?: string type: AssetType category?: string url: string thumbnail_url?: string local_path?: string file_size?: number mime_type?: string width?: number height?: number duration?: number format?: string image_gen_id?: number video_gen_id?: number tag_ids?: number[] } export interface UpdateAssetRequest { name?: string description?: string category?: string thumbnail_url?: string tag_ids?: number[] is_favorite?: boolean } export interface ListAssetsParams { drama_id?: string episode_id?: number storyboard_id?: number type?: 'image' | 'video' | 'audio' category?: string tag_ids?: number[] is_favorite?: boolean search?: string page?: number page_size?: number } export const ASSET_CATEGORIES = { image: ['角色', '场景', '道具', '背景', '其他'], video: ['分镜', '特效', '片头', '片尾', '其他'], audio: ['配音', '音效', '背景音乐', '片头曲', '片尾曲', '其他'] } ================================================ FILE: web/src/types/drama.ts ================================================ import { Prop } from './prop' export interface Drama { id: string title: string description?: string genre?: string style?: string total_episodes: number total_duration: number total_scenes?: number duration?: number status: DramaStatus thumbnail?: string tags?: any metadata?: any created_at: string updated_at: string characters?: Character[] episodes?: Episode[] scenes?: Scene[] props?: Prop[] } export type DramaStatus = 'draft' | 'planning' | 'production' | 'completed' | 'archived' | 'generating' | 'error' export interface Character { id: number drama_id: string name: string role?: string description?: string appearance?: string personality?: string voice_style?: string background?: string reference_images?: any seed_value?: string sort_order?: number image_url?: string local_path?: string image_generation_status?: string image_generation_error?: string created_at: string updated_at: string } export interface Episode { id: string drama_id: string episode_number: number title: string content: string description?: string script_content?: string duration?: number status: string video_url?: string thumbnail?: string storyboard_count?: number scene_count?: number composition_count?: number video_count?: number timeline_status?: string storyboards?: Storyboard[] scenes?: Scene[] characters?: Character[] shots?: any[] created_at: string updated_at: string } export interface Storyboard { id: string episode_id: string storyboard_number: number title?: string description?: string location?: string time?: string duration?: number dialogue?: string action?: string atmosphere?: string image_prompt?: string video_prompt?: string characters?: any image_url?: string video_url?: string composed_image?: string scene_id?: string scene?: Scene created_at: string updated_at: string [key: string]: any } export interface Scene { id: string drama_id: string location: string time: string prompt: string description?: string title?: string storyboard_number?: number storyboard_count?: number image_url?: string local_path?: string video_url?: string status: string image_generation_status?: string image_generation_error?: string created_at: string updated_at: string } export interface CreateDramaRequest { title: string description?: string genre?: string style?: string tags?: string } export interface UpdateDramaRequest { title?: string description?: string genre?: string style?: string tags?: string status?: DramaStatus } export interface DramaListQuery { page?: number page_size?: number status?: DramaStatus genre?: string keyword?: string } export interface DramaStats { total: number by_status: Array<{ status: string count: number }> } ================================================ FILE: web/src/types/generation.ts ================================================ export interface GenerateCharactersRequest { drama_id: string episode_id?: number outline?: string count?: number temperature?: number model?: string // 指定使用的文本模型 } export interface ParseScriptRequest { drama_id: string script_content: string auto_split?: boolean } export interface ParseScriptResult { episodes: ParsedEpisode[] characters: ParsedCharacter[] summary: string } export interface ParsedCharacter { name: string role: string description: string personality: string } export interface ParsedEpisode { episode_number: number title: string description: string script_content: string duration: number chapter_start?: number chapter_end?: number start_marker?: string end_marker?: string } ================================================ FILE: web/src/types/image.ts ================================================ export interface ImageGeneration { id: number storyboard_id?: number scene_id?: string drama_id: string character_id?: number image_type?: string frame_type?: string provider: string prompt: string negative_prompt?: string model: string size?: string quality?: string style?: string steps?: number cfg_scale?: number seed?: number image_url?: string image_generation?: any local_path?: string status: ImageStatus task_id?: string error_msg?: string width?: number height?: number created_at: string updated_at: string completed_at?: string } export type ImageStatus = 'pending' | 'processing' | 'completed' | 'failed' export type ImageProvider = 'openai' | 'dalle' | 'midjourney' | 'stable_diffusion' | 'sd' export interface GenerateImageRequest { scene_id?: number storyboard_id?: number drama_id: string image_type?: string frame_type?: string prompt: string negative_prompt?: string reference_images?: string[] provider?: string model?: string size?: string quality?: string style?: string steps?: number cfg_scale?: number seed?: number width?: number height?: number } export interface ImageGenerationListParams { drama_id?: string scene_id?: string storyboard_id?: number frame_type?: string status?: ImageStatus page?: number page_size?: number } ================================================ FILE: web/src/types/prop.ts ================================================ export interface Prop { id: number drama_id: number name: string type?: string description?: string prompt?: string image_url?: string reference_images?: any created_at: string updated_at: string } export interface CreatePropRequest { drama_id: number name: string type?: string description?: string prompt?: string image_url?: string } export interface UpdatePropRequest { name?: string type?: string description?: string prompt?: string image_url?: string } ================================================ FILE: web/src/types/timeline.ts ================================================ import type { Asset } from './asset' export interface Timeline { id: number drama_id: number episode_id?: number name: string description?: string duration: number fps: number resolution?: string status: TimelineStatus tracks?: TimelineTrack[] created_at: string updated_at: string } export type TimelineStatus = 'draft' | 'editing' | 'completed' | 'exporting' export interface TimelineTrack { id: number timeline_id: number name: string type: TrackType order: number is_locked: boolean is_muted: boolean volume?: number clips?: TimelineClip[] created_at: string } export type TrackType = 'video' | 'audio' | 'text' export interface TimelineClip { id: number track_id: number asset_id?: number asset?: Asset scene_id?: number name: string start_time: number end_time: number duration: number trim_start?: number trim_end?: number speed?: number volume?: number is_muted: boolean fade_in?: number fade_out?: number transition_in_id?: number transition_out_id?: number in_transition?: ClipTransition out_transition?: ClipTransition effects?: ClipEffect[] created_at: string } export interface ClipTransition { id: number type: TransitionType duration: number easing?: string config?: Record } export type TransitionType = 'fade' | 'crossfade' | 'slide' | 'wipe' | 'zoom' | 'dissolve' export interface ClipEffect { id: number clip_id: number type: EffectType name: string is_enabled: boolean order: number config?: Record } export type EffectType = 'filter' | 'color' | 'blur' | 'brightness' | 'contrast' | 'saturation' export interface CreateTimelineRequest { drama_id: number episode_id?: number name: string description?: string fps?: number resolution?: string } export interface UpdateTimelineRequest { name?: string description?: string fps?: number resolution?: string status?: TimelineStatus } export interface CreateTrackRequest { name: string type: TrackType order?: number volume?: number } export interface UpdateTrackRequest { name?: string order?: number is_locked?: boolean is_muted?: boolean volume?: number } export interface CreateClipRequest { track_id: number asset_id?: number scene_id?: number name?: string start_time: number duration: number trim_start?: number trim_end?: number speed?: number volume?: number fade_in?: number fade_out?: number } export interface UpdateClipRequest { name?: string start_time?: number duration?: number trim_start?: number trim_end?: number speed?: number volume?: number is_muted?: boolean fade_in?: number fade_out?: number } export interface CreateTransitionRequest { type: TransitionType duration: number easing?: string config?: Record } export const TRANSITION_TYPES = [ { label: '淡入淡出', value: 'fade' }, { label: '交叉淡化', value: 'crossfade' }, { label: '滑动', value: 'slide' }, { label: '擦除', value: 'wipe' }, { label: '缩放', value: 'zoom' }, { label: '溶解', value: 'dissolve' } ] export const EFFECT_TYPES = [ { label: '滤镜', value: 'filter' }, { label: '色彩', value: 'color' }, { label: '模糊', value: 'blur' }, { label: '亮度', value: 'brightness' }, { label: '对比度', value: 'contrast' }, { label: '饱和度', value: 'saturation' } ] ================================================ FILE: web/src/types/user.ts ================================================ export interface User { id: number username: string email: string avatar?: string nickname?: string phone?: string role: string status: number created_at: string } export interface UserConfig { text_provider: string text_model: string text_api_key_set: boolean image_provider: string image_model: string image_api_key_set: boolean video_provider: string video_model: string video_api_key_set: boolean default_style: string default_resolution: string default_fps: number } ================================================ FILE: web/src/types/video.ts ================================================ export interface VideoGeneration { id: number storyboard_id?: number scene_id?: string // 已废弃,保留用于兼容 drama_id: string image_gen_id?: number provider: string prompt: string model?: string image_url?: string first_frame_url?: string duration?: number fps?: number resolution?: string aspect_ratio?: string style?: string motion_level?: number camera_motion?: string seed?: number video_url?: string local_path?: string status: VideoStatus task_id?: string error_msg?: string width?: number height?: number created_at: string updated_at: string completed_at?: string } export type VideoStatus = 'pending' | 'processing' | 'completed' | 'failed' export type VideoProvider = 'runway' | 'pika' | 'doubao' | 'openai' export interface GenerateVideoRequest { storyboard_id?: number scene_id?: string // 已废弃,保留用于兼容 drama_id: string image_gen_id?: number image_url?: string prompt: string provider?: string model?: string duration?: number fps?: number aspect_ratio?: string style?: string motion_level?: number camera_motion?: string seed?: number reference_mode?: string // 参考图模式:single, first_last, multiple, none first_frame_url?: string // 首帧图片URL last_frame_url?: string // 尾帧图片URL reference_image_urls?: string[] // 多图参考模式 } export interface VideoGenerationListParams { drama_id?: string storyboard_id?: string scene_id?: string // 已废弃,保留用于兼容 status?: string // 支持单个状态或逗号分隔的多个状态,如 "pending,processing" page?: number page_size?: number } export const VIDEO_ASPECT_RATIOS = [ { label: '16:9 (横屏)', value: '16:9' }, { label: '9:16 (竖屏)', value: '9:16' }, { label: '1:1 (正方形)', value: '1:1' }, { label: '4:3 (传统)', value: '4:3' } ] export const CAMERA_MOTIONS = [ { label: '静止', value: 'static' }, { label: '推进', value: 'zoom_in' }, { label: '拉远', value: 'zoom_out' }, { label: '左移', value: 'pan_left' }, { label: '右移', value: 'pan_right' }, { label: '上移', value: 'tilt_up' }, { label: '下移', value: 'tilt_down' }, { label: '环绕', value: 'orbit' } ] ================================================ FILE: web/src/utils/ffmpeg.ts ================================================ import { FFmpeg } from '@ffmpeg/ffmpeg' import { fetchFile, toBlobURL } from '@ffmpeg/util' let ffmpegInstance: FFmpeg | null = null let loadPromise: Promise | null = null export interface VideoTrimOptions { startTime: number endTime: number } export interface VideoMergeOptions { clips: Array<{ url: string startTime?: number endTime?: number }> } export interface ProgressCallback { (progress: number): void } async function getFFmpeg(): Promise { if (ffmpegInstance) { return ffmpegInstance } if (loadPromise) { return loadPromise } loadPromise = (async () => { const ffmpeg = new FFmpeg() ffmpeg.on('log', ({ message }) => { console.log('[FFmpeg]', message) }) const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd' await ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm') }) ffmpegInstance = ffmpeg return ffmpeg })() return loadPromise } export async function trimVideo( videoUrl: string, options: VideoTrimOptions, onProgress?: ProgressCallback ): Promise { const ffmpeg = await getFFmpeg() if (onProgress) onProgress(10) const inputFileName = 'input.mp4' const outputFileName = 'output.mp4' await ffmpeg.writeFile(inputFileName, await fetchFile(videoUrl)) if (onProgress) onProgress(30) const args = [ '-i', inputFileName, '-ss', options.startTime.toString(), '-to', options.endTime.toString(), '-c', 'copy', '-avoid_negative_ts', '1', outputFileName ] await ffmpeg.exec(args) if (onProgress) onProgress(80) const data = await ffmpeg.readFile(outputFileName) as Uint8Array await ffmpeg.deleteFile(inputFileName) await ffmpeg.deleteFile(outputFileName) if (onProgress) onProgress(100) return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) } export async function mergeVideos( options: VideoMergeOptions, onProgress?: ProgressCallback ): Promise { const ffmpeg = await getFFmpeg() if (onProgress) onProgress(5) const tempFiles: string[] = [] for (let i = 0; i < options.clips.length; i++) { const clip = options.clips[i] const fileName = `clip_${i}.mp4` await ffmpeg.writeFile(fileName, await fetchFile(clip.url)) tempFiles.push(fileName) if (onProgress) { onProgress(5 + (i + 1) / options.clips.length * 40) } } const listContent = tempFiles.map(file => `file '${file}'`).join('\n') await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent)) if (onProgress) onProgress(50) await ffmpeg.exec([ '-f', 'concat', '-safe', '0', '-i', 'filelist.txt', '-c', 'copy', 'output.mp4' ]) if (onProgress) onProgress(90) const data = await ffmpeg.readFile('output.mp4') as Uint8Array for (const file of tempFiles) { await ffmpeg.deleteFile(file) } await ffmpeg.deleteFile('filelist.txt') await ffmpeg.deleteFile('output.mp4') if (onProgress) onProgress(100) return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) } export async function trimAndMergeVideos( clips: Array<{ url: string startTime: number endTime: number }>, onProgress?: ProgressCallback ): Promise { const ffmpeg = await getFFmpeg() if (onProgress) onProgress(5) const trimmedFiles: string[] = [] for (let i = 0; i < clips.length; i++) { const clip = clips[i] const inputName = `input_${i}.mp4` const outputName = `trimmed_${i}.mp4` await ffmpeg.writeFile(inputName, await fetchFile(clip.url)) await ffmpeg.exec([ '-i', inputName, '-ss', clip.startTime.toString(), '-to', clip.endTime.toString(), '-c', 'copy', '-avoid_negative_ts', '1', outputName ]) await ffmpeg.deleteFile(inputName) trimmedFiles.push(outputName) if (onProgress) { onProgress(5 + (i + 1) / clips.length * 60) } } const listContent = trimmedFiles.map(file => `file '${file}'`).join('\n') await ffmpeg.writeFile('filelist.txt', new TextEncoder().encode(listContent)) if (onProgress) onProgress(70) await ffmpeg.exec([ '-f', 'concat', '-safe', '0', '-i', 'filelist.txt', '-c', 'copy', 'final.mp4' ]) if (onProgress) onProgress(95) const data = await ffmpeg.readFile('final.mp4') as Uint8Array for (const file of trimmedFiles) { await ffmpeg.deleteFile(file) } await ffmpeg.deleteFile('filelist.txt') await ffmpeg.deleteFile('final.mp4') if (onProgress) onProgress(100) return new Blob([new Uint8Array(data)], { type: 'video/mp4' }) } export async function isFFmpegLoaded(): Promise { return ffmpegInstance !== null } export async function unloadFFmpeg(): Promise { if (ffmpegInstance) { await ffmpegInstance.terminate() ffmpegInstance = null loadPromise = null } } ================================================ FILE: web/src/utils/image.ts ================================================ /** * 图片URL工具函数 */ /** * 修复图片URL,处理相对路径和绝对路径 */ export function fixImageUrl(url: string): string { if (!url) return ""; if (url.startsWith("http") || url.startsWith("data:")) return url; return `${import.meta.env.VITE_API_BASE_URL || ""}${url}`; } /** * 获取图片URL,优先使用 local_path * @param item 包含 local_path 或 image_url 的对象 * @returns 处理后的图片URL */ export function getImageUrl(item: any): string { if (!item) return ""; // 优先使用 local_path if (item.local_path) { // local_path 是相对路径(如 images/xxx.jpg),需要添加 /static/ 前缀 return `/static/${item.local_path}`; } // 回退到 image_url if (item.image_url) { return fixImageUrl(item.image_url); } return ""; } /** * 检查是否有图片 */ export function hasImage(item: any): boolean { return !!(item?.local_path || item?.image_url); } /** * 获取视频URL,优先使用 local_path * @param item 包含 local_path 或 video_url 或 url 的对象 * @returns 处理后的视频URL */ export function getVideoUrl(item: any): string { if (!item) return ""; // 优先使用 local_path if (item.local_path) { // 如果 local_path 已经是完整 URL,直接返回 if (item.local_path.startsWith("http")) { return item.local_path; } // 否则添加 /static/ 前缀 return `/static/${item.local_path}`; } // 回退到 video_url if (item.video_url) { return fixImageUrl(item.video_url); } // 回退到 url(用于 assets) if (item.url) { return fixImageUrl(item.url); } return ""; } /** * 检查是否有视频 */ export function hasVideo(item: any): boolean { return !!(item?.local_path || item?.video_url || item?.url); } ================================================ FILE: web/src/utils/request.ts ================================================ import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' import axios from 'axios' import { ElMessage } from 'element-plus' interface CustomAxiosInstance extends Omit { get(url: string, config?: AxiosRequestConfig): Promise post(url: string, data?: any, config?: AxiosRequestConfig): Promise put(url: string, data?: any, config?: AxiosRequestConfig): Promise patch(url: string, data?: any, config?: AxiosRequestConfig): Promise delete(url: string, config?: AxiosRequestConfig): Promise } const request = axios.create({ baseURL: '/api/v1', timeout: 600000, // 10分钟超时,匹配后端AI生成接口 headers: { 'Content-Type': 'application/json' } }) as CustomAxiosInstance // 开源版本 - 无需认证token request.interceptors.request.use( (config: InternalAxiosRequestConfig) => { return config }, (error: AxiosError) => { return Promise.reject(error) } ) request.interceptors.response.use( (response) => { const res = response.data if (res.success) { return res.data } else { // 不在这里显示错误提示,让业务代码自行处理 return Promise.reject(new Error(res.error?.message || '请求失败')) } }, (error: AxiosError) => { // 不在拦截器中自动显示错误提示,让业务代码根据具体情况处理 // 只抛出错误供调用者捕获 return Promise.reject(error) } ) export default request ================================================ FILE: web/src/utils/videoMerger.ts ================================================ import { FFmpeg } from '@ffmpeg/ffmpeg' import { fetchFile, toBlobURL } from '@ffmpeg/util' export interface VideoClip { url: string startTime: number endTime: number duration: number transition?: TransitionEffect } export type TransitionType = 'fade' | 'fadeblack' | 'fadewhite' | 'slideleft' | 'slideright' | 'slideup' | 'slidedown' | 'wipeleft' | 'wiperight' | 'circleopen' | 'circleclose' | 'none' export interface TransitionEffect { type: TransitionType duration: number // 转场时长(秒) } export interface MergeProgress { phase: 'loading' | 'processing' | 'encoding' | 'completed' progress: number message: string } class VideoMerger { private ffmpeg: FFmpeg private loaded: boolean = false private onProgress?: (progress: MergeProgress) => void constructor() { this.ffmpeg = new FFmpeg() } async initialize(onProgress?: (progress: MergeProgress) => void) { if (this.loaded) return this.onProgress = onProgress this.onProgress?.({ phase: 'loading', progress: 0, message: '正在加载FFmpeg引擎(首次需要下载约30MB)...' }) // CDN列表(优先使用国内CDN) const cdnList = [ 'https://unpkg.zhimg.com/@ffmpeg/core@0.12.6/dist/esm', // 知乎CDN镜像(国内) 'https://npm.elemecdn.com/@ffmpeg/core@0.12.6/dist/esm', // 饿了么CDN(国内) 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm', // jsDelivr(全球CDN,国内可用) 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm', // unpkg(国外) ] this.ffmpeg.on('log', ({ message }) => { console.log('[FFmpeg]', message) }) this.ffmpeg.on('progress', ({ progress, time }) => { this.onProgress?.({ phase: 'encoding', progress: Math.round(progress * 100), message: `正在合并视频... ${Math.round(progress * 100)}%` }) }) // 尝试多个CDN源 let lastError: Error | null = null for (let i = 0; i < cdnList.length; i++) { const baseURL = cdnList[i] try { this.onProgress?.({ phase: 'loading', progress: (i / cdnList.length) * 50, message: `正在从CDN ${i + 1}/${cdnList.length} 加载FFmpeg...` }) // 添加超时控制 const loadPromise = this.ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), }) const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('加载超时')), 60000) // 60秒超时 }) await Promise.race([loadPromise, timeoutPromise]) // 加载成功 this.loaded = true this.onProgress?.({ phase: 'loading', progress: 100, message: 'FFmpeg加载完成' }) return } catch (error) { console.error(`CDN ${i + 1} 加载失败:`, error) lastError = error as Error if (i < cdnList.length - 1) { this.onProgress?.({ phase: 'loading', progress: ((i + 1) / cdnList.length) * 50, message: `CDN ${i + 1} 失败,尝试备用源...` }) } } } // 所有CDN都失败 throw new Error(`FFmpeg加载失败: ${lastError?.message || '未知错误'}。请检查网络连接或稍后重试。`) } async mergeVideos(clips: VideoClip[]): Promise { if (!this.loaded) { await this.initialize(this.onProgress) } if (clips.length === 0) { throw new Error('没有视频片段') } this.onProgress?.({ phase: 'processing', progress: 0, message: '正在下载视频片段...' }) // 并行下载所有视频文件 this.onProgress?.({ phase: 'processing', progress: 0, message: `正在下载 ${clips.length} 个视频片段...` }) const downloadPromises = clips.map((clip, i) => fetchFile(clip.url).then(data => ({ index: i, data })) ) const downloads = await Promise.all(downloadPromises) this.onProgress?.({ phase: 'processing', progress: 30, message: '下载完成,正在处理视频...' }) // 写入文件系统并处理 const inputFiles: string[] = [] for (let i = 0; i < clips.length; i++) { const clip = clips[i] const download = downloads.find(d => d.index === i)! const inputFileName = `input${i}.mp4` const outputFileName = `clip${i}.mp4` // 写入原始视频 await this.ffmpeg.writeFile(inputFileName, download.data) // 如果需要裁剪,先裁剪视频 if (clip.startTime > 0 || clip.endTime < clip.duration) { this.onProgress?.({ phase: 'processing', progress: Math.round(30 + (i / clips.length) * 20), message: `正在裁剪视频片段 ${i + 1}/${clips.length}...` }) await this.ffmpeg.exec([ '-i', inputFileName, '-ss', clip.startTime.toString(), '-t', (clip.endTime - clip.startTime).toString(), '-c', 'copy', outputFileName ]) inputFiles.push(outputFileName) await this.ffmpeg.deleteFile(inputFileName) } else { inputFiles.push(inputFileName) } } this.onProgress?.({ phase: 'processing', progress: 50, message: '正在准备合并...' }) // 检查是否有转场效果 const hasTransitions = clips.some(clip => clip.transition && clip.transition.type !== 'none') if (!hasTransitions || clips.length === 1) { // 没有转场效果,使用简单的concat方式(更快) const concatContent = inputFiles.map(f => `file '${f}'`).join('\n') await this.ffmpeg.writeFile('concat.txt', concatContent) this.onProgress?.({ phase: 'encoding', progress: 0, message: '正在合并视频...' }) await this.ffmpeg.exec([ '-f', 'concat', '-safe', '0', '-i', 'concat.txt', '-c', 'copy', '-movflags', '+faststart', 'output.mp4' ]) } else { // 有转场效果,使用filter_complex(需要重新编码) this.onProgress?.({ phase: 'encoding', progress: 0, message: '正在添加转场效果并合并视频(这需要较长时间)...' }) await this.mergeWithTransitions(inputFiles, clips) } this.onProgress?.({ phase: 'completed', progress: 90, message: '正在生成最终文件...' }) // 读取输出文件 const data = await this.ffmpeg.readFile('output.mp4') const blob = new Blob([data], { type: 'video/mp4' }) // 清理临时文件 for (const file of inputFiles) { await this.ffmpeg.deleteFile(file) } await this.ffmpeg.deleteFile('concat.txt') await this.ffmpeg.deleteFile('output.mp4') this.onProgress?.({ phase: 'completed', progress: 100, message: '合并完成!' }) return blob } private async mergeWithTransitions(inputFiles: string[], clips: VideoClip[]) { // 构建FFmpeg filter_complex命令 const filterParts: string[] = [] const inputs: string[] = [] // 为每个输入添加标签 for (let i = 0; i < inputFiles.length; i++) { inputs.push('-i', inputFiles[i]) filterParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`) filterParts.push(`[${i}:a]asetpts=PTS-STARTPTS[a${i}]`) } // 构建转场链 let videoChain = 'v0' let audioChain = 'a0' for (let i = 1; i < clips.length; i++) { const transition = clips[i].transition const transType = transition?.type || 'fade' const transDuration = transition?.duration || 1.0 const offset = clips.slice(0, i).reduce((sum, c) => sum + c.duration, 0) - transDuration // 视频转场 const xfadeFilter = this.getXfadeFilter(transType, transDuration, offset) filterParts.push(`[${videoChain}][v${i}]${xfadeFilter}[v${i}out]`) videoChain = `v${i}out` // 音频交叉淡入淡出 filterParts.push(`[${audioChain}][a${i}]acrossfade=d=${transDuration}:c1=tri:c2=tri[a${i}out]`) audioChain = `a${i}out` } const filterComplex = filterParts.join(';') // 执行FFmpeg命令 await this.ffmpeg.exec([ ...inputs, '-filter_complex', filterComplex, '-map', `[${videoChain}]`, '-map', `[${audioChain}]`, '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', 'output.mp4' ]) } private getXfadeFilter(type: TransitionType, duration: number, offset: number): string { const xfadeTypes: Record = { 'fade': 'fade', 'fadeblack': 'fadeblack', 'fadewhite': 'fadewhite', 'slideleft': 'slideleft', 'slideright': 'slideright', 'slideup': 'slideup', 'slidedown': 'slidedown', 'wipeleft': 'wipeleft', 'wiperight': 'wiperight', 'circleopen': 'circleopen', 'circleclose': 'circleclose' } const xfadeType = xfadeTypes[type] || 'fade' return `xfade=transition=${xfadeType}:duration=${duration}:offset=${offset}` } async terminate() { if (this.loaded) { this.ffmpeg.terminate() this.loaded = false } } } export const videoMerger = new VideoMerger() ================================================ FILE: web/src/views/dashboard/Dashboard.vue ================================================ ================================================ FILE: web/src/views/drama/DramaCreate.vue ================================================ ================================================ FILE: web/src/views/drama/DramaList.vue ================================================ ================================================ FILE: web/src/views/drama/DramaManagement.vue ================================================ ================================================ FILE: web/src/views/drama/DramaWorkflow.vue ================================================ ================================================ FILE: web/src/views/drama/EpisodeWorkflow.vue ================================================ ================================================ FILE: web/src/views/drama/ProfessionalEditor.vue ================================================ ================================================ FILE: web/src/views/drama/components/UploadScriptDialog.vue ================================================ ================================================ FILE: web/src/views/editor/TimelineEditor.vue ================================================ ================================================ FILE: web/src/views/generation/ImageGeneration.vue ================================================ ================================================ FILE: web/src/views/generation/VideoGeneration.vue ================================================ ================================================ FILE: web/src/views/generation/components/GenerateImageDialog.vue ================================================ ================================================ FILE: web/src/views/generation/components/GenerateVideoDialog.vue ================================================ ================================================ FILE: web/src/views/generation/components/ImageDetailDialog.vue ================================================ ================================================ FILE: web/src/views/generation/components/VideoDetailDialog.vue ================================================ ================================================ FILE: web/src/views/script/ScriptEdit.vue ================================================ ================================================ FILE: web/src/views/settings/AIConfig.vue ================================================ ================================================ FILE: web/src/views/settings/SystemSettings.vue ================================================ ================================================ FILE: web/src/views/settings/components/ConfigList.vue ================================================ ================================================ FILE: web/src/views/storyboard/StoryboardEdit.vue ================================================ ================================================ FILE: web/src/views/workflow/CharacterExtraction.vue ================================================ ================================================ FILE: web/src/views/workflow/CharacterImages.vue ================================================ ================================================ FILE: web/src/views/workflow/DramaSettings.vue ================================================ ================================================ FILE: web/src/views/workflow/SceneImages.vue ================================================ ================================================ FILE: web/src/views/workflow/StoryboardGeneration.vue ================================================ ================================================ FILE: web/tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}", ], darkMode: 'class', theme: { extend: { colors: { // Primary brand colors / 主品牌色 primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', }, // Neutral colors for backgrounds / 中性色背景 surface: { light: '#ffffff', DEFAULT: '#f8fafc', dark: '#0f172a', }, // Card backgrounds / 卡片背景 card: { light: '#ffffff', dark: '#1e293b', }, // Border colors / 边框色 border: { light: '#e2e8f0', dark: '#334155', }, // Text colors / 文字色 content: { primary: '#0f172a', secondary: '#64748b', muted: '#94a3b8', 'primary-dark': '#f1f5f9', 'secondary-dark': '#94a3b8', 'muted-dark': '#64748b', }, }, boxShadow: { 'card': '0 1px 3px 0 rgb(0 0 0 / 0.05), 0 1px 2px -1px rgb(0 0 0 / 0.05)', 'card-hover': '0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.05)', 'card-dark': '0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3)', 'card-hover-dark': '0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3)', }, borderRadius: { 'xl': '0.875rem', '2xl': '1rem', '3xl': '1.5rem', }, transitionTimingFunction: { 'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', }, }, }, plugins: [], } ================================================ FILE: web/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": [ "ES2020", "DOM", "DOM.Iterable" ], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": [ "./src/*" ] } }, "include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue" ], "references": [ { "path": "./tsconfig.node.json" } ] } ================================================ FILE: web/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "types": ["node"] }, "include": ["vite.config.ts"] } ================================================ FILE: web/vite.config.ts ================================================ import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, server: { host: '0.0.0.0', port: 3012, proxy: { '/api': { target: 'http://localhost:5678', changeOrigin: true }, '/static': { target: 'http://localhost:5678', changeOrigin: true } } } })