This commit is contained in:
parent
6af651ccd6
commit
566c3a476f
@ -176,4 +176,31 @@
|
||||
- **耗时**: 663s
|
||||
- **花费**: $0.0000
|
||||
- **退出码**: 0
|
||||
- **工作摘要**: Phase 3 原型设计全部完成。以下是交付的 6 个 HTML 原型文件:
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 UI 变更记录 [2026/3/13 手动开发]
|
||||
|
||||
### GenerationCard.tsx 变更
|
||||
- **结果区域**: 从 `<img>` 改为 `<video controls>` — 现在显示真实生成的视频而非截图
|
||||
- **失败状态**: 新增失败展示区域,显示红色错误信息 `task.errorMessage`
|
||||
- **进度文本**: 从 "视频生成中... {progress}%" 简化为 "视频生成中..."(因 Seedance 不返回精确进度)
|
||||
- **样式**: 视频元素增加 `maxWidth: 100%, borderRadius: 8px`
|
||||
|
||||
### VideoGenerationPage.tsx 变更
|
||||
- **挂载加载**: 新增 `useEffect → loadTasks()`,页面挂载时从后端加载历史任务
|
||||
- **刷新保持**: 刷新页面不再丢失生成记录
|
||||
|
||||
### 注意事项
|
||||
- 参考素材的缩略图现在使用 TOS 公网 URL(不再是 blob: URL),刷新后仍可显示
|
||||
- 视频播放器加载依赖 Seedance 结果 URL 的有效性(24 小时)
|
||||
|
||||
---
|
||||
|
||||
原型设计 [2026/3/12 16:59:29]
|
||||
- **触发**: design_prototype
|
||||
- **耗时**: 663s
|
||||
- **花费**: $0.0000
|
||||
- **退出码**: 0
|
||||
- **工作摘要**: Phase 3 原型设计全部完成。以下是交付的 6 个 HTML 原型文件: ### 管理后台(4 页面,共享左侧 Sidebar 导航) | 文件 | 路由 | 核心内容 | |------|------|---------| | `admin-dashboard.html` | `/admin/dashboard` | 4 个统计卡片(带趋势箭头)+ SVG 折线图(30天消费趋势)+ 水平柱状图排行榜 Top 10 | | `admin-users.html` | `/admin/users` | 搜索/状态筛选 + 用户表格(9列含操作)+ 分页 + 用户详情抽屉 + 配额编辑 Modal | | `admin-records.html` | `/admin/records` | 用户搜索 + 日期范围筛选 + 消费明细表格 + 导出 CSV 按钮 + 分页(1,234 条) | | `admin-settings.html` | `/admin/settings` | 全局配额设置表单 + 系统公告(Toggle 开关 + 文本域)+ Toast 保存提示 | ### 用户端...
|
||||
|
||||
@ -222,3 +222,89 @@ Implemented Phase 3 features as defined in PRD v3.0: **Quota System Refactor + A
|
||||
- **花费**: $0.0000
|
||||
- **退出码**: 0
|
||||
- **工作摘要**: Phase 3 implementation is complete. Here's a summary of everything done: **Backend (Django)** - Quota system refactored from call-count to seconds-based (`daily_seconds_limit=600`, `monthly_seconds_limit=6000`) - Added `seconds_consumed` field to GenerationRecord model - QuotaConfig updated with announcement fields - 10 new API endpoints covering admin stats/users/records/settings and user profile - Migrations created and applied, `manage.py check` passes **Frontend (React + TypeScript)** - Inst...
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-13 Phase 4: TOS 存储 + Seedance API 集成(手动开发)
|
||||
|
||||
### What was done
|
||||
实现了完整的火山引擎 TOS 对象存储 + Seedance 视频生成 API 集成,替换原有的 mock 进度模拟。
|
||||
|
||||
**新增文件**
|
||||
- `backend/utils/tos_client.py` — 火山引擎 TOS (S3 兼容) 文件上传工具,使用 boto3
|
||||
- `backend/utils/seedance_client.py` — Seedance/ARK API 客户端(创建任务、查询任务、提取视频URL、状态映射)
|
||||
- `backend/apps/generation/migrations/0003_generationrecord_ark_task_id_and_more.py` — 新增字段迁移
|
||||
|
||||
**Backend Changes**
|
||||
- `requirements.txt`: 新增 `boto3>=1.34`, `requests>=2.31`
|
||||
- `config/settings.py`: 新增 TOS 配置(AK/SK/Bucket/Endpoint/CDN域名)+ ARK API 配置(API Key/Base URL)
|
||||
- `apps/generation/models.py`: GenerationRecord 新增 4 个字段:
|
||||
- `ark_task_id` CharField — 火山 ARK 任务 ID(如 `cgt-2026xxxx`)
|
||||
- `result_url` CharField(1000) — 生成的视频结果 URL
|
||||
- `error_message` TextField — 错误信息
|
||||
- `reference_urls` JSONField — 参考素材信息(URL、类型、角色、标签)
|
||||
- `apps/generation/serializers.py`: VideoGenerateSerializer 新增 `references` ListField
|
||||
- `apps/generation/views.py`: 完全重写,新增 3 个视图:
|
||||
- `upload_media_view` POST /media/upload — 上传文件到 TOS,返回公网 URL
|
||||
- `video_tasks_list_view` GET /video/tasks — 用户最近任务列表(页面刷新持久化)
|
||||
- `video_task_detail_view` GET /video/tasks/<uuid> — 单个任务状态(如仍在处理中则轮询 Seedance API)
|
||||
- `video_generate_view` 改写 — 接收 JSON(含 TOS URLs)→ 创建 Seedance 任务 → 存储 ark_task_id
|
||||
- `apps/generation/urls.py`: 新增 3 条路由
|
||||
|
||||
**Frontend Changes**
|
||||
- `src/types/index.ts`:
|
||||
- `UploadedFile` 新增 `tosUrl` 字段
|
||||
- `ReferenceSnapshot` 新增 `role` 字段
|
||||
- `GenerationTask` 新增 `taskId`(后端 UUID)、`errorMessage` 字段
|
||||
- 新增 `BackendTask` 接口(后端返回的任务数据结构)
|
||||
- `src/lib/api.ts`:
|
||||
- 新增 `mediaApi.upload()` — 上传文件到后端 → TOS
|
||||
- `videoApi.generate()` 改为发送 JSON(不再是 FormData)
|
||||
- 新增 `videoApi.getTasks()` — 获取用户任务列表
|
||||
- 新增 `videoApi.getTaskStatus()` — 获取单个任务状态
|
||||
- `src/store/generation.ts`: **完全重写** — 核心变更:
|
||||
- 删除 `simulateProgress()` mock 进度模拟
|
||||
- `addTask()` 改为 async: 上传文件 → TOS → 调用生成 API → 轮询状态
|
||||
- 新增 `loadTasks()`: 从后端加载任务列表(页面刷新后恢复)
|
||||
- 新增 `startPolling()` / `stopPolling()`: 每 5 秒轮询 Seedance 任务状态
|
||||
- `regenerate()`: 复用已有 TOS URL 避免重复上传
|
||||
- 使用 `Map<string, Timer>` 管理轮询计时器
|
||||
- `src/components/VideoGenerationPage.tsx`: 挂载时调用 `loadTasks()` 恢复历史任务
|
||||
- `src/components/GenerationCard.tsx`: 结果区域从 `<img>` 改为 `<video controls>`;新增失败状态显示
|
||||
|
||||
### 完整数据流
|
||||
```
|
||||
用户上传文件 → POST /media/upload → TOS 存储桶 → 返回公网 URL
|
||||
用户生成视频 → POST /video/generate (JSON + URLs) → Seedance API 创建任务
|
||||
前端轮询 → GET /video/tasks/{task_id} → 后端查询 Seedance API → 更新 DB → 返回状态
|
||||
任务完成 → result_url 存入 DB → 前端显示 <video> 播放器
|
||||
刷新页面 → GET /video/tasks → 从 DB 加载所有记录 → 恢复展示
|
||||
```
|
||||
|
||||
### TOS 配置
|
||||
- Bucket: `video-huoshan`
|
||||
- Region: `cn-guangzhou`
|
||||
- 外网 Endpoint: `tos-cn-guangzhou.volces.com`
|
||||
- S3 兼容 Endpoint: `tos-s3-cn-guangzhou.volces.com`
|
||||
- CDN 域名: `video-huoshan.tos-cn-guangzhou.volces.com`
|
||||
|
||||
### Seedance Model ID 映射
|
||||
- `seedance_2.0` → `doubao-seedance-2-0-260128`
|
||||
- `seedance_2.0_fast` → `doubao-seedance-2-0-fast-260128`
|
||||
|
||||
### 关键技术决策
|
||||
- TOS 使用 S3 兼容协议(boto3)而非火山原生 SDK — 更通用,依赖更少
|
||||
- 文件上传和视频生成分两步:先上传到 TOS 拿 URL,再用 URL 调 Seedance API
|
||||
- 前端轮询而非后端轮询(无需 Celery 等任务队列)
|
||||
- Seedance 结果 URL 24 小时有效(暂未实现下载到 TOS 持久化)
|
||||
- 如果 ARK_API_KEY 未设置,生成接口仍创建 DB 记录但跳过 Seedance API 调用
|
||||
|
||||
### 需要用户操作
|
||||
1. 设置 `ARK_API_KEY` 环境变量(Seedance API 密钥)
|
||||
2. TOS Bucket 设置公共读(Seedance API 需要访问上传的文件)
|
||||
3. 配置 CORS 允许前端域名
|
||||
|
||||
### Build verification
|
||||
- TypeScript: `tsc --noEmit` 0 errors
|
||||
- Backend: `python -c "import django; ..."` 全部导入成功
|
||||
- Migration: 已创建并应用 (0003)
|
||||
|
||||
@ -34,3 +34,4 @@
|
||||
| 2026/3/12 17:06:33 | product-agent | 设计评审 | review_prototype | 424s | $0.0000 |
|
||||
| 2026/3/12 17:26:11 | dev-agent | 代码开发 | develop_from_prototype | 1178s | $0.0000 |
|
||||
| 2026/3/12 17:47:19 | test-agent | 测试验证 | test_code | 1268s | $0.0000 |
|
||||
| 2026/3/13 手动开发 | (手动) | Phase 4: TOS存储+Seedance API集成 | manual | - | - |
|
||||
|
||||
@ -340,3 +340,36 @@ PRD v2.1 将 Phase 2 标记为"🔲 待开发"且验收标准注明"当前不验
|
||||
- **花费**: $0.0000
|
||||
- **退出码**: 0
|
||||
- **工作摘要**: ## 评审完成 — 结论: **APPROVED** 评审报告已写入 `docs/design-review.md`。 ### 核心发现 Phase 3 原型质量**极高**,完整覆盖了 PRD 中所有需要原型的功能: | 维度 | 结果 | |------|------| | P0 核心功能 | **4/4** (100%) | | P1 重要功能 | **19/19** (100%) | | P2 锦上添花 | 1/3 (不影响评审) | | 后台管理专项检查 | **4/4** 全部通过 | | 综合评分 | **4.8/5** | ### 原型覆盖一览 - **仪表盘** — 4 统计卡片 + 折线图(30天Mock数据) + 排行柱状图(Top 10) + 时间选择器 - **用户管理** — 表格(搜索/筛选/分页) + 配额编辑Modal + 用户详情Drawer + 启用/禁用 - **消费记录** — 明细表格 + 日期筛选 + 用户搜索 + 导出CSV + 分页 - **系统设置** — 配额表单 + 公告管理(Toggle开关) + Toast反馈 - **个人中...
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: TOS 存储 + Seedance API 集成 [2026/3/13 手动开发]
|
||||
|
||||
### 关键决策
|
||||
- **存储方案选择火山引擎 TOS** — S3 兼容协议,使用 boto3,Bucket: video-huoshan (cn-guangzhou)
|
||||
- **文件上传采用两步流程** — 先 POST /media/upload 上传到 TOS 获取公网 URL,再 POST /video/generate 携带 URL 调 Seedance API
|
||||
- **视频生成接口对接火山方舟 Seedance API** — create_task + query_task 异步模式
|
||||
- **前端轮询替代 mock 进度** — 每 5 秒 GET /video/tasks/{task_id},后端代理查询 Seedance API 状态
|
||||
- **页面刷新持久化** — GET /video/tasks 从数据库加载历史任务,解决刷新页面记录丢失问题
|
||||
- **GenerationRecord 新增 4 字段** — ark_task_id, result_url, error_message, reference_urls(JSONField)
|
||||
|
||||
### 新增/变更 API 端点
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| /api/v1/media/upload | POST | 上传文件到 TOS,返回公网 URL |
|
||||
| /api/v1/video/generate | POST | **改写**: 接收 JSON(含 TOS URLs)→ 创建 Seedance 任务 |
|
||||
| /api/v1/video/tasks | GET | **新增**: 用户最近任务列表 |
|
||||
| /api/v1/video/tasks/<uuid> | GET | **新增**: 单个任务状态(轮询 Seedance) |
|
||||
|
||||
### Seedance API 对接
|
||||
- 创建任务: POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
|
||||
- 查询任务: GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id}
|
||||
- Model ID: `doubao-seedance-2-0-260128` / `doubao-seedance-2-0-fast-260128`
|
||||
- 参数: content[](text/image_url/video_url), ratio, duration, generate_audio, watermark
|
||||
|
||||
### 注意事项
|
||||
- ARK_API_KEY 需要用户在火山引擎方舟平台获取并设置为环境变量
|
||||
- TOS Bucket 需要设置公共读(Seedance API 需通过 URL 访问上传的文件)
|
||||
- Seedance 结果视频 URL 24 小时有效,暂未实现下载到 TOS 持久化
|
||||
- 前端 generation store 完全重写:删除 mock 进度,改为真实 API 调用 + 轮询
|
||||
- video/generate 接口从 FormData 改为 JSON 请求格式
|
||||
|
||||
@ -253,6 +253,53 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 变更待测 [2026/3/13 手动开发]
|
||||
|
||||
### 变更摘要
|
||||
Phase 4 实现了 TOS 对象存储 + Seedance 视频生成 API 集成,替换了原有的 mock 进度模拟。
|
||||
|
||||
### 需要验证的核心变更
|
||||
|
||||
**后端新增/变更**
|
||||
- `backend/utils/tos_client.py` — TOS 文件上传(boto3 S3 兼容)
|
||||
- `backend/utils/seedance_client.py` — Seedance API 客户端(create_task, query_task, extract_video_url, map_status)
|
||||
- `backend/apps/generation/models.py` — GenerationRecord 新增 4 字段: `ark_task_id`, `result_url`, `error_message`, `reference_urls`
|
||||
- `backend/apps/generation/views.py` — 新增 3 个视图: upload_media_view, video_tasks_list_view, video_task_detail_view; video_generate_view 完全重写
|
||||
- `backend/apps/generation/urls.py` — 新增 3 条路由
|
||||
- Migration 0003 已创建并应用
|
||||
|
||||
**前端核心变更**
|
||||
- `src/types/index.ts` — 新增 BackendTask 接口; GenerationTask 新增 taskId, errorMessage; UploadedFile 新增 tosUrl
|
||||
- `src/lib/api.ts` — 新增 mediaApi.upload(), videoApi.getTasks(), videoApi.getTaskStatus(); videoApi.generate() 改为 JSON
|
||||
- `src/store/generation.ts` — **完全重写**: 删除 simulateProgress(), addTask 改 async, 新增 loadTasks()/startPolling()/stopPolling()
|
||||
- `src/components/VideoGenerationPage.tsx` — 新增 useEffect → loadTasks()
|
||||
- `src/components/GenerationCard.tsx` — 结果区从 <img> 改 <video controls>; 新增失败状态显示
|
||||
|
||||
### 受影响的现有测试
|
||||
- `test/unit/generationStore.test.ts` — **可能失败**: generation store 完全重写, addTask 改为 async, 删除了 simulateProgress, 引入了 mediaApi/videoApi mock
|
||||
- `test/unit/phase3Features.test.ts` — 可能受 types 变更影响 (GenerationTask 新增 taskId 字段)
|
||||
- `test/e2e/video-generation.spec.ts` — E2E 测试可能受影响 (生成流程改为真实 API 调用)
|
||||
|
||||
### 需要新增的测试
|
||||
1. TOS 上传测试 (mock boto3 client)
|
||||
2. Seedance API 客户端测试 (mock requests)
|
||||
3. upload_media_view 端点测试
|
||||
4. video_tasks_list_view / video_task_detail_view 端点测试
|
||||
5. video_generate_view 集成测试 (mock TOS + Seedance)
|
||||
6. 前端 generation store 单元测试重写 (mock mediaApi + videoApi)
|
||||
7. 前端轮询机制测试 (startPolling/stopPolling)
|
||||
8. 页面刷新持久化 E2E 测试
|
||||
|
||||
### 新增 API 端点待测
|
||||
| 端点 | 方法 | 验证内容 |
|
||||
|------|------|---------|
|
||||
| /api/v1/media/upload | POST | 文件类型验证、大小限制、TOS 上传、返回 URL |
|
||||
| /api/v1/video/tasks | GET | 返回用户任务列表、按 created_at 降序 |
|
||||
| /api/v1/video/tasks/<uuid> | GET | 返回任务详情、如在处理中则查询 Seedance |
|
||||
| /api/v1/video/generate | POST | JSON 格式(不再是 FormData)、references 数组、Seedance 调用 |
|
||||
|
||||
---
|
||||
|
||||
## 测试验证 [2026/3/12 17:47:19]
|
||||
- **触发**: test_code
|
||||
- **耗时**: 1268s
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@ web/tsconfig.tsbuildinfo
|
||||
# === Backend (Python/Django) ===
|
||||
backend/venv/
|
||||
backend/db.sqlite3
|
||||
backend/test_db.sqlite3
|
||||
backend/__pycache__/
|
||||
backend/**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
364
CLAUDE.md
Normal file
364
CLAUDE.md
Normal file
@ -0,0 +1,364 @@
|
||||
# Jimeng Clone — AI Video Generation Platform
|
||||
|
||||
> This file is the single source of truth for AI-assisted development on this project.
|
||||
> AI agents (including Claude Code) should read this file first before making any changes.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development Environment
|
||||
- **Backend**: Python 3.12 + Django 4.2 + DRF + SimpleJWT
|
||||
- **Frontend**: React 18 + Vite 6 + TypeScript 5 + Zustand
|
||||
- **Database**: SQLite (dev) / Aliyun RDS MySQL (prod)
|
||||
- **UI Library**: Arco Design (`@arco-design/web-react`)
|
||||
- **Charts**: ECharts 6 + echarts-for-react
|
||||
|
||||
### Setup Commands
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver 8000
|
||||
|
||||
# Frontend
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # Dev server at localhost:5173
|
||||
npm run build # Production build: tsc -b && vite build
|
||||
```
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Frontend unit tests
|
||||
cd web && npx vitest run
|
||||
|
||||
# Backend checks
|
||||
cd backend && python manage.py check
|
||||
```
|
||||
|
||||
### Admin Credentials (Dev)
|
||||
- Username: `admin` / Password: `admin123`
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
jimeng-clone/
|
||||
├── backend/ # Django REST Framework backend
|
||||
│ ├── config/ # Django settings, urls, wsgi
|
||||
│ │ ├── settings.py # Main config (DB, CORS, JWT, apps)
|
||||
│ │ └── urls.py # Root URL routing
|
||||
│ ├── apps/
|
||||
│ │ ├── accounts/ # User auth: models, views, serializers, urls
|
||||
│ │ └── generation/ # Video generation: models, views, serializers, urls
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ └── Dockerfile # Python 3.12 + gunicorn
|
||||
├── web/ # React 18 + Vite frontend
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Root router (all routes defined here)
|
||||
│ │ ├── main.tsx # Entry point
|
||||
│ │ ├── pages/ # Page-level components (one per route)
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── store/ # Zustand state stores
|
||||
│ │ ├── lib/ # Utilities (API client, log center)
|
||||
│ │ └── types/ # TypeScript type definitions
|
||||
│ ├── nginx.conf # Production nginx config
|
||||
│ └── Dockerfile # Node 18 + nginx
|
||||
├── k8s/ # Kubernetes deployment configs
|
||||
├── docs/ # PRD, design review documents
|
||||
├── .gitea/workflows/ # CI/CD pipeline
|
||||
└── .autonomous/ # Autonomous skill task tracking
|
||||
```
|
||||
|
||||
### Key Files (Most Commonly Modified)
|
||||
- `web/src/App.tsx` — Route definitions. Modify when adding new pages.
|
||||
- `web/src/store/auth.ts` — Auth state (login/logout/JWT refresh)
|
||||
- `web/src/store/generation.ts` — Video generation state & API calls
|
||||
- `web/src/store/inputBar.ts` — Input bar state (mode, files, prompt)
|
||||
- `web/src/lib/api.ts` — Axios client with JWT interceptor. API base URL configured here.
|
||||
- `web/src/types/index.ts` — All TypeScript interfaces
|
||||
- `backend/config/settings.py` — Django settings (DB, CORS, JWT, installed apps)
|
||||
- `backend/config/urls.py` — Root URL routing
|
||||
- `backend/apps/accounts/models.py` — User model (extends AbstractUser)
|
||||
- `backend/apps/generation/models.py` — GenerationRecord + QuotaConfig models
|
||||
- `backend/apps/generation/views.py` — All API endpoints (video, admin, profile)
|
||||
- `backend/apps/generation/urls.py` — API URL patterns
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Code Style
|
||||
- **Frontend**: TypeScript strict mode, functional components, named exports
|
||||
- **Backend**: PEP 8, function-based views with `@api_view` decorator
|
||||
- **CSS**: Inline styles + Arco Design component props (no separate CSS modules)
|
||||
|
||||
### Naming Conventions
|
||||
- **React Components**: PascalCase files (e.g., `LoginPage.tsx`, `InputBar.tsx`)
|
||||
- **Stores**: camelCase files (e.g., `auth.ts`, `generation.ts`)
|
||||
- **Django Apps**: lowercase (e.g., `accounts`, `generation`)
|
||||
- **API URLs**: `/api/v1/` prefix, no trailing slashes in URL patterns
|
||||
- **Database**: snake_case (Django default)
|
||||
|
||||
### State Management Pattern
|
||||
- Zustand stores in `web/src/store/`, one store per domain
|
||||
- Each store exports a `use[Name]Store` hook
|
||||
- API calls are defined inside stores (not in components)
|
||||
- Pattern: `const doSomething = useAuthStore((s) => s.doSomething)`
|
||||
|
||||
### Component Pattern
|
||||
- Pages in `web/src/pages/` — full-page components mounted by router
|
||||
- Reusable components in `web/src/components/`
|
||||
- Protected routes use `<ProtectedRoute>` wrapper, admin routes add `requireAdmin` prop
|
||||
|
||||
### Backend Pattern
|
||||
- Function-based views with `@api_view(['GET', 'POST'])` decorator
|
||||
- Auth via `@permission_classes([IsAuthenticated])`
|
||||
- Admin endpoints check `request.user.is_staff`
|
||||
- Singleton pattern for QuotaConfig (pk always = 1)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth (`/api/v1/auth/`)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/auth/register` | User registration |
|
||||
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens) |
|
||||
| POST | `/api/v1/auth/token/refresh` | Refresh JWT access token |
|
||||
| GET | `/api/v1/auth/me` | Get current user info |
|
||||
|
||||
### Video Generation (`/api/v1/`)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/v1/media/upload` | Upload reference media files |
|
||||
| POST | `/api/v1/video/generate` | Submit video generation task |
|
||||
| GET | `/api/v1/video/tasks` | List user's generation tasks |
|
||||
| GET | `/api/v1/video/tasks/<uuid>` | Get task status & result |
|
||||
|
||||
### Admin (`/api/v1/admin/`)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/admin/stats` | Dashboard statistics & charts data |
|
||||
| GET | `/api/v1/admin/users` | List all users (search, filter, paginate) |
|
||||
| POST | `/api/v1/admin/users/create` | Create new user |
|
||||
| GET/PUT | `/api/v1/admin/users/<id>` | Get/update user details |
|
||||
| PUT | `/api/v1/admin/users/<id>/quota` | Update user quota limits |
|
||||
| PUT | `/api/v1/admin/users/<id>/status` | Toggle user active status |
|
||||
| GET | `/api/v1/admin/records` | List all generation records |
|
||||
| GET/PUT | `/api/v1/admin/settings` | Get/update global settings (QuotaConfig) |
|
||||
|
||||
### Profile (`/api/v1/profile/`)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/profile/overview` | User consumption summary |
|
||||
| GET | `/api/v1/profile/records` | User's own generation records |
|
||||
|
||||
## Database Models
|
||||
|
||||
### User (extends AbstractUser)
|
||||
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### GenerationRecord
|
||||
- `user` (FK), `task_id` (UUID), `ark_task_id`, `prompt`, `mode` (universal|keyframe)
|
||||
- `model` (seedance_2.0|seedance_2.0_fast), `aspect_ratio`, `duration`, `seconds_consumed`
|
||||
- `status` (queued|processing|completed|failed), `result_url`, `error_message`, `reference_urls` (JSON)
|
||||
- Index: (user, created_at)
|
||||
|
||||
### QuotaConfig (Singleton, pk=1)
|
||||
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
||||
- `announcement`, `announcement_enabled`, `updated_at`
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Path | Component | Auth | Description |
|
||||
|------|-----------|------|-------------|
|
||||
| `/login` | LoginPage | No | Login page |
|
||||
| `/` | VideoGenerationPage | Yes | Main video generation UI |
|
||||
| `/profile` | ProfilePage | Yes | User consumption overview |
|
||||
| `/admin/dashboard` | DashboardPage | Admin | Stats & charts |
|
||||
| `/admin/users` | UsersPage | Admin | User management |
|
||||
| `/admin/records` | RecordsPage | Admin | Generation records |
|
||||
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
|
||||
|
||||
## Incremental Development Guide
|
||||
|
||||
### How to Add Features to This Project
|
||||
|
||||
This project was generated and maintained using the **Autonomous Skill** for Claude Code.
|
||||
For incremental development, you have two approaches:
|
||||
|
||||
#### Approach 1: Direct AI Development (Small Changes)
|
||||
For small features, bug fixes, or UI tweaks — just describe what you want directly to Claude Code.
|
||||
Claude Code will read this CLAUDE.md to understand the project context.
|
||||
|
||||
Example prompts:
|
||||
- "Add a logout button to the navbar"
|
||||
- "Fix the date format in the records table"
|
||||
- "Add input validation to the login form"
|
||||
- "Change the default daily quota from 600 to 1200 seconds"
|
||||
|
||||
**IMPORTANT — Update Documentation After Every Change:**
|
||||
Even for the smallest change, you MUST update documentation to keep it in sync:
|
||||
|
||||
1. **Update `CLAUDE.md`** (this file):
|
||||
- New API endpoint → add to API Endpoints table
|
||||
- New page/route → add to Frontend Routes table
|
||||
- New model/field → add to Database Models section
|
||||
- New component → add to Directory Structure or Key Files
|
||||
- Any config change → update relevant section
|
||||
- Always add a row to the **Change History** table at the bottom
|
||||
|
||||
2. **Update Claude Code memory** (if auto-memory is available):
|
||||
- Update the project's memory files under `~/.claude/projects/` to reflect the change
|
||||
- This ensures future AI sessions across different conversations also know about the change
|
||||
|
||||
3. **Even non-architectural changes must be tracked**:
|
||||
- Bug fix → add to Change History
|
||||
- UI tweak → add to Change History
|
||||
- Dependency update → update Quick Start / Tech Stack section
|
||||
- Config value change → update relevant section
|
||||
|
||||
Failing to update documentation means future AI sessions will work with stale context and may introduce conflicts or duplicate work.
|
||||
|
||||
#### Approach 2: Autonomous Skill (Large Features)
|
||||
For complex, multi-step features, use the Autonomous Skill:
|
||||
|
||||
```
|
||||
autonomous: Add user notification system with email and in-app alerts
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
```bash
|
||||
cd /path/to/jimeng-clone
|
||||
~/.claude/plugins/marketplaces/claude-code-settings/plugins/autonomous-skill/skills/autonomous-skill/scripts/run-session.sh "Add user notification system"
|
||||
```
|
||||
|
||||
The autonomous skill will:
|
||||
1. Read this CLAUDE.md to understand the project
|
||||
2. Break the feature into 10-100 sub-tasks in `.autonomous/<task-name>/task_list.md`
|
||||
3. Execute across multiple sessions automatically
|
||||
4. Auto-update this CLAUDE.md with architectural changes after each session
|
||||
|
||||
#### When to Use Which Approach
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Bug fix / single-line change | Direct AI |
|
||||
| Small UI tweak (button, color, text) | Direct AI |
|
||||
| Add a single API endpoint | Direct AI |
|
||||
| Modify existing component behavior | Direct AI |
|
||||
| New feature module (3+ files) | Autonomous |
|
||||
| Add entire admin page with CRUD | Autonomous |
|
||||
| Refactor across multiple files | Autonomous |
|
||||
| Add new subsystem (notifications, payments) | Autonomous |
|
||||
|
||||
### How to Add a New Page
|
||||
|
||||
1. Create page component in `web/src/pages/NewPage.tsx`
|
||||
2. Add route in `web/src/App.tsx` inside `<Routes>`:
|
||||
```tsx
|
||||
<Route path="/new-page" element={<ProtectedRoute><NewPage /></ProtectedRoute>} />
|
||||
```
|
||||
3. If it needs state, create store in `web/src/store/newPage.ts`
|
||||
4. Add API calls in the store using `api.get()` / `api.post()` from `web/src/lib/api.ts`
|
||||
|
||||
### How to Add a New API Endpoint
|
||||
|
||||
1. Add view function in `backend/apps/generation/views.py` (or relevant app):
|
||||
```python
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def new_endpoint_view(request):
|
||||
return Response({'data': ...})
|
||||
```
|
||||
2. Add URL pattern in `backend/apps/generation/urls.py`:
|
||||
```python
|
||||
path('new/endpoint', views.new_endpoint_view, name='new_endpoint'),
|
||||
```
|
||||
3. Add TypeScript types in `web/src/types/index.ts`
|
||||
4. Add API call in the relevant Zustand store
|
||||
|
||||
### How to Add a New Database Model
|
||||
|
||||
1. Define model in `backend/apps/generation/models.py` (or relevant app)
|
||||
2. Run migrations:
|
||||
```bash
|
||||
cd backend
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
3. Register in admin: `backend/apps/generation/admin.py`
|
||||
4. Create serializer if needed: `backend/apps/generation/serializers.py`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `DATABASE_URL` | MySQL connection string (prod) | Prod only |
|
||||
| `SECRET_KEY` | Django secret key | Yes |
|
||||
| `TOS_ACCESS_KEY` | Volcano Engine TOS AccessKeyId | Yes (upload) |
|
||||
| `TOS_SECRET_KEY` | Volcano Engine TOS SecretAccessKey | Yes (upload) |
|
||||
| `TOS_BUCKET` | Volcano TOS bucket name (default: `video-huoshan`) | Yes (upload) |
|
||||
| `TOS_ENDPOINT` | TOS endpoint URL (default: `https://tos-cn-guangzhou.volces.com`) | Yes (upload) |
|
||||
| `TOS_REGION` | TOS region (default: `cn-guangzhou`) | Yes (upload) |
|
||||
| `ARK_API_KEY` | Volcano Engine ARK API key for Seedance | Yes (video gen) |
|
||||
| `ARK_BASE_URL` | ARK API base URL (default: `https://ark.cn-beijing.volces.com/api/v3`) | No |
|
||||
|
||||
## Deployment
|
||||
|
||||
- **CI/CD**: Gitea Actions (`.gitea/workflows/deploy.yaml`)
|
||||
- **Registry**: Huawei Cloud SWR
|
||||
- **Orchestration**: Kubernetes (`k8s/` directory)
|
||||
- **Backend URL**: `video-huoshan-api.airlabs.art`
|
||||
- **Frontend URL**: `video-huoshan-web.airlabs.art`
|
||||
- **Database**: Aliyun RDS MySQL (`rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com:3306`)
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit Tests**: 227 tests in `web/` via Vitest — `cd web && npx vitest run`
|
||||
- **E2E Tests**: 49 tests via Playwright — `cd web && npx playwright test`
|
||||
- **Backend**: Django system checks — `cd backend && python manage.py check`
|
||||
- **Status**: 211 passing, 16 pre-existing path-resolution failures in phase2/phase3 tests
|
||||
- **Test DB Isolation**: E2E tests use `TESTING=true` env var → backend writes to `test_db.sqlite3` (not `db.sqlite3`). Playwright auto-starts a test backend on port 8000 with this env var. Dev data is never polluted by tests.
|
||||
|
||||
## Design Guidelines
|
||||
|
||||
- **Color Palette**: `#0a0a0f` (bg), `#111118`, `#16161e`, `#2a2a38`, `#00b8e6` (accent)
|
||||
- **Typography**: Noto Sans SC (Chinese) + Space Grotesk (UI) + JetBrains Mono (code)
|
||||
- **Style**: Linear/Vercel dark theme aesthetic
|
||||
- **Responsive**: `grid-cols-4 max-lg:grid-cols-2`
|
||||
|
||||
## Change History
|
||||
|
||||
| Date | Change | Scope |
|
||||
|------|--------|-------|
|
||||
| 2025-01 | Phase 1: Core video generation UI | Frontend |
|
||||
| 2025-02 | Phase 2: Backend + Auth + Admin panel | Full stack |
|
||||
| 2025-03 | Phase 3: Seconds-based quota, profile page, ECharts dashboard | Full stack |
|
||||
| 2026-03 | Added CLAUDE.md for AI-assisted incremental development | Documentation |
|
||||
| 2026-03-13 | Phase 4: TOS upload + Seedance API integration + DB persistence | Full stack |
|
||||
| 2026-03-13 | Test DB isolation: TESTING=true → test_db.sqlite3, Playwright auto-starts test backend | Backend + Config |
|
||||
| 2026-03-13 | Removed mock data fallback from DashboardPage — charts show real data only | Frontend |
|
||||
|
||||
### Phase 4 Details (2026-03-13)
|
||||
|
||||
**Backend new files:**
|
||||
- `backend/utils/tos_client.py` — Volcano Engine TOS upload via official `tos` SDK
|
||||
- `backend/utils/seedance_client.py` — Seedance/ARK API client (create_task, query_task)
|
||||
|
||||
**Backend changes:**
|
||||
- `backend/config/settings.py` — Added TOS and ARK config
|
||||
- `backend/apps/generation/models.py` — Added `ark_task_id`, `result_url`, `error_message`, `reference_urls` (JSONField)
|
||||
- `backend/apps/generation/views.py` — New endpoints: `upload_media_view`, `video_tasks_list_view`, `video_task_detail_view`; Rewritten `video_generate_view` with Seedance integration
|
||||
- `backend/apps/generation/urls.py` — Added 3 routes: `media/upload`, `video/tasks`, `video/tasks/<uuid>`
|
||||
- `backend/apps/generation/serializers.py` — Added `references` field to VideoGenerateSerializer
|
||||
- `backend/requirements.txt` — Added `tos>=2.7`, `requests>=2.31`
|
||||
- Migration: `0003_generationrecord_ark_task_id_and_more.py`
|
||||
|
||||
**Frontend changes:**
|
||||
- `web/src/types/index.ts` — Added `BackendTask` interface, `tosUrl` to `UploadedFile`, `taskId`/`errorMessage` to `GenerationTask`
|
||||
- `web/src/lib/api.ts` — Added `mediaApi.upload()`, `videoApi.getTasks()`, `videoApi.getTaskStatus()`; Changed `videoApi.generate()` to JSON
|
||||
- `web/src/store/generation.ts` — Complete rewrite: async TOS upload + API calls + polling + `loadTasks()` for refresh persistence
|
||||
- `web/src/components/VideoGenerationPage.tsx` — Added `loadTasks()` on mount
|
||||
- `web/src/components/GenerationCard.tsx` — Video player for results, error state display
|
||||
|
||||
**Flow:** Upload files → TOS → Generate API (Seedance) → DB record → Poll status → Display result
|
||||
@ -30,10 +30,13 @@ def register_view(request):
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@api_view(['GET', 'POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def login_view(request):
|
||||
"""POST /api/v1/auth/login"""
|
||||
"""GET/POST /api/v1/auth/login"""
|
||||
if request.method == 'GET':
|
||||
return Response({'message': 'Use POST to login', 'required_fields': ['username', 'password']})
|
||||
|
||||
username = request.data.get('username', '')
|
||||
password = request.data.get('password', '')
|
||||
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-13 05:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('generation', '0002_alter_quotaconfig_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='generationrecord',
|
||||
name='ark_task_id',
|
||||
field=models.CharField(blank=True, default='', max_length=100, verbose_name='火山ARK任务ID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='generationrecord',
|
||||
name='error_message',
|
||||
field=models.TextField(blank=True, default='', verbose_name='错误信息'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='generationrecord',
|
||||
name='reference_urls',
|
||||
field=models.JSONField(blank=True, default=list, verbose_name='参考素材信息'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='generationrecord',
|
||||
name='result_url',
|
||||
field=models.CharField(blank=True, default='', max_length=1000, verbose_name='生成结果URL'),
|
||||
),
|
||||
]
|
||||
@ -27,6 +27,7 @@ class GenerationRecord(models.Model):
|
||||
verbose_name='用户',
|
||||
)
|
||||
task_id = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='任务ID')
|
||||
ark_task_id = models.CharField(max_length=100, blank=True, default='', verbose_name='火山ARK任务ID')
|
||||
prompt = models.TextField(blank=True, verbose_name='提示词')
|
||||
mode = models.CharField(max_length=20, choices=MODE_CHOICES, verbose_name='创作模式')
|
||||
model = models.CharField(max_length=30, choices=MODEL_CHOICES, verbose_name='模型')
|
||||
@ -34,6 +35,9 @@ class GenerationRecord(models.Model):
|
||||
duration = models.IntegerField(verbose_name='视频时长(秒)')
|
||||
seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
||||
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||||
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -7,6 +7,7 @@ class VideoGenerateSerializer(serializers.Serializer):
|
||||
model = serializers.ChoiceField(choices=['seedance_2.0', 'seedance_2.0_fast'])
|
||||
aspect_ratio = serializers.CharField(max_length=10)
|
||||
duration = serializers.IntegerField()
|
||||
references = serializers.ListField(child=serializers.DictField(), required=False, default=list)
|
||||
|
||||
|
||||
class QuotaUpdateSerializer(serializers.Serializer):
|
||||
|
||||
@ -2,8 +2,12 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Media upload
|
||||
path('media/upload', views.upload_media_view, name='media_upload'),
|
||||
# Video generation
|
||||
path('video/generate', views.video_generate_view, name='video_generate'),
|
||||
path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'),
|
||||
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
|
||||
# Admin: Dashboard
|
||||
path('admin/stats', views.admin_stats_view, name='admin_stats'),
|
||||
# Admin: User management
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import logging
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, permission_classes, parser_classes
|
||||
from rest_framework.parsers import MultiPartParser, JSONParser
|
||||
from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -14,18 +17,92 @@ from .serializers import (
|
||||
UserStatusSerializer, SystemSettingsSerializer,
|
||||
AdminCreateUserSerializer,
|
||||
)
|
||||
from utils.tos_client import upload_file as tos_upload
|
||||
from utils.seedance_client import create_task, query_task, extract_video_url, map_status
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File validation constants
|
||||
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'}
|
||||
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
|
||||
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
|
||||
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Video Generation
|
||||
# Media Upload
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@parser_classes([MultiPartParser])
|
||||
def upload_media_view(request):
|
||||
"""POST /api/v1/media/upload — Upload file to TOS, return public URL."""
|
||||
file = request.FILES.get('file')
|
||||
if not file:
|
||||
return Response({'error': '未上传文件'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ext = file.name.rsplit('.', 1)[-1].lower() if '.' in file.name else ''
|
||||
|
||||
if ext in ALLOWED_IMAGE_EXTS:
|
||||
media_type = 'image'
|
||||
max_size = MAX_IMAGE_SIZE
|
||||
elif ext in ALLOWED_VIDEO_EXTS:
|
||||
media_type = 'video'
|
||||
max_size = MAX_VIDEO_SIZE
|
||||
else:
|
||||
return Response(
|
||||
{'error': f'不支持的文件格式: {ext}'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if file.size > max_size:
|
||||
limit_mb = max_size // (1024 * 1024)
|
||||
return Response(
|
||||
{'error': f'{media_type} 文件不能超过 {limit_mb}MB'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
url = tos_upload(file, folder=media_type)
|
||||
except Exception as e:
|
||||
logger.exception('TOS upload failed')
|
||||
return Response(
|
||||
{'error': f'文件上传失败: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'url': url,
|
||||
'type': media_type,
|
||||
'filename': file.name,
|
||||
'size': file.size,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Video Generation (with Seedance API)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def video_generate_view(request):
|
||||
"""POST /api/v1/video/generate — Phase 3: seconds-based quota"""
|
||||
"""POST /api/v1/video/generate — Create video generation task.
|
||||
|
||||
Accepts JSON:
|
||||
{
|
||||
"prompt": "...",
|
||||
"mode": "universal" | "keyframe",
|
||||
"model": "seedance_2.0" | "seedance_2.0_fast",
|
||||
"aspect_ratio": "16:9",
|
||||
"duration": 10,
|
||||
"references": [
|
||||
{"url": "https://...", "type": "image", "role": "reference_image", "label": "图片1"},
|
||||
{"url": "https://...", "type": "video", "role": "reference_video", "label": "视频1"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
serializer = VideoGenerateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@ -34,6 +111,7 @@ def video_generate_view(request):
|
||||
first_of_month = today.replace(day=1)
|
||||
duration = serializer.validated_data['duration']
|
||||
|
||||
# ── Quota check ──
|
||||
daily_used = user.generation_records.filter(
|
||||
created_at__date=today
|
||||
).aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||
@ -61,26 +139,167 @@ def video_generate_view(request):
|
||||
'monthly_seconds_used': monthly_used,
|
||||
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
|
||||
# ── Build Seedance content items from references ──
|
||||
references = request.data.get('references', [])
|
||||
content_items = []
|
||||
reference_snapshots = []
|
||||
|
||||
for ref in references:
|
||||
url = ref.get('url', '')
|
||||
ref_type = ref.get('type', 'image')
|
||||
role = ref.get('role', '')
|
||||
label = ref.get('label', '')
|
||||
|
||||
reference_snapshots.append({
|
||||
'url': url,
|
||||
'type': ref_type,
|
||||
'role': role,
|
||||
'label': label,
|
||||
})
|
||||
|
||||
if ref_type == 'image':
|
||||
item = {
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': url},
|
||||
}
|
||||
if role:
|
||||
item['role'] = role
|
||||
content_items.append(item)
|
||||
elif ref_type == 'video':
|
||||
item = {
|
||||
'type': 'video_url',
|
||||
'video_url': {'url': url},
|
||||
}
|
||||
if role:
|
||||
item['role'] = role
|
||||
content_items.append(item)
|
||||
|
||||
# ── Create DB record first ──
|
||||
prompt = serializer.validated_data['prompt']
|
||||
mode = serializer.validated_data['mode']
|
||||
model = serializer.validated_data['model']
|
||||
aspect_ratio = serializer.validated_data['aspect_ratio']
|
||||
|
||||
record = GenerationRecord.objects.create(
|
||||
user=user,
|
||||
prompt=serializer.validated_data['prompt'],
|
||||
mode=serializer.validated_data['mode'],
|
||||
model=serializer.validated_data['model'],
|
||||
aspect_ratio=serializer.validated_data['aspect_ratio'],
|
||||
prompt=prompt,
|
||||
mode=mode,
|
||||
model=model,
|
||||
aspect_ratio=aspect_ratio,
|
||||
duration=duration,
|
||||
seconds_consumed=duration,
|
||||
reference_urls=reference_snapshots,
|
||||
)
|
||||
|
||||
# ── Call Seedance API (or skip if not enabled) ──
|
||||
from django.conf import settings as django_settings
|
||||
if django_settings.SEEDANCE_ENABLED and django_settings.ARK_API_KEY:
|
||||
try:
|
||||
ark_response = create_task(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
content_items=content_items,
|
||||
aspect_ratio=aspect_ratio,
|
||||
duration=duration,
|
||||
)
|
||||
ark_task_id = ark_response.get('id', '')
|
||||
record.ark_task_id = ark_task_id
|
||||
record.status = 'processing'
|
||||
record.save(update_fields=['ark_task_id', 'status'])
|
||||
except Exception as e:
|
||||
logger.exception('Seedance API create task failed')
|
||||
record.status = 'failed'
|
||||
record.error_message = str(e)
|
||||
record.save(update_fields=['status', 'error_message'])
|
||||
else:
|
||||
# Seedance not enabled — treat as completed immediately
|
||||
record.status = 'completed'
|
||||
record.save(update_fields=['status'])
|
||||
|
||||
remaining = user.daily_seconds_limit - daily_used - duration
|
||||
return Response({
|
||||
'task_id': str(record.task_id),
|
||||
'status': 'queued',
|
||||
'ark_task_id': record.ark_task_id,
|
||||
'status': record.status,
|
||||
'estimated_time': 120,
|
||||
'seconds_consumed': duration,
|
||||
'remaining_seconds_today': max(remaining, 0),
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Video Tasks: List + Detail (for frontend polling)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def video_tasks_list_view(request):
|
||||
"""GET /api/v1/video/tasks — User's recent generation tasks."""
|
||||
user = request.user
|
||||
page_size = min(int(request.query_params.get('page_size', 50)), 100)
|
||||
|
||||
records = user.generation_records.order_by('-created_at')[:page_size]
|
||||
|
||||
results = []
|
||||
for r in records:
|
||||
results.append(_serialize_task(r))
|
||||
|
||||
return Response({'results': results})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def video_task_detail_view(request, task_id):
|
||||
"""GET /api/v1/video/tasks/<task_id> — Get task status, poll Seedance if active."""
|
||||
try:
|
||||
record = GenerationRecord.objects.get(task_id=task_id, user=request.user)
|
||||
except GenerationRecord.DoesNotExist:
|
||||
return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# If task is still active, poll Seedance API for latest status
|
||||
if record.status in ('queued', 'processing') and record.ark_task_id:
|
||||
try:
|
||||
ark_resp = query_task(record.ark_task_id)
|
||||
new_status = map_status(ark_resp.get('status', ''))
|
||||
record.status = new_status
|
||||
|
||||
if new_status == 'completed':
|
||||
video_url = extract_video_url(ark_resp)
|
||||
if video_url:
|
||||
record.result_url = video_url
|
||||
elif new_status == 'failed':
|
||||
error = ark_resp.get('error', {})
|
||||
record.error_message = (
|
||||
error.get('message', '') if isinstance(error, dict) else str(error)
|
||||
)
|
||||
|
||||
record.save(update_fields=['status', 'result_url', 'error_message'])
|
||||
except Exception as e:
|
||||
logger.exception('Seedance API query failed for %s', record.ark_task_id)
|
||||
|
||||
return Response(_serialize_task(record))
|
||||
|
||||
|
||||
def _serialize_task(record):
|
||||
"""Serialize a GenerationRecord for the frontend."""
|
||||
return {
|
||||
'id': record.id,
|
||||
'task_id': str(record.task_id),
|
||||
'ark_task_id': record.ark_task_id,
|
||||
'prompt': record.prompt,
|
||||
'mode': record.mode,
|
||||
'model': record.model,
|
||||
'aspect_ratio': record.aspect_ratio,
|
||||
'duration': record.duration,
|
||||
'seconds_consumed': record.seconds_consumed,
|
||||
'status': record.status,
|
||||
'result_url': record.result_url,
|
||||
'error_message': record.error_message,
|
||||
'reference_urls': record.reference_urls or [],
|
||||
'created_at': record.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Admin: Dashboard Stats
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@ -62,8 +62,19 @@ TEMPLATES = [
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
# Database configuration
|
||||
# Use MySQL (Aliyun RDS) when USE_MYSQL=true, otherwise SQLite for local dev
|
||||
if os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
|
||||
# TESTING=true → isolated test_db.sqlite3 (never pollutes dev data)
|
||||
# USE_MYSQL=true → Aliyun RDS MySQL (production)
|
||||
# Otherwise → SQLite db.sqlite3 (local dev)
|
||||
TESTING = os.environ.get('TESTING', 'false').lower() in ('true', '1', 'yes')
|
||||
|
||||
if TESTING:
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'test_db.sqlite3',
|
||||
}
|
||||
}
|
||||
elif os.environ.get('USE_MYSQL', 'false').lower() in ('true', '1', 'yes'):
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
@ -136,3 +147,22 @@ STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TOS (Volcano Engine Object Storage)
|
||||
# ──────────────────────────────────────────────
|
||||
TOS_ACCESS_KEY = os.environ.get('TOS_ACCESS_KEY', 'AKLTYjVlOTQwOWFkZDY4NGNhMmFkZGRhODQzMTUwNmIxOTM')
|
||||
TOS_SECRET_KEY = os.environ.get('TOS_SECRET_KEY', 'TlRFMU5EUmtNamxsWlRsaU5HSXdPV0l6TVRBeVpqWTNNR00xT1RZNE0yRQ==')
|
||||
TOS_ENDPOINT = os.environ.get('TOS_ENDPOINT', 'https://tos-cn-guangzhou.volces.com')
|
||||
TOS_S3_ENDPOINT = os.environ.get('TOS_S3_ENDPOINT', 'https://tos-s3-cn-guangzhou.volces.com')
|
||||
TOS_BUCKET = os.environ.get('TOS_BUCKET', 'video-huoshan')
|
||||
TOS_REGION = os.environ.get('TOS_REGION', 'cn-guangzhou')
|
||||
TOS_CDN_DOMAIN = os.environ.get('TOS_CDN_DOMAIN', 'https://video-huoshan.tos-cn-guangzhou.volces.com')
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Seedance API (Volcano Engine ARK)
|
||||
# ──────────────────────────────────────────────
|
||||
ARK_API_KEY = os.environ.get('ARK_API_KEY', '846b6981-9954-4c58-bb39-63079393bdb8')
|
||||
ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
|
||||
# Set to True when Seedance model is activated on ARK platform
|
||||
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'
|
||||
|
||||
@ -4,3 +4,5 @@ djangorestframework-simplejwt>=5.3,<6.0
|
||||
django-cors-headers>=4.3,<5.0
|
||||
mysqlclient>=2.2,<3.0
|
||||
gunicorn>=21.2,<23.0
|
||||
tos>=2.7,<3.0
|
||||
requests>=2.31,<3.0
|
||||
|
||||
@ -57,15 +57,22 @@ def _send(payload):
|
||||
def custom_exception_handler(exc, context):
|
||||
"""DRF custom exception handler with Log Center reporting."""
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework.exceptions import (
|
||||
MethodNotAllowed, NotAuthenticated, AuthenticationFailed,
|
||||
NotFound, PermissionDenied,
|
||||
)
|
||||
|
||||
# Report to Log Center
|
||||
request = context.get('request')
|
||||
report_error(exc, {
|
||||
'view': str(context.get('view', '')),
|
||||
'request_path': getattr(request, 'path', None),
|
||||
'request_method': getattr(request, 'method', None),
|
||||
'user': str(getattr(request, 'user', 'anonymous')),
|
||||
})
|
||||
# Only report unexpected errors, skip normal HTTP client errors
|
||||
if not isinstance(exc, (MethodNotAllowed, NotAuthenticated,
|
||||
AuthenticationFailed, NotFound,
|
||||
PermissionDenied)):
|
||||
request = context.get('request')
|
||||
report_error(exc, {
|
||||
'view': str(context.get('view', '')),
|
||||
'request_path': getattr(request, 'path', None),
|
||||
'request_method': getattr(request, 'method', None),
|
||||
'user': str(getattr(request, 'user', 'anonymous')),
|
||||
})
|
||||
|
||||
# Default DRF handling
|
||||
response = exception_handler(exc, context)
|
||||
|
||||
95
backend/utils/seedance_client.py
Normal file
95
backend/utils/seedance_client.py
Normal file
@ -0,0 +1,95 @@
|
||||
"""Volcano Engine Seedance (ARK) video generation API client."""
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
MODEL_MAP = {
|
||||
'seedance_2.0': 'doubao-seedance-2-0-260128',
|
||||
'seedance_2.0_fast': 'doubao-seedance-2-0-fast-260128',
|
||||
}
|
||||
|
||||
|
||||
def _headers():
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {settings.ARK_API_KEY}',
|
||||
}
|
||||
|
||||
|
||||
def create_task(prompt, model, content_items, aspect_ratio, duration, generate_audio=True):
|
||||
"""Create a video generation task.
|
||||
|
||||
Args:
|
||||
prompt: Text prompt for video generation.
|
||||
model: Model key ('seedance_2.0' or 'seedance_2.0_fast').
|
||||
content_items: List of media content dicts (image_url, video_url, audio_url).
|
||||
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
|
||||
duration: Video duration in seconds.
|
||||
generate_audio: Whether to generate audio with the video.
|
||||
|
||||
Returns:
|
||||
dict: API response with task id and status.
|
||||
"""
|
||||
url = f'{settings.ARK_BASE_URL}/contents/generations/tasks'
|
||||
|
||||
content = []
|
||||
if prompt:
|
||||
content.append({'type': 'text', 'text': prompt})
|
||||
content.extend(content_items)
|
||||
|
||||
payload = {
|
||||
'model': MODEL_MAP.get(model, model),
|
||||
'content': content,
|
||||
'generate_audio': generate_audio,
|
||||
'ratio': aspect_ratio,
|
||||
'duration': duration,
|
||||
'watermark': False,
|
||||
}
|
||||
|
||||
resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def query_task(task_id):
|
||||
"""Query a video generation task by its ARK task ID.
|
||||
|
||||
Returns:
|
||||
dict: Task status, content (video URL on success), usage info.
|
||||
"""
|
||||
url = f'{settings.ARK_BASE_URL}/contents/generations/tasks/{task_id}'
|
||||
resp = requests.get(url, headers=_headers(), timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def extract_video_url(task_response):
|
||||
"""Extract the video URL from a completed task response."""
|
||||
content = task_response.get('content')
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# content could be a list of items or a dict
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if item.get('type') == 'video_url':
|
||||
return item.get('video_url', {}).get('url')
|
||||
elif isinstance(content, dict):
|
||||
if 'video_url' in content:
|
||||
url = content['video_url']
|
||||
return url.get('url') if isinstance(url, dict) else url
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def map_status(ark_status):
|
||||
"""Map ARK task status to our DB status."""
|
||||
mapping = {
|
||||
'running': 'processing',
|
||||
'submitted': 'queued',
|
||||
'queued': 'queued',
|
||||
'succeeded': 'completed',
|
||||
'failed': 'failed',
|
||||
}
|
||||
return mapping.get(ark_status, 'processing')
|
||||
48
backend/utils/tos_client.py
Normal file
48
backend/utils/tos_client.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Volcano Engine TOS file upload utility using official TOS SDK."""
|
||||
|
||||
import uuid
|
||||
import tos
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
CONTENT_TYPE_MAP = {
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||
'webp': 'image/webp', 'gif': 'image/gif', 'bmp': 'image/bmp',
|
||||
'tiff': 'image/tiff',
|
||||
'mp4': 'video/mp4', 'mov': 'video/quicktime',
|
||||
'mp3': 'audio/mpeg', 'wav': 'audio/wav',
|
||||
}
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_tos_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
endpoint = settings.TOS_ENDPOINT.replace('https://', '').replace('http://', '')
|
||||
_client = tos.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY,
|
||||
sk=settings.TOS_SECRET_KEY,
|
||||
endpoint=endpoint,
|
||||
region=settings.TOS_REGION,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def upload_file(file_obj, folder='uploads'):
|
||||
"""Upload a file to TOS bucket, return its public URL."""
|
||||
ext = file_obj.name.rsplit('.', 1)[-1].lower()
|
||||
key = f'{folder}/{uuid.uuid4().hex}.{ext}'
|
||||
content_type = CONTENT_TYPE_MAP.get(ext, 'application/octet-stream')
|
||||
|
||||
client = get_tos_client()
|
||||
content = file_obj.read()
|
||||
|
||||
client.put_object(
|
||||
bucket=settings.TOS_BUCKET,
|
||||
key=key,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
return f'{settings.TOS_CDN_DOMAIN}/{key}'
|
||||
24
web/package-lock.json
generated
24
web/package-lock.json
generated
@ -170,7 +170,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -532,7 +531,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@ -573,7 +571,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@ -1563,7 +1560,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@ -1655,7 +1653,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@ -1667,7 +1664,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@ -1843,6 +1839,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -1853,6 +1850,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@ -1952,7 +1950,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -2212,7 +2209,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@ -2682,7 +2680,6 @@
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
@ -2778,6 +2775,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@ -2931,7 +2929,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -3021,6 +3018,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@ -3035,7 +3033,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@ -3075,7 +3074,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@ -3100,7 +3098,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@ -3596,7 +3593,6 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@ -9,10 +9,20 @@ export default defineConfig({
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30000,
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
// Backend test server with isolated test_db.sqlite3
|
||||
command: 'cd ../backend && source venv/bin/activate && TESTING=true python manage.py migrate --run-syncdb > /dev/null 2>&1 && TESTING=true python manage.py runserver 8000',
|
||||
port: 8000,
|
||||
reuseExistingServer: false,
|
||||
timeout: 30000,
|
||||
env: { TESTING: 'true' },
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@ -82,7 +82,7 @@ export function GenerationCard({ task }: Props) {
|
||||
{isGenerating ? (
|
||||
<div className={styles.generating}>
|
||||
<div className={styles.loadingSpinner} />
|
||||
<span className={styles.loadingText}>视频生成中... {task.progress}%</span>
|
||||
<span className={styles.loadingText}>视频生成中...</span>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
@ -90,8 +90,17 @@ export function GenerationCard({ task }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : task.status === 'failed' ? (
|
||||
<div className={styles.resultPlaceholder}>
|
||||
<span style={{ color: '#e74c3c' }}>{task.errorMessage || '生成失败'}</span>
|
||||
</div>
|
||||
) : task.resultUrl ? (
|
||||
<img src={task.resultUrl} alt="生成结果" className={styles.resultMedia} />
|
||||
<video
|
||||
src={task.resultUrl}
|
||||
controls
|
||||
className={styles.resultMedia}
|
||||
style={{ maxWidth: '100%', borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.resultPlaceholder}>
|
||||
<VideoIcon />
|
||||
|
||||
@ -9,9 +9,15 @@ import styles from './VideoGenerationPage.module.css';
|
||||
|
||||
export function VideoGenerationPage() {
|
||||
const tasks = useGenerationStore((s) => s.tasks);
|
||||
const loadTasks = useGenerationStore((s) => s.loadTasks);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const prevCountRef = useRef(tasks.length);
|
||||
|
||||
// Load tasks from backend on mount (persist across page refresh)
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, [loadTasks]);
|
||||
|
||||
// Auto-scroll to top when new task is added
|
||||
useEffect(() => {
|
||||
if (tasks.length > prevCountRef.current && scrollRef.current) {
|
||||
|
||||
@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios';
|
||||
import type {
|
||||
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
|
||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||
BackendTask,
|
||||
} from '../types';
|
||||
import { reportError } from './logCenter';
|
||||
|
||||
@ -75,18 +76,46 @@ export const authApi = {
|
||||
api.get<User & { quota: Quota }>('/auth/me'),
|
||||
};
|
||||
|
||||
// Media upload API
|
||||
export const mediaApi = {
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post<{
|
||||
url: string;
|
||||
type: 'image' | 'video';
|
||||
filename: string;
|
||||
size: number;
|
||||
}>('/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Video generation API
|
||||
export const videoApi = {
|
||||
generate: (formData: FormData) =>
|
||||
generate: (data: {
|
||||
prompt: string;
|
||||
mode: string;
|
||||
model: string;
|
||||
aspect_ratio: string;
|
||||
duration: number;
|
||||
references: { url: string; type: string; role: string; label: string }[];
|
||||
}) =>
|
||||
api.post<{
|
||||
task_id: string;
|
||||
ark_task_id: string;
|
||||
status: string;
|
||||
estimated_time: number;
|
||||
seconds_consumed: number;
|
||||
remaining_seconds_today: number;
|
||||
}>('/video/generate', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
}>('/video/generate', data),
|
||||
|
||||
getTasks: () =>
|
||||
api.get<{ results: BackendTask[] }>('/video/tasks'),
|
||||
|
||||
getTaskStatus: (taskId: string) =>
|
||||
api.get<BackendTask>(`/video/tasks/${taskId}`),
|
||||
};
|
||||
|
||||
// Admin APIs
|
||||
|
||||
@ -11,33 +11,6 @@ import styles from './DashboardPage.module.css';
|
||||
|
||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
|
||||
|
||||
// Generate mock data for development when backend returns empty
|
||||
function generateMockTrend(): { date: string; seconds: number }[] {
|
||||
const data: { date: string; seconds: number }[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
|
||||
const base = isWeekend ? 1200 : 3000;
|
||||
const variation = Math.random() * 2000 - 800;
|
||||
data.push({
|
||||
date: d.toISOString().slice(0, 10),
|
||||
seconds: Math.max(0, Math.round(base + variation)),
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function generateMockTopUsers(): { user_id: number; username: string; seconds_consumed: number }[] {
|
||||
const names = ['alice', 'bob', 'charlie', 'diana', 'edward', 'fiona', 'george', 'helen', 'ivan', 'julia'];
|
||||
return names.map((name, i) => ({
|
||||
user_id: i + 1,
|
||||
username: name,
|
||||
seconds_consumed: Math.round(5000 - i * 400 + Math.random() * 200),
|
||||
}));
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -45,28 +18,9 @@ export function DashboardPage() {
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await adminApi.getStats();
|
||||
// If daily_trend is all zeros (no real data), use mock
|
||||
const hasRealTrend = data.daily_trend.some((d) => d.seconds > 0);
|
||||
if (!hasRealTrend) {
|
||||
data.daily_trend = generateMockTrend();
|
||||
}
|
||||
if (data.top_users.length === 0) {
|
||||
data.top_users = generateMockTopUsers();
|
||||
}
|
||||
setStats(data);
|
||||
} catch {
|
||||
showToast('加载数据失败');
|
||||
// Use complete mock data
|
||||
setStats({
|
||||
total_users: 1234,
|
||||
new_users_today: 23,
|
||||
seconds_consumed_today: 4560,
|
||||
seconds_consumed_this_month: 89010,
|
||||
today_change_percent: -5.0,
|
||||
month_change_percent: 8.0,
|
||||
daily_trend: generateMockTrend(),
|
||||
top_users: generateMockTopUsers(),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -1,60 +1,162 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GenerationTask, ReferenceSnapshot, UploadedFile } from '../types';
|
||||
import type { GenerationTask, ReferenceSnapshot, UploadedFile, BackendTask } from '../types';
|
||||
import { useInputBarStore } from './inputBar';
|
||||
import { videoApi } from '../lib/api';
|
||||
import { videoApi, mediaApi } from '../lib/api';
|
||||
import { useAuthStore } from './auth';
|
||||
import { showToast } from '../components/Toast';
|
||||
|
||||
let taskCounter = 0;
|
||||
|
||||
interface GenerationState {
|
||||
tasks: GenerationTask[];
|
||||
addTask: () => string | null;
|
||||
removeTask: (id: string) => void;
|
||||
reEdit: (id: string) => void;
|
||||
regenerate: (id: string) => void;
|
||||
// Map backend status to frontend TaskStatus
|
||||
function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' {
|
||||
if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed';
|
||||
if (backendStatus === 'failed') return 'failed';
|
||||
return 'generating';
|
||||
}
|
||||
|
||||
function simulateProgress(taskId: string) {
|
||||
const store = useGenerationStore.getState;
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += Math.random() * 15 + 5;
|
||||
if (progress >= 100) {
|
||||
progress = 100;
|
||||
clearInterval(interval);
|
||||
const task = store().tasks.find((t) => t.id === taskId);
|
||||
// Use the first reference image as mock result, or a placeholder
|
||||
const resultUrl = task?.references.find((r) => r.type === 'image')?.previewUrl;
|
||||
function mapProgress(backendStatus: string): number {
|
||||
if (backendStatus === 'completed') return 100;
|
||||
if (backendStatus === 'failed') return 0;
|
||||
return 50;
|
||||
}
|
||||
|
||||
// Convert a BackendTask to a frontend GenerationTask
|
||||
function backendToFrontend(bt: BackendTask): GenerationTask {
|
||||
const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({
|
||||
id: `ref_${bt.task_id}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video',
|
||||
previewUrl: ref.url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: `backend_${bt.task_id}`,
|
||||
taskId: bt.task_id,
|
||||
prompt: bt.prompt,
|
||||
editorHtml: bt.prompt,
|
||||
mode: bt.mode,
|
||||
model: bt.model,
|
||||
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
|
||||
duration: bt.duration as GenerationTask['duration'],
|
||||
references,
|
||||
status: mapStatus(bt.status),
|
||||
progress: mapProgress(bt.status),
|
||||
resultUrl: bt.result_url || undefined,
|
||||
errorMessage: bt.error_message || undefined,
|
||||
createdAt: new Date(bt.created_at).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
// Active polling timers
|
||||
const pollTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
function startPolling(taskId: string, frontendId: string) {
|
||||
if (pollTimers.has(frontendId)) return;
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await videoApi.getTaskStatus(taskId);
|
||||
const newStatus = mapStatus(data.status);
|
||||
|
||||
useGenerationStore.setState((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === taskId
|
||||
? { ...t, status: 'completed' as const, progress: 100, resultUrl }
|
||||
t.id === frontendId
|
||||
? {
|
||||
...t,
|
||||
status: newStatus,
|
||||
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
|
||||
resultUrl: data.result_url || t.resultUrl,
|
||||
errorMessage: data.error_message || t.errorMessage,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
useGenerationStore.setState((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, progress: Math.round(progress) } : t
|
||||
),
|
||||
}));
|
||||
|
||||
if (newStatus === 'completed' || newStatus === 'failed') {
|
||||
clearInterval(timer);
|
||||
pollTimers.delete(frontendId);
|
||||
if (newStatus === 'completed') {
|
||||
useAuthStore.getState().fetchUserInfo();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently continue polling on error
|
||||
}
|
||||
}, 400);
|
||||
}, 5000);
|
||||
|
||||
pollTimers.set(frontendId, timer);
|
||||
}
|
||||
|
||||
function stopPolling(frontendId: string) {
|
||||
const timer = pollTimers.get(frontendId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
pollTimers.delete(frontendId);
|
||||
}
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
tasks: GenerationTask[];
|
||||
isLoading: boolean;
|
||||
addTask: () => Promise<string | null>;
|
||||
removeTask: (id: string) => void;
|
||||
reEdit: (id: string) => void;
|
||||
regenerate: (id: string) => void;
|
||||
loadTasks: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
|
||||
addTask: () => {
|
||||
loadTasks: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const { data } = await videoApi.getTasks();
|
||||
const tasks = data.results.map(backendToFrontend);
|
||||
set({ tasks, isLoading: false });
|
||||
|
||||
// Start polling for any active tasks
|
||||
for (const task of tasks) {
|
||||
if (task.status === 'generating' && task.taskId) {
|
||||
startPolling(task.taskId, task.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addTask: async () => {
|
||||
const input = useInputBarStore.getState();
|
||||
if (!input.canSubmit()) return null;
|
||||
|
||||
taskCounter++;
|
||||
const id = `task_${taskCounter}_${Date.now()}`;
|
||||
// Collect files to upload (or existing TOS URLs for regeneration)
|
||||
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video'; role: string; label: string }[] = [];
|
||||
|
||||
// Snapshot references
|
||||
const references: ReferenceSnapshot[] =
|
||||
if (input.mode === 'universal') {
|
||||
for (const ref of input.references) {
|
||||
if (ref.tosUrl) {
|
||||
// Already uploaded to TOS (regeneration)
|
||||
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
|
||||
} else if (ref.file) {
|
||||
filesToUpload.push({ file: ref.file, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (input.firstFrame?.tosUrl) {
|
||||
filesToUpload.push({ tosUrl: input.firstFrame.tosUrl, type: 'image', role: 'first_frame', label: '首帧' });
|
||||
} else if (input.firstFrame?.file) {
|
||||
filesToUpload.push({ file: input.firstFrame.file, type: 'image', role: 'first_frame', label: '首帧' });
|
||||
}
|
||||
if (input.lastFrame?.tosUrl) {
|
||||
filesToUpload.push({ tosUrl: input.lastFrame.tosUrl, type: 'image', role: 'last_frame', label: '尾帧' });
|
||||
} else if (input.lastFrame?.file) {
|
||||
filesToUpload.push({ file: input.lastFrame.file, type: 'image', role: 'last_frame', label: '尾帧' });
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot references for local display before uploading
|
||||
const localRefs: ReferenceSnapshot[] =
|
||||
input.mode === 'universal'
|
||||
? input.references.map((r) => ({
|
||||
id: r.id,
|
||||
@ -77,23 +179,26 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
},
|
||||
].filter(Boolean) as ReferenceSnapshot[];
|
||||
|
||||
const task: GenerationTask = {
|
||||
id,
|
||||
// Create a placeholder task immediately for UI feedback
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const placeholderTask: GenerationTask = {
|
||||
id: tempId,
|
||||
taskId: '',
|
||||
prompt: input.prompt,
|
||||
editorHtml: input.editorHtml,
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
aspectRatio: input.aspectRatio,
|
||||
duration: input.duration,
|
||||
references,
|
||||
references: localRefs,
|
||||
status: 'generating',
|
||||
progress: 0,
|
||||
progress: 5,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
set((s) => ({ tasks: [task, ...s.tasks] }));
|
||||
set((s) => ({ tasks: [placeholderTask, ...s.tasks] }));
|
||||
|
||||
// Clear input after submit (don't revoke URLs since task snapshots reference them)
|
||||
// Clear input
|
||||
useInputBarStore.setState({
|
||||
prompt: '',
|
||||
editorHtml: '',
|
||||
@ -102,30 +207,100 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
lastFrame: null,
|
||||
});
|
||||
|
||||
// Start mock progress (frontend simulation)
|
||||
simulateProgress(id);
|
||||
try {
|
||||
// Upload files to TOS (or reuse existing TOS URLs)
|
||||
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
|
||||
|
||||
// Call backend API to record the generation (fire-and-forget)
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', input.prompt);
|
||||
formData.append('mode', input.mode);
|
||||
formData.append('model', input.model);
|
||||
formData.append('aspect_ratio', input.aspectRatio);
|
||||
formData.append('duration', String(input.duration));
|
||||
for (const item of filesToUpload) {
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === tempId ? { ...t, progress: Math.min(t.progress + 10, 40) } : t
|
||||
),
|
||||
}));
|
||||
|
||||
videoApi.generate(formData).then(() => {
|
||||
// Refresh quota info after successful generation
|
||||
useAuthStore.getState().fetchUserInfo();
|
||||
}).catch((err) => {
|
||||
if (err.response?.status === 429) {
|
||||
showToast(err.response.data.message || '今日额度已用完');
|
||||
if (item.tosUrl) {
|
||||
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
|
||||
} else if (item.file) {
|
||||
const { data: uploadResult } = await mediaApi.upload(item.file);
|
||||
uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return id;
|
||||
// Update progress: files uploaded
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === tempId ? { ...t, progress: 50 } : t
|
||||
),
|
||||
}));
|
||||
|
||||
// Call generate API
|
||||
const { data: genResult } = await videoApi.generate({
|
||||
prompt: input.prompt,
|
||||
mode: input.mode,
|
||||
model: input.model,
|
||||
aspect_ratio: input.aspectRatio,
|
||||
duration: input.duration,
|
||||
references: uploadedRefs,
|
||||
});
|
||||
|
||||
// Update task with real backend IDs
|
||||
const frontendId = `backend_${genResult.task_id}`;
|
||||
const taskStatus = mapStatus(genResult.status);
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === tempId
|
||||
? {
|
||||
...t,
|
||||
id: frontendId,
|
||||
taskId: genResult.task_id,
|
||||
status: taskStatus as GenerationTask['status'],
|
||||
progress: taskStatus === 'completed' ? 100 : 60,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}));
|
||||
|
||||
// Update reference previews with TOS URLs (persist on refresh)
|
||||
const updatedRefs = localRefs.map((ref, i) => ({
|
||||
...ref,
|
||||
previewUrl: uploadedRefs[i]?.url || ref.previewUrl,
|
||||
}));
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === frontendId ? { ...t, references: updatedRefs } : t
|
||||
),
|
||||
}));
|
||||
|
||||
// Start polling only if task is still generating
|
||||
if (taskStatus === 'generating') {
|
||||
startPolling(genResult.task_id, frontendId);
|
||||
}
|
||||
|
||||
// Refresh quota
|
||||
useAuthStore.getState().fetchUserInfo();
|
||||
|
||||
return frontendId;
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { message?: string } } };
|
||||
if (error.response?.status === 429) {
|
||||
showToast(error.response.data?.message || '今日额度已用完');
|
||||
} else {
|
||||
showToast('生成失败,请重试');
|
||||
}
|
||||
|
||||
// Mark task as failed
|
||||
set((s) => ({
|
||||
tasks: s.tasks.map((t) =>
|
||||
t.id === tempId ? { ...t, status: 'failed' as const, progress: 0 } : t
|
||||
),
|
||||
}));
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
removeTask: (id) => {
|
||||
stopPolling(id);
|
||||
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
|
||||
},
|
||||
|
||||
@ -135,12 +310,10 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
|
||||
const inputStore = useInputBarStore.getState();
|
||||
|
||||
// Switch mode first
|
||||
if (inputStore.mode !== task.mode) {
|
||||
inputStore.switchMode(task.mode);
|
||||
}
|
||||
|
||||
// Restore references from task snapshot (without original File)
|
||||
const references: UploadedFile[] = task.references.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
@ -148,7 +321,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
label: r.label,
|
||||
}));
|
||||
|
||||
// Set prompt, editorHtml, settings, and references
|
||||
useInputBarStore.setState({
|
||||
prompt: task.prompt,
|
||||
editorHtml: task.editorHtml || task.prompt,
|
||||
@ -162,18 +334,33 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
const task = get().tasks.find((t) => t.id === id);
|
||||
if (!task) return;
|
||||
|
||||
taskCounter++;
|
||||
const newId = `task_${taskCounter}_${Date.now()}`;
|
||||
const newTask: GenerationTask = {
|
||||
...task,
|
||||
id: newId,
|
||||
status: 'generating',
|
||||
progress: 0,
|
||||
resultUrl: undefined,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
// Restore task data into input bar and trigger addTask
|
||||
const inputStore = useInputBarStore.getState();
|
||||
|
||||
set((s) => ({ tasks: [newTask, ...s.tasks] }));
|
||||
simulateProgress(newId);
|
||||
if (inputStore.mode !== task.mode) {
|
||||
inputStore.switchMode(task.mode);
|
||||
}
|
||||
|
||||
// For regeneration, we need to re-submit with the same TOS URLs
|
||||
// Set up the input bar state, then call addTask
|
||||
const references: UploadedFile[] = task.references.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
previewUrl: r.previewUrl,
|
||||
label: r.label,
|
||||
tosUrl: r.previewUrl, // TOS URL from previous upload
|
||||
}));
|
||||
|
||||
useInputBarStore.setState({
|
||||
prompt: task.prompt,
|
||||
editorHtml: task.editorHtml || task.prompt,
|
||||
model: task.model,
|
||||
aspectRatio: task.aspectRatio,
|
||||
duration: task.duration,
|
||||
references: task.mode === 'universal' ? references : [],
|
||||
});
|
||||
|
||||
// Trigger generation
|
||||
get().addTask();
|
||||
},
|
||||
}));
|
||||
|
||||
@ -10,6 +10,7 @@ export interface UploadedFile {
|
||||
type: 'image' | 'video';
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
tosUrl?: string; // TOS URL after upload
|
||||
}
|
||||
|
||||
export interface DropdownOption<T = string> {
|
||||
@ -26,10 +27,12 @@ export interface ReferenceSnapshot {
|
||||
type: 'image' | 'video';
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface GenerationTask {
|
||||
id: string;
|
||||
taskId: string; // backend UUID task_id
|
||||
prompt: string;
|
||||
editorHtml: string;
|
||||
mode: CreationMode;
|
||||
@ -40,9 +43,27 @@ export interface GenerationTask {
|
||||
status: TaskStatus;
|
||||
progress: number;
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface BackendTask {
|
||||
id: number;
|
||||
task_id: string;
|
||||
ark_task_id: string;
|
||||
prompt: string;
|
||||
mode: CreationMode;
|
||||
model: ModelOption;
|
||||
aspect_ratio: string;
|
||||
duration: number;
|
||||
seconds_consumed: number;
|
||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
||||
result_url: string;
|
||||
error_message: string;
|
||||
reference_urls: { url: string; type: string; role: string; label: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface User {
|
||||
id: number;
|
||||
|
||||
@ -10,6 +10,26 @@ vi.stubGlobal('localStorage', {
|
||||
key: vi.fn(() => null),
|
||||
});
|
||||
|
||||
const mockGenerate = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
task_id: 'test-uuid-001',
|
||||
ark_task_id: '',
|
||||
status: 'queued',
|
||||
estimated_time: 120,
|
||||
seconds_consumed: 5,
|
||||
remaining_seconds_today: 595,
|
||||
},
|
||||
});
|
||||
|
||||
const mockUpload = vi.fn().mockResolvedValue({
|
||||
data: { url: 'https://tos.example.com/image/test.jpg', type: 'image', filename: 'test.jpg', size: 1234 },
|
||||
});
|
||||
|
||||
const mockGetTasks = vi.fn().mockResolvedValue({ data: { results: [] } });
|
||||
const mockGetTaskStatus = vi.fn().mockResolvedValue({
|
||||
data: { task_id: 'test-uuid-001', status: 'queued', result_url: '', error_message: '', reference_urls: [] },
|
||||
});
|
||||
|
||||
// Mock auth API to prevent network calls from auth store
|
||||
vi.mock('../../src/lib/api', () => ({
|
||||
authApi: {
|
||||
@ -18,7 +38,14 @@ vi.mock('../../src/lib/api', () => ({
|
||||
refreshToken: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
},
|
||||
videoApi: { generate: vi.fn().mockResolvedValue({ data: { remaining_quota: 45 } }) },
|
||||
videoApi: {
|
||||
generate: (...args: unknown[]) => mockGenerate(...args),
|
||||
getTasks: (...args: unknown[]) => mockGetTasks(...args),
|
||||
getTaskStatus: (...args: unknown[]) => mockGetTaskStatus(...args),
|
||||
},
|
||||
mediaApi: {
|
||||
upload: (...args: unknown[]) => mockUpload(...args),
|
||||
},
|
||||
adminApi: { getStats: vi.fn(), getUserRankings: vi.fn(), updateUserQuota: vi.fn() },
|
||||
default: { interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } } },
|
||||
}));
|
||||
@ -27,6 +54,19 @@ vi.mock('../../src/components/Toast', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetchUserInfo to prevent network calls
|
||||
vi.mock('../../src/store/auth', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
useAuthStore: Object.assign(
|
||||
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||
selector({ fetchUserInfo: vi.fn() }),
|
||||
{ getState: () => ({ fetchUserInfo: vi.fn() }) }
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { useGenerationStore } from '../../src/store/generation';
|
||||
import { useInputBarStore } from '../../src/store/inputBar';
|
||||
|
||||
@ -38,7 +78,26 @@ describe('Generation Store', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useInputBarStore.getState().reset();
|
||||
useGenerationStore.setState({ tasks: [] });
|
||||
useGenerationStore.setState({ tasks: [], isLoading: false });
|
||||
mockGenerate.mockClear();
|
||||
mockUpload.mockClear();
|
||||
mockGetTasks.mockClear();
|
||||
mockGetTaskStatus.mockClear();
|
||||
// Reset generate mock to return unique IDs
|
||||
let callCount = 0;
|
||||
mockGenerate.mockImplementation(() => {
|
||||
callCount++;
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
task_id: `test-uuid-${String(callCount).padStart(3, '0')}`,
|
||||
ark_task_id: '',
|
||||
status: 'queued',
|
||||
estimated_time: 120,
|
||||
seconds_consumed: 5,
|
||||
remaining_seconds_today: 595,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -46,145 +105,192 @@ describe('Generation Store', () => {
|
||||
});
|
||||
|
||||
describe('addTask', () => {
|
||||
it('should return null when canSubmit is false', () => {
|
||||
const result = useGenerationStore.getState().addTask();
|
||||
it('should return null when canSubmit is false', async () => {
|
||||
const result = await useGenerationStore.getState().addTask();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should create a task when prompt has text', () => {
|
||||
it('should create a task when prompt has text', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('test prompt');
|
||||
const id = useGenerationStore.getState().addTask();
|
||||
const id = await useGenerationStore.getState().addTask();
|
||||
expect(id).not.toBeNull();
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(1);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should create task with correct properties', () => {
|
||||
it('should create task with correct properties', async () => {
|
||||
useInputBarStore.getState().setPrompt('a beautiful scene');
|
||||
useInputBarStore.getState().setModel('seedance_2.0_fast');
|
||||
useInputBarStore.getState().setAspectRatio('16:9');
|
||||
useInputBarStore.getState().setDuration(10);
|
||||
|
||||
useGenerationStore.getState().addTask();
|
||||
// Placeholder task is created synchronously
|
||||
const promise = useGenerationStore.getState().addTask();
|
||||
const task = useGenerationStore.getState().tasks[0];
|
||||
|
||||
expect(task.prompt).toBe('a beautiful scene');
|
||||
expect(task.model).toBe('seedance_2.0_fast');
|
||||
expect(task.aspectRatio).toBe('16:9');
|
||||
expect(task.duration).toBe(10);
|
||||
expect(task.mode).toBe('universal');
|
||||
expect(task.status).toBe('generating');
|
||||
expect(task.progress).toBe(0);
|
||||
|
||||
vi.useRealTimers();
|
||||
await promise;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should snapshot references in universal mode', () => {
|
||||
it('should snapshot references in universal mode', async () => {
|
||||
useInputBarStore.getState().addReferences([
|
||||
createMockFile('img1.jpg', 'image/jpeg'),
|
||||
createMockFile('img2.jpg', 'image/jpeg'),
|
||||
]);
|
||||
|
||||
useGenerationStore.getState().addTask();
|
||||
const promise = useGenerationStore.getState().addTask();
|
||||
const task = useGenerationStore.getState().tasks[0];
|
||||
|
||||
expect(task.references).toHaveLength(2);
|
||||
expect(task.references[0].type).toBe('image');
|
||||
|
||||
vi.useRealTimers();
|
||||
await promise;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should snapshot frames in keyframe mode', () => {
|
||||
it('should snapshot frames in keyframe mode', async () => {
|
||||
useInputBarStore.getState().switchMode('keyframe');
|
||||
useInputBarStore.getState().setFirstFrame(createMockFile('first.jpg', 'image/jpeg'));
|
||||
useInputBarStore.getState().setLastFrame(createMockFile('last.jpg', 'image/jpeg'));
|
||||
|
||||
useGenerationStore.getState().addTask();
|
||||
const promise = useGenerationStore.getState().addTask();
|
||||
const task = useGenerationStore.getState().tasks[0];
|
||||
|
||||
expect(task.references).toHaveLength(2);
|
||||
expect(task.references[0].label).toBe('首帧');
|
||||
expect(task.references[1].label).toBe('尾帧');
|
||||
|
||||
vi.useRealTimers();
|
||||
await promise;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should clear input after submit', () => {
|
||||
it('should clear input after submit', async () => {
|
||||
useInputBarStore.getState().setPrompt('test');
|
||||
useInputBarStore.getState().addReferences([createMockFile('img.jpg', 'image/jpeg')]);
|
||||
|
||||
useGenerationStore.getState().addTask();
|
||||
|
||||
const promise = useGenerationStore.getState().addTask();
|
||||
// Input is cleared synchronously after placeholder creation
|
||||
expect(useInputBarStore.getState().prompt).toBe('');
|
||||
expect(useInputBarStore.getState().references).toHaveLength(0);
|
||||
|
||||
vi.useRealTimers();
|
||||
await promise;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should prepend new tasks (newest first)', () => {
|
||||
it('should prepend new tasks (newest first)', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('first');
|
||||
useGenerationStore.getState().addTask();
|
||||
await useGenerationStore.getState().addTask();
|
||||
|
||||
useInputBarStore.getState().setPrompt('second');
|
||||
useGenerationStore.getState().addTask();
|
||||
await useGenerationStore.getState().addTask();
|
||||
|
||||
const tasks = useGenerationStore.getState().tasks;
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0].prompt).toBe('second');
|
||||
expect(tasks[1].prompt).toBe('first');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should simulate progress over time', () => {
|
||||
useInputBarStore.getState().setPrompt('test');
|
||||
useGenerationStore.getState().addTask();
|
||||
it('should call videoApi.generate with correct data', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('test prompt');
|
||||
useInputBarStore.getState().setModel('seedance_2.0');
|
||||
useInputBarStore.getState().setAspectRatio('16:9');
|
||||
useInputBarStore.getState().setDuration(10);
|
||||
|
||||
// Advance timers to trigger progress
|
||||
vi.advanceTimersByTime(2000);
|
||||
await useGenerationStore.getState().addTask();
|
||||
|
||||
const task = useGenerationStore.getState().tasks[0];
|
||||
expect(task.progress).toBeGreaterThan(0);
|
||||
expect(mockGenerate).toHaveBeenCalledWith({
|
||||
prompt: 'test prompt',
|
||||
mode: 'universal',
|
||||
model: 'seedance_2.0',
|
||||
aspect_ratio: '16:9',
|
||||
duration: 10,
|
||||
references: [],
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should upload files to TOS before generating', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().addReferences([
|
||||
createMockFile('img.jpg', 'image/jpeg'),
|
||||
]);
|
||||
|
||||
await useGenerationStore.getState().addTask();
|
||||
|
||||
expect(mockUpload).toHaveBeenCalledTimes(1);
|
||||
expect(mockGenerate).toHaveBeenCalledTimes(1);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTask', () => {
|
||||
it('should remove a task by id', () => {
|
||||
it('should remove a task by id', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('test');
|
||||
const id = useGenerationStore.getState().addTask()!;
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(1);
|
||||
const id = await useGenerationStore.getState().addTask();
|
||||
|
||||
useGenerationStore.getState().removeTask(id);
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(1);
|
||||
useGenerationStore.getState().removeTask(id!);
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(0);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should not affect other tasks', () => {
|
||||
it('should not affect other tasks', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('first');
|
||||
const id1 = useGenerationStore.getState().addTask()!;
|
||||
const id1 = await useGenerationStore.getState().addTask();
|
||||
|
||||
useInputBarStore.getState().setPrompt('second');
|
||||
useGenerationStore.getState().addTask();
|
||||
await useGenerationStore.getState().addTask();
|
||||
|
||||
useGenerationStore.getState().removeTask(id1);
|
||||
useGenerationStore.getState().removeTask(id1!);
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(1);
|
||||
expect(useGenerationStore.getState().tasks[0].prompt).toBe('second');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reEdit', () => {
|
||||
it('should restore prompt from task', () => {
|
||||
it('should restore prompt from task', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('original prompt');
|
||||
const id = useGenerationStore.getState().addTask()!;
|
||||
const id = await useGenerationStore.getState().addTask();
|
||||
|
||||
// Input is cleared after submit
|
||||
expect(useInputBarStore.getState().prompt).toBe('');
|
||||
|
||||
useGenerationStore.getState().reEdit(id);
|
||||
useGenerationStore.getState().reEdit(id!);
|
||||
expect(useInputBarStore.getState().prompt).toBe('original prompt');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should restore settings from task', () => {
|
||||
it('should restore settings from task', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('test');
|
||||
useInputBarStore.getState().setAspectRatio('16:9');
|
||||
useInputBarStore.getState().setDuration(10);
|
||||
const id = useGenerationStore.getState().addTask()!;
|
||||
const id = await useGenerationStore.getState().addTask();
|
||||
|
||||
// Reset to defaults
|
||||
useInputBarStore.getState().setAspectRatio('21:9');
|
||||
useInputBarStore.getState().setDuration(15);
|
||||
|
||||
useGenerationStore.getState().reEdit(id);
|
||||
useGenerationStore.getState().reEdit(id!);
|
||||
expect(useInputBarStore.getState().aspectRatio).toBe('16:9');
|
||||
expect(useInputBarStore.getState().duration).toBe(10);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should do nothing for non-existent task', () => {
|
||||
@ -195,19 +301,22 @@ describe('Generation Store', () => {
|
||||
});
|
||||
|
||||
describe('regenerate', () => {
|
||||
it('should create a new task based on existing one', () => {
|
||||
it('should create a new task based on existing one', async () => {
|
||||
vi.useRealTimers();
|
||||
useInputBarStore.getState().setPrompt('test');
|
||||
useGenerationStore.getState().addTask();
|
||||
const originalId = await useGenerationStore.getState().addTask();
|
||||
|
||||
const originalId = useGenerationStore.getState().tasks[0].id;
|
||||
useGenerationStore.getState().regenerate(originalId);
|
||||
useGenerationStore.getState().regenerate(originalId!);
|
||||
// Allow time for the async regenerate to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(2);
|
||||
const newTask = useGenerationStore.getState().tasks[0]; // newest first
|
||||
const tasks = useGenerationStore.getState().tasks;
|
||||
expect(tasks.length).toBeGreaterThanOrEqual(2);
|
||||
const newTask = tasks[0]; // newest first
|
||||
expect(newTask.id).not.toBe(originalId);
|
||||
expect(newTask.prompt).toBe('test');
|
||||
expect(newTask.status).toBe('generating');
|
||||
expect(newTask.progress).toBe(0);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should do nothing for non-existent task', () => {
|
||||
@ -215,4 +324,48 @@ describe('Generation Store', () => {
|
||||
expect(useGenerationStore.getState().tasks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTasks', () => {
|
||||
it('should fetch tasks from backend on load', async () => {
|
||||
vi.useRealTimers();
|
||||
mockGetTasks.mockResolvedValueOnce({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
task_id: 'uuid-from-db',
|
||||
ark_task_id: 'ark-123',
|
||||
prompt: 'loaded prompt',
|
||||
mode: 'universal',
|
||||
model: 'seedance_2.0',
|
||||
aspect_ratio: '16:9',
|
||||
duration: 10,
|
||||
seconds_consumed: 10,
|
||||
status: 'completed',
|
||||
result_url: 'https://example.com/video.mp4',
|
||||
error_message: '',
|
||||
reference_urls: [],
|
||||
created_at: '2026-03-13T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await useGenerationStore.getState().loadTasks();
|
||||
|
||||
const tasks = useGenerationStore.getState().tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].prompt).toBe('loaded prompt');
|
||||
expect(tasks[0].status).toBe('completed');
|
||||
expect(tasks[0].resultUrl).toBe('https://example.com/video.mp4');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should set isLoading state', async () => {
|
||||
vi.useRealTimers();
|
||||
await useGenerationStore.getState().loadTasks();
|
||||
expect(useGenerationStore.getState().isLoading).toBe(false);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user