Spaces:
Sleeping
Sleeping
Upload 8 files
Browse files- .dockerignore +32 -0
- Dockerfile +40 -0
- README.md +156 -11
- package.json +20 -0
- public/index.html +557 -0
- public/script.js +1010 -0
- public/style.css +1136 -0
- server.js +598 -0
.dockerignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore Node dependencies and caches
|
| 2 |
+
node_modules
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
pnpm-debug.log*
|
| 7 |
+
.cache
|
| 8 |
+
.tmp
|
| 9 |
+
*.log
|
| 10 |
+
|
| 11 |
+
# Ignore environment files and secrets
|
| 12 |
+
.env
|
| 13 |
+
.env.*
|
| 14 |
+
stored_api_key.json
|
| 15 |
+
stored_api_key*.json
|
| 16 |
+
|
| 17 |
+
# OS artifacts
|
| 18 |
+
.DS_Store
|
| 19 |
+
Thumbs.db
|
| 20 |
+
|
| 21 |
+
# Docker compose (not needed in image)
|
| 22 |
+
docker-compose.yml
|
| 23 |
+
|
| 24 |
+
# Runtime generated media/uploads
|
| 25 |
+
video/
|
| 26 |
+
uploads/
|
| 27 |
+
|
| 28 |
+
# Tests and temporary directories
|
| 29 |
+
tmp/
|
| 30 |
+
temp/
|
| 31 |
+
tests/
|
| 32 |
+
test/
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FAL AI 视频生成器 - Hugging Face Spaces (Docker Runtime)
|
| 2 |
+
# Node 18 Alpine minimal image
|
| 3 |
+
FROM node:18-alpine
|
| 4 |
+
|
| 5 |
+
# Set working directory
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Install runtime tools (curl) for HEALTHCHECK
|
| 9 |
+
RUN apk add --no-cache curl
|
| 10 |
+
|
| 11 |
+
# Set env
|
| 12 |
+
ENV NODE_ENV=production
|
| 13 |
+
|
| 14 |
+
# Copy package manifests
|
| 15 |
+
COPY package.json package-lock.json* ./
|
| 16 |
+
|
| 17 |
+
# Install deps
|
| 18 |
+
RUN npm ci --only=production || npm install --production
|
| 19 |
+
|
| 20 |
+
# Copy app code
|
| 21 |
+
COPY server.js ./server.js
|
| 22 |
+
COPY public ./public
|
| 23 |
+
|
| 24 |
+
# Create writable dirs
|
| 25 |
+
RUN mkdir -p video uploads && \
|
| 26 |
+
adduser -D -h /app appuser && \
|
| 27 |
+
chown -R appuser:appuser /app
|
| 28 |
+
|
| 29 |
+
# Expose default port
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
# Healthcheck (uses PORT if provided by platform)
|
| 33 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 34 |
+
CMD curl -sf http://127.0.0.1:${PORT:-7860}/api/health || exit 1
|
| 35 |
+
|
| 36 |
+
# Run as non-root
|
| 37 |
+
USER appuser
|
| 38 |
+
|
| 39 |
+
# Start
|
| 40 |
+
CMD ["node","server.js"]
|
README.md
CHANGED
|
@@ -1,11 +1,156 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FAL AI 视频生成器(Hugging Face Spaces 版)
|
| 2 |
+
|
| 3 |
+
本目录为 Hugging Face Spaces 部署包,包含运行所需的前端与后端文件,开箱即用。后端与前端逻辑与根目录保持一致,仅进行目录组织与便于部署的说明。
|
| 4 |
+
|
| 5 |
+
- 后端文件:[server.js](huggingface-deploy/server.js)
|
| 6 |
+
- 前端文件夹:[public/index.html](huggingface-deploy/public/index.html), [public/script.js](huggingface-deploy/public/script.js), [public/style.css](huggingface-deploy/public/style.css)
|
| 7 |
+
- 包配置:[package.json](huggingface-deploy/package.json)
|
| 8 |
+
|
| 9 |
+
## 部署到 Hugging Face Spaces(Node.js)
|
| 10 |
+
|
| 11 |
+
1. 在 Hugging Face 创建 Space,选择 Runtime:Node.js。
|
| 12 |
+
2. 将本目录内容上传至 Space 根目录(保持文件结构一致)。
|
| 13 |
+
3. 在 Space Settings → Secrets 添加环境变量:
|
| 14 |
+
- 名称:`FAL_KEY`
|
| 15 |
+
- 值:您的 FAL AI API Key(从 https://fal.ai/dashboard 获取)
|
| 16 |
+
4. 启动命令默认使用 [package.json](huggingface-deploy/package.json) 的 `start` 脚本:`node server.js`。
|
| 17 |
+
5. 端口:后端监听 `process.env.PORT || 7860` 且绑定到 `0.0.0.0`,与 Spaces 兼容。
|
| 18 |
+
|
| 19 |
+
提示:
|
| 20 |
+
- 若不使用环境变量,也可以在前端点击“API 密钥设置”中的“保存设置”按钮,将密钥写入服务目录下 `stored_api_key.json`(类 Space 环境可能不保证持久化,建议优先使用 Secret)。
|
| 21 |
+
- 本包包含完整的前端资源与功能,无需改动即可运行。
|
| 22 |
+
|
| 23 |
+
## 本地运行
|
| 24 |
+
|
| 25 |
+
在本地进行快速验证:
|
| 26 |
+
|
| 27 |
+
- 安装依赖(Node.js 18+)
|
| 28 |
+
- 进入部署目录后执行:
|
| 29 |
+
- Windows:`cd huggingface-deploy && npm install && npm start`
|
| 30 |
+
- macOS/Linux:`cd huggingface-deploy && npm install && npm start`
|
| 31 |
+
- 启动成功后访问 `http://localhost:7860`。
|
| 32 |
+
|
| 33 |
+
注意:
|
| 34 |
+
- 若在本地未配置 `FAL_KEY` 环境变量,请在页面中通过“API 密钥设置”临时设置或保存密钥。
|
| 35 |
+
|
| 36 |
+
## 前端使用说明(要点)
|
| 37 |
+
|
| 38 |
+
- 打开页面后,点击右上角“API 密钥设置”:
|
| 39 |
+
- 可临时设置或保存密钥到服务端(Space 推荐使用 Secret)。
|
| 40 |
+
- 提供“测试连接”,使用免配额健康检查,验证密钥是否有效。
|
| 41 |
+
- 三个功能页:
|
| 42 |
+
- 图片转视频
|
| 43 |
+
- 文本转视频
|
| 44 |
+
- 视频库(生成的视频本地保存后自动列出)
|
| 45 |
+
- 生成栏(队列):
|
| 46 |
+
- 页面顶部标签下方新增“生成队列”,每个任务独立显示进度与状态,不再使用全屏遮罩。
|
| 47 |
+
- 支持同时生成多个视频;生成过程中不锁定按钮,表单可继续调整图片与提示词再次生成。
|
| 48 |
+
- 移动端优化:
|
| 49 |
+
- 小屏布局优化、队列卡片化进度显示、按钮与控件间距适配。
|
| 50 |
+
- 配色优化:
|
| 51 |
+
- 已移除绿色成功色系,统一采用蓝紫系主色;通知“成功”为蓝色,交互主色为蓝色/紫色。
|
| 52 |
+
|
| 53 |
+
## 服务端端点(概览)
|
| 54 |
+
|
| 55 |
+
- 保存密钥:POST `/api/save-key`(写入 `stored_api_key.json`)
|
| 56 |
+
- 检查密钥来源:GET `/api/check-key`
|
| 57 |
+
- 测试密钥:POST `/api/test-key`(免配额健康检查)
|
| 58 |
+
- 图片转视频:POST `/api/image-to-video`(表单支持文件或图片URL;模型切换)
|
| 59 |
+
- 文本转视频:POST `/api/text-to-video`(JSON;模型切换)
|
| 60 |
+
- 获取本地视频列表:GET `/api/videos`
|
| 61 |
+
- 静态视频访问:`/video/*`
|
| 62 |
+
|
| 63 |
+
说明:
|
| 64 |
+
- 服务端会在生成完成后尝试下载远端视频至本地 `video/` 目录,并在“视频库”中展示。
|
| 65 |
+
- 若远端下载失败,仍会返回远端视频 URL,不影响主流程。
|
| 66 |
+
|
| 67 |
+
## 目录结构(部署包)
|
| 68 |
+
|
| 69 |
+
- [server.js](huggingface-deploy/server.js):Node.js/Express 服务(与根目录版本一致)
|
| 70 |
+
- public/
|
| 71 |
+
- [index.html](huggingface-deploy/public/index.html):页面结构(含生成队列)
|
| 72 |
+
- [script.js](huggingface-deploy/public/script.js):前端交互(并发生成、非全屏进度、视频库刷新)
|
| 73 |
+
- [style.css](huggingface-deploy/public/style.css):样式(移动端优化、去绿色配色)
|
| 74 |
+
- [package.json](huggingface-deploy/package.json):依赖与启动脚本
|
| 75 |
+
- README.md(本说明)
|
| 76 |
+
|
| 77 |
+
## 注意事项
|
| 78 |
+
|
| 79 |
+
- Spaces 的文件写入可能不持久,建议:
|
| 80 |
+
- 密钥通过 Secret 注入(`FAL_KEY`)。
|
| 81 |
+
- 生成的视频保存在 `video/`(Spaces 持久化行为视运行类型与设置,必要时使用外部存储)。
|
| 82 |
+
- 若你仅通过 Secret 设置密钥,不需要点击“保存设置”;前端会识别后端已配置的环境变量状态。
|
| 83 |
+
- 服务默认端口 7860,可在 Space 环境由系统注入 `PORT`,服务会自动适配。
|
| 84 |
+
|
| 85 |
+
## 更新同步
|
| 86 |
+
|
| 87 |
+
当你在根目录修改前端或后端:
|
| 88 |
+
- 可将根目录的 [public](public/index.html) 与 [server.js](server.js) 同步复制到本部署目录。
|
| 89 |
+
- 已内置的��端“生成队列”与配色优化已同步至此包,更新后同样可用。
|
| 90 |
+
|
| 91 |
+
## 使用 Docker 本地运行
|
| 92 |
+
|
| 93 |
+
- 先决条件:
|
| 94 |
+
- 已安装 Docker(推荐 24+)
|
| 95 |
+
- 无需本地 Node.js 环境(镜像基于 Node 18 Alpine)
|
| 96 |
+
- 构建镜像:
|
| 97 |
+
- 在目录 [huggingface-deploy](huggingface-deploy) 下执行
|
| 98 |
+
- Windows(CMD):
|
| 99 |
+
- `docker build -t fal-video-generator:latest .`
|
| 100 |
+
- macOS/Linux:
|
| 101 |
+
- `docker build -t fal-video-generator:latest .`
|
| 102 |
+
- 运行容器(映射端口与持久化视频/上传目录):
|
| 103 |
+
- Windows(CMD):
|
| 104 |
+
- `docker run --rm -p 7860:7860 -e FAL_KEY=你的密钥 -v %cd%\\video:/app/video -v %cd%\\uploads:/app/uploads fal-video-generator:latest`
|
| 105 |
+
- macOS/Linux:
|
| 106 |
+
- `docker run --rm -p 7860:7860 -e FAL_KEY=你的密钥 -v "$(pwd)/video:/app/video" -v "$(pwd)/uploads:/app/uploads" fal-video-generator:latest`
|
| 107 |
+
- 访问地址:
|
| 108 |
+
- http://localhost:7860
|
| 109 |
+
- 说明:
|
| 110 |
+
- 容器内服务监听 `process.env.PORT || 7860` 并绑定 `0.0.0.0`(见 [server.js](huggingface-deploy/server.js))
|
| 111 |
+
- 健康检查:容器每 30 秒检查一次 `/api/health`(见 [Dockerfile](huggingface-deploy/Dockerfile))
|
| 112 |
+
- 若希望容器重启后仍保留视频文件,务必挂载宿主机目录到 `/app/video` 和 `/app/uploads`
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## 部署到 Hugging Face Spaces(Docker Runtime)
|
| 117 |
+
|
| 118 |
+
- 创建 Space:
|
| 119 |
+
- Runtime 选择 "Docker"
|
| 120 |
+
- 仓库内容(Space 根目录必须包含以下文件/目录):
|
| 121 |
+
- [Dockerfile](huggingface-deploy/Dockerfile)
|
| 122 |
+
- [package.json](huggingface-deploy/package.json)
|
| 123 |
+
- [server.js](huggingface-deploy/server.js)
|
| 124 |
+
- public/
|
| 125 |
+
- [index.html](huggingface-deploy/public/index.html)
|
| 126 |
+
- [script.js](huggingface-deploy/public/script.js)
|
| 127 |
+
- [style.css](huggingface-deploy/public/style.css)
|
| 128 |
+
- 上传方式:
|
| 129 |
+
- 请将 [huggingface-deploy](huggingface-deploy) 目录中的“内容”上传到 Space 仓库的根目录(不要包含上层路径),确保 `Dockerfile` 位于仓库根目录
|
| 130 |
+
- 环境变量/Secrets:
|
| 131 |
+
- 在 Space Settings → Secrets 添加:
|
| 132 |
+
- `FAL_KEY`:你的 FAL AI API Key(https://fal.ai/dashboard 获取)
|
| 133 |
+
- 端口与启动:
|
| 134 |
+
- Spaces 会注入 `PORT` 环境变量;服务端在 [server.js](huggingface-deploy/server.js) 中已自动适配
|
| 135 |
+
- [Dockerfile](huggingface-deploy/Dockerfile) 使用 `CMD ["node","server.js"]`,无需额外启动命令
|
| 136 |
+
- EXPOSE 7860 仅为文档性声明,实际由平台端口映射管理
|
| 137 |
+
- 健康检查与运行状态:
|
| 138 |
+
- [Dockerfile](huggingface-deploy/Dockerfile) 中 `HEALTHCHECK` 会探测 `/api/health`,用于平台判断容器是否健康
|
| 139 |
+
- 文件持久化与存储:
|
| 140 |
+
- 容器内生成的视频保存到 `/app/video` 并在前端“视频库”页展示;Spaces 的持久化取决于运行类型与策略
|
| 141 |
+
- 若需长期持久化,可考虑外部对象存储或将生成结果回传至你自己的存储服务
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## 常见问题(Docker/Spaces)
|
| 146 |
+
|
| 147 |
+
- API 密钥未配置/401:
|
| 148 |
+
- 请在 Secrets 中设置 `FAL_KEY`,或在页面中通过“API 密钥设置”临时输入/保存(保存到 `stored_api_key.json` 的持久性由运行环境决定)
|
| 149 |
+
- 网络受限/无法访问 FAL API:
|
| 150 |
+
- 若平台限制出网,请在 Space 设置中开启 `Internet Access`
|
| 151 |
+
- 生成失败/速率限制(429):
|
| 152 |
+
- 提示“Rate Limited”,说明请求过于频繁,请降低并发或稍后重试
|
| 153 |
+
- Node 版本兼容:
|
| 154 |
+
- 镜像基于 Node 18(见 [Dockerfile](huggingface-deploy/Dockerfile)),与依赖版本兼容
|
| 155 |
+
- 本地验证与对齐:
|
| 156 |
+
- 你可先按“使用 Docker 本地运行”步骤验证,再将同样的目录内容上传到 Spaces
|
package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "huggingface-space-fal-video-generator",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "FAL AI 视频生成器 - Hugging Face Spaces 部署包",
|
| 6 |
+
"main": "server.js",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"start": "node server.js"
|
| 9 |
+
},
|
| 10 |
+
"engines": {
|
| 11 |
+
"node": ">=18"
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"@fal-ai/client": "^0.10.0",
|
| 15 |
+
"cors": "^2.8.5",
|
| 16 |
+
"dotenv": "^16.4.5",
|
| 17 |
+
"express": "^4.19.2",
|
| 18 |
+
"multer": "^1.4.5-lts.1"
|
| 19 |
+
}
|
| 20 |
+
}
|
public/index.html
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>FAL AI 视频生成器</title>
|
| 7 |
+
<link rel="stylesheet" href="style.css">
|
| 8 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div class="container">
|
| 13 |
+
<!-- 头部 -->
|
| 14 |
+
<header class="header">
|
| 15 |
+
<div class="header-content">
|
| 16 |
+
<h1 class="title">
|
| 17 |
+
<i class="fas fa-video"></i>
|
| 18 |
+
FAL AI 视频生成器
|
| 19 |
+
</h1>
|
| 20 |
+
<div class="header-controls">
|
| 21 |
+
<button id="apiKeyButton" class="api-key-button" title="API 密钥设置">
|
| 22 |
+
<i class="fas fa-key"></i>
|
| 23 |
+
<span id="apiKeyStatus">未配置</span>
|
| 24 |
+
</button>
|
| 25 |
+
<button id="themeToggle" class="theme-toggle" title="切换主题">
|
| 26 |
+
<i class="fas fa-moon"></i>
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<!-- API Key 设置弹窗 -->
|
| 33 |
+
<div id="apiKeyModal" class="modal" style="display: none;">
|
| 34 |
+
<div class="modal-content">
|
| 35 |
+
<div class="modal-header">
|
| 36 |
+
<h3><i class="fas fa-key"></i> API 密钥设置</h3>
|
| 37 |
+
<button id="closeModal" class="close-button">
|
| 38 |
+
<i class="fas fa-times"></i>
|
| 39 |
+
</button>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="modal-body">
|
| 42 |
+
<div class="api-key-info">
|
| 43 |
+
<p><strong>当前状态:</strong> <span id="modalApiKeyStatus">检查中...</span></p>
|
| 44 |
+
<p class="api-key-hint">
|
| 45 |
+
<i class="fas fa-info-circle"></i>
|
| 46 |
+
请在 <a href="https://fal.ai/dashboard" target="_blank">FAL AI 控制台</a> 获取您的 API 密钥
|
| 47 |
+
</p>
|
| 48 |
+
<p class="api-key-warning">
|
| 49 |
+
<i class="fas fa-shield-alt"></i>
|
| 50 |
+
<strong>重要提醒:</strong> "测试连接" 使用免费健康检查端点,不会消耗 API 配额或产生费用
|
| 51 |
+
</p>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<div class="form-group">
|
| 55 |
+
<label for="apiKeyInput" class="form-label">
|
| 56 |
+
<i class="fas fa-key"></i>
|
| 57 |
+
FAL AI API 密钥
|
| 58 |
+
</label>
|
| 59 |
+
<div class="api-key-input-group">
|
| 60 |
+
<input
|
| 61 |
+
type="password"
|
| 62 |
+
id="apiKeyInput"
|
| 63 |
+
class="form-input"
|
| 64 |
+
placeholder="输入您的 FAL AI API 密钥"
|
| 65 |
+
>
|
| 66 |
+
<button id="toggleApiKeyVisibility" class="toggle-visibility" type="button">
|
| 67 |
+
<i class="fas fa-eye"></i>
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="api-key-options">
|
| 73 |
+
<label class="checkbox-label">
|
| 74 |
+
<input type="checkbox" id="saveApiKey" checked>
|
| 75 |
+
<span class="checkmark"></span>
|
| 76 |
+
保存 API 密钥到本地(下次自动使用)
|
| 77 |
+
</label>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="modal-actions">
|
| 81 |
+
<button id="saveApiKeyButton" class="save-button">
|
| 82 |
+
<i class="fas fa-save"></i>
|
| 83 |
+
保存设置
|
| 84 |
+
</button>
|
| 85 |
+
<button id="testApiKeyButton" class="test-button">
|
| 86 |
+
<i class="fas fa-plug"></i>
|
| 87 |
+
测试连接
|
| 88 |
+
</button>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<!-- 主要内容 -->
|
| 95 |
+
<main class="main">
|
| 96 |
+
<!-- 功能选择标签 -->
|
| 97 |
+
<div class="tabs">
|
| 98 |
+
<button class="tab-button active" data-tab="image-to-video">
|
| 99 |
+
<i class="fas fa-image"></i>
|
| 100 |
+
图片转视频
|
| 101 |
+
</button>
|
| 102 |
+
<button class="tab-button" data-tab="text-to-video">
|
| 103 |
+
<i class="fas fa-text-width"></i>
|
| 104 |
+
文本转视频
|
| 105 |
+
</button>
|
| 106 |
+
<button class="tab-button" data-tab="video-library">
|
| 107 |
+
<i class="fas fa-video"></i>
|
| 108 |
+
视频库
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<!-- 生成队列(生成栏进度) -->
|
| 113 |
+
<div id="generationQueue" class="generation-queue">
|
| 114 |
+
<div class="queue-header">
|
| 115 |
+
<h3><i class="fas fa-tasks"></i> 生成队列</h3>
|
| 116 |
+
<span id="queueCount" class="queue-count">0 任务</span>
|
| 117 |
+
</div>
|
| 118 |
+
<div id="queueList" class="queue-list empty">
|
| 119 |
+
<div class="empty-queue">
|
| 120 |
+
<i class="fas fa-hourglass-half"></i>
|
| 121 |
+
<p>暂无进行中的任务</p>
|
| 122 |
+
<p class="upload-hint">点击上方“生成视频”即可开始一个新任务</p>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<!-- 图片转视频面板 -->
|
| 128 |
+
<div id="image-to-video" class="tab-content active">
|
| 129 |
+
<div class="form-container">
|
| 130 |
+
<form id="imageToVideoForm" class="generation-form">
|
| 131 |
+
<!-- 图片上传区域 -->
|
| 132 |
+
<div class="form-group">
|
| 133 |
+
<label class="form-label">
|
| 134 |
+
<i class="fas fa-image"></i>
|
| 135 |
+
选择图片
|
| 136 |
+
</label>
|
| 137 |
+
<div class="image-upload-area" id="imageUploadArea">
|
| 138 |
+
<div class="upload-placeholder">
|
| 139 |
+
<i class="fas fa-cloud-upload-alt"></i>
|
| 140 |
+
<p>点击或拖拽上传图片</p>
|
| 141 |
+
<p class="upload-hint">支持 JPG, PNG, GIF 格式,最大 50MB</p>
|
| 142 |
+
</div>
|
| 143 |
+
<input type="file" id="imageFile" accept="image/*" style="display: none;">
|
| 144 |
+
<div class="image-preview" id="imagePreview" style="display: none;">
|
| 145 |
+
<img id="previewImg" src="" alt="预览图片">
|
| 146 |
+
<button type="button" class="remove-image" id="removeImage">
|
| 147 |
+
<i class="fas fa-times"></i>
|
| 148 |
+
</button>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
<div class="form-group">
|
| 152 |
+
<label class="form-label">或输入图片URL</label>
|
| 153 |
+
<input type="url" id="imageUrl" class="form-input" placeholder="https://example.com/image.jpg">
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<!-- 文本提示 -->
|
| 158 |
+
<div class="form-group">
|
| 159 |
+
<label for="i2vPrompt" class="form-label">
|
| 160 |
+
<i class="fas fa-edit"></i>
|
| 161 |
+
文本描述 *
|
| 162 |
+
</label>
|
| 163 |
+
<textarea
|
| 164 |
+
id="i2vPrompt"
|
| 165 |
+
class="form-textarea"
|
| 166 |
+
placeholder="描述你想要生成的视频内容,例如:白龙战士静静站立,眼中充满决心和力量。摄像机慢慢靠近或围绕战士转动,突出角色的强大存在感和英雄气质。"
|
| 167 |
+
required
|
| 168 |
+
></textarea>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<!-- 模型切换(图片转视频) -->
|
| 172 |
+
<div class="form-group">
|
| 173 |
+
<label for="i2vModel" class="form-label">
|
| 174 |
+
<i class="fas fa-brain"></i>
|
| 175 |
+
模型选择
|
| 176 |
+
</label>
|
| 177 |
+
<select id="i2vModel" class="form-select">
|
| 178 |
+
<option value="seedance-pro-fast" selected>Seedance 1.0 Pro Fast(Bytedance)</option>
|
| 179 |
+
<option value="wan-v2.2-a14b">WAN v2.2-a14b(FAL AI)</option>
|
| 180 |
+
</select>
|
| 181 |
+
<p class="upload-hint">不同模型支持的参数不同,选择后将自动切换对应设置。</p>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<!-- 高级设置 -->
|
| 185 |
+
<details class="advanced-settings">
|
| 186 |
+
<summary>
|
| 187 |
+
<i class="fas fa-cog"></i>
|
| 188 |
+
高级设置
|
| 189 |
+
</summary>
|
| 190 |
+
<div class="settings-grid">
|
| 191 |
+
<!-- WAN 模型设置(图片转视频) -->
|
| 192 |
+
<div id="i2vWanSettings" class="model-settings" style="display: none;">
|
| 193 |
+
<div class="form-group">
|
| 194 |
+
<label for="i2vNegativePrompt" class="form-label">负面提示</label>
|
| 195 |
+
<textarea id="i2vNegativePrompt" class="form-textarea" placeholder="描述不希望出现的内容"></textarea>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div class="form-group">
|
| 199 |
+
<label for="i2vFrames" class="form-label">帧数 (17-121)</label>
|
| 200 |
+
<input type="range" id="i2vFrames" class="form-range" min="17" max="121" value="81">
|
| 201 |
+
<span class="range-value" id="i2vFramesValue">81</span>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div class="form-group">
|
| 205 |
+
<label for="i2vFps" class="form-label">帧率 (4-60)</label>
|
| 206 |
+
<input type="range" id="i2vFps" class="form-range" min="4" max="60" value="16">
|
| 207 |
+
<span class="range-value" id="i2vFpsValue">16</span>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div class="form-group">
|
| 211 |
+
<label for="i2vResolution" class="form-label">分辨率</label>
|
| 212 |
+
<select id="i2vResolution" class="form-select">
|
| 213 |
+
<option value="480p">480p</option>
|
| 214 |
+
<option value="580p">580p</option>
|
| 215 |
+
<option value="720p" selected>720p</option>
|
| 216 |
+
</select>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div class="form-group">
|
| 220 |
+
<label for="i2vAspectRatio" class="form-label">宽高比</label>
|
| 221 |
+
<select id="i2vAspectRatio" class="form-select">
|
| 222 |
+
<option value="auto" selected>自动</option>
|
| 223 |
+
<option value="16:9">16:9</option>
|
| 224 |
+
<option value="9:16">9:16</option>
|
| 225 |
+
<option value="1:1">1:1</option>
|
| 226 |
+
</select>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<div class="form-group">
|
| 230 |
+
<label for="i2vQuality" class="form-label">视频质量</label>
|
| 231 |
+
<select id="i2vQuality" class="form-select">
|
| 232 |
+
<option value="low">低</option>
|
| 233 |
+
<option value="medium">中</option>
|
| 234 |
+
<option value="high" selected>高</option>
|
| 235 |
+
<option value="maximum">最高</option>
|
| 236 |
+
</select>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
+
<div class="form-group">
|
| 240 |
+
<label class="checkbox-label">
|
| 241 |
+
<input type="checkbox" id="i2vDisableSafety">
|
| 242 |
+
<span class="checkmark"></span>
|
| 243 |
+
关闭内容审查 (生成更自由的内容)
|
| 244 |
+
</label>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
<!-- Seedance 1.0 Pro Fast 模型设置(图片转视频) -->
|
| 249 |
+
<div id="i2vSeedanceSettings" class="model-settings">
|
| 250 |
+
<div class="form-group">
|
| 251 |
+
<label for="i2vSeedAspectRatio" class="form-label">宽高比</label>
|
| 252 |
+
<select id="i2vSeedAspectRatio" class="form-select">
|
| 253 |
+
<option value="auto" selected>自动</option>
|
| 254 |
+
<option value="16:9">16:9</option>
|
| 255 |
+
<option value="21:9">21:9</option>
|
| 256 |
+
<option value="4:3">4:3</option>
|
| 257 |
+
<option value="1:1">1:1</option>
|
| 258 |
+
<option value="3:4">3:4</option>
|
| 259 |
+
<option value="9:16">9:16</option>
|
| 260 |
+
</select>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
<div class="form-group">
|
| 264 |
+
<label for="i2vSeedResolution" class="form-label">分辨率</label>
|
| 265 |
+
<select id="i2vSeedResolution" class="form-select">
|
| 266 |
+
<option value="480p">480p</option>
|
| 267 |
+
<option value="720p">720p</option>
|
| 268 |
+
<option value="1080p" selected>1080p</option>
|
| 269 |
+
</select>
|
| 270 |
+
</div>
|
| 271 |
+
|
| 272 |
+
<div class="form-group">
|
| 273 |
+
<label for="i2vSeedDuration" class="form-label">时长(秒)</label>
|
| 274 |
+
<select id="i2vSeedDuration" class="form-select">
|
| 275 |
+
<option value="3">3</option>
|
| 276 |
+
<option value="4">4</option>
|
| 277 |
+
<option value="5" selected>5</option>
|
| 278 |
+
<option value="6">6</option>
|
| 279 |
+
<option value="7">7</option>
|
| 280 |
+
<option value="8">8</option>
|
| 281 |
+
<option value="9">9</option>
|
| 282 |
+
<option value="10">10</option>
|
| 283 |
+
<option value="11">11</option>
|
| 284 |
+
<option value="12">12</option>
|
| 285 |
+
</select>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
<div class="form-group">
|
| 289 |
+
<label class="checkbox-label">
|
| 290 |
+
<input type="checkbox" id="i2vCameraFixed">
|
| 291 |
+
<span class="checkmark"></span>
|
| 292 |
+
固定相机(camera_fixed)
|
| 293 |
+
</label>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<div class="form-group">
|
| 297 |
+
<label for="i2vSeedValue" class="form-label">随机种子(-1 为随机)</label>
|
| 298 |
+
<input type="number" id="i2vSeedValue" class="form-input" value="-1" step="1" min="-1" placeholder="-1">
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<div class="form-group">
|
| 302 |
+
<label class="checkbox-label">
|
| 303 |
+
<input type="checkbox" id="i2vEnableSafety" checked>
|
| 304 |
+
<span class="checkmark"></span>
|
| 305 |
+
启用内容审查(enable_safety_checker)
|
| 306 |
+
</label>
|
| 307 |
+
<p class="upload-hint">提示:Playground 可能不允许关闭安全审查;通过 API 可配置。</p>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</details>
|
| 312 |
+
|
| 313 |
+
<!-- 提交按钮 -->
|
| 314 |
+
<button type="submit" class="submit-button" id="i2vSubmitButton">
|
| 315 |
+
<i class="fas fa-magic"></i>
|
| 316 |
+
生成视频
|
| 317 |
+
</button>
|
| 318 |
+
</form>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<!-- 文本转视频面板 -->
|
| 323 |
+
<div id="text-to-video" class="tab-content">
|
| 324 |
+
<div class="form-container">
|
| 325 |
+
<form id="textToVideoForm" class="generation-form">
|
| 326 |
+
<!-- 文本提示 -->
|
| 327 |
+
<div class="form-group">
|
| 328 |
+
<label for="t2vPrompt" class="form-label">
|
| 329 |
+
<i class="fas fa-edit"></i>
|
| 330 |
+
文本描述 *
|
| 331 |
+
</label>
|
| 332 |
+
<textarea
|
| 333 |
+
id="t2vPrompt"
|
| 334 |
+
class="form-textarea"
|
| 335 |
+
placeholder="描述你想要生成的视频内容,例如:一只可爱的小猫在花园里玩耍,阳光透过树叶洒下斑驳的光影。"
|
| 336 |
+
required
|
| 337 |
+
></textarea>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<!-- 模型切换 -->
|
| 341 |
+
<div class="form-group">
|
| 342 |
+
<label for="t2vModel" class="form-label">
|
| 343 |
+
<i class="fas fa-brain"></i>
|
| 344 |
+
模型选择
|
| 345 |
+
</label>
|
| 346 |
+
<select id="t2vModel" class="form-select">
|
| 347 |
+
<option value="seedance-pro-fast" selected>Seedance 1.0 Pro Fast(Bytedance)</option>
|
| 348 |
+
<option value="wan-v2.2-a14b">WAN v2.2-a14b(FAL AI)</option>
|
| 349 |
+
</select>
|
| 350 |
+
<p class="upload-hint">不同模型支持的参数不同,选择后将自动切换对应设置。</p>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<!-- 高级设置 -->
|
| 354 |
+
<details class="advanced-settings">
|
| 355 |
+
<summary>
|
| 356 |
+
<i class="fas fa-cog"></i>
|
| 357 |
+
高级设置
|
| 358 |
+
</summary>
|
| 359 |
+
|
| 360 |
+
<div class="settings-grid">
|
| 361 |
+
<!-- WAN 模型设置 -->
|
| 362 |
+
<div id="wanSettings" class="model-settings" style="display: none;">
|
| 363 |
+
<div class="form-group">
|
| 364 |
+
<label for="t2vNegativePrompt" class="form-label">负面提示</label>
|
| 365 |
+
<textarea id="t2vNegativePrompt" class="form-textarea" placeholder="描述不希望出现的内容"></textarea>
|
| 366 |
+
</div>
|
| 367 |
+
|
| 368 |
+
<div class="form-group">
|
| 369 |
+
<label for="t2vFrames" class="form-label">帧数 (17-121)</label>
|
| 370 |
+
<input type="range" id="t2vFrames" class="form-range" min="17" max="121" value="81">
|
| 371 |
+
<span class="range-value" id="t2vFramesValue">81</span>
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
<div class="form-group">
|
| 375 |
+
<label for="t2vFps" class="form-label">帧率 (4-60)</label>
|
| 376 |
+
<input type="range" id="t2vFps" class="form-range" min="4" max="60" value="16">
|
| 377 |
+
<span class="range-value" id="t2vFpsValue">16</span>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<div class="form-group">
|
| 381 |
+
<label for="t2vResolution" class="form-label">分辨率</label>
|
| 382 |
+
<select id="t2vResolution" class="form-select">
|
| 383 |
+
<option value="480p">480p</option>
|
| 384 |
+
<option value="580p">580p</option>
|
| 385 |
+
<option value="720p" selected>720p</option>
|
| 386 |
+
</select>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<div class="form-group">
|
| 390 |
+
<label for="t2vAspectRatio" class="form-label">宽高比</label>
|
| 391 |
+
<select id="t2vAspectRatio" class="form-select">
|
| 392 |
+
<option value="16:9" selected>16:9</option>
|
| 393 |
+
<option value="9:16">9:16</option>
|
| 394 |
+
<option value="1:1">1:1</option>
|
| 395 |
+
</select>
|
| 396 |
+
</div>
|
| 397 |
+
|
| 398 |
+
<div class="form-group">
|
| 399 |
+
<label for="t2vQuality" class="form-label">视频质量</label>
|
| 400 |
+
<select id="t2vQuality" class="form-select">
|
| 401 |
+
<option value="low">低</option>
|
| 402 |
+
<option value="medium">中</option>
|
| 403 |
+
<option value="high" selected>高</option>
|
| 404 |
+
<option value="maximum">最高</option>
|
| 405 |
+
</select>
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<div class="form-group">
|
| 409 |
+
<label class="checkbox-label">
|
| 410 |
+
<input type="checkbox" id="t2vDisableSafety">
|
| 411 |
+
<span class="checkmark"></span>
|
| 412 |
+
关闭内容审查 (生成更自由的内容)
|
| 413 |
+
</label>
|
| 414 |
+
</div>
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
<!-- Seedance 1.0 Pro Fast 模型设置 -->
|
| 418 |
+
<div id="seedanceSettings" class="model-settings">
|
| 419 |
+
<div class="form-group">
|
| 420 |
+
<label for="seedAspectRatio" class="form-label">宽高比</label>
|
| 421 |
+
<select id="seedAspectRatio" class="form-select">
|
| 422 |
+
<option value="16:9" selected>16:9</option>
|
| 423 |
+
<option value="21:9">21:9</option>
|
| 424 |
+
<option value="4:3">4:3</option>
|
| 425 |
+
<option value="1:1">1:1</option>
|
| 426 |
+
<option value="3:4">3:4</option>
|
| 427 |
+
<option value="9:16">9:16</option>
|
| 428 |
+
</select>
|
| 429 |
+
</div>
|
| 430 |
+
|
| 431 |
+
<div class="form-group">
|
| 432 |
+
<label for="seedResolution" class="form-label">分辨率</label>
|
| 433 |
+
<select id="seedResolution" class="form-select">
|
| 434 |
+
<option value="480p">480p</option>
|
| 435 |
+
<option value="720p">720p</option>
|
| 436 |
+
<option value="1080p" selected>1080p</option>
|
| 437 |
+
</select>
|
| 438 |
+
</div>
|
| 439 |
+
|
| 440 |
+
<div class="form-group">
|
| 441 |
+
<label for="seedDuration" class="form-label">时长(秒)</label>
|
| 442 |
+
<select id="seedDuration" class="form-select">
|
| 443 |
+
<option value="3">3</option>
|
| 444 |
+
<option value="4">4</option>
|
| 445 |
+
<option value="5" selected>5</option>
|
| 446 |
+
<option value="6">6</option>
|
| 447 |
+
<option value="7">7</option>
|
| 448 |
+
<option value="8">8</option>
|
| 449 |
+
<option value="9">9</option>
|
| 450 |
+
<option value="10">10</option>
|
| 451 |
+
<option value="11">11</option>
|
| 452 |
+
<option value="12">12</option>
|
| 453 |
+
</select>
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
<div class="form-group">
|
| 457 |
+
<label class="checkbox-label">
|
| 458 |
+
<input type="checkbox" id="t2vCameraFixed">
|
| 459 |
+
<span class="checkmark"></span>
|
| 460 |
+
固定相机(camera_fixed)
|
| 461 |
+
</label>
|
| 462 |
+
</div>
|
| 463 |
+
|
| 464 |
+
<div class="form-group">
|
| 465 |
+
<label for="t2vSeed" class="form-label">随机种子(-1 为随机)</label>
|
| 466 |
+
<input type="number" id="t2vSeed" class="form-input" value="-1" step="1" min="-1" placeholder="-1">
|
| 467 |
+
</div>
|
| 468 |
+
|
| 469 |
+
<div class="form-group">
|
| 470 |
+
<label class="checkbox-label">
|
| 471 |
+
<input type="checkbox" id="t2vEnableSafety" checked>
|
| 472 |
+
<span class="checkmark"></span>
|
| 473 |
+
启用内容审查(enable_safety_checker)
|
| 474 |
+
</label>
|
| 475 |
+
<p class="upload-hint">提示:部分平台的 Playground 不允许关闭安全审查;API 可配置。</p>
|
| 476 |
+
</div>
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
</details>
|
| 480 |
+
|
| 481 |
+
<!-- 提交按钮 -->
|
| 482 |
+
<button type="submit" class="submit-button" id="t2vSubmitButton">
|
| 483 |
+
<i class="fas fa-magic"></i>
|
| 484 |
+
生成视频
|
| 485 |
+
</button>
|
| 486 |
+
</form>
|
| 487 |
+
</div>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<!-- 视频库面板 -->
|
| 491 |
+
<div id="video-library" class="tab-content">
|
| 492 |
+
<div class="form-container">
|
| 493 |
+
<div class="video-library-header">
|
| 494 |
+
<h3><i class="fas fa-video"></i> 本地视频库</h3>
|
| 495 |
+
<div class="library-controls">
|
| 496 |
+
<button id="refreshLibrary" class="refresh-button">
|
| 497 |
+
<i class="fas fa-sync-alt"></i>
|
| 498 |
+
刷新
|
| 499 |
+
</button>
|
| 500 |
+
<span id="videoCount" class="video-count">共 0 个视频</span>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
|
| 504 |
+
<div id="videoLibraryContent" class="video-library-content">
|
| 505 |
+
<div class="loading-library">
|
| 506 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 507 |
+
正在加载视频库...
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
<!-- 结果显示区域 -->
|
| 514 |
+
<div id="resultContainer" class="result-container" style="display: none;">
|
| 515 |
+
<div class="result-header">
|
| 516 |
+
<h3><i class="fas fa-video"></i> 生成结果</h3>
|
| 517 |
+
<button id="clearResult" class="clear-button">
|
| 518 |
+
<i class="fas fa-times"></i>
|
| 519 |
+
</button>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="result-content">
|
| 522 |
+
<video id="resultVideo" controls class="result-video">
|
| 523 |
+
您的浏览器不支持视频播放。
|
| 524 |
+
</video>
|
| 525 |
+
<div class="result-actions">
|
| 526 |
+
<a id="downloadLink" class="download-button" download>
|
| 527 |
+
<i class="fas fa-download"></i>
|
| 528 |
+
下载视频
|
| 529 |
+
</a>
|
| 530 |
+
</div>
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
|
| 534 |
+
<!-- 加载状态 -->
|
| 535 |
+
<div id="loadingContainer" class="loading-container" style="display: none;">
|
| 536 |
+
<div class="loading-content">
|
| 537 |
+
<div class="loading-spinner"></div>
|
| 538 |
+
<h3>正在生成视频...</h3>
|
| 539 |
+
<p id="loadingMessage">请稍候,这可能需要几分钟时间</p>
|
| 540 |
+
<div class="loading-progress">
|
| 541 |
+
<div class="progress-bar">
|
| 542 |
+
<div class="progress-fill" id="progressFill"></div>
|
| 543 |
+
</div>
|
| 544 |
+
</div>
|
| 545 |
+
</div>
|
| 546 |
+
</div>
|
| 547 |
+
</main>
|
| 548 |
+
|
| 549 |
+
<!-- 底部 -->
|
| 550 |
+
<footer class="footer">
|
| 551 |
+
<p>© 2024 FAL AI 视频生成器 | 基于 FAL AI 技术</p>
|
| 552 |
+
</footer>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
<script src="script.js"></script>
|
| 556 |
+
</body>
|
| 557 |
+
</html>
|
public/script.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 全局变量
|
| 2 |
+
let currentTheme = 'light';
|
| 3 |
+
let isGenerating = false;
|
| 4 |
+
let currentApiKey = null;
|
| 5 |
+
|
| 6 |
+
// 初始化
|
| 7 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 8 |
+
console.log('页面加载完成,开始初始化...');
|
| 9 |
+
|
| 10 |
+
try {
|
| 11 |
+
initializeTheme();
|
| 12 |
+
initializeEventListeners();
|
| 13 |
+
initializeRangeSliders();
|
| 14 |
+
checkApiKeyStatus();
|
| 15 |
+
loadVideoLibrary();
|
| 16 |
+
console.log('初始化完成');
|
| 17 |
+
} catch (error) {
|
| 18 |
+
console.error('初始化失败:', error);
|
| 19 |
+
}
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
// 主题切换功能
|
| 23 |
+
function initializeTheme() {
|
| 24 |
+
const savedTheme = localStorage.getItem('theme') || 'light';
|
| 25 |
+
setTheme(savedTheme);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function setTheme(theme) {
|
| 29 |
+
currentTheme = theme;
|
| 30 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 31 |
+
localStorage.setItem('theme', theme);
|
| 32 |
+
|
| 33 |
+
const themeToggle = document.getElementById('themeToggle');
|
| 34 |
+
if (themeToggle) {
|
| 35 |
+
const icon = themeToggle.querySelector('i');
|
| 36 |
+
if (icon) {
|
| 37 |
+
if (theme === 'dark') {
|
| 38 |
+
icon.className = 'fas fa-sun';
|
| 39 |
+
themeToggle.title = '切换到浅色模式';
|
| 40 |
+
} else {
|
| 41 |
+
icon.className = 'fas fa-moon';
|
| 42 |
+
themeToggle.title = '切换到深色模式';
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function toggleTheme() {
|
| 49 |
+
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
| 50 |
+
setTheme(newTheme);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// API Key 管理功能
|
| 54 |
+
async function checkApiKeyStatus() {
|
| 55 |
+
try {
|
| 56 |
+
const response = await fetch('/api/check-key');
|
| 57 |
+
const data = await response.json();
|
| 58 |
+
updateApiKeyStatus(data);
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.error('检查 API key 状态失败:', error);
|
| 61 |
+
updateApiKeyStatus({ hasStoredKey: false, hasEnvKey: false, keySource: 'none' });
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function updateApiKeyStatus(status) {
|
| 66 |
+
const { hasStoredKey, hasEnvKey, keySource } = status;
|
| 67 |
+
|
| 68 |
+
const apiKeyStatus = document.getElementById('apiKeyStatus');
|
| 69 |
+
const apiKeyButton = document.getElementById('apiKeyButton');
|
| 70 |
+
const modalApiKeyStatus = document.getElementById('modalApiKeyStatus');
|
| 71 |
+
|
| 72 |
+
if (apiKeyStatus) {
|
| 73 |
+
if (keySource === 'environment') {
|
| 74 |
+
apiKeyStatus.textContent = '环境变量';
|
| 75 |
+
if (apiKeyButton) apiKeyButton.classList.add('configured');
|
| 76 |
+
if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:环境变量)';
|
| 77 |
+
} else if (keySource === 'stored') {
|
| 78 |
+
apiKeyStatus.textContent = '已保存';
|
| 79 |
+
if (apiKeyButton) apiKeyButton.classList.add('configured');
|
| 80 |
+
if (modalApiKeyStatus) modalApiKeyStatus.textContent = '已配置(来源:本地存储)';
|
| 81 |
+
} else {
|
| 82 |
+
apiKeyStatus.textContent = '未配置';
|
| 83 |
+
if (apiKeyButton) apiKeyButton.classList.remove('configured');
|
| 84 |
+
if (modalApiKeyStatus) modalApiKeyStatus.textContent = '未配置 - 请输入 API 密钥';
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 事件监听器初始化
|
| 90 |
+
function initializeEventListeners() {
|
| 91 |
+
console.log('初始化事件监听器...');
|
| 92 |
+
|
| 93 |
+
// 主题切换
|
| 94 |
+
const themeToggle = document.getElementById('themeToggle');
|
| 95 |
+
if (themeToggle) {
|
| 96 |
+
themeToggle.addEventListener('click', toggleTheme);
|
| 97 |
+
console.log('主题切换按钮已绑定');
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// API Key 管理
|
| 101 |
+
const apiKeyButton = document.getElementById('apiKeyButton');
|
| 102 |
+
const apiKeyModal = document.getElementById('apiKeyModal');
|
| 103 |
+
const closeModal = document.getElementById('closeModal');
|
| 104 |
+
|
| 105 |
+
if (apiKeyButton && apiKeyModal) {
|
| 106 |
+
apiKeyButton.addEventListener('click', () => {
|
| 107 |
+
apiKeyModal.style.display = 'flex';
|
| 108 |
+
checkApiKeyStatus();
|
| 109 |
+
});
|
| 110 |
+
console.log('API Key 按钮已绑定');
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if (closeModal && apiKeyModal) {
|
| 114 |
+
closeModal.addEventListener('click', () => {
|
| 115 |
+
apiKeyModal.style.display = 'none';
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
// 点击模态框背景关闭
|
| 119 |
+
apiKeyModal.addEventListener('click', (e) => {
|
| 120 |
+
if (e.target === apiKeyModal) {
|
| 121 |
+
apiKeyModal.style.display = 'none';
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
console.log('模态框关闭事件已绑定');
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// API Key 相关按钮
|
| 128 |
+
const toggleApiKeyVisibility = document.getElementById('toggleApiKeyVisibility');
|
| 129 |
+
const saveApiKeyButton = document.getElementById('saveApiKeyButton');
|
| 130 |
+
const testApiKeyButton = document.getElementById('testApiKeyButton');
|
| 131 |
+
|
| 132 |
+
if (toggleApiKeyVisibility) {
|
| 133 |
+
toggleApiKeyVisibility.addEventListener('click', toggleApiKeyVisibilityFunc);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (saveApiKeyButton) {
|
| 137 |
+
saveApiKeyButton.addEventListener('click', saveApiKey);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (testApiKeyButton) {
|
| 141 |
+
testApiKeyButton.addEventListener('click', testApiKey);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// ESC 键关闭模态框
|
| 145 |
+
document.addEventListener('keydown', (e) => {
|
| 146 |
+
if (e.key === 'Escape' && apiKeyModal && apiKeyModal.style.display === 'flex') {
|
| 147 |
+
apiKeyModal.style.display = 'none';
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
// 标签页切换
|
| 152 |
+
const tabButtons = document.querySelectorAll('.tab-button');
|
| 153 |
+
tabButtons.forEach(button => {
|
| 154 |
+
button.addEventListener('click', () => switchTab(button.dataset.tab));
|
| 155 |
+
});
|
| 156 |
+
console.log(`${tabButtons.length} 个标签按钮已绑定`);
|
| 157 |
+
|
| 158 |
+
// 图片上传相关
|
| 159 |
+
const imageUploadArea = document.getElementById('imageUploadArea');
|
| 160 |
+
const imageFile = document.getElementById('imageFile');
|
| 161 |
+
const removeImage = document.getElementById('removeImage');
|
| 162 |
+
|
| 163 |
+
if (imageUploadArea && imageFile) {
|
| 164 |
+
imageUploadArea.addEventListener('click', () => imageFile.click());
|
| 165 |
+
imageUploadArea.addEventListener('dragover', handleDragOver);
|
| 166 |
+
imageUploadArea.addEventListener('drop', handleDrop);
|
| 167 |
+
imageUploadArea.addEventListener('dragleave', handleDragLeave);
|
| 168 |
+
imageFile.addEventListener('change', handleImageSelect);
|
| 169 |
+
console.log('图片上传事件已绑定');
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (removeImage) {
|
| 173 |
+
removeImage.addEventListener('click', clearImagePreview);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// 表单提交
|
| 177 |
+
const imageToVideoForm = document.getElementById('imageToVideoForm');
|
| 178 |
+
const textToVideoForm = document.getElementById('textToVideoForm');
|
| 179 |
+
|
| 180 |
+
if (imageToVideoForm) {
|
| 181 |
+
imageToVideoForm.addEventListener('submit', handleImageToVideoSubmit);
|
| 182 |
+
console.log('图片转视频表单已绑定');
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (textToVideoForm) {
|
| 186 |
+
textToVideoForm.addEventListener('submit', handleTextToVideoSubmit);
|
| 187 |
+
console.log('文本转视频表单已绑定');
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// 结果清除
|
| 191 |
+
const clearResult = document.getElementById('clearResult');
|
| 192 |
+
if (clearResult) {
|
| 193 |
+
clearResult.addEventListener('click', clearResults);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// 视频库相关
|
| 197 |
+
const refreshLibrary = document.getElementById('refreshLibrary');
|
| 198 |
+
if (refreshLibrary) {
|
| 199 |
+
refreshLibrary.addEventListener('click', loadVideoLibrary);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// 模型选择(文本转视频)
|
| 203 |
+
const t2vModelSelect = document.getElementById('t2vModel');
|
| 204 |
+
if (t2vModelSelect) {
|
| 205 |
+
t2vModelSelect.addEventListener('change', updateModelSettingsVisibility);
|
| 206 |
+
// 初始化时根据默认选择显示对应参数
|
| 207 |
+
updateModelSettingsVisibility();
|
| 208 |
+
console.log('模型选择切换事件已绑定');
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// 模型选择(图片转视频)
|
| 212 |
+
const i2vModelSelect = document.getElementById('i2vModel');
|
| 213 |
+
if (i2vModelSelect) {
|
| 214 |
+
i2vModelSelect.addEventListener('change', updateI2VModelSettingsVisibility);
|
| 215 |
+
// 初始化时根据默认选择显示对应参数
|
| 216 |
+
updateI2VModelSettingsVisibility();
|
| 217 |
+
console.log('图片转视频模型选择切换事件已绑定');
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
console.log('事件监听器初始化完成');
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// 范围滑块初始化
|
| 224 |
+
function initializeRangeSliders() {
|
| 225 |
+
const ranges = document.querySelectorAll('.form-range');
|
| 226 |
+
ranges.forEach(range => {
|
| 227 |
+
const valueSpan = document.getElementById(range.id + 'Value');
|
| 228 |
+
if (valueSpan) {
|
| 229 |
+
valueSpan.textContent = range.value;
|
| 230 |
+
range.addEventListener('input', () => {
|
| 231 |
+
valueSpan.textContent = range.value;
|
| 232 |
+
});
|
| 233 |
+
}
|
| 234 |
+
});
|
| 235 |
+
console.log(`${ranges.length} 个滑块已初始化`);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// 根据模型选择显示/隐藏对应高级设置
|
| 239 |
+
function updateModelSettingsVisibility() {
|
| 240 |
+
const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast';
|
| 241 |
+
const wanSettings = document.getElementById('wanSettings');
|
| 242 |
+
const seedSettings = document.getElementById('seedanceSettings');
|
| 243 |
+
|
| 244 |
+
if (wanSettings && seedSettings) {
|
| 245 |
+
if (model === 'wan-v2.2-a14b') {
|
| 246 |
+
wanSettings.style.display = 'block';
|
| 247 |
+
seedSettings.style.display = 'none';
|
| 248 |
+
} else {
|
| 249 |
+
wanSettings.style.display = 'none';
|
| 250 |
+
seedSettings.style.display = 'block';
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// 根据图片转视频模型选择显示/隐藏对应高级设置
|
| 256 |
+
function updateI2VModelSettingsVisibility() {
|
| 257 |
+
const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast';
|
| 258 |
+
const wanSettings = document.getElementById('i2vWanSettings');
|
| 259 |
+
const seedSettings = document.getElementById('i2vSeedanceSettings');
|
| 260 |
+
|
| 261 |
+
if (wanSettings && seedSettings) {
|
| 262 |
+
if (model === 'wan-v2.2-a14b') {
|
| 263 |
+
wanSettings.style.display = 'block';
|
| 264 |
+
seedSettings.style.display = 'none';
|
| 265 |
+
} else {
|
| 266 |
+
wanSettings.style.display = 'none';
|
| 267 |
+
seedSettings.style.display = 'block';
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// API Key 相关函数
|
| 273 |
+
function toggleApiKeyVisibilityFunc() {
|
| 274 |
+
const apiKeyInput = document.getElementById('apiKeyInput');
|
| 275 |
+
const toggleButton = document.getElementById('toggleApiKeyVisibility');
|
| 276 |
+
|
| 277 |
+
if (apiKeyInput && toggleButton) {
|
| 278 |
+
const icon = toggleButton.querySelector('i');
|
| 279 |
+
if (apiKeyInput.type === 'password') {
|
| 280 |
+
apiKeyInput.type = 'text';
|
| 281 |
+
if (icon) icon.className = 'fas fa-eye-slash';
|
| 282 |
+
} else {
|
| 283 |
+
apiKeyInput.type = 'password';
|
| 284 |
+
if (icon) icon.className = 'fas fa-eye';
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function saveApiKey() {
|
| 290 |
+
const apiKeyInput = document.getElementById('apiKeyInput');
|
| 291 |
+
const saveApiKeyCheckbox = document.getElementById('saveApiKey');
|
| 292 |
+
const saveApiKeyButton = document.getElementById('saveApiKeyButton');
|
| 293 |
+
|
| 294 |
+
if (!apiKeyInput) return;
|
| 295 |
+
|
| 296 |
+
const apiKey = apiKeyInput.value.trim();
|
| 297 |
+
const shouldSave = saveApiKeyCheckbox ? saveApiKeyCheckbox.checked : true;
|
| 298 |
+
|
| 299 |
+
if (!apiKey) {
|
| 300 |
+
showNotification('请输入 API 密钥', 'error');
|
| 301 |
+
return;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// 基本检查
|
| 305 |
+
if (apiKey.length < 10) {
|
| 306 |
+
showNotification('API 密钥长度不足', 'error');
|
| 307 |
+
return;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// 显示保存状态
|
| 311 |
+
if (saveApiKeyButton) {
|
| 312 |
+
saveApiKeyButton.disabled = true;
|
| 313 |
+
saveApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 保存中...';
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
if (shouldSave) {
|
| 317 |
+
try {
|
| 318 |
+
console.log('正在保存 API 密钥到服务器...');
|
| 319 |
+
const response = await fetch('/api/save-key', {
|
| 320 |
+
method: 'POST',
|
| 321 |
+
headers: { 'Content-Type': 'application/json' },
|
| 322 |
+
body: JSON.stringify({ apiKey })
|
| 323 |
+
});
|
| 324 |
+
|
| 325 |
+
const result = await response.json();
|
| 326 |
+
console.log('保存结果:', result);
|
| 327 |
+
|
| 328 |
+
if (result.success) {
|
| 329 |
+
showNotification('API 密钥保存成功', 'success');
|
| 330 |
+
checkApiKeyStatus();
|
| 331 |
+
const apiKeyModal = document.getElementById('apiKeyModal');
|
| 332 |
+
if (apiKeyModal) apiKeyModal.style.display = 'none';
|
| 333 |
+
} else {
|
| 334 |
+
showNotification(result.error || '保存失败', 'error');
|
| 335 |
+
console.error('保存失败:', result);
|
| 336 |
+
}
|
| 337 |
+
} catch (error) {
|
| 338 |
+
console.error('保存 API key 失败:', error);
|
| 339 |
+
showNotification('保存失败,请检查网络连接', 'error');
|
| 340 |
+
}
|
| 341 |
+
} else {
|
| 342 |
+
// 临时设置
|
| 343 |
+
currentApiKey = apiKey;
|
| 344 |
+
showNotification('API 密钥已设置(临时)', 'success');
|
| 345 |
+
const apiKeyModal = document.getElementById('apiKeyModal');
|
| 346 |
+
if (apiKeyModal) apiKeyModal.style.display = 'none';
|
| 347 |
+
|
| 348 |
+
const apiKeyStatus = document.getElementById('apiKeyStatus');
|
| 349 |
+
const apiKeyButton = document.getElementById('apiKeyButton');
|
| 350 |
+
if (apiKeyStatus) apiKeyStatus.textContent = '临时设置';
|
| 351 |
+
if (apiKeyButton) apiKeyButton.classList.add('configured');
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// 恢复按钮状态
|
| 355 |
+
if (saveApiKeyButton) {
|
| 356 |
+
saveApiKeyButton.disabled = false;
|
| 357 |
+
saveApiKeyButton.innerHTML = '<i class="fas fa-save"></i> 保存设置';
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
async function testApiKey() {
|
| 362 |
+
const testApiKeyButton = document.getElementById('testApiKeyButton');
|
| 363 |
+
const apiKeyInput = document.getElementById('apiKeyInput');
|
| 364 |
+
|
| 365 |
+
if (!testApiKeyButton) return;
|
| 366 |
+
|
| 367 |
+
const apiKey = apiKeyInput ? apiKeyInput.value.trim() : null;
|
| 368 |
+
|
| 369 |
+
testApiKeyButton.disabled = true;
|
| 370 |
+
testApiKeyButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 连接中...';
|
| 371 |
+
|
| 372 |
+
try {
|
| 373 |
+
const response = await fetch('/api/test-key', {
|
| 374 |
+
method: 'POST',
|
| 375 |
+
headers: { 'Content-Type': 'application/json' },
|
| 376 |
+
body: JSON.stringify({ apiKey })
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
const result = await response.json();
|
| 380 |
+
|
| 381 |
+
if (result.success) {
|
| 382 |
+
showNotification(`✅ ${result.message}`, 'success');
|
| 383 |
+
if (result.note) {
|
| 384 |
+
setTimeout(() => {
|
| 385 |
+
showNotification(result.note, 'info');
|
| 386 |
+
}, 1500);
|
| 387 |
+
}
|
| 388 |
+
} else {
|
| 389 |
+
showNotification(result.error || 'API 密钥格式验证失败', 'error');
|
| 390 |
+
|
| 391 |
+
// 显示具体的格式问题
|
| 392 |
+
if (result.tips && result.tips.length > 0) {
|
| 393 |
+
console.error('格式问题:', result.tips);
|
| 394 |
+
setTimeout(() => {
|
| 395 |
+
result.tips.forEach((tip, index) => {
|
| 396 |
+
setTimeout(() => {
|
| 397 |
+
showNotification(tip, 'warning');
|
| 398 |
+
}, index * 1000);
|
| 399 |
+
});
|
| 400 |
+
}, 1000);
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
} catch (error) {
|
| 404 |
+
console.error('测试 API key 失败:', error);
|
| 405 |
+
showNotification('网��连接失败,请重试', 'error');
|
| 406 |
+
} finally {
|
| 407 |
+
testApiKeyButton.disabled = false;
|
| 408 |
+
testApiKeyButton.innerHTML = '<i class="fas fa-plug"></i> 测试连接';
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// 标签页切换
|
| 413 |
+
function switchTab(tabId) {
|
| 414 |
+
const tabButtons = document.querySelectorAll('.tab-button');
|
| 415 |
+
const tabContents = document.querySelectorAll('.tab-content');
|
| 416 |
+
|
| 417 |
+
tabButtons.forEach(btn => {
|
| 418 |
+
btn.classList.toggle('active', btn.dataset.tab === tabId);
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
tabContents.forEach(content => {
|
| 422 |
+
content.classList.toggle('active', content.id === tabId);
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
clearResults();
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// 图片处理函数
|
| 429 |
+
function handleDragOver(e) {
|
| 430 |
+
e.preventDefault();
|
| 431 |
+
const imageUploadArea = document.getElementById('imageUploadArea');
|
| 432 |
+
if (imageUploadArea) imageUploadArea.classList.add('dragover');
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function handleDragLeave(e) {
|
| 436 |
+
e.preventDefault();
|
| 437 |
+
const imageUploadArea = document.getElementById('imageUploadArea');
|
| 438 |
+
if (imageUploadArea) imageUploadArea.classList.remove('dragover');
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function handleDrop(e) {
|
| 442 |
+
e.preventDefault();
|
| 443 |
+
const imageUploadArea = document.getElementById('imageUploadArea');
|
| 444 |
+
if (imageUploadArea) imageUploadArea.classList.remove('dragover');
|
| 445 |
+
|
| 446 |
+
const files = e.dataTransfer.files;
|
| 447 |
+
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
| 448 |
+
handleImageFile(files[0]);
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function handleImageSelect(e) {
|
| 453 |
+
const file = e.target.files[0];
|
| 454 |
+
if (file && file.type.startsWith('image/')) {
|
| 455 |
+
handleImageFile(file);
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
function handleImageFile(file) {
|
| 460 |
+
if (file.size > 50 * 1024 * 1024) {
|
| 461 |
+
showNotification('图片文件大小不能超过 50MB', 'error');
|
| 462 |
+
return;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
const reader = new FileReader();
|
| 466 |
+
reader.onload = function(e) {
|
| 467 |
+
const previewImg = document.getElementById('previewImg');
|
| 468 |
+
const imagePreview = document.getElementById('imagePreview');
|
| 469 |
+
const uploadPlaceholder = document.querySelector('.upload-placeholder');
|
| 470 |
+
const imageUrl = document.getElementById('imageUrl');
|
| 471 |
+
|
| 472 |
+
if (previewImg) previewImg.src = e.target.result;
|
| 473 |
+
if (imagePreview) imagePreview.style.display = 'block';
|
| 474 |
+
if (uploadPlaceholder) uploadPlaceholder.style.display = 'none';
|
| 475 |
+
if (imageUrl) imageUrl.value = '';
|
| 476 |
+
};
|
| 477 |
+
reader.readAsDataURL(file);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function clearImagePreview() {
|
| 481 |
+
const imagePreview = document.getElementById('imagePreview');
|
| 482 |
+
const uploadPlaceholder = document.querySelector('.upload-placeholder');
|
| 483 |
+
const imageFile = document.getElementById('imageFile');
|
| 484 |
+
const imageUrl = document.getElementById('imageUrl');
|
| 485 |
+
|
| 486 |
+
if (imagePreview) imagePreview.style.display = 'none';
|
| 487 |
+
if (uploadPlaceholder) uploadPlaceholder.style.display = 'block';
|
| 488 |
+
if (imageFile) imageFile.value = '';
|
| 489 |
+
if (imageUrl) imageUrl.value = '';
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
// 表单提交处理
|
| 493 |
+
async function handleImageToVideoSubmit(e) {
|
| 494 |
+
e.preventDefault();
|
| 495 |
+
// 并发生成允许,不阻塞生成按钮
|
| 496 |
+
|
| 497 |
+
const prompt = document.getElementById('i2vPrompt')?.value.trim();
|
| 498 |
+
const imageUrl = document.getElementById('imageUrl')?.value.trim();
|
| 499 |
+
const imageFile = document.getElementById('imageFile');
|
| 500 |
+
const model = document.getElementById('i2vModel')?.value || 'seedance-pro-fast';
|
| 501 |
+
|
| 502 |
+
if (!prompt) {
|
| 503 |
+
showNotification('请输入文本描述', 'error');
|
| 504 |
+
return;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
if (!imageFile?.files[0] && !imageUrl) {
|
| 508 |
+
showNotification('请选择图片或输入图片URL', 'error');
|
| 509 |
+
return;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
const formData = new FormData();
|
| 513 |
+
|
| 514 |
+
if (imageFile?.files[0]) {
|
| 515 |
+
formData.append('image', imageFile.files[0]);
|
| 516 |
+
} else {
|
| 517 |
+
formData.append('image_url', imageUrl);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
const apiKey = currentApiKey;
|
| 521 |
+
if (apiKey) {
|
| 522 |
+
formData.append('userApiKey', apiKey);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
formData.append('prompt', prompt);
|
| 526 |
+
formData.append('model', model);
|
| 527 |
+
|
| 528 |
+
if (model === 'seedance-pro-fast') {
|
| 529 |
+
// Seedance 1.0 Pro Fast 所需参数
|
| 530 |
+
formData.append('aspect_ratio', document.getElementById('i2vSeedAspectRatio')?.value || 'auto');
|
| 531 |
+
formData.append('resolution', document.getElementById('i2vSeedResolution')?.value || '1080p');
|
| 532 |
+
formData.append('duration', document.getElementById('i2vSeedDuration')?.value || '5');
|
| 533 |
+
formData.append('camera_fixed', document.getElementById('i2vCameraFixed')?.checked ? 'true' : 'false');
|
| 534 |
+
formData.append('seed', (document.getElementById('i2vSeedValue')?.value ?? '-1').toString());
|
| 535 |
+
formData.append('enable_safety_checker', document.getElementById('i2vEnableSafety')?.checked ? 'true' : 'false');
|
| 536 |
+
} else {
|
| 537 |
+
// WAN v2.2-a14b 参数(保持原逻辑)
|
| 538 |
+
formData.append('negative_prompt', document.getElementById('i2vNegativePrompt')?.value || '');
|
| 539 |
+
formData.append('num_frames', document.getElementById('i2vFrames')?.value || '81');
|
| 540 |
+
formData.append('frames_per_second', document.getElementById('i2vFps')?.value || '16');
|
| 541 |
+
formData.append('resolution', document.getElementById('i2vResolution')?.value || '720p');
|
| 542 |
+
formData.append('aspect_ratio', document.getElementById('i2vAspectRatio')?.value || 'auto');
|
| 543 |
+
formData.append('video_quality', document.getElementById('i2vQuality')?.value || 'high');
|
| 544 |
+
formData.append('enable_safety_checker', document.getElementById('i2vDisableSafety')?.checked ? 'false' : 'true');
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
await generateVideo('/api/image-to-video', formData);
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
async function handleTextToVideoSubmit(e) {
|
| 551 |
+
e.preventDefault();
|
| 552 |
+
// 并发生成允许,不阻塞生成按钮
|
| 553 |
+
|
| 554 |
+
const prompt = document.getElementById('t2vPrompt')?.value.trim();
|
| 555 |
+
if (!prompt) {
|
| 556 |
+
showNotification('请输入文本描述', 'error');
|
| 557 |
+
return;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const model = document.getElementById('t2vModel')?.value || 'seedance-pro-fast';
|
| 561 |
+
const apiKey = currentApiKey;
|
| 562 |
+
|
| 563 |
+
if (model === 'seedance-pro-fast') {
|
| 564 |
+
// Bytedance Seedance 1.0 Pro Fast 入参
|
| 565 |
+
const requestData = {
|
| 566 |
+
prompt: prompt,
|
| 567 |
+
aspect_ratio: document.getElementById('seedAspectRatio')?.value || '16:9',
|
| 568 |
+
resolution: document.getElementById('seedResolution')?.value || '1080p',
|
| 569 |
+
duration: document.getElementById('seedDuration')?.value || '5',
|
| 570 |
+
camera_fixed: !!document.getElementById('t2vCameraFixed')?.checked,
|
| 571 |
+
seed: parseInt(document.getElementById('t2vSeed')?.value ?? '-1', 10),
|
| 572 |
+
enable_safety_checker: !!document.getElementById('t2vEnableSafety')?.checked,
|
| 573 |
+
model: 'seedance-pro-fast'
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
if (apiKey) {
|
| 577 |
+
requestData.userApiKey = apiKey;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
await generateVideo('/api/text-to-video', requestData, 'json');
|
| 581 |
+
return;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
// WAN v2.2-a14b 入参(保持原逻辑)
|
| 585 |
+
const requestData = {
|
| 586 |
+
prompt: prompt,
|
| 587 |
+
negative_prompt: document.getElementById('t2vNegativePrompt')?.value || '',
|
| 588 |
+
num_frames: parseInt(document.getElementById('t2vFrames')?.value || '81', 10),
|
| 589 |
+
frames_per_second: parseInt(document.getElementById('t2vFps')?.value || '16', 10),
|
| 590 |
+
resolution: document.getElementById('t2vResolution')?.value || '720p',
|
| 591 |
+
aspect_ratio: document.getElementById('t2vAspectRatio')?.value || '16:9',
|
| 592 |
+
video_quality: document.getElementById('t2vQuality')?.value || 'high',
|
| 593 |
+
enable_safety_checker: document.getElementById('t2vDisableSafety')?.checked ? false : true,
|
| 594 |
+
model: 'wan-v2.2-a14b'
|
| 595 |
+
};
|
| 596 |
+
|
| 597 |
+
if (apiKey) {
|
| 598 |
+
requestData.userApiKey = apiKey;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
await generateVideo('/api/text-to-video', requestData, 'json');
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
// 视频生成
|
| 605 |
+
async function generateVideo(endpoint, data, contentType = 'form') {
|
| 606 |
+
// 在生成栏中创建队列项并显示进度,允许并发生成
|
| 607 |
+
const queueList = document.getElementById('queueList');
|
| 608 |
+
const queueCount = document.getElementById('queueCount');
|
| 609 |
+
|
| 610 |
+
if (!queueList) {
|
| 611 |
+
// 兼容旧页面:如果没有队列容器,直接执行旧逻辑的通知与结果展示
|
| 612 |
+
try {
|
| 613 |
+
const options = { method: 'POST' };
|
| 614 |
+
if (contentType === 'json') {
|
| 615 |
+
options.headers = { 'Content-Type': 'application/json' };
|
| 616 |
+
options.body = JSON.stringify(data);
|
| 617 |
+
} else {
|
| 618 |
+
options.body = data;
|
| 619 |
+
}
|
| 620 |
+
const response = await fetch(endpoint, options);
|
| 621 |
+
const result = await response.json();
|
| 622 |
+
if (result.success && result.data && result.data.video) {
|
| 623 |
+
showResult(result.data.video.url, result.data.prompt);
|
| 624 |
+
showNotification('视频生成成功!', 'success');
|
| 625 |
+
} else {
|
| 626 |
+
throw new Error(result.error || '视频生成失败');
|
| 627 |
+
}
|
| 628 |
+
} catch (error) {
|
| 629 |
+
console.error('生成错误:', error);
|
| 630 |
+
showNotification(error.message || '生成失败,请重试', 'error');
|
| 631 |
+
}
|
| 632 |
+
return;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
// 若为空队列占位,先移除
|
| 636 |
+
if (queueList.classList.contains('empty')) {
|
| 637 |
+
queueList.classList.remove('empty');
|
| 638 |
+
queueList.innerHTML = '';
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
const taskId = `task-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
| 642 |
+
const title = endpoint.includes('image') ? '图片转视频' : '文本转视频';
|
| 643 |
+
const promptText = (() => {
|
| 644 |
+
if (typeof data === 'object' && contentType === 'json') {
|
| 645 |
+
return data.prompt ?? '';
|
| 646 |
+
}
|
| 647 |
+
// FormData 场景:直接从页面读取
|
| 648 |
+
const domId = endpoint.includes('image') ? 'i2vPrompt' : 't2vPrompt';
|
| 649 |
+
return document.getElementById(domId)?.value?.trim() ?? '';
|
| 650 |
+
})();
|
| 651 |
+
|
| 652 |
+
const item = document.createElement('div');
|
| 653 |
+
item.className = 'queue-item';
|
| 654 |
+
item.id = taskId;
|
| 655 |
+
item.innerHTML = `
|
| 656 |
+
<div class="info">
|
| 657 |
+
<div class="title">${title}</div>
|
| 658 |
+
<div class="prompt">${escapeHtml(promptText)}</div>
|
| 659 |
+
<div class="meta">${new Date().toLocaleString('zh-CN')}</div>
|
| 660 |
+
</div>
|
| 661 |
+
<div class="queue-progress">
|
| 662 |
+
<div class="progress-bar"><div class="progress-fill" id="${taskId}-progress"></div></div>
|
| 663 |
+
<div class="queue-status running" id="${taskId}-status">运行中</div>
|
| 664 |
+
</div>
|
| 665 |
+
`;
|
| 666 |
+
queueList.appendChild(item);
|
| 667 |
+
|
| 668 |
+
// 更新队列计数
|
| 669 |
+
if (queueCount) {
|
| 670 |
+
const count = queueList.querySelectorAll('.queue-item').length;
|
| 671 |
+
queueCount.textContent = `${count} 任务`;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
// 每个任务独立的进度模拟(服务端暂不提供实时进度回传)
|
| 675 |
+
let progress = 0;
|
| 676 |
+
const progressEl = () => document.getElementById(`${taskId}-progress`);
|
| 677 |
+
const statusEl = () => document.getElementById(`${taskId}-status`);
|
| 678 |
+
const progressInterval = setInterval(() => {
|
| 679 |
+
progress += Math.random() * 15;
|
| 680 |
+
if (progress > 90) progress = 90;
|
| 681 |
+
if (progressEl()) progressEl().style.width = progress + '%';
|
| 682 |
+
}, 900);
|
| 683 |
+
|
| 684 |
+
try {
|
| 685 |
+
const options = { method: 'POST' };
|
| 686 |
+
if (contentType === 'json') {
|
| 687 |
+
options.headers = { 'Content-Type': 'application/json' };
|
| 688 |
+
options.body = JSON.stringify(data);
|
| 689 |
+
} else {
|
| 690 |
+
options.body = data;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
const response = await fetch(endpoint, options);
|
| 694 |
+
const result = await response.json();
|
| 695 |
+
|
| 696 |
+
if (result.success && result.data && result.data.video) {
|
| 697 |
+
clearInterval(progressInterval);
|
| 698 |
+
if (progressEl()) progressEl().style.width = '100%';
|
| 699 |
+
if (statusEl()) {
|
| 700 |
+
statusEl().className = 'queue-status done';
|
| 701 |
+
statusEl().textContent = '完成';
|
| 702 |
+
}
|
| 703 |
+
showResult(result.data.video.url, result.data.prompt);
|
| 704 |
+
showNotification('视频生成成功!', 'success');
|
| 705 |
+
} else {
|
| 706 |
+
throw new Error(result.error || '视频生成失败');
|
| 707 |
+
}
|
| 708 |
+
} catch (error) {
|
| 709 |
+
clearInterval(progressInterval);
|
| 710 |
+
console.error('生成错误:', error);
|
| 711 |
+
if (statusEl()) {
|
| 712 |
+
statusEl().className = 'queue-status error';
|
| 713 |
+
statusEl().textContent = '错误';
|
| 714 |
+
}
|
| 715 |
+
if (error.message.includes('API') || error.message.includes('密钥')) {
|
| 716 |
+
showNotification('请先配置 API 密钥', 'error');
|
| 717 |
+
setTimeout(() => {
|
| 718 |
+
const apiKeyModal = document.getElementById('apiKeyModal');
|
| 719 |
+
if (apiKeyModal) apiKeyModal.style.display = 'flex';
|
| 720 |
+
}, 1000);
|
| 721 |
+
} else {
|
| 722 |
+
showNotification(error.message || '生成失败,请重试', 'error');
|
| 723 |
+
}
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
function escapeHtml(str) {
|
| 727 |
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
| 728 |
+
return String(str || '').replace(/[&<>"']/g, s => map[s]);
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
// 加载状态
|
| 733 |
+
function showLoading() {
|
| 734 |
+
// 不再显示全屏遮罩,也不禁用按钮,避免占用全屏和阻塞生成键
|
| 735 |
+
const loadingContainer = document.getElementById('loadingContainer');
|
| 736 |
+
if (loadingContainer) loadingContainer.style.display = 'none';
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
function hideLoading() {
|
| 740 |
+
// 保持按钮可用,不做处理
|
| 741 |
+
const loadingContainer = document.getElementById('loadingContainer');
|
| 742 |
+
if (loadingContainer) loadingContainer.style.display = 'none';
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
function simulateProgress() {
|
| 746 |
+
// 已改为每个任务独立的进度显示,此处不再使用全局模拟
|
| 747 |
+
return;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
// 结果显示
|
| 751 |
+
function showResult(videoUrl, prompt) {
|
| 752 |
+
const resultVideo = document.getElementById('resultVideo');
|
| 753 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 754 |
+
const resultContainer = document.getElementById('resultContainer');
|
| 755 |
+
|
| 756 |
+
if (resultVideo) resultVideo.src = videoUrl;
|
| 757 |
+
if (downloadLink) {
|
| 758 |
+
downloadLink.href = videoUrl;
|
| 759 |
+
downloadLink.download = `generated-video-${Date.now()}.mp4`;
|
| 760 |
+
}
|
| 761 |
+
if (resultContainer) {
|
| 762 |
+
resultContainer.style.display = 'block';
|
| 763 |
+
resultContainer.scrollIntoView({ behavior: 'smooth' });
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
function clearResults() {
|
| 768 |
+
const resultContainer = document.getElementById('resultContainer');
|
| 769 |
+
const resultVideo = document.getElementById('resultVideo');
|
| 770 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 771 |
+
|
| 772 |
+
if (resultContainer) resultContainer.style.display = 'none';
|
| 773 |
+
if (resultVideo) resultVideo.src = '';
|
| 774 |
+
if (downloadLink) downloadLink.href = '';
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
// 通知系统
|
| 778 |
+
function showNotification(message, type = 'info') {
|
| 779 |
+
const notification = document.createElement('div');
|
| 780 |
+
notification.className = `notification notification-${type}`;
|
| 781 |
+
notification.innerHTML = `
|
| 782 |
+
<div class="notification-content">
|
| 783 |
+
<i class="fas ${getNotificationIcon(type)}"></i>
|
| 784 |
+
<span>${message}</span>
|
| 785 |
+
</div>
|
| 786 |
+
`;
|
| 787 |
+
|
| 788 |
+
notification.style.cssText = `
|
| 789 |
+
position: fixed;
|
| 790 |
+
top: 20px;
|
| 791 |
+
right: 20px;
|
| 792 |
+
background: ${getNotificationColor(type)};
|
| 793 |
+
color: white;
|
| 794 |
+
padding: 1rem 1.5rem;
|
| 795 |
+
border-radius: 0.5rem;
|
| 796 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 797 |
+
z-index: 1001;
|
| 798 |
+
transform: translateX(100%);
|
| 799 |
+
transition: transform 0.3s ease;
|
| 800 |
+
max-width: 400px;
|
| 801 |
+
`;
|
| 802 |
+
|
| 803 |
+
document.body.appendChild(notification);
|
| 804 |
+
|
| 805 |
+
setTimeout(() => {
|
| 806 |
+
notification.style.transform = 'translateX(0)';
|
| 807 |
+
}, 100);
|
| 808 |
+
|
| 809 |
+
setTimeout(() => {
|
| 810 |
+
notification.style.transform = 'translateX(100%)';
|
| 811 |
+
setTimeout(() => {
|
| 812 |
+
if (document.body.contains(notification)) {
|
| 813 |
+
document.body.removeChild(notification);
|
| 814 |
+
}
|
| 815 |
+
}, 300);
|
| 816 |
+
}, 4000);
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
function getNotificationIcon(type) {
|
| 820 |
+
switch (type) {
|
| 821 |
+
case 'success': return 'fa-check-circle';
|
| 822 |
+
case 'error': return 'fa-exclamation-circle';
|
| 823 |
+
case 'warning': return 'fa-exclamation-triangle';
|
| 824 |
+
default: return 'fa-info-circle';
|
| 825 |
+
}
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
function getNotificationColor(type) {
|
| 829 |
+
switch (type) {
|
| 830 |
+
case 'success': return '#3b82f6';
|
| 831 |
+
case 'error': return '#ef4444';
|
| 832 |
+
case 'warning': return '#f59e0b';
|
| 833 |
+
default: return '#3b82f6';
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
// 视频库功能
|
| 838 |
+
async function loadVideoLibrary() {
|
| 839 |
+
const videoLibraryContent = document.getElementById('videoLibraryContent');
|
| 840 |
+
const videoCount = document.getElementById('videoCount');
|
| 841 |
+
|
| 842 |
+
if (!videoLibraryContent) return;
|
| 843 |
+
|
| 844 |
+
// 显示加载状态
|
| 845 |
+
videoLibraryContent.innerHTML = `
|
| 846 |
+
<div class="loading-library">
|
| 847 |
+
<i class="fas fa-spinner fa-spin"></i>
|
| 848 |
+
正在加载视频库...
|
| 849 |
+
</div>
|
| 850 |
+
`;
|
| 851 |
+
|
| 852 |
+
try {
|
| 853 |
+
const response = await fetch('/api/videos');
|
| 854 |
+
const data = await response.json();
|
| 855 |
+
|
| 856 |
+
if (data.videos && data.videos.length > 0) {
|
| 857 |
+
displayVideoLibrary(data.videos);
|
| 858 |
+
if (videoCount) {
|
| 859 |
+
videoCount.textContent = `共 ${data.videos.length} 个视频`;
|
| 860 |
+
}
|
| 861 |
+
} else {
|
| 862 |
+
displayEmptyLibrary();
|
| 863 |
+
if (videoCount) {
|
| 864 |
+
videoCount.textContent = '共 0 个视频';
|
| 865 |
+
}
|
| 866 |
+
}
|
| 867 |
+
} catch (error) {
|
| 868 |
+
console.error('加载视频库失败:', error);
|
| 869 |
+
videoLibraryContent.innerHTML = `
|
| 870 |
+
<div class="empty-library">
|
| 871 |
+
<i class="fas fa-exclamation-triangle"></i>
|
| 872 |
+
<p>加载视频库失败</p>
|
| 873 |
+
<button onclick="loadVideoLibrary()" class="refresh-button">重试</button>
|
| 874 |
+
</div>
|
| 875 |
+
`;
|
| 876 |
+
}
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
function displayVideoLibrary(videos) {
|
| 880 |
+
const videoLibraryContent = document.getElementById('videoLibraryContent');
|
| 881 |
+
|
| 882 |
+
const videoGrid = document.createElement('div');
|
| 883 |
+
videoGrid.className = 'video-grid';
|
| 884 |
+
|
| 885 |
+
videos.forEach(video => {
|
| 886 |
+
const videoItem = createVideoItem(video);
|
| 887 |
+
videoGrid.appendChild(videoItem);
|
| 888 |
+
});
|
| 889 |
+
|
| 890 |
+
videoLibraryContent.innerHTML = '';
|
| 891 |
+
videoLibraryContent.appendChild(videoGrid);
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
function createVideoItem(video) {
|
| 895 |
+
const item = document.createElement('div');
|
| 896 |
+
item.className = 'video-item';
|
| 897 |
+
|
| 898 |
+
const fileSize = formatFileSize(video.size);
|
| 899 |
+
const createdDate = new Date(video.created).toLocaleString('zh-CN');
|
| 900 |
+
|
| 901 |
+
item.innerHTML = `
|
| 902 |
+
<video class="video-preview" controls preload="metadata">
|
| 903 |
+
<source src="/${video.path}" type="video/mp4">
|
| 904 |
+
您的浏览器不支持视频播放。
|
| 905 |
+
</video>
|
| 906 |
+
<div class="video-info">
|
| 907 |
+
<div class="video-filename">${video.filename}</div>
|
| 908 |
+
<div class="video-meta">
|
| 909 |
+
<span>${createdDate}</span>
|
| 910 |
+
<span class="video-size">${fileSize}</span>
|
| 911 |
+
</div>
|
| 912 |
+
<div class="video-actions">
|
| 913 |
+
<button class="video-action-btn play-video-btn" onclick="playVideoInModal('/${video.path}')">
|
| 914 |
+
<i class="fas fa-play"></i>
|
| 915 |
+
播放
|
| 916 |
+
</button>
|
| 917 |
+
<button class="video-action-btn download-video-btn" onclick="downloadVideo('/${video.path}', '${video.filename}')">
|
| 918 |
+
<i class="fas fa-download"></i>
|
| 919 |
+
下载
|
| 920 |
+
</button>
|
| 921 |
+
</div>
|
| 922 |
+
</div>
|
| 923 |
+
`;
|
| 924 |
+
|
| 925 |
+
return item;
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
function displayEmptyLibrary() {
|
| 929 |
+
const videoLibraryContent = document.getElementById('videoLibraryContent');
|
| 930 |
+
videoLibraryContent.innerHTML = `
|
| 931 |
+
<div class="empty-library">
|
| 932 |
+
<i class="fas fa-video"></i>
|
| 933 |
+
<p>暂无视频</p>
|
| 934 |
+
<p class="upload-hint">生成的视频会自动保存到这里</p>
|
| 935 |
+
</div>
|
| 936 |
+
`;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
function formatFileSize(bytes) {
|
| 940 |
+
if (bytes === 0) return '0 Bytes';
|
| 941 |
+
const k = 1024;
|
| 942 |
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
| 943 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 944 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
function playVideoInModal(videoPath) {
|
| 948 |
+
// 在结果容器中播放视频
|
| 949 |
+
const resultVideo = document.getElementById('resultVideo');
|
| 950 |
+
const resultContainer = document.getElementById('resultContainer');
|
| 951 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 952 |
+
|
| 953 |
+
if (resultVideo && resultContainer && downloadLink) {
|
| 954 |
+
resultVideo.src = videoPath;
|
| 955 |
+
downloadLink.href = videoPath;
|
| 956 |
+
downloadLink.download = videoPath.split('/').pop();
|
| 957 |
+
resultContainer.style.display = 'block';
|
| 958 |
+
resultContainer.scrollIntoView({ behavior: 'smooth' });
|
| 959 |
+
}
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
function downloadVideo(videoPath, filename) {
|
| 963 |
+
const link = document.createElement('a');
|
| 964 |
+
link.href = videoPath;
|
| 965 |
+
link.download = filename;
|
| 966 |
+
document.body.appendChild(link);
|
| 967 |
+
link.click();
|
| 968 |
+
document.body.removeChild(link);
|
| 969 |
+
showNotification('开始下载视频', 'success');
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
// 修改 showResult 函数,生成视频后自动刷新视频库
|
| 973 |
+
function showResult(videoUrl, prompt) {
|
| 974 |
+
const resultVideo = document.getElementById('resultVideo');
|
| 975 |
+
const downloadLink = document.getElementById('downloadLink');
|
| 976 |
+
const resultContainer = document.getElementById('resultContainer');
|
| 977 |
+
|
| 978 |
+
if (resultVideo) resultVideo.src = videoUrl;
|
| 979 |
+
if (downloadLink) {
|
| 980 |
+
downloadLink.href = videoUrl;
|
| 981 |
+
downloadLink.download = `generated-video-${Date.now()}.mp4`;
|
| 982 |
+
}
|
| 983 |
+
if (resultContainer) {
|
| 984 |
+
resultContainer.style.display = 'block';
|
| 985 |
+
resultContainer.scrollIntoView({ behavior: 'smooth' });
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
// 刷新视频库
|
| 989 |
+
setTimeout(() => {
|
| 990 |
+
loadVideoLibrary();
|
| 991 |
+
}, 2000);
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
// 错误处理
|
| 995 |
+
window.addEventListener('error', function(e) {
|
| 996 |
+
console.error('全局错误:', e.error);
|
| 997 |
+
if (isGenerating) {
|
| 998 |
+
hideLoading();
|
| 999 |
+
showNotification('发生未知错误,请重试', 'error');
|
| 1000 |
+
isGenerating = false;
|
| 1001 |
+
}
|
| 1002 |
+
});
|
| 1003 |
+
|
| 1004 |
+
window.addEventListener('online', () => {
|
| 1005 |
+
showNotification('网络连接已恢复', 'success');
|
| 1006 |
+
});
|
| 1007 |
+
|
| 1008 |
+
window.addEventListener('offline', () => {
|
| 1009 |
+
showNotification('网络连接已断开', 'warning');
|
| 1010 |
+
});
|
public/style.css
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* CSS 变量定义 */
|
| 2 |
+
:root {
|
| 3 |
+
/* 浅色主题 */
|
| 4 |
+
--bg-primary: #ffffff;
|
| 5 |
+
--bg-secondary: #f8fafc;
|
| 6 |
+
--bg-tertiary: #f1f5f9;
|
| 7 |
+
--text-primary: #1e293b;
|
| 8 |
+
--text-secondary: #64748b;
|
| 9 |
+
--text-muted: #94a3b8;
|
| 10 |
+
--border-color: #e2e8f0;
|
| 11 |
+
--accent-primary: #3b82f6;
|
| 12 |
+
--accent-secondary: #1d4ed8;
|
| 13 |
+
--accent-hover: #2563eb;
|
| 14 |
+
--success-color: #6366f1;
|
| 15 |
+
--error-color: #ef4444;
|
| 16 |
+
--warning-color: #f59e0b;
|
| 17 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 18 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 19 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 20 |
+
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 21 |
+
--gradient-secondary: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/* 深色主题 */
|
| 25 |
+
[data-theme="dark"] {
|
| 26 |
+
--bg-primary: #0f172a;
|
| 27 |
+
--bg-secondary: #1e293b;
|
| 28 |
+
--bg-tertiary: #334155;
|
| 29 |
+
--text-primary: #f8fafc;
|
| 30 |
+
--text-secondary: #cbd5e1;
|
| 31 |
+
--text-muted: #64748b;
|
| 32 |
+
--border-color: #475569;
|
| 33 |
+
--accent-primary: #60a5fa;
|
| 34 |
+
--accent-secondary: #3b82f6;
|
| 35 |
+
--accent-hover: #2563eb;
|
| 36 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
| 37 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
| 38 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* 基础样式重置 */
|
| 42 |
+
* {
|
| 43 |
+
margin: 0;
|
| 44 |
+
padding: 0;
|
| 45 |
+
box-sizing: border-box;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
body {
|
| 49 |
+
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 50 |
+
background-color: var(--bg-secondary);
|
| 51 |
+
color: var(--text-primary);
|
| 52 |
+
line-height: 1.6;
|
| 53 |
+
transition: background-color 0.3s ease, color 0.3s ease;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.container {
|
| 57 |
+
min-height: 100vh;
|
| 58 |
+
display: flex;
|
| 59 |
+
flex-direction: column;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* 头部样式 */
|
| 63 |
+
.header {
|
| 64 |
+
background: var(--bg-primary);
|
| 65 |
+
border-bottom: 1px solid var(--border-color);
|
| 66 |
+
box-shadow: var(--shadow-sm);
|
| 67 |
+
position: sticky;
|
| 68 |
+
top: 0;
|
| 69 |
+
z-index: 100;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.header-content {
|
| 73 |
+
max-width: 1200px;
|
| 74 |
+
margin: 0 auto;
|
| 75 |
+
padding: 1rem 2rem;
|
| 76 |
+
display: flex;
|
| 77 |
+
justify-content: space-between;
|
| 78 |
+
align-items: center;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.title {
|
| 82 |
+
font-size: 1.75rem;
|
| 83 |
+
font-weight: 700;
|
| 84 |
+
background: var(--gradient-primary);
|
| 85 |
+
background-clip: text;
|
| 86 |
+
-webkit-background-clip: text;
|
| 87 |
+
-webkit-text-fill-color: transparent;
|
| 88 |
+
display: flex;
|
| 89 |
+
align-items: center;
|
| 90 |
+
gap: 0.75rem;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.title i {
|
| 94 |
+
color: var(--accent-primary);
|
| 95 |
+
-webkit-text-fill-color: var(--accent-primary);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.header-controls {
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
gap: 1rem;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.api-key-button,
|
| 105 |
+
.theme-toggle {
|
| 106 |
+
background: var(--bg-tertiary);
|
| 107 |
+
border: 1px solid var(--border-color);
|
| 108 |
+
border-radius: 0.5rem;
|
| 109 |
+
padding: 0.75rem;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
transition: all 0.3s ease;
|
| 112 |
+
display: flex;
|
| 113 |
+
align-items: center;
|
| 114 |
+
gap: 0.5rem;
|
| 115 |
+
color: var(--text-secondary);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.api-key-button:hover,
|
| 119 |
+
.theme-toggle:hover {
|
| 120 |
+
background: var(--accent-primary);
|
| 121 |
+
color: white;
|
| 122 |
+
transform: translateY(-1px);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.api-key-button.configured {
|
| 126 |
+
background: var(--success-color);
|
| 127 |
+
color: white;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* 模态框样式 */
|
| 131 |
+
.modal {
|
| 132 |
+
position: fixed;
|
| 133 |
+
top: 0;
|
| 134 |
+
left: 0;
|
| 135 |
+
width: 100%;
|
| 136 |
+
height: 100%;
|
| 137 |
+
background: rgba(0, 0, 0, 0.7);
|
| 138 |
+
display: flex;
|
| 139 |
+
align-items: center;
|
| 140 |
+
justify-content: center;
|
| 141 |
+
z-index: 1000;
|
| 142 |
+
backdrop-filter: blur(4px);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.modal-content {
|
| 146 |
+
background: var(--bg-primary);
|
| 147 |
+
border-radius: 1rem;
|
| 148 |
+
max-width: 500px;
|
| 149 |
+
width: 90%;
|
| 150 |
+
max-height: 90vh;
|
| 151 |
+
overflow-y: auto;
|
| 152 |
+
box-shadow: var(--shadow-lg);
|
| 153 |
+
border: 1px solid var(--border-color);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.modal-header {
|
| 157 |
+
padding: 1.5rem;
|
| 158 |
+
border-bottom: 1px solid var(--border-color);
|
| 159 |
+
display: flex;
|
| 160 |
+
justify-content: space-between;
|
| 161 |
+
align-items: center;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.modal-header h3 {
|
| 165 |
+
display: flex;
|
| 166 |
+
align-items: center;
|
| 167 |
+
gap: 0.5rem;
|
| 168 |
+
color: var(--text-primary);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.close-button {
|
| 172 |
+
background: none;
|
| 173 |
+
border: none;
|
| 174 |
+
font-size: 1.25rem;
|
| 175 |
+
cursor: pointer;
|
| 176 |
+
color: var(--text-muted);
|
| 177 |
+
padding: 0.5rem;
|
| 178 |
+
border-radius: 0.25rem;
|
| 179 |
+
transition: all 0.3s ease;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.close-button:hover {
|
| 183 |
+
background: var(--error-color);
|
| 184 |
+
color: white;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.modal-body {
|
| 188 |
+
padding: 1.5rem;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.api-key-info {
|
| 192 |
+
background: var(--bg-tertiary);
|
| 193 |
+
border-radius: 0.5rem;
|
| 194 |
+
padding: 1rem;
|
| 195 |
+
margin-bottom: 1.5rem;
|
| 196 |
+
border-left: 4px solid var(--accent-primary);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.api-key-hint {
|
| 200 |
+
color: var(--text-secondary);
|
| 201 |
+
font-size: 0.875rem;
|
| 202 |
+
margin-top: 0.5rem;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.api-key-hint a {
|
| 206 |
+
color: var(--accent-primary);
|
| 207 |
+
text-decoration: none;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.api-key-hint a:hover {
|
| 211 |
+
text-decoration: underline;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.api-key-warning {
|
| 215 |
+
color: var(--accent-primary);
|
| 216 |
+
font-size: 0.875rem;
|
| 217 |
+
margin-top: 0.5rem;
|
| 218 |
+
padding: 0.75rem;
|
| 219 |
+
background: rgba(59, 130, 246, 0.1);
|
| 220 |
+
border-radius: 0.375rem;
|
| 221 |
+
border: 1px solid rgba(59, 130, 246, 0.2);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.api-key-warning i {
|
| 225 |
+
color: var(--accent-primary);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.api-key-input-group {
|
| 229 |
+
position: relative;
|
| 230 |
+
display: flex;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.api-key-input-group .form-input {
|
| 234 |
+
flex: 1;
|
| 235 |
+
padding-right: 3rem;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.toggle-visibility {
|
| 239 |
+
position: absolute;
|
| 240 |
+
right: 0.5rem;
|
| 241 |
+
top: 50%;
|
| 242 |
+
transform: translateY(-50%);
|
| 243 |
+
background: none;
|
| 244 |
+
border: none;
|
| 245 |
+
cursor: pointer;
|
| 246 |
+
color: var(--text-muted);
|
| 247 |
+
padding: 0.5rem;
|
| 248 |
+
border-radius: 0.25rem;
|
| 249 |
+
transition: all 0.3s ease;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.toggle-visibility:hover {
|
| 253 |
+
color: var(--accent-primary);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.api-key-options {
|
| 257 |
+
margin: 1rem 0;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.checkbox-label {
|
| 261 |
+
display: flex;
|
| 262 |
+
align-items: center;
|
| 263 |
+
gap: 0.75rem;
|
| 264 |
+
cursor: pointer;
|
| 265 |
+
color: var(--text-secondary);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.checkbox-label input[type="checkbox"] {
|
| 269 |
+
display: none;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.checkmark {
|
| 273 |
+
width: 20px;
|
| 274 |
+
height: 20px;
|
| 275 |
+
border: 2px solid var(--border-color);
|
| 276 |
+
border-radius: 0.25rem;
|
| 277 |
+
position: relative;
|
| 278 |
+
transition: all 0.3s ease;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.checkbox-label input[type="checkbox"]:checked + .checkmark {
|
| 282 |
+
background: var(--accent-primary);
|
| 283 |
+
border-color: var(--accent-primary);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
| 287 |
+
content: '✓';
|
| 288 |
+
position: absolute;
|
| 289 |
+
color: white;
|
| 290 |
+
font-size: 0.875rem;
|
| 291 |
+
top: 50%;
|
| 292 |
+
left: 50%;
|
| 293 |
+
transform: translate(-50%, -50%);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.modal-actions {
|
| 297 |
+
display: flex;
|
| 298 |
+
gap: 1rem;
|
| 299 |
+
margin-top: 1.5rem;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.save-button,
|
| 303 |
+
.test-button {
|
| 304 |
+
flex: 1;
|
| 305 |
+
padding: 0.75rem 1.5rem;
|
| 306 |
+
border: none;
|
| 307 |
+
border-radius: 0.5rem;
|
| 308 |
+
cursor: pointer;
|
| 309 |
+
font-weight: 600;
|
| 310 |
+
transition: all 0.3s ease;
|
| 311 |
+
display: flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
justify-content: center;
|
| 314 |
+
gap: 0.5rem;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.save-button {
|
| 318 |
+
background: var(--accent-primary);
|
| 319 |
+
color: white;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.save-button:hover {
|
| 323 |
+
background: var(--accent-hover);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.test-button {
|
| 327 |
+
background: var(--accent-primary);
|
| 328 |
+
color: white;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.test-button:hover {
|
| 332 |
+
background: var(--accent-hover);
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/* 主要内容 */
|
| 336 |
+
.main {
|
| 337 |
+
flex: 1;
|
| 338 |
+
max-width: 1200px;
|
| 339 |
+
margin: 0 auto;
|
| 340 |
+
padding: 2rem;
|
| 341 |
+
width: 100%;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* 标签页 */
|
| 345 |
+
.tabs {
|
| 346 |
+
display: flex;
|
| 347 |
+
gap: 0.5rem;
|
| 348 |
+
margin-bottom: 2rem;
|
| 349 |
+
background: var(--bg-primary);
|
| 350 |
+
padding: 0.5rem;
|
| 351 |
+
border-radius: 0.75rem;
|
| 352 |
+
box-shadow: var(--shadow-sm);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.tab-button {
|
| 356 |
+
flex: 1;
|
| 357 |
+
background: transparent;
|
| 358 |
+
border: none;
|
| 359 |
+
padding: 1rem 1.5rem;
|
| 360 |
+
border-radius: 0.5rem;
|
| 361 |
+
cursor: pointer;
|
| 362 |
+
transition: all 0.3s ease;
|
| 363 |
+
color: var(--text-secondary);
|
| 364 |
+
font-weight: 500;
|
| 365 |
+
display: flex;
|
| 366 |
+
align-items: center;
|
| 367 |
+
justify-content: center;
|
| 368 |
+
gap: 0.5rem;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.tab-button:hover {
|
| 372 |
+
background: var(--bg-tertiary);
|
| 373 |
+
color: var(--text-primary);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.tab-button.active {
|
| 377 |
+
background: var(--accent-primary);
|
| 378 |
+
color: white;
|
| 379 |
+
box-shadow: var(--shadow-md);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* 标签内容 */
|
| 383 |
+
.tab-content {
|
| 384 |
+
display: none;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.tab-content.active {
|
| 388 |
+
display: block;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
/* 表单容器 */
|
| 392 |
+
.form-container {
|
| 393 |
+
background: var(--bg-primary);
|
| 394 |
+
border-radius: 1rem;
|
| 395 |
+
padding: 2rem;
|
| 396 |
+
box-shadow: var(--shadow-lg);
|
| 397 |
+
border: 1px solid var(--border-color);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.generation-form {
|
| 401 |
+
display: flex;
|
| 402 |
+
flex-direction: column;
|
| 403 |
+
gap: 1.5rem;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/* 表单组 */
|
| 407 |
+
.form-group {
|
| 408 |
+
display: flex;
|
| 409 |
+
flex-direction: column;
|
| 410 |
+
gap: 0.5rem;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.form-label {
|
| 414 |
+
font-weight: 600;
|
| 415 |
+
color: var(--text-primary);
|
| 416 |
+
display: flex;
|
| 417 |
+
align-items: center;
|
| 418 |
+
gap: 0.5rem;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.form-label i {
|
| 422 |
+
color: var(--accent-primary);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/* 输入框 */
|
| 426 |
+
.form-input,
|
| 427 |
+
.form-textarea,
|
| 428 |
+
.form-select {
|
| 429 |
+
padding: 0.75rem 1rem;
|
| 430 |
+
border: 2px solid var(--border-color);
|
| 431 |
+
border-radius: 0.5rem;
|
| 432 |
+
background: var(--bg-secondary);
|
| 433 |
+
color: var(--text-primary);
|
| 434 |
+
font-size: 1rem;
|
| 435 |
+
transition: all 0.3s ease;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.form-input:focus,
|
| 439 |
+
.form-textarea:focus,
|
| 440 |
+
.form-select:focus {
|
| 441 |
+
outline: none;
|
| 442 |
+
border-color: var(--accent-primary);
|
| 443 |
+
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.form-textarea {
|
| 447 |
+
min-height: 100px;
|
| 448 |
+
resize: vertical;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
/* 图片上传区域 */
|
| 452 |
+
.image-upload-area {
|
| 453 |
+
position: relative;
|
| 454 |
+
border: 2px dashed var(--border-color);
|
| 455 |
+
border-radius: 0.75rem;
|
| 456 |
+
padding: 2rem;
|
| 457 |
+
text-align: center;
|
| 458 |
+
cursor: pointer;
|
| 459 |
+
transition: all 0.3s ease;
|
| 460 |
+
background: var(--bg-secondary);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.image-upload-area:hover {
|
| 464 |
+
border-color: var(--accent-primary);
|
| 465 |
+
background: var(--bg-tertiary);
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
.image-upload-area.dragover {
|
| 469 |
+
border-color: var(--accent-primary);
|
| 470 |
+
background: var(--accent-primary);
|
| 471 |
+
background-opacity: 0.1;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.upload-placeholder i {
|
| 475 |
+
font-size: 3rem;
|
| 476 |
+
color: var(--accent-primary);
|
| 477 |
+
margin-bottom: 1rem;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.upload-placeholder p {
|
| 481 |
+
color: var(--text-secondary);
|
| 482 |
+
margin: 0.5rem 0;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.upload-hint {
|
| 486 |
+
font-size: 0.875rem;
|
| 487 |
+
color: var(--text-muted);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.image-preview {
|
| 491 |
+
position: relative;
|
| 492 |
+
display: inline-block;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.image-preview img {
|
| 496 |
+
max-width: 300px;
|
| 497 |
+
max-height: 200px;
|
| 498 |
+
border-radius: 0.5rem;
|
| 499 |
+
box-shadow: var(--shadow-md);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.remove-image {
|
| 503 |
+
position: absolute;
|
| 504 |
+
top: -0.5rem;
|
| 505 |
+
right: -0.5rem;
|
| 506 |
+
background: var(--error-color);
|
| 507 |
+
color: white;
|
| 508 |
+
border: none;
|
| 509 |
+
border-radius: 50%;
|
| 510 |
+
width: 2rem;
|
| 511 |
+
height: 2rem;
|
| 512 |
+
cursor: pointer;
|
| 513 |
+
display: flex;
|
| 514 |
+
align-items: center;
|
| 515 |
+
justify-content: center;
|
| 516 |
+
transition: all 0.3s ease;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.remove-image:hover {
|
| 520 |
+
transform: scale(1.1);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
/* 范围滑块 */
|
| 524 |
+
.form-range {
|
| 525 |
+
width: 100%;
|
| 526 |
+
height: 6px;
|
| 527 |
+
border-radius: 3px;
|
| 528 |
+
background: var(--bg-tertiary);
|
| 529 |
+
outline: none;
|
| 530 |
+
-webkit-appearance: none;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.form-range::-webkit-slider-thumb {
|
| 534 |
+
-webkit-appearance: none;
|
| 535 |
+
appearance: none;
|
| 536 |
+
width: 20px;
|
| 537 |
+
height: 20px;
|
| 538 |
+
border-radius: 50%;
|
| 539 |
+
background: var(--accent-primary);
|
| 540 |
+
cursor: pointer;
|
| 541 |
+
box-shadow: var(--shadow-md);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.form-range::-moz-range-thumb {
|
| 545 |
+
width: 20px;
|
| 546 |
+
height: 20px;
|
| 547 |
+
border-radius: 50%;
|
| 548 |
+
background: var(--accent-primary);
|
| 549 |
+
cursor: pointer;
|
| 550 |
+
border: none;
|
| 551 |
+
box-shadow: var(--shadow-md);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
.range-value {
|
| 555 |
+
color: var(--accent-primary);
|
| 556 |
+
font-weight: 600;
|
| 557 |
+
margin-left: 0.5rem;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/* 高级设置 */
|
| 561 |
+
.advanced-settings {
|
| 562 |
+
border: 1px solid var(--border-color);
|
| 563 |
+
border-radius: 0.75rem;
|
| 564 |
+
overflow: hidden;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.advanced-settings summary {
|
| 568 |
+
padding: 1rem 1.5rem;
|
| 569 |
+
background: var(--bg-tertiary);
|
| 570 |
+
cursor: pointer;
|
| 571 |
+
font-weight: 600;
|
| 572 |
+
display: flex;
|
| 573 |
+
align-items: center;
|
| 574 |
+
gap: 0.5rem;
|
| 575 |
+
transition: background-color 0.3s ease;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
.advanced-settings summary:hover {
|
| 579 |
+
background: var(--border-color);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.advanced-settings[open] summary {
|
| 583 |
+
border-bottom: 1px solid var(--border-color);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.settings-grid {
|
| 587 |
+
padding: 1.5rem;
|
| 588 |
+
display: grid;
|
| 589 |
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
| 590 |
+
gap: 1.5rem;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
/* 提交按钮 */
|
| 594 |
+
.submit-button {
|
| 595 |
+
background: var(--gradient-primary);
|
| 596 |
+
color: white;
|
| 597 |
+
border: none;
|
| 598 |
+
padding: 1rem 2rem;
|
| 599 |
+
border-radius: 0.75rem;
|
| 600 |
+
font-size: 1.1rem;
|
| 601 |
+
font-weight: 600;
|
| 602 |
+
cursor: pointer;
|
| 603 |
+
transition: all 0.3s ease;
|
| 604 |
+
display: flex;
|
| 605 |
+
align-items: center;
|
| 606 |
+
justify-content: center;
|
| 607 |
+
gap: 0.75rem;
|
| 608 |
+
box-shadow: var(--shadow-md);
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.submit-button:hover {
|
| 612 |
+
transform: translateY(-2px);
|
| 613 |
+
box-shadow: var(--shadow-lg);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
.submit-button:disabled {
|
| 617 |
+
opacity: 0.6;
|
| 618 |
+
cursor: not-allowed;
|
| 619 |
+
transform: none;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
/* 结果容器 */
|
| 623 |
+
|
| 624 |
+
/* 生成队列(生成栏) */
|
| 625 |
+
.generation-queue {
|
| 626 |
+
background: var(--bg-primary);
|
| 627 |
+
border: 1px solid var(--border-color);
|
| 628 |
+
border-radius: 1rem;
|
| 629 |
+
padding: 1.5rem;
|
| 630 |
+
box-shadow: var(--shadow-lg);
|
| 631 |
+
margin-bottom: 1.5rem;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.queue-header {
|
| 635 |
+
display: flex;
|
| 636 |
+
justify-content: space-between;
|
| 637 |
+
align-items: center;
|
| 638 |
+
margin-bottom: 1rem;
|
| 639 |
+
padding-bottom: 1rem;
|
| 640 |
+
border-bottom: 1px solid var(--border-color);
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.queue-header h3 {
|
| 644 |
+
display: flex;
|
| 645 |
+
align-items: center;
|
| 646 |
+
gap: 0.5rem;
|
| 647 |
+
color: var(--text-primary);
|
| 648 |
+
margin: 0;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
.queue-count {
|
| 652 |
+
color: var(--text-muted);
|
| 653 |
+
font-size: 0.875rem;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.queue-list {
|
| 657 |
+
display: flex;
|
| 658 |
+
flex-direction: column;
|
| 659 |
+
gap: 0.75rem;
|
| 660 |
+
min-height: 80px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.empty-queue {
|
| 664 |
+
display: flex;
|
| 665 |
+
flex-direction: column;
|
| 666 |
+
align-items: center;
|
| 667 |
+
justify-content: center;
|
| 668 |
+
color: var(--text-secondary);
|
| 669 |
+
gap: 0.75rem;
|
| 670 |
+
padding: 1rem;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.queue-item {
|
| 674 |
+
display: grid;
|
| 675 |
+
grid-template-columns: 1fr auto;
|
| 676 |
+
align-items: center;
|
| 677 |
+
gap: 0.75rem;
|
| 678 |
+
padding: 0.75rem 1rem;
|
| 679 |
+
border: 1px solid var(--border-color);
|
| 680 |
+
border-radius: 0.75rem;
|
| 681 |
+
background: var(--bg-secondary);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.queue-item .info {
|
| 685 |
+
display: flex;
|
| 686 |
+
flex-direction: column;
|
| 687 |
+
gap: 0.25rem;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.queue-item .title {
|
| 691 |
+
font-weight: 600;
|
| 692 |
+
color: var(--text-primary);
|
| 693 |
+
font-size: 0.9rem;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.queue-item .prompt {
|
| 697 |
+
color: var(--text-secondary);
|
| 698 |
+
font-size: 0.8rem;
|
| 699 |
+
display: -webkit-box;
|
| 700 |
+
-webkit-line-clamp: 2;
|
| 701 |
+
-webkit-box-orient: vertical;
|
| 702 |
+
overflow: hidden;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.queue-item .meta {
|
| 706 |
+
color: var(--text-muted);
|
| 707 |
+
font-size: 0.75rem;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.queue-progress {
|
| 711 |
+
min-width: 220px;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.queue-progress .progress-bar {
|
| 715 |
+
width: 100%;
|
| 716 |
+
height: 6px;
|
| 717 |
+
background: var(--bg-tertiary);
|
| 718 |
+
border-radius: 3px;
|
| 719 |
+
overflow: hidden;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.queue-progress .progress-fill {
|
| 723 |
+
height: 100%;
|
| 724 |
+
background: var(--gradient-primary);
|
| 725 |
+
width: 0%;
|
| 726 |
+
transition: width 0.3s ease;
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.queue-status {
|
| 730 |
+
font-size: 0.75rem;
|
| 731 |
+
padding: 0.25rem 0.5rem;
|
| 732 |
+
border-radius: 0.25rem;
|
| 733 |
+
text-align: center;
|
| 734 |
+
min-width: 72px;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.queue-status.pending {
|
| 738 |
+
background: var(--bg-tertiary);
|
| 739 |
+
color: var(--text-secondary);
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.queue-status.running {
|
| 743 |
+
background: var(--accent-primary);
|
| 744 |
+
color: white;
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.queue-status.done {
|
| 748 |
+
background: var(--accent-secondary);
|
| 749 |
+
color: white;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
.queue-status.error {
|
| 753 |
+
background: var(--error-color);
|
| 754 |
+
color: white;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* 移动端优化:生成队列 */
|
| 758 |
+
@media (max-width: 768px) {
|
| 759 |
+
.generation-queue {
|
| 760 |
+
padding: 1rem;
|
| 761 |
+
}
|
| 762 |
+
.queue-item {
|
| 763 |
+
grid-template-columns: 1fr;
|
| 764 |
+
}
|
| 765 |
+
.queue-progress {
|
| 766 |
+
min-width: 100%;
|
| 767 |
+
}
|
| 768 |
+
}
|
| 769 |
+
.result-container {
|
| 770 |
+
background: var(--bg-primary);
|
| 771 |
+
border-radius: 1rem;
|
| 772 |
+
padding: 2rem;
|
| 773 |
+
box-shadow: var(--shadow-lg);
|
| 774 |
+
border: 1px solid var(--border-color);
|
| 775 |
+
margin-top: 2rem;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
.result-header {
|
| 779 |
+
display: flex;
|
| 780 |
+
justify-content: space-between;
|
| 781 |
+
align-items: center;
|
| 782 |
+
margin-bottom: 1.5rem;
|
| 783 |
+
padding-bottom: 1rem;
|
| 784 |
+
border-bottom: 1px solid var(--border-color);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.result-header h3 {
|
| 788 |
+
display: flex;
|
| 789 |
+
align-items: center;
|
| 790 |
+
gap: 0.5rem;
|
| 791 |
+
color: var(--text-primary);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.clear-button {
|
| 795 |
+
background: var(--error-color);
|
| 796 |
+
color: white;
|
| 797 |
+
border: none;
|
| 798 |
+
border-radius: 0.5rem;
|
| 799 |
+
padding: 0.5rem 1rem;
|
| 800 |
+
cursor: pointer;
|
| 801 |
+
transition: all 0.3s ease;
|
| 802 |
+
display: flex;
|
| 803 |
+
align-items: center;
|
| 804 |
+
gap: 0.5rem;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.clear-button:hover {
|
| 808 |
+
background: #dc2626;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.result-video {
|
| 812 |
+
width: 100%;
|
| 813 |
+
max-width: 800px;
|
| 814 |
+
border-radius: 0.75rem;
|
| 815 |
+
box-shadow: var(--shadow-md);
|
| 816 |
+
margin-bottom: 1rem;
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
.result-actions {
|
| 820 |
+
display: flex;
|
| 821 |
+
gap: 1rem;
|
| 822 |
+
justify-content: center;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
.download-button {
|
| 826 |
+
background: var(--accent-primary);
|
| 827 |
+
color: white;
|
| 828 |
+
text-decoration: none;
|
| 829 |
+
padding: 0.75rem 1.5rem;
|
| 830 |
+
border-radius: 0.5rem;
|
| 831 |
+
font-weight: 600;
|
| 832 |
+
transition: all 0.3s ease;
|
| 833 |
+
display: flex;
|
| 834 |
+
align-items: center;
|
| 835 |
+
gap: 0.5rem;
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
.download-button:hover {
|
| 839 |
+
background: var(--accent-hover);
|
| 840 |
+
transform: translateY(-1px);
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
/* 加载状态 */
|
| 844 |
+
.loading-container {
|
| 845 |
+
position: fixed;
|
| 846 |
+
top: 0;
|
| 847 |
+
left: 0;
|
| 848 |
+
right: 0;
|
| 849 |
+
bottom: 0;
|
| 850 |
+
background: rgba(0, 0, 0, 0.8);
|
| 851 |
+
display: flex;
|
| 852 |
+
align-items: center;
|
| 853 |
+
justify-content: center;
|
| 854 |
+
z-index: 1000;
|
| 855 |
+
backdrop-filter: blur(4px);
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
.loading-content {
|
| 859 |
+
background: var(--bg-primary);
|
| 860 |
+
border-radius: 1rem;
|
| 861 |
+
padding: 3rem;
|
| 862 |
+
text-align: center;
|
| 863 |
+
max-width: 400px;
|
| 864 |
+
box-shadow: var(--shadow-lg);
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.loading-spinner {
|
| 868 |
+
width: 60px;
|
| 869 |
+
height: 60px;
|
| 870 |
+
border: 4px solid var(--border-color);
|
| 871 |
+
border-top: 4px solid var(--accent-primary);
|
| 872 |
+
border-radius: 50%;
|
| 873 |
+
animation: spin 1s linear infinite;
|
| 874 |
+
margin: 0 auto 1.5rem;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
@keyframes spin {
|
| 878 |
+
0% { transform: rotate(0deg); }
|
| 879 |
+
100% { transform: rotate(360deg); }
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
.loading-progress {
|
| 883 |
+
margin-top: 1.5rem;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
.progress-bar {
|
| 887 |
+
width: 100%;
|
| 888 |
+
height: 8px;
|
| 889 |
+
background: var(--bg-tertiary);
|
| 890 |
+
border-radius: 4px;
|
| 891 |
+
overflow: hidden;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
.progress-fill {
|
| 895 |
+
height: 100%;
|
| 896 |
+
background: var(--gradient-primary);
|
| 897 |
+
width: 0%;
|
| 898 |
+
transition: width 0.3s ease;
|
| 899 |
+
animation: pulse 2s infinite;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
@keyframes pulse {
|
| 903 |
+
0%, 100% { opacity: 1; }
|
| 904 |
+
50% { opacity: 0.7; }
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
/* 视频库样式 */
|
| 908 |
+
.video-library-header {
|
| 909 |
+
display: flex;
|
| 910 |
+
justify-content: space-between;
|
| 911 |
+
align-items: center;
|
| 912 |
+
margin-bottom: 2rem;
|
| 913 |
+
padding-bottom: 1rem;
|
| 914 |
+
border-bottom: 1px solid var(--border-color);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.video-library-header h3 {
|
| 918 |
+
display: flex;
|
| 919 |
+
align-items: center;
|
| 920 |
+
gap: 0.5rem;
|
| 921 |
+
color: var(--text-primary);
|
| 922 |
+
margin: 0;
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
.library-controls {
|
| 926 |
+
display: flex;
|
| 927 |
+
align-items: center;
|
| 928 |
+
gap: 1rem;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
.refresh-button {
|
| 932 |
+
background: var(--accent-primary);
|
| 933 |
+
color: white;
|
| 934 |
+
border: none;
|
| 935 |
+
border-radius: 0.5rem;
|
| 936 |
+
padding: 0.5rem 1rem;
|
| 937 |
+
cursor: pointer;
|
| 938 |
+
transition: all 0.3s ease;
|
| 939 |
+
display: flex;
|
| 940 |
+
align-items: center;
|
| 941 |
+
gap: 0.5rem;
|
| 942 |
+
font-size: 0.875rem;
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
.refresh-button:hover {
|
| 946 |
+
background: var(--accent-hover);
|
| 947 |
+
transform: translateY(-1px);
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
.video-count {
|
| 951 |
+
color: var(--text-secondary);
|
| 952 |
+
font-size: 0.875rem;
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
.video-library-content {
|
| 956 |
+
min-height: 300px;
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
.loading-library {
|
| 960 |
+
display: flex;
|
| 961 |
+
flex-direction: column;
|
| 962 |
+
align-items: center;
|
| 963 |
+
justify-content: center;
|
| 964 |
+
height: 200px;
|
| 965 |
+
color: var(--text-secondary);
|
| 966 |
+
gap: 1rem;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.video-grid {
|
| 970 |
+
display: grid;
|
| 971 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
| 972 |
+
gap: 1.5rem;
|
| 973 |
+
margin-top: 1rem;
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
.video-item {
|
| 977 |
+
background: var(--bg-secondary);
|
| 978 |
+
border-radius: 0.75rem;
|
| 979 |
+
padding: 1rem;
|
| 980 |
+
border: 1px solid var(--border-color);
|
| 981 |
+
transition: all 0.3s ease;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.video-item:hover {
|
| 985 |
+
transform: translateY(-2px);
|
| 986 |
+
box-shadow: var(--shadow-md);
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
.video-preview {
|
| 990 |
+
width: 100%;
|
| 991 |
+
border-radius: 0.5rem;
|
| 992 |
+
margin-bottom: 1rem;
|
| 993 |
+
background: #000;
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
.video-info {
|
| 997 |
+
display: flex;
|
| 998 |
+
flex-direction: column;
|
| 999 |
+
gap: 0.5rem;
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
.video-filename {
|
| 1003 |
+
font-weight: 600;
|
| 1004 |
+
color: var(--text-primary);
|
| 1005 |
+
font-size: 0.875rem;
|
| 1006 |
+
word-break: break-all;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.video-meta {
|
| 1010 |
+
display: flex;
|
| 1011 |
+
justify-content: space-between;
|
| 1012 |
+
align-items: center;
|
| 1013 |
+
font-size: 0.75rem;
|
| 1014 |
+
color: var(--text-muted);
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
.video-size {
|
| 1018 |
+
color: var(--accent-primary);
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.video-actions {
|
| 1022 |
+
display: flex;
|
| 1023 |
+
gap: 0.5rem;
|
| 1024 |
+
margin-top: 0.5rem;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.video-action-btn {
|
| 1028 |
+
flex: 1;
|
| 1029 |
+
padding: 0.5rem;
|
| 1030 |
+
border: none;
|
| 1031 |
+
border-radius: 0.375rem;
|
| 1032 |
+
cursor: pointer;
|
| 1033 |
+
font-size: 0.75rem;
|
| 1034 |
+
transition: all 0.3s ease;
|
| 1035 |
+
display: flex;
|
| 1036 |
+
align-items: center;
|
| 1037 |
+
justify-content: center;
|
| 1038 |
+
gap: 0.25rem;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.download-video-btn {
|
| 1042 |
+
background: var(--accent-primary);
|
| 1043 |
+
color: white;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
.download-video-btn:hover {
|
| 1047 |
+
background: var(--accent-hover);
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
.play-video-btn {
|
| 1051 |
+
background: var(--accent-primary);
|
| 1052 |
+
color: white;
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
.play-video-btn:hover {
|
| 1056 |
+
background: var(--accent-hover);
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.empty-library {
|
| 1060 |
+
display: flex;
|
| 1061 |
+
flex-direction: column;
|
| 1062 |
+
align-items: center;
|
| 1063 |
+
justify-content: center;
|
| 1064 |
+
height: 200px;
|
| 1065 |
+
color: var(--text-secondary);
|
| 1066 |
+
gap: 1rem;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.empty-library i {
|
| 1070 |
+
font-size: 3rem;
|
| 1071 |
+
color: var(--text-muted);
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
/* 底部 */
|
| 1075 |
+
.footer {
|
| 1076 |
+
background: var(--bg-primary);
|
| 1077 |
+
border-top: 1px solid var(--border-color);
|
| 1078 |
+
padding: 2rem;
|
| 1079 |
+
text-align: center;
|
| 1080 |
+
color: var(--text-muted);
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/* 响应式设计 */
|
| 1084 |
+
@media (max-width: 768px) {
|
| 1085 |
+
.main {
|
| 1086 |
+
padding: 1rem;
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
.header-content {
|
| 1090 |
+
padding: 1rem;
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
.title {
|
| 1094 |
+
font-size: 1.5rem;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
.form-container {
|
| 1098 |
+
padding: 1.5rem;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
.settings-grid {
|
| 1102 |
+
grid-template-columns: 1fr;
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
.tabs {
|
| 1106 |
+
flex-direction: column;
|
| 1107 |
+
}
|
| 1108 |
+
|
| 1109 |
+
.loading-content {
|
| 1110 |
+
margin: 1rem;
|
| 1111 |
+
padding: 2rem;
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
.modal-content {
|
| 1115 |
+
width: 95%;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.modal-actions {
|
| 1119 |
+
flex-direction: column;
|
| 1120 |
+
}
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
@media (max-width: 480px) {
|
| 1124 |
+
.header-content {
|
| 1125 |
+
flex-direction: column;
|
| 1126 |
+
gap: 1rem;
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
.title {
|
| 1130 |
+
font-size: 1.25rem;
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
.form-container {
|
| 1134 |
+
padding: 1rem;
|
| 1135 |
+
}
|
| 1136 |
+
}
|
server.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
const multer = require('multer');
|
| 4 |
+
const path = require('path');
|
| 5 |
+
const fs = require('fs');
|
| 6 |
+
const https = require('https');
|
| 7 |
+
const http = require('http');
|
| 8 |
+
require('dotenv').config();
|
| 9 |
+
|
| 10 |
+
const app = express();
|
| 11 |
+
const PORT = process.env.PORT || 7860;
|
| 12 |
+
|
| 13 |
+
// 中间件配置
|
| 14 |
+
app.use(cors());
|
| 15 |
+
app.use(express.json({ limit: '50mb' }));
|
| 16 |
+
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
| 17 |
+
app.use(express.static(path.join(__dirname, 'public')));
|
| 18 |
+
|
| 19 |
+
// API key 存储文件路径
|
| 20 |
+
const API_KEY_FILE = path.join(__dirname, 'stored_api_key.json');
|
| 21 |
+
|
| 22 |
+
// 视频存储目录
|
| 23 |
+
const VIDEO_DIR = path.join(__dirname, 'video');
|
| 24 |
+
|
| 25 |
+
// 文件上传配置
|
| 26 |
+
const storage = multer.diskStorage({
|
| 27 |
+
destination: (req, file, cb) => {
|
| 28 |
+
const uploadDir = path.join(__dirname, 'uploads');
|
| 29 |
+
if (!fs.existsSync(uploadDir)) {
|
| 30 |
+
fs.mkdirSync(uploadDir, { recursive: true });
|
| 31 |
+
}
|
| 32 |
+
cb(null, uploadDir);
|
| 33 |
+
},
|
| 34 |
+
filename: (req, file, cb) => {
|
| 35 |
+
cb(null, Date.now() + '-' + file.originalname);
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const upload = multer({
|
| 40 |
+
storage: storage,
|
| 41 |
+
limits: { fileSize: 50 * 1024 * 1024 }
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
// 读取存储的 API key
|
| 45 |
+
function getStoredApiKey() {
|
| 46 |
+
try {
|
| 47 |
+
if (fs.existsSync(API_KEY_FILE)) {
|
| 48 |
+
const data = fs.readFileSync(API_KEY_FILE, 'utf8');
|
| 49 |
+
const parsed = JSON.parse(data);
|
| 50 |
+
return parsed.apiKey || null;
|
| 51 |
+
}
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.log('读取存储的 API key 失败:', error.message);
|
| 54 |
+
}
|
| 55 |
+
return null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// 保存 API key
|
| 59 |
+
function saveApiKey(apiKey) {
|
| 60 |
+
try {
|
| 61 |
+
const data = { apiKey, savedAt: new Date().toISOString() };
|
| 62 |
+
fs.writeFileSync(API_KEY_FILE, JSON.stringify(data, null, 2));
|
| 63 |
+
return true;
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error('保存 API key 失败:', error);
|
| 66 |
+
return false;
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// 获取有效的 API key
|
| 71 |
+
function getValidApiKey(userApiKey = null) {
|
| 72 |
+
// 优先级:用户提供的 > 环境变量 > 存储的
|
| 73 |
+
return userApiKey || process.env.FAL_KEY || getStoredApiKey();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// 下载并保存视频文件
|
| 77 |
+
function downloadVideo(videoUrl, filename) {
|
| 78 |
+
return new Promise((resolve, reject) => {
|
| 79 |
+
// 确保视频目录存在
|
| 80 |
+
if (!fs.existsSync(VIDEO_DIR)) {
|
| 81 |
+
fs.mkdirSync(VIDEO_DIR, { recursive: true });
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const filepath = path.join(VIDEO_DIR, filename);
|
| 85 |
+
const file = fs.createWriteStream(filepath);
|
| 86 |
+
|
| 87 |
+
const protocol = videoUrl.startsWith('https:') ? https : http;
|
| 88 |
+
|
| 89 |
+
protocol.get(videoUrl, (response) => {
|
| 90 |
+
if (response.statusCode !== 200) {
|
| 91 |
+
reject(new Error(`下载失败,状态码: ${response.statusCode}`));
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
response.pipe(file);
|
| 96 |
+
|
| 97 |
+
file.on('finish', () => {
|
| 98 |
+
file.close();
|
| 99 |
+
console.log(`视频已保存到: ${filepath}`);
|
| 100 |
+
resolve(filepath);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
file.on('error', (err) => {
|
| 104 |
+
fs.unlink(filepath, () => {}); // 删除不完整的文件
|
| 105 |
+
reject(err);
|
| 106 |
+
});
|
| 107 |
+
}).on('error', (err) => {
|
| 108 |
+
reject(err);
|
| 109 |
+
});
|
| 110 |
+
});
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// 生成唯一的文件名
|
| 114 |
+
function generateVideoFilename(type = 'video') {
|
| 115 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 116 |
+
return `${type}-${timestamp}.mp4`;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// FAL AI 客户端初始化
|
| 120 |
+
let fal;
|
| 121 |
+
try {
|
| 122 |
+
fal = require('@fal-ai/client');
|
| 123 |
+
// 如果是对象形式的导入,尝试获取 fal 属性
|
| 124 |
+
if (fal && typeof fal === 'object' && fal.fal) {
|
| 125 |
+
fal = fal.fal;
|
| 126 |
+
}
|
| 127 |
+
} catch (error) {
|
| 128 |
+
console.error('FAL AI 客户端初始化失败:', error);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// 路由
|
| 132 |
+
app.get('/', (req, res) => {
|
| 133 |
+
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// API key 管理接口
|
| 137 |
+
app.post('/api/save-key', (req, res) => {
|
| 138 |
+
const { apiKey } = req.body;
|
| 139 |
+
|
| 140 |
+
if (!apiKey || !apiKey.trim()) {
|
| 141 |
+
return res.status(400).json({ error: 'API key 不能为空' });
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (saveApiKey(apiKey.trim())) {
|
| 145 |
+
res.json({ success: true, message: 'API key 保存成功' });
|
| 146 |
+
} else {
|
| 147 |
+
res.status(500).json({ error: 'API key 保存失败' });
|
| 148 |
+
}
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
app.get('/api/check-key', (req, res) => {
|
| 152 |
+
const storedKey = getStoredApiKey();
|
| 153 |
+
const hasEnvKey = !!process.env.FAL_KEY;
|
| 154 |
+
|
| 155 |
+
res.json({
|
| 156 |
+
hasStoredKey: !!storedKey,
|
| 157 |
+
hasEnvKey: hasEnvKey,
|
| 158 |
+
keySource: hasEnvKey ? 'environment' : (storedKey ? 'stored' : 'none')
|
| 159 |
+
});
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// 图片到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast)
|
| 163 |
+
app.post('/api/image-to-video', upload.single('image'), async (req, res) => {
|
| 164 |
+
try {
|
| 165 |
+
const userApiKey = req.body.userApiKey;
|
| 166 |
+
const apiKey = getValidApiKey(userApiKey);
|
| 167 |
+
|
| 168 |
+
if (!apiKey) {
|
| 169 |
+
return res.status(400).json({ error: '请提供 FAL AI API 密钥' });
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
const prompt = req.body.prompt;
|
| 173 |
+
if (!prompt) {
|
| 174 |
+
return res.status(400).json({ error: '请提供文本提示' });
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 解析图片输入(文件或 URL)
|
| 178 |
+
let image_url;
|
| 179 |
+
if (req.file) {
|
| 180 |
+
const file = fs.readFileSync(req.file.path);
|
| 181 |
+
const blob = new Blob([file], { type: req.file.mimetype });
|
| 182 |
+
image_url = await fal.storage.upload(blob);
|
| 183 |
+
} else if (req.body.image_url) {
|
| 184 |
+
image_url = req.body.image_url;
|
| 185 |
+
} else {
|
| 186 |
+
return res.status(400).json({ error: '请提供图片文件或图片URL' });
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// 模型选择:默认 WAN,支持 seedance-pro-fast
|
| 190 |
+
const model = req.body.model || 'wan-v2.2-a14b';
|
| 191 |
+
|
| 192 |
+
// 设置临时环境变量
|
| 193 |
+
const originalFalKey = process.env.FAL_KEY;
|
| 194 |
+
process.env.FAL_KEY = apiKey;
|
| 195 |
+
|
| 196 |
+
let result;
|
| 197 |
+
|
| 198 |
+
if (model === 'seedance-pro-fast') {
|
| 199 |
+
// Bytedance Seedance 1.0 Pro Fast 入参映射
|
| 200 |
+
const {
|
| 201 |
+
aspect_ratio = 'auto',
|
| 202 |
+
resolution = '1080p',
|
| 203 |
+
duration = '5',
|
| 204 |
+
camera_fixed = false,
|
| 205 |
+
seed = -1,
|
| 206 |
+
enable_safety_checker = true,
|
| 207 |
+
} = req.body;
|
| 208 |
+
|
| 209 |
+
result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/image-to-video", {
|
| 210 |
+
input: {
|
| 211 |
+
prompt,
|
| 212 |
+
image_url,
|
| 213 |
+
aspect_ratio,
|
| 214 |
+
resolution,
|
| 215 |
+
duration,
|
| 216 |
+
camera_fixed: typeof camera_fixed === 'string' ? camera_fixed === 'true' : !!camera_fixed,
|
| 217 |
+
seed: parseInt(seed, 10),
|
| 218 |
+
enable_safety_checker: typeof enable_safety_checker === 'string' ? enable_safety_checker === 'true' : !!enable_safety_checker,
|
| 219 |
+
},
|
| 220 |
+
logs: true,
|
| 221 |
+
onQueueUpdate: (update) => {
|
| 222 |
+
if (update.status === "IN_PROGRESS") {
|
| 223 |
+
console.log('生成进度(Seedance I2V):', update.logs?.map(log => log.message).join('\n'));
|
| 224 |
+
}
|
| 225 |
+
},
|
| 226 |
+
});
|
| 227 |
+
} else {
|
| 228 |
+
// WAN v2.2-a14b 入参映射(保持原逻辑)
|
| 229 |
+
const {
|
| 230 |
+
negative_prompt = "",
|
| 231 |
+
num_frames = 81,
|
| 232 |
+
frames_per_second = 16,
|
| 233 |
+
resolution = "720p",
|
| 234 |
+
aspect_ratio = "auto",
|
| 235 |
+
video_quality = "high",
|
| 236 |
+
enable_safety_checker = "true"
|
| 237 |
+
} = req.body;
|
| 238 |
+
|
| 239 |
+
result = await fal.subscribe("fal-ai/wan/v2.2-a14b/image-to-video", {
|
| 240 |
+
input: {
|
| 241 |
+
image_url,
|
| 242 |
+
prompt,
|
| 243 |
+
negative_prompt,
|
| 244 |
+
num_frames: parseInt(num_frames),
|
| 245 |
+
frames_per_second: parseInt(frames_per_second),
|
| 246 |
+
resolution,
|
| 247 |
+
aspect_ratio,
|
| 248 |
+
video_quality,
|
| 249 |
+
num_inference_steps: 27,
|
| 250 |
+
enable_safety_checker: enable_safety_checker === "true",
|
| 251 |
+
acceleration: "regular",
|
| 252 |
+
guidance_scale: 3.5,
|
| 253 |
+
shift: 5
|
| 254 |
+
},
|
| 255 |
+
logs: true,
|
| 256 |
+
onQueueUpdate: (update) => {
|
| 257 |
+
if (update.status === "IN_PROGRESS") {
|
| 258 |
+
console.log('生成进度(WAN I2V):', update.logs?.map(log => log.message).join('\n'));
|
| 259 |
+
}
|
| 260 |
+
},
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// 恢复原始环境变量
|
| 265 |
+
if (originalFalKey) {
|
| 266 |
+
process.env.FAL_KEY = originalFalKey;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// 下载并保存视频到本地
|
| 270 |
+
let localVideoPath = null;
|
| 271 |
+
if (result.data && result.data.video && result.data.video.url) {
|
| 272 |
+
try {
|
| 273 |
+
const filename = generateVideoFilename('image-to-video');
|
| 274 |
+
localVideoPath = await downloadVideo(result.data.video.url, filename);
|
| 275 |
+
console.log(`图片转视频完成,已保存到: ${localVideoPath}`);
|
| 276 |
+
} catch (downloadError) {
|
| 277 |
+
console.error('视频下载失败:', downloadError);
|
| 278 |
+
// 不影响主要功能,继续返回结果
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if (req.file && fs.existsSync(req.file.path)) {
|
| 283 |
+
fs.unlinkSync(req.file.path);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
res.json({
|
| 287 |
+
success: true,
|
| 288 |
+
data: {
|
| 289 |
+
...result.data,
|
| 290 |
+
localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null
|
| 291 |
+
},
|
| 292 |
+
requestId: result.requestId
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
} catch (error) {
|
| 296 |
+
console.error('视频生成错误:', error);
|
| 297 |
+
|
| 298 |
+
if (req.file && fs.existsSync(req.file.path)) {
|
| 299 |
+
fs.unlinkSync(req.file.path);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
res.status(500).json({
|
| 303 |
+
success: false,
|
| 304 |
+
error: error.message || '视频生成失败'
|
| 305 |
+
});
|
| 306 |
+
}
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
// 文本到视频生成 API(支持模型切换:WAN v2.2-a14b 与 Bytedance Seedance 1.0 Pro Fast)
|
| 310 |
+
app.post('/api/text-to-video', async (req, res) => {
|
| 311 |
+
try {
|
| 312 |
+
const userApiKey = req.body.userApiKey;
|
| 313 |
+
const apiKey = getValidApiKey(userApiKey);
|
| 314 |
+
|
| 315 |
+
if (!apiKey) {
|
| 316 |
+
return res.status(400).json({ error: '请提供 FAL AI API 密钥' });
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
const prompt = req.body.prompt;
|
| 320 |
+
if (!prompt) {
|
| 321 |
+
return res.status(400).json({ error: '请提供文本提示' });
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 模型选择:默认 WAN,支持 seedance-pro-fast
|
| 325 |
+
const model = req.body.model || 'wan-v2.2-a14b';
|
| 326 |
+
|
| 327 |
+
// 设置临时环境变量
|
| 328 |
+
const originalFalKey = process.env.FAL_KEY;
|
| 329 |
+
process.env.FAL_KEY = apiKey;
|
| 330 |
+
|
| 331 |
+
let result;
|
| 332 |
+
|
| 333 |
+
if (model === 'seedance-pro-fast') {
|
| 334 |
+
// Bytedance Seedance 1.0 Pro Fast 入参映射
|
| 335 |
+
const {
|
| 336 |
+
aspect_ratio = '16:9',
|
| 337 |
+
resolution = '1080p',
|
| 338 |
+
duration = '5',
|
| 339 |
+
camera_fixed = false,
|
| 340 |
+
seed = -1,
|
| 341 |
+
enable_safety_checker = true,
|
| 342 |
+
} = req.body;
|
| 343 |
+
|
| 344 |
+
result = await fal.subscribe("fal-ai/bytedance/seedance/v1/pro/fast/text-to-video", {
|
| 345 |
+
input: {
|
| 346 |
+
prompt,
|
| 347 |
+
aspect_ratio,
|
| 348 |
+
resolution,
|
| 349 |
+
duration,
|
| 350 |
+
camera_fixed: !!camera_fixed,
|
| 351 |
+
seed: parseInt(seed, 10),
|
| 352 |
+
enable_safety_checker: !!enable_safety_checker,
|
| 353 |
+
},
|
| 354 |
+
logs: true,
|
| 355 |
+
onQueueUpdate: (update) => {
|
| 356 |
+
if (update.status === "IN_PROGRESS") {
|
| 357 |
+
console.log('生成进度(Seedance):', update.logs?.map(log => log.message).join('\n'));
|
| 358 |
+
}
|
| 359 |
+
},
|
| 360 |
+
});
|
| 361 |
+
} else {
|
| 362 |
+
// WAN v2.2-a14b 入参映射(保持原逻辑)
|
| 363 |
+
const {
|
| 364 |
+
negative_prompt = "",
|
| 365 |
+
num_frames = 81,
|
| 366 |
+
frames_per_second = 16,
|
| 367 |
+
resolution = "720p",
|
| 368 |
+
aspect_ratio = "16:9",
|
| 369 |
+
video_quality = "high",
|
| 370 |
+
enable_safety_checker = true
|
| 371 |
+
} = req.body;
|
| 372 |
+
|
| 373 |
+
result = await fal.subscribe("fal-ai/wan/v2.2-a14b/text-to-video", {
|
| 374 |
+
input: {
|
| 375 |
+
prompt,
|
| 376 |
+
negative_prompt,
|
| 377 |
+
num_frames: parseInt(num_frames),
|
| 378 |
+
frames_per_second: parseInt(frames_per_second),
|
| 379 |
+
resolution,
|
| 380 |
+
aspect_ratio,
|
| 381 |
+
video_quality,
|
| 382 |
+
num_inference_steps: 27,
|
| 383 |
+
enable_safety_checker: enable_safety_checker,
|
| 384 |
+
acceleration: "regular",
|
| 385 |
+
guidance_scale: 3.5,
|
| 386 |
+
shift: 5
|
| 387 |
+
},
|
| 388 |
+
logs: true,
|
| 389 |
+
onQueueUpdate: (update) => {
|
| 390 |
+
if (update.status === "IN_PROGRESS") {
|
| 391 |
+
console.log('生成进度(WAN):', update.logs?.map(log => log.message).join('\n'));
|
| 392 |
+
}
|
| 393 |
+
},
|
| 394 |
+
});
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// 恢复原始环境变量
|
| 398 |
+
if (originalFalKey) {
|
| 399 |
+
process.env.FAL_KEY = originalFalKey;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// 下载并保存视频到本地
|
| 403 |
+
let localVideoPath = null;
|
| 404 |
+
if (result.data && result.data.video && result.data.video.url) {
|
| 405 |
+
try {
|
| 406 |
+
const filename = generateVideoFilename('text-to-video');
|
| 407 |
+
localVideoPath = await downloadVideo(result.data.video.url, filename);
|
| 408 |
+
console.log(`文本转视频完成,已保存到: ${localVideoPath}`);
|
| 409 |
+
} catch (downloadError) {
|
| 410 |
+
console.error('视频下载失败:', downloadError);
|
| 411 |
+
// 不影响主要功能,继续返回结果
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
res.json({
|
| 416 |
+
success: true,
|
| 417 |
+
data: {
|
| 418 |
+
...result.data,
|
| 419 |
+
localPath: localVideoPath ? path.relative(__dirname, localVideoPath) : null
|
| 420 |
+
},
|
| 421 |
+
requestId: result.requestId
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
} catch (error) {
|
| 425 |
+
console.error('视频生成错误:', error);
|
| 426 |
+
res.status(500).json({
|
| 427 |
+
success: false,
|
| 428 |
+
error: error.message || '视频生成失败'
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
// 获取本地视频列表
|
| 434 |
+
app.get('/api/videos', (req, res) => {
|
| 435 |
+
try {
|
| 436 |
+
if (!fs.existsSync(VIDEO_DIR)) {
|
| 437 |
+
return res.json({ videos: [] });
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
const files = fs.readdirSync(VIDEO_DIR)
|
| 441 |
+
.filter(file => file.endsWith('.mp4'))
|
| 442 |
+
.map(file => {
|
| 443 |
+
const filepath = path.join(VIDEO_DIR, file);
|
| 444 |
+
const stats = fs.statSync(filepath);
|
| 445 |
+
return {
|
| 446 |
+
filename: file,
|
| 447 |
+
path: path.join('video', file),
|
| 448 |
+
size: stats.size,
|
| 449 |
+
created: stats.birthtime,
|
| 450 |
+
modified: stats.mtime
|
| 451 |
+
};
|
| 452 |
+
})
|
| 453 |
+
.sort((a, b) => new Date(b.created) - new Date(a.created)); // 按创建时间倒序
|
| 454 |
+
|
| 455 |
+
res.json({ videos: files });
|
| 456 |
+
} catch (error) {
|
| 457 |
+
console.error('获取视频列表失败:', error);
|
| 458 |
+
res.status(500).json({ error: '获取视频列表失败' });
|
| 459 |
+
}
|
| 460 |
+
});
|
| 461 |
+
|
| 462 |
+
// 提供视频文件访问
|
| 463 |
+
app.use('/video', express.static(path.join(__dirname, 'video')));
|
| 464 |
+
|
| 465 |
+
// 测试 API 密钥连接(使用免费的健康检查端点)
|
| 466 |
+
app.post('/api/test-key', async (req, res) => {
|
| 467 |
+
try {
|
| 468 |
+
const { apiKey } = req.body;
|
| 469 |
+
const testKey = apiKey || getValidApiKey();
|
| 470 |
+
|
| 471 |
+
if (!testKey) {
|
| 472 |
+
return res.status(400).json({
|
| 473 |
+
error: 'API 密钥未提供',
|
| 474 |
+
success: false
|
| 475 |
+
});
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
// 使用 FAL AI 的免费健康检查端点测试连接
|
| 479 |
+
const https = require('https');
|
| 480 |
+
|
| 481 |
+
const testPromise = new Promise((resolve, reject) => {
|
| 482 |
+
// 使用 FAL AI 的状态检查端点(不消耗配额)
|
| 483 |
+
const options = {
|
| 484 |
+
hostname: 'fal.run',
|
| 485 |
+
port: 443,
|
| 486 |
+
path: '/status',
|
| 487 |
+
method: 'GET',
|
| 488 |
+
headers: {
|
| 489 |
+
'Authorization': `Key ${testKey}`,
|
| 490 |
+
'User-Agent': 'FAL-Video-Generator/1.0'
|
| 491 |
+
},
|
| 492 |
+
timeout: 5000
|
| 493 |
+
};
|
| 494 |
+
|
| 495 |
+
const req = https.request(options, (res) => {
|
| 496 |
+
let data = '';
|
| 497 |
+
res.on('data', (chunk) => data += chunk);
|
| 498 |
+
res.on('end', () => {
|
| 499 |
+
if (res.statusCode === 401) {
|
| 500 |
+
reject(new Error('Unauthorized - API 密钥无效'));
|
| 501 |
+
} else if (res.statusCode === 403) {
|
| 502 |
+
reject(new Error('Forbidden - API 密钥被禁用'));
|
| 503 |
+
} else if (res.statusCode === 429) {
|
| 504 |
+
reject(new Error('Rate Limited - 请求过于频繁'));
|
| 505 |
+
} else if (res.statusCode >= 200 && res.statusCode < 300) {
|
| 506 |
+
resolve({ statusCode: res.statusCode, data });
|
| 507 |
+
} else {
|
| 508 |
+
// 其他状态码,但至少证明密钥被识别了
|
| 509 |
+
resolve({ statusCode: res.statusCode, data });
|
| 510 |
+
}
|
| 511 |
+
});
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
req.on('error', (err) => {
|
| 515 |
+
if (err.code === 'ENOTFOUND') {
|
| 516 |
+
reject(new Error('Network - 无法连接到 FAL AI 服务器'));
|
| 517 |
+
} else if (err.code === 'ECONNREFUSED') {
|
| 518 |
+
reject(new Error('Connection - 连接被拒绝'));
|
| 519 |
+
} else {
|
| 520 |
+
reject(new Error(`Network - ${err.message}`));
|
| 521 |
+
}
|
| 522 |
+
});
|
| 523 |
+
|
| 524 |
+
req.on('timeout', () => {
|
| 525 |
+
req.destroy();
|
| 526 |
+
reject(new Error('Timeout - 连接超时'));
|
| 527 |
+
});
|
| 528 |
+
|
| 529 |
+
req.end();
|
| 530 |
+
});
|
| 531 |
+
|
| 532 |
+
try {
|
| 533 |
+
const result = await testPromise;
|
| 534 |
+
|
| 535 |
+
res.json({
|
| 536 |
+
success: true,
|
| 537 |
+
message: '✅ 连接成功!API 密钥有效',
|
| 538 |
+
keyFormat: testKey.substring(0, 8) + '...' + testKey.substring(testKey.length - 4),
|
| 539 |
+
statusCode: result.statusCode,
|
| 540 |
+
note: '🆓 使用免费健康检查端点,不消耗配额'
|
| 541 |
+
});
|
| 542 |
+
|
| 543 |
+
} catch (testError) {
|
| 544 |
+
let errorMessage = '❌ 连接测试失败';
|
| 545 |
+
let errorType = 'error';
|
| 546 |
+
|
| 547 |
+
if (testError.message.includes('Unauthorized')) {
|
| 548 |
+
errorMessage = '❌ API 密钥无效或已过期';
|
| 549 |
+
errorType = 'auth';
|
| 550 |
+
} else if (testError.message.includes('Forbidden')) {
|
| 551 |
+
errorMessage = '❌ API 密钥被禁用';
|
| 552 |
+
errorType = 'auth';
|
| 553 |
+
} else if (testError.message.includes('Rate Limited')) {
|
| 554 |
+
errorMessage = '⚠️ 请求过于频繁,请稍后重试';
|
| 555 |
+
errorType = 'rate';
|
| 556 |
+
} else if (testError.message.includes('Network')) {
|
| 557 |
+
errorMessage = '🌐 网络连接问题';
|
| 558 |
+
errorType = 'network';
|
| 559 |
+
} else if (testError.message.includes('Timeout')) {
|
| 560 |
+
errorMessage = '⏱️ 连接超时,请重试';
|
| 561 |
+
errorType = 'timeout';
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
res.status(errorType === 'auth' ? 401 : 500).json({
|
| 565 |
+
success: false,
|
| 566 |
+
error: errorMessage,
|
| 567 |
+
errorType: errorType,
|
| 568 |
+
details: testError.message
|
| 569 |
+
});
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
} catch (error) {
|
| 573 |
+
console.error('API 密钥连接测试失败:', error);
|
| 574 |
+
res.status(500).json({
|
| 575 |
+
success: false,
|
| 576 |
+
error: '测试过程中发生错误',
|
| 577 |
+
details: error.message
|
| 578 |
+
});
|
| 579 |
+
}
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
app.get('/api/health', (req, res) => {
|
| 583 |
+
const apiKey = getValidApiKey();
|
| 584 |
+
res.json({
|
| 585 |
+
status: 'healthy',
|
| 586 |
+
timestamp: new Date().toISOString(),
|
| 587 |
+
hasApiKey: !!apiKey,
|
| 588 |
+
keyPreview: apiKey ? apiKey.substring(0, 8) + '...' : null
|
| 589 |
+
});
|
| 590 |
+
});
|
| 591 |
+
|
| 592 |
+
app.listen(PORT, '0.0.0.0', () => {
|
| 593 |
+
console.log(`服务器运行在端口 ${PORT}`);
|
| 594 |
+
const apiKey = getValidApiKey();
|
| 595 |
+
console.log(`API Key 状态: ${apiKey ? '已配置' : '未配置'}`);
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
module.exports = app;
|