Merge pull request #46 from ljquan/feat/image-video-update
feat: 添加 Sora-2 故事场景模式和角色提取复用功能
This commit is contained in:
37 files changed
+6057
-150
No files matched your search
@@ -2,4 +2,4 @@
|
||||
"version": "0.3.2",
|
||||
"buildTime": "2025-12-23T08:04:24.882Z",
|
||||
"gitCommit": "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
# Sora-2 角色创建功能开发计划
|
||||
|
||||
## 功能概述
|
||||
|
||||
为 Sora-2 视频模型添加角色创建和复用功能,用户可以从已完成的视频任务中提取角色,并在后续视频生成中通过 `@username` 方式引用该角色。
|
||||
|
||||
## API 接口说明
|
||||
|
||||
### 1. 创建角色接口
|
||||
|
||||
- **URL**: `POST https://api.tu-zi.com/v1/videos`
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `character_from_task` | string | 是 | 源视频任务ID,格式: `sora-2:task_xxx` |
|
||||
| `model` | string | 是 | 固定值: `sora-2-character` |
|
||||
| `chacter_timestamps` | string | 否 | 时间范围,格式: `start,end`,差值需在1-3秒内 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": "sora-2-character:ch_6944ed3a51888191bd1ac40c763620fb"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 查询角色接口
|
||||
|
||||
- **URL**: `GET https://api.tu-zi.com/v1/videos/{id}`
|
||||
- **认证**: Bearer Token
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"id": "sora-2-character:ch_6944ed3a51888191bd1ac40c763620fb",
|
||||
"username": "xscjkajd.lunaradian",
|
||||
"permalink": "https://sora.chatgpt.com/profile/xscjkajd.lunaradian",
|
||||
"profile_picture_url": "https://filesystem.site/cdn/xxx.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
## 开发任务清单
|
||||
|
||||
### 阶段一:基础架构(预计 2-3 天)
|
||||
|
||||
#### Task 1: 创建角色类型定义
|
||||
- **文件**: `packages/drawnix/src/types/character.types.ts`
|
||||
- **内容**:
|
||||
- `SoraCharacter` 接口(角色完整信息)
|
||||
- `CharacterStatus` 枚举(pending/processing/completed/failed)
|
||||
- `CreateCharacterParams` 接口(创建参数)
|
||||
- `CharacterQueryResponse` 接口(查询响应)
|
||||
|
||||
#### Task 2: 实现角色 API 服务
|
||||
- **文件**: `packages/drawnix/src/services/character-api-service.ts`
|
||||
- **功能**:
|
||||
- `createCharacter()` - 提交角色创建请求
|
||||
- `queryCharacter()` - 查询角色状态
|
||||
- `createCharacterWithPolling()` - 创建并轮询直到完成
|
||||
- **依赖**: Task 1
|
||||
|
||||
#### Task 3: 实现角色存储服务
|
||||
- **文件**: `packages/drawnix/src/services/character-storage-service.ts`
|
||||
- **功能**:
|
||||
- IndexedDB 存储(使用 localforage)
|
||||
- `saveCharacter()` - 保存角色
|
||||
- `getCharacters()` - 获取所有角色
|
||||
- `getCharacterById()` - 按ID获取
|
||||
- `deleteCharacter()` - 删除角色
|
||||
- `updateCharacter()` - 更新角色状态
|
||||
- **存储键**: `sora-characters`
|
||||
- **依赖**: Task 1
|
||||
|
||||
#### Task 4: 创建角色状态管理 Hook
|
||||
- **文件**: `packages/drawnix/src/hooks/useCharacters.ts`
|
||||
- **功能**:
|
||||
- 角色列表状态管理(RxJS BehaviorSubject)
|
||||
- `characters` - 角色列表
|
||||
- `pendingCharacters` - 处理中的角色
|
||||
- `createCharacter()` - 创建角色(带状态更新)
|
||||
- `deleteCharacter()` - 删除角色
|
||||
- `refreshCharacters()` - 刷新列表
|
||||
- **依赖**: Task 2, Task 3
|
||||
|
||||
### 阶段二:UI 组件(预计 2-3 天)
|
||||
|
||||
#### Task 5: 实现角色创建对话框
|
||||
- **文件**: `packages/drawnix/src/components/character/CharacterCreateDialog.tsx`
|
||||
- **功能**:
|
||||
- 显示源视频信息
|
||||
- 时间范围选择器(滑块或输入框)
|
||||
- 时间约束验证(1-3秒)
|
||||
- 提交/取消按钮
|
||||
- 创建中状态显示
|
||||
- **UI**: 使用 TDesign Dialog + Slider/InputNumber
|
||||
- **依赖**: Task 4
|
||||
|
||||
#### Task 6: 在 TaskItem 中添加「提取角色」按钮
|
||||
- **文件**: `packages/drawnix/src/components/task-queue/TaskItem.tsx`
|
||||
- **修改**:
|
||||
- 添加「提取角色」按钮(仅对 sora-2 已完成任务显示)
|
||||
- 添加 `onExtractCharacter` 回调
|
||||
- 按钮条件判断逻辑
|
||||
- **依赖**: Task 5
|
||||
|
||||
#### Task 7: 实现角色卡片组件
|
||||
- **文件**: `packages/drawnix/src/components/character/CharacterCard.tsx`
|
||||
- **功能**:
|
||||
- 显示头像(profile_picture_url)
|
||||
- 显示 @username
|
||||
- 复制按钮(复制 @username)
|
||||
- 删除按钮
|
||||
- 处理中状态(loading)
|
||||
- **样式**: `character.scss`
|
||||
- **依赖**: Task 1
|
||||
|
||||
#### Task 8: 实现角色列表/选择器组件
|
||||
- **文件**: `packages/drawnix/src/components/character/CharacterList.tsx`
|
||||
- **功能**:
|
||||
- 显示所有已创建的角色
|
||||
- 支持选择角色(用于插入提示词)
|
||||
- 空状态提示
|
||||
- 可选:搜索过滤
|
||||
- **依赖**: Task 4, Task 7
|
||||
|
||||
### 阶段三:提示词集成(预计 1-2 天)
|
||||
|
||||
#### Task 9: 在 PromptInput 中集成 @ 提及功能
|
||||
- **文件**: `packages/drawnix/src/components/ttd-dialog/shared/PromptInput.tsx`
|
||||
- **修改**:
|
||||
- 监听 `@` 输入
|
||||
- 弹出角色选择器(Popup/Dropdown)
|
||||
- 选择后插入 `@username`
|
||||
- 支持键盘导航(上下选择,回车确认)
|
||||
- **新文件**: `CharacterMentionPopup.tsx`
|
||||
- **依赖**: Task 4, Task 8
|
||||
|
||||
### 阶段四:状态同步与优化(预计 1 天)
|
||||
|
||||
#### Task 10: 页面刷新恢复
|
||||
- 从 IndexedDB 恢复角色列表
|
||||
- 恢复处理中的角色创建任务
|
||||
- 继续轮询未完成的角色
|
||||
|
||||
#### Task 11: 错误处理与用户反馈
|
||||
- API 错误提示(网络错误、超时、服务器错误)
|
||||
- 角色创建失败处理
|
||||
- 重试机制
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
packages/drawnix/src/
|
||||
├── types/
|
||||
│ └── character.types.ts # 角色类型定义
|
||||
├── services/
|
||||
│ ├── character-api-service.ts # 角色 API 服务
|
||||
│ └── character-storage-service.ts # 角色存储服务
|
||||
├── hooks/
|
||||
│ └── useCharacters.ts # 角色状态管理 Hook
|
||||
├── components/
|
||||
│ ├── character/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── CharacterCreateDialog.tsx
|
||||
│ │ ├── CharacterCard.tsx
|
||||
│ │ ├── CharacterList.tsx
|
||||
│ │ ├── CharacterMentionPopup.tsx
|
||||
│ │ └── character.scss
|
||||
│ └── task-queue/
|
||||
│ └── TaskItem.tsx # 修改:添加提取角色按钮
|
||||
└── components/ttd-dialog/shared/
|
||||
└── PromptInput.tsx # 修改:添加 @ 提及功能
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户交互流程 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 用户点击「提取角色」 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 2. CharacterCreateDialog 弹出 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 3. 用户选择时间范围 → 点击创建 │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 4. useCharacters.createCharacter() │
|
||||
│ │ │
|
||||
│ ├──► characterApiService.createCharacter() │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ POST /v1/videos (character_from_task, model) │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ 返回 character_id │
|
||||
│ │ │
|
||||
│ ├──► characterStorageService.saveCharacter(pending) │
|
||||
│ │ │
|
||||
│ └──► 开始轮询 queryCharacter() │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ GET /v1/videos/{id} │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 返回 username, profile_picture_url │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 5. characterStorageService.updateCharacter(completed) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 6. UI 更新显示角色卡片 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## UI 设计参考
|
||||
|
||||
### 1. 提取角色按钮位置
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TaskItem (sora-2 已完成任务) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 🎬 [prompt text...] │
|
||||
│ │
|
||||
│ sora-2 | 10秒 | 1920x1080 | ✅ 已完成 │
|
||||
│ │
|
||||
│ [预览图] │
|
||||
│ │
|
||||
│ [删除] [下载] [插入] [缓存] [编辑] [👤 提取角色] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 角色创建对话框
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 提取角色 [X] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 从视频中提取角色,创建后可在后续视频中通过 @username 引用 │
|
||||
│ │
|
||||
│ 源视频: 一只白色的猫在花园里玩耍... │
|
||||
│ 任务ID: sora-2:task_01kbh21pg... │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 时间范围 (角色在视频中出现的位置) │
|
||||
│ │
|
||||
│ 开始时间: [0 ] 秒 结束时间: [3 ] 秒 │
|
||||
│ │
|
||||
│ ⓘ 时间范围需在 1-3 秒之间 │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ [取消] [创建角色] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. 角色卡片
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ┌────────┐ │
|
||||
│ │ 头像 │ @xscjkajd.lunar... │
|
||||
│ │ 64x64 │ │
|
||||
│ └────────┘ [📋 复制] [🗑 删除] │
|
||||
│ │
|
||||
│ 来源: 白猫视频任务 │
|
||||
│ 创建于: 2024-01-15 14:30 │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. @ 提及弹窗
|
||||
|
||||
```
|
||||
提示词输入框:
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 一只猫在追逐 @| │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 📷 @xscjkajd.lunaradian │ ← 选中
|
||||
│ 📷 @abc123.character │
|
||||
│ ─────────────────────────────── │
|
||||
│ + 从视频提取新角色... │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 模型判断逻辑
|
||||
|
||||
```typescript
|
||||
// 判断是否可以提取角色
|
||||
const canExtractCharacter = (task: Task): boolean => {
|
||||
return (
|
||||
task.status === TaskStatus.COMPLETED &&
|
||||
task.type === TaskType.VIDEO &&
|
||||
task.remoteId?.startsWith('sora-2:') &&
|
||||
['sora-2', 'sora-2-pro'].includes(task.params.model || '')
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 时间范围验证
|
||||
|
||||
```typescript
|
||||
const validateTimestamps = (start: number, end: number, videoDuration: number): boolean => {
|
||||
const duration = end - start;
|
||||
return (
|
||||
start >= 0 &&
|
||||
end <= videoDuration &&
|
||||
duration >= 1 &&
|
||||
duration <= 3
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. IndexedDB 存储结构
|
||||
|
||||
```typescript
|
||||
// 存储键: 'sora-characters'
|
||||
// 数据结构: SoraCharacter[]
|
||||
{
|
||||
id: 'sora-2-character:ch_xxx',
|
||||
username: 'xscjkajd.lunaradian',
|
||||
profilePictureUrl: 'https://...',
|
||||
permalink: 'https://...',
|
||||
sourceTaskId: 'local-task-uuid',
|
||||
sourceVideoId: 'sora-2:task_xxx',
|
||||
characterTimestamps: '0,3',
|
||||
status: 'completed',
|
||||
createdAt: 1705312200000,
|
||||
completedAt: 1705312260000,
|
||||
}
|
||||
```
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. **API 集成测试**
|
||||
- 角色创建请求格式正确
|
||||
- 轮询查询正常工作
|
||||
- 错误响应处理
|
||||
|
||||
2. **UI 交互测试**
|
||||
- 提取角色按钮仅对符合条件的任务显示
|
||||
- 时间范围选择器约束正确
|
||||
- 角色创建状态实时更新
|
||||
- @ 提及弹窗正确显示和选择
|
||||
|
||||
3. **数据持久化测试**
|
||||
- 刷新页面后角色列表恢复
|
||||
- 角色删除后正确移除
|
||||
- 处理中任务恢复轮询
|
||||
|
||||
## 开发顺序建议
|
||||
|
||||
```
|
||||
Week 1:
|
||||
├── Day 1-2: Task 1-3 (类型定义 + API服务 + 存储服务)
|
||||
├── Day 3: Task 4 (状态管理 Hook)
|
||||
└── Day 4-5: Task 5-6 (创建对话框 + TaskItem按钮)
|
||||
|
||||
Week 2:
|
||||
├── Day 1-2: Task 7-8 (角色卡片 + 列表组件)
|
||||
├── Day 3-4: Task 9 (@ 提及功能)
|
||||
└── Day 5: Task 10-11 (状态同步 + 错误处理)
|
||||
```
|
||||
|
||||
## 后续扩展
|
||||
|
||||
1. **角色库面板** - 独立的角色管理界面
|
||||
2. **角色分组/标签** - 组织和筛选角色
|
||||
3. **角色使用统计** - 追踪角色使用频率
|
||||
4. **批量导出** - 导出角色数据备份
|
||||
5. **角色分享** - 跨设备同步角色(需后端支持)
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* CharacterAvatar Component
|
||||
*
|
||||
* Displays character avatar with automatic cache fallback.
|
||||
* Uses cached blob URL when original URL fails or is unavailable.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UserIcon } from 'tdesign-icons-react';
|
||||
import { characterAvatarCacheService } from '../../services/character-avatar-cache-service';
|
||||
|
||||
export interface CharacterAvatarProps {
|
||||
/** Character ID for cache lookup */
|
||||
characterId: string;
|
||||
/** Original profile picture URL */
|
||||
profilePictureUrl?: string;
|
||||
/** Alt text for image */
|
||||
alt?: string;
|
||||
/** CSS class name */
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterAvatar - displays avatar with cache fallback
|
||||
*/
|
||||
export const CharacterAvatar: React.FC<CharacterAvatarProps> = ({
|
||||
characterId,
|
||||
profilePictureUrl,
|
||||
alt = 'Character',
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(profilePictureUrl || null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load avatar URL with cache fallback
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadAvatar = async () => {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
|
||||
// Try to get cached URL first
|
||||
const cachedUrl = await characterAvatarCacheService.getCachedUrl(characterId);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (cachedUrl) {
|
||||
// Use cached URL
|
||||
setImageUrl(cachedUrl);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use original URL and trigger background cache
|
||||
if (profilePictureUrl) {
|
||||
setImageUrl(profilePictureUrl);
|
||||
// Cache in background
|
||||
characterAvatarCacheService.cacheAvatar(characterId, profilePictureUrl)
|
||||
.catch(() => {
|
||||
// Ignore cache errors
|
||||
});
|
||||
} else {
|
||||
setHasError(true);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadAvatar();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [characterId, profilePictureUrl]);
|
||||
|
||||
// Handle image load error - try cache fallback
|
||||
const handleError = async () => {
|
||||
// Only try cache if we haven't already
|
||||
if (imageUrl === profilePictureUrl) {
|
||||
const cachedUrl = await characterAvatarCacheService.getCachedUrl(characterId);
|
||||
if (cachedUrl && cachedUrl !== imageUrl) {
|
||||
setImageUrl(cachedUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setHasError(true);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Show placeholder if no URL or error
|
||||
if (hasError || !imageUrl) {
|
||||
return (
|
||||
<div className={className} onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||
<UserIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: isLoading ? 'none' : 'block',
|
||||
cursor: onClick ? 'pointer' : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterAvatar;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* CharacterCard Component
|
||||
*
|
||||
* Displays a single character with avatar, username, and actions.
|
||||
* Supports different states: processing, completed, failed.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button, Tooltip, MessagePlugin, Loading, ImageViewer } from 'tdesign-react';
|
||||
import { DeleteIcon, CopyIcon, UserIcon } from 'tdesign-icons-react';
|
||||
import type { SoraCharacter } from '../../types/character.types';
|
||||
import { CharacterAvatar } from './CharacterAvatar';
|
||||
import './character.scss';
|
||||
|
||||
export interface CharacterCardProps {
|
||||
/** The character to display */
|
||||
character: SoraCharacter;
|
||||
/** Callback when delete button is clicked */
|
||||
onDelete?: (id: string) => void;
|
||||
/** Callback when character is selected */
|
||||
onSelect?: (character: SoraCharacter) => void;
|
||||
/** Whether the card is compact (for inline display) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterCard component - displays a single character
|
||||
*/
|
||||
export const CharacterCard: React.FC<CharacterCardProps> = ({
|
||||
character,
|
||||
onDelete,
|
||||
onSelect,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [imageViewerVisible, setImageViewerVisible] = useState(false);
|
||||
const isProcessing = character.status === 'processing' || character.status === 'pending';
|
||||
const isFailed = character.status === 'failed';
|
||||
const isCompleted = character.status === 'completed';
|
||||
|
||||
// Copy username to clipboard
|
||||
const handleCopyUsername = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!character.username) return;
|
||||
|
||||
const mention = `@${character.username}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(mention);
|
||||
MessagePlugin.success(`已复制: ${mention}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
MessagePlugin.error('复制失败');
|
||||
}
|
||||
}, [character.username]);
|
||||
|
||||
// Handle card click
|
||||
const handleClick = useCallback(() => {
|
||||
if (isCompleted && onSelect) {
|
||||
onSelect(character);
|
||||
}
|
||||
}, [isCompleted, onSelect, character]);
|
||||
|
||||
// Handle delete click
|
||||
const handleDelete = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(character.id);
|
||||
}, [onDelete, character.id]);
|
||||
|
||||
// Handle avatar click to open image viewer
|
||||
const handleAvatarClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isCompleted && character.profilePictureUrl) {
|
||||
setImageViewerVisible(true);
|
||||
}
|
||||
}, [isCompleted, character.profilePictureUrl]);
|
||||
|
||||
// Render avatar with cache support
|
||||
const renderAvatar = () => {
|
||||
if (character.profilePictureUrl || isCompleted) {
|
||||
return (
|
||||
<Tooltip content="点击查看大图" theme="light">
|
||||
<CharacterAvatar
|
||||
characterId={character.id}
|
||||
profilePictureUrl={character.profilePictureUrl}
|
||||
alt={character.username || 'Character'}
|
||||
className="character-card__avatar-img"
|
||||
onClick={handleAvatarClick}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="character-card__avatar-placeholder">
|
||||
<UserIcon />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render status
|
||||
const renderStatus = () => {
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="character-card__status character-card__status--processing">
|
||||
<Loading size="small" />
|
||||
<span>创建中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isFailed) {
|
||||
return (
|
||||
<div className="character-card__status character-card__status--failed">
|
||||
创建失败: {character.error || '未知错误'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
// Compact mode for inline display
|
||||
return (
|
||||
<div
|
||||
className={`character-card character-card--compact ${isProcessing ? 'character-card--loading' : ''} ${isFailed ? 'character-card--failed' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="character-card__avatar">{renderAvatar()}</div>
|
||||
<span className="character-card__username-text">
|
||||
{character.username || '创建中...'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`character-card ${isProcessing ? 'character-card--loading' : ''} ${isFailed ? 'character-card--failed' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="character-card__avatar">{renderAvatar()}</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="character-card__info">
|
||||
{/* Username with copy button */}
|
||||
{isCompleted && character.username ? (
|
||||
<Tooltip content="点击复制" theme="light">
|
||||
<div
|
||||
className="character-card__username"
|
||||
onClick={handleCopyUsername}
|
||||
>
|
||||
@{character.username}
|
||||
<CopyIcon className="character-card__username-copy" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="character-card__username" style={{ cursor: 'default' }}>
|
||||
{isProcessing ? '创建中...' : '角色'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Character ID */}
|
||||
{character.id && (
|
||||
<Tooltip content={character.id} theme="light" placement="bottom">
|
||||
<div className="character-card__source">{character.id}</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{renderStatus()}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="character-card__actions">
|
||||
<Tooltip content="删除角色" theme="light">
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="danger"
|
||||
icon={<DeleteIcon />}
|
||||
data-track="character_click_delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Image Viewer for avatar - render in portal with high z-index to ensure it's on top */}
|
||||
{character.profilePictureUrl && imageViewerVisible && createPortal(
|
||||
<ImageViewer
|
||||
visible={imageViewerVisible}
|
||||
onClose={() => setImageViewerVisible(false)}
|
||||
images={[character.profilePictureUrl]}
|
||||
title={`@${character.username || 'Character'}`}
|
||||
zIndex={8000}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCard;
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* CharacterCreateDialog Component
|
||||
*
|
||||
* Dialog for creating a character from a Sora-2 video task.
|
||||
* Uses video frame preview for time range selection.
|
||||
* Character creation is handled through the task queue system.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Dialog, MessagePlugin } from 'tdesign-react';
|
||||
import { useTaskQueue } from '../../hooks/useTaskQueue';
|
||||
import { formatCharacterTimestamps, getCharacterModel } from '../../types/character.types';
|
||||
import { useMediaUrl } from '../../hooks/useMediaCache';
|
||||
import { CharacterTimeRangeSelector } from './CharacterTimeRangeSelector';
|
||||
import { Task, TaskType, TaskStatus } from '../../types/task.types';
|
||||
import './character.scss';
|
||||
|
||||
export interface CharacterCreateDialogProps {
|
||||
/** Whether the dialog is visible */
|
||||
visible: boolean;
|
||||
/** The source video task */
|
||||
task: Task | null;
|
||||
/** Callback when dialog is closed */
|
||||
onClose: () => void;
|
||||
/** Callback when character creation starts */
|
||||
onCreateStart?: () => void;
|
||||
/** Callback when character creation completes (API call done, polling started) */
|
||||
onCreateComplete?: (characterId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterCreateDialog component
|
||||
*/
|
||||
export const CharacterCreateDialog: React.FC<CharacterCreateDialogProps> = ({
|
||||
visible,
|
||||
task,
|
||||
onClose,
|
||||
onCreateStart,
|
||||
onCreateComplete,
|
||||
}) => {
|
||||
const { createTask, tasks } = useTaskQueue();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Get video URL from cache or original
|
||||
const { url: videoUrl } = useMediaUrl(task?.id ?? '', task?.result?.url);
|
||||
|
||||
// Get video duration from task params
|
||||
const videoDuration = useMemo(() => {
|
||||
if (!task?.params.seconds) return 10; // Default fallback
|
||||
return parseFloat(task.params.seconds.toString());
|
||||
}, [task?.params.seconds]);
|
||||
|
||||
// Check if already creating for this task (look for pending/processing character tasks)
|
||||
const alreadyCreating = useMemo(() => {
|
||||
if (!task) return false;
|
||||
return tasks.some(
|
||||
t => t.type === TaskType.CHARACTER &&
|
||||
t.params.sourceLocalTaskId === task.id &&
|
||||
(t.status === TaskStatus.PENDING || t.status === TaskStatus.PROCESSING)
|
||||
);
|
||||
}, [task, tasks]);
|
||||
|
||||
// Handle time range confirmation - create a task in the queue
|
||||
const handleConfirm = useCallback(async (startTime: number, endTime: number) => {
|
||||
if (!task || !task.remoteId) {
|
||||
MessagePlugin.error('无效的任务数据');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
onCreateStart?.();
|
||||
|
||||
try {
|
||||
const timestamps = formatCharacterTimestamps(startTime, endTime);
|
||||
|
||||
// Create a character task in the queue
|
||||
// Use the actual character API model name (e.g., sora-2-character, sora-2-pro-character)
|
||||
const characterModel = getCharacterModel(task.params.model);
|
||||
const characterTask = createTask(
|
||||
{
|
||||
prompt: task.params.prompt || '角色提取',
|
||||
model: characterModel,
|
||||
sourceVideoTaskId: task.remoteId,
|
||||
characterTimestamps: timestamps,
|
||||
sourceLocalTaskId: task.id,
|
||||
},
|
||||
TaskType.CHARACTER
|
||||
);
|
||||
|
||||
MessagePlugin.success('角色创建任务已加入队列');
|
||||
onCreateComplete?.(characterTask.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to create character task:', err);
|
||||
const errorMessage = (err as Error).message || '创建任务失败';
|
||||
MessagePlugin.error(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [task, createTask, onCreateStart, onCreateComplete, onClose]);
|
||||
|
||||
// Handle dialog close
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel from time selector
|
||||
const handleCancel = () => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!task || !videoUrl) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
header="角色"
|
||||
onClose={handleClose}
|
||||
footer={null}
|
||||
width={560}
|
||||
className="character-create-dialog"
|
||||
closeOnOverlayClick={!isSubmitting}
|
||||
closeOnEscKeydown={!isSubmitting}
|
||||
>
|
||||
<div className="character-create-dialog__description">
|
||||
从视频中提取角色,创建后可通过 <code>@username</code> 在提示词中引用
|
||||
</div>
|
||||
|
||||
{/* Already creating warning */}
|
||||
{alreadyCreating && (
|
||||
<div className="character-create-dialog__warning">
|
||||
此视频已有一个角色正在创建中
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video frame time range selector */}
|
||||
<CharacterTimeRangeSelector
|
||||
videoUrl={videoUrl}
|
||||
videoDuration={videoDuration}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
disabled={isSubmitting || alreadyCreating}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCreateDialog;
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* CharacterList Component
|
||||
*
|
||||
* Displays a list of characters with management capabilities.
|
||||
* Supports filtering by status and provides delete functionality.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Button, Dialog, MessagePlugin, Loading } from 'tdesign-react';
|
||||
import { RefreshIcon, UserAddIcon } from 'tdesign-icons-react';
|
||||
import { CharacterCard } from './CharacterCard';
|
||||
import { useCharacters } from '../../hooks/useCharacters';
|
||||
import type { SoraCharacter } from '../../types/character.types';
|
||||
import './character.scss';
|
||||
|
||||
export interface CharacterListProps {
|
||||
/** Callback when a character is selected */
|
||||
onSelect?: (character: SoraCharacter) => void;
|
||||
/** Whether to show completed characters only */
|
||||
completedOnly?: boolean;
|
||||
/** Maximum number of characters to display */
|
||||
maxItems?: number;
|
||||
/** Whether to show the header with title and actions */
|
||||
showHeader?: boolean;
|
||||
/** Custom title */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterList component - displays all characters
|
||||
*/
|
||||
export const CharacterList: React.FC<CharacterListProps> = ({
|
||||
onSelect,
|
||||
completedOnly = false,
|
||||
maxItems,
|
||||
showHeader = true,
|
||||
title = '角色列表',
|
||||
}) => {
|
||||
const {
|
||||
characters,
|
||||
completedCharacters,
|
||||
pendingCharacters,
|
||||
isLoading,
|
||||
deleteCharacter,
|
||||
refreshCharacters,
|
||||
} = useCharacters();
|
||||
|
||||
const [deleteConfirmId, setDeleteConfirmId] = React.useState<string | null>(null);
|
||||
|
||||
// Get characters to display
|
||||
const displayCharacters = React.useMemo(() => {
|
||||
let list = completedOnly ? completedCharacters : characters;
|
||||
// Sort by creation time (newest first)
|
||||
list = [...list].sort((a, b) => b.createdAt - a.createdAt);
|
||||
if (maxItems && list.length > maxItems) {
|
||||
list = list.slice(0, maxItems);
|
||||
}
|
||||
return list;
|
||||
}, [completedOnly, characters, completedCharacters, maxItems]);
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteConfirmId(id);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
|
||||
const success = await deleteCharacter(deleteConfirmId);
|
||||
if (success) {
|
||||
MessagePlugin.success('角色已删除');
|
||||
} else {
|
||||
MessagePlugin.error('删除失败');
|
||||
}
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, deleteCharacter]);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await refreshCharacters();
|
||||
MessagePlugin.success('已刷新');
|
||||
}, [refreshCharacters]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="character-list__loading">
|
||||
<Loading size="small" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (displayCharacters.length === 0) {
|
||||
return (
|
||||
<div className="character-list">
|
||||
{showHeader && (
|
||||
<div className="character-list__header">
|
||||
<h4>{title}</h4>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
icon={<RefreshIcon />}
|
||||
data-track="character_click_refresh"
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="character-list__empty">
|
||||
<div className="character-list__empty-icon">
|
||||
<UserAddIcon />
|
||||
</div>
|
||||
<div>暂无角色</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '4px', color: '#999' }}>
|
||||
从已完成的 Sora-2 视频中提取角色
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="character-list">
|
||||
{showHeader && (
|
||||
<div className="character-list__header">
|
||||
<h4>
|
||||
{title}
|
||||
{pendingCharacters.length > 0 && (
|
||||
<span style={{ fontWeight: 'normal', fontSize: '12px', color: '#666', marginLeft: '8px' }}>
|
||||
({pendingCharacters.length} 创建中)
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
icon={<RefreshIcon />}
|
||||
data-track="character_click_refresh"
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="character-list__items">
|
||||
{displayCharacters.map((character) => (
|
||||
<CharacterCard
|
||||
key={character.id}
|
||||
character={character}
|
||||
onDelete={handleDeleteClick}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
visible={!!deleteConfirmId}
|
||||
header="确认删除"
|
||||
onClose={() => setDeleteConfirmId(null)}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
确定要删除此角色吗?删除后将无法在提示词中使用该角色。
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterList;
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CharacterMentionPopup Component
|
||||
*
|
||||
* Popup for selecting characters when user types @ in the prompt input.
|
||||
* Shows filtered list of completed characters based on search query.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useCharacters } from '../../hooks/useCharacters';
|
||||
import type { SoraCharacter } from '../../types/character.types';
|
||||
import { CharacterAvatar } from './CharacterAvatar';
|
||||
import './character.scss';
|
||||
|
||||
export interface CharacterMentionPopupProps {
|
||||
/** Whether the popup is visible */
|
||||
visible: boolean;
|
||||
/** Search query (characters after @) */
|
||||
query: string;
|
||||
/** Position of the popup (cursor position) */
|
||||
position: { top: number; left: number };
|
||||
/** Whether to show popup below cursor instead of above */
|
||||
showBelow?: boolean;
|
||||
/** Index of currently selected item */
|
||||
selectedIndex: number;
|
||||
/** Callback when a character is selected */
|
||||
onSelect: (character: SoraCharacter) => void;
|
||||
/** Callback when popup should close */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterMentionPopup component - autocomplete for @ mentions
|
||||
*/
|
||||
export const CharacterMentionPopup: React.FC<CharacterMentionPopupProps> = ({
|
||||
visible,
|
||||
query,
|
||||
position,
|
||||
showBelow = false,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { completedCharacters, isLoading } = useCharacters();
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter characters by query
|
||||
const filteredCharacters = useMemo(() => {
|
||||
if (!query) return completedCharacters;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return completedCharacters.filter((c) =>
|
||||
c.username.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}, [completedCharacters, query]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (!visible || !popupRef.current) return;
|
||||
const selectedElement = popupRef.current.querySelector(
|
||||
'.character-mention-popup__item--selected'
|
||||
);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [visible, selectedIndex]);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [visible, onClose]);
|
||||
|
||||
// Handle character click
|
||||
const handleCharacterClick = useCallback(
|
||||
(character: SoraCharacter) => {
|
||||
onSelect(character);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// Popup style - positioned at cursor, above or below based on available space
|
||||
const popupStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transform: showBelow ? 'none' : 'translateY(-100%)',
|
||||
zIndex: 10000,
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="character-mention-popup"
|
||||
style={popupStyle}
|
||||
>
|
||||
<div className="character-mention-popup__empty">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredCharacters.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="character-mention-popup"
|
||||
style={popupStyle}
|
||||
>
|
||||
<div className="character-mention-popup__empty">
|
||||
{query ? `未找到 "${query}" 相关角色` : '暂无可用角色'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className="character-mention-popup"
|
||||
style={popupStyle}
|
||||
>
|
||||
{filteredCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id}
|
||||
className={`character-mention-popup__item ${index === selectedIndex ? 'character-mention-popup__item--selected' : ''}`}
|
||||
onClick={() => handleCharacterClick(character)}
|
||||
data-track="character_click_mention"
|
||||
>
|
||||
<div className="character-mention-popup__item-avatar">
|
||||
<CharacterAvatar
|
||||
characterId={character.id}
|
||||
profilePictureUrl={character.profilePictureUrl}
|
||||
alt={character.username}
|
||||
/>
|
||||
</div>
|
||||
<span className="character-mention-popup__item-name">
|
||||
@{character.username}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterMentionPopup;
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CharacterSelector Component
|
||||
*
|
||||
* A compact character selector for use in video generation dialogs.
|
||||
* Displays completed characters as selectable chips.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Tooltip } from 'tdesign-react';
|
||||
import { UserIcon, InfoCircleIcon } from 'tdesign-icons-react';
|
||||
import { useCharacters } from '../../hooks/useCharacters';
|
||||
import type { SoraCharacter } from '../../types/character.types';
|
||||
import './character.scss';
|
||||
|
||||
export interface CharacterSelectorProps {
|
||||
/** Currently selected character IDs */
|
||||
selectedIds?: string[];
|
||||
/** Callback when selection changes */
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
/** Whether to allow multiple selection */
|
||||
multiple?: boolean;
|
||||
/** Maximum characters to show before "show more" */
|
||||
maxVisible?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CharacterSelector component - compact character selection
|
||||
*/
|
||||
export const CharacterSelector: React.FC<CharacterSelectorProps> = ({
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
multiple = false,
|
||||
maxVisible = 5,
|
||||
}) => {
|
||||
const { completedCharacters, isLoading } = useCharacters();
|
||||
|
||||
// Get visible characters
|
||||
const visibleCharacters = useMemo(() => {
|
||||
if (completedCharacters.length <= maxVisible) {
|
||||
return completedCharacters;
|
||||
}
|
||||
return completedCharacters.slice(0, maxVisible);
|
||||
}, [completedCharacters, maxVisible]);
|
||||
|
||||
const hasMore = completedCharacters.length > maxVisible;
|
||||
|
||||
// Handle character click
|
||||
const handleCharacterClick = useCallback((character: SoraCharacter) => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
if (multiple) {
|
||||
// Toggle selection
|
||||
if (selectedIds.includes(character.id)) {
|
||||
onSelectionChange(selectedIds.filter(id => id !== character.id));
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, character.id]);
|
||||
}
|
||||
} else {
|
||||
// Single selection - toggle if already selected
|
||||
if (selectedIds.includes(character.id)) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
onSelectionChange([character.id]);
|
||||
}
|
||||
}
|
||||
}, [multiple, selectedIds, onSelectionChange]);
|
||||
|
||||
// Get selected characters for display
|
||||
const getSelectedUsernames = useCallback(() => {
|
||||
return completedCharacters
|
||||
.filter(c => selectedIds.includes(c.id))
|
||||
.map(c => `@${c.username}`)
|
||||
.join(', ');
|
||||
}, [completedCharacters, selectedIds]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (completedCharacters.length === 0) {
|
||||
return (
|
||||
<div className="character-selector">
|
||||
<div className="character-selector__label">
|
||||
<UserIcon style={{ marginRight: 4 }} />
|
||||
角色
|
||||
<Tooltip
|
||||
content="从已完成的 Sora-2 视频中提取角色后可在此选择"
|
||||
theme="light"
|
||||
>
|
||||
<InfoCircleIcon style={{ marginLeft: 4, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="character-selector__empty">暂无可用角色</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="character-selector">
|
||||
<div className="character-selector__label">
|
||||
<UserIcon style={{ marginRight: 4 }} />
|
||||
角色
|
||||
<span className="character-selector__hint">
|
||||
(选中后会自动添加到提示词)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="character-selector__chips">
|
||||
{visibleCharacters.map((character) => (
|
||||
<Tooltip
|
||||
key={character.id}
|
||||
content={character.sourcePrompt || `@${character.username}`}
|
||||
theme="light"
|
||||
>
|
||||
<div
|
||||
className={`character-selector__chip ${selectedIds.includes(character.id) ? 'character-selector__chip--selected' : ''}`}
|
||||
onClick={() => handleCharacterClick(character)}
|
||||
data-track="character_click_select"
|
||||
>
|
||||
<div className="character-selector__chip-avatar">
|
||||
{character.profilePictureUrl ? (
|
||||
<img src={character.profilePictureUrl} alt={character.username} />
|
||||
) : (
|
||||
<UserIcon />
|
||||
)}
|
||||
</div>
|
||||
<span>@{character.username}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<Tooltip
|
||||
content={`还有 ${completedCharacters.length - maxVisible} 个角色`}
|
||||
theme="light"
|
||||
>
|
||||
<div className="character-selector__chip">
|
||||
+{completedCharacters.length - maxVisible}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
已选择: {getSelectedUsernames()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterSelector;
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* CharacterTimeRangeSelector Component
|
||||
*
|
||||
* Video timeline-style time range selector for character extraction.
|
||||
* Supports smooth dragging edges to resize (1-3 seconds) and dragging center to move.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button } from 'tdesign-react';
|
||||
import './character-time-selector.scss';
|
||||
|
||||
export interface CharacterTimeRangeSelectorProps {
|
||||
/** Video URL to preview */
|
||||
videoUrl: string;
|
||||
/** Video duration in seconds */
|
||||
videoDuration: number;
|
||||
/** Callback when time range is confirmed */
|
||||
onConfirm: (startTime: number, endTime: number) => void;
|
||||
/** Callback when cancelled */
|
||||
onCancel: () => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MIN_DURATION = 1; // Minimum 1 second
|
||||
const MAX_DURATION = 3; // Maximum 3 seconds
|
||||
|
||||
/**
|
||||
* CharacterTimeRangeSelector - Video timeline style range selection
|
||||
*/
|
||||
export const CharacterTimeRangeSelector: React.FC<CharacterTimeRangeSelectorProps> = ({
|
||||
videoUrl,
|
||||
videoDuration,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const frameUpdateRef = useRef<number | null>(null);
|
||||
const lastSeekTimeRef = useRef<number>(0);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(videoDuration);
|
||||
const [startTime, setStartTime] = useState(0);
|
||||
const [endTime, setEndTime] = useState(Math.min(MAX_DURATION, videoDuration));
|
||||
const [currentPreviewTime, setCurrentPreviewTime] = useState(0);
|
||||
const [frameImage, setFrameImage] = useState<string>('');
|
||||
const [isVideoReady, setIsVideoReady] = useState(false);
|
||||
const [dragType, setDragType] = useState<'left' | 'right' | 'center' | null>(null);
|
||||
|
||||
// Calculate range (use raw values for smooth animation)
|
||||
const rangeDuration = endTime - startTime;
|
||||
const rangeWidthPercent = duration > 0 ? (rangeDuration / duration) * 100 : 100;
|
||||
const rangeLeftPercent = duration > 0 ? (startTime / duration) * 100 : 0;
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (videoUrl) {
|
||||
setIsLoading(true);
|
||||
setFrameImage('');
|
||||
setIsVideoReady(false);
|
||||
setDragType(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameUpdateRef.current !== null) {
|
||||
cancelAnimationFrame(frameUpdateRef.current);
|
||||
}
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
// Video metadata loaded
|
||||
const handleMetadataLoaded = () => {
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
const actualDuration = video.duration;
|
||||
setDuration(actualDuration);
|
||||
setIsVideoReady(true);
|
||||
setEndTime(Math.min(MAX_DURATION, actualDuration));
|
||||
}
|
||||
};
|
||||
|
||||
// Generate frame image
|
||||
const generateFrameImage = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||
setFrameImage(imageDataUrl);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate frame image:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Video can play - generate initial frame
|
||||
const handleCanPlay = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (video && isLoading) {
|
||||
// If video is ready and we're at position 0, generate frame directly
|
||||
// since seeking to 0 when already at 0 won't trigger seeked event
|
||||
if (video.currentTime === 0 || Math.abs(video.currentTime - startTime) < 0.1) {
|
||||
generateFrameImage();
|
||||
}
|
||||
}
|
||||
}, [isLoading, startTime, generateFrameImage]);
|
||||
|
||||
// Throttled seek - only seek if time changed significantly
|
||||
const seekToTime = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isVideoReady) return;
|
||||
|
||||
// Throttle seeks to prevent too many updates
|
||||
const timeDiff = Math.abs(time - lastSeekTimeRef.current);
|
||||
if (timeDiff < 0.1) return;
|
||||
|
||||
lastSeekTimeRef.current = time;
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
video.currentTime = time;
|
||||
}
|
||||
}, [isVideoReady]);
|
||||
|
||||
// Handle seeked event
|
||||
const handleSeeked = useCallback(() => {
|
||||
generateFrameImage();
|
||||
}, [generateFrameImage]);
|
||||
|
||||
// Set to start frame when ready
|
||||
useEffect(() => {
|
||||
if (isVideoReady && duration > 0) {
|
||||
setCurrentPreviewTime(startTime);
|
||||
const video = videoRef.current;
|
||||
if (video && video.readyState >= 2) {
|
||||
video.currentTime = startTime;
|
||||
}
|
||||
}
|
||||
}, [isVideoReady, duration]);
|
||||
|
||||
// Convert mouse position to time (smooth, no rounding during drag)
|
||||
const getTimeFromMouseEvent = useCallback((e: MouseEvent): number => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return 0;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = Math.max(0, Math.min(1, x / rect.width));
|
||||
return percent * duration;
|
||||
}, [duration]);
|
||||
|
||||
// Handle left handle drag
|
||||
const handleLeftMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled || !isVideoReady) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragType('left');
|
||||
|
||||
const currentEndTime = endTime;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newStart = getTimeFromMouseEvent(moveEvent);
|
||||
// Ensure minimum 1s and maximum 3s range
|
||||
const maxStart = currentEndTime - MIN_DURATION;
|
||||
const minStart = Math.max(0, currentEndTime - MAX_DURATION);
|
||||
const clampedStart = Math.max(minStart, Math.min(maxStart, newStart));
|
||||
|
||||
setStartTime(clampedStart);
|
||||
setCurrentPreviewTime(clampedStart);
|
||||
seekToTime(clampedStart);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragType(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
// Final seek with exact time
|
||||
seekToTime(startTime);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle right handle drag
|
||||
const handleRightMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled || !isVideoReady) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragType('right');
|
||||
|
||||
const currentStartTime = startTime;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newEnd = getTimeFromMouseEvent(moveEvent);
|
||||
// Ensure minimum 1s and maximum 3s range
|
||||
const minEnd = currentStartTime + MIN_DURATION;
|
||||
const maxEnd = Math.min(duration, currentStartTime + MAX_DURATION);
|
||||
const clampedEnd = Math.max(minEnd, Math.min(maxEnd, newEnd));
|
||||
|
||||
setEndTime(clampedEnd);
|
||||
setCurrentPreviewTime(clampedEnd);
|
||||
seekToTime(clampedEnd);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragType(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
seekToTime(endTime);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle center drag (move entire range)
|
||||
const handleCenterMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled || !isVideoReady) return;
|
||||
e.preventDefault();
|
||||
setDragType('center');
|
||||
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const startX = e.clientX;
|
||||
const startStartTime = startTime;
|
||||
const currentDuration = endTime - startTime;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaTime = (deltaX / rect.width) * duration;
|
||||
|
||||
let newStart = startStartTime + deltaTime;
|
||||
// Keep range within bounds
|
||||
newStart = Math.max(0, Math.min(duration - currentDuration, newStart));
|
||||
|
||||
setStartTime(newStart);
|
||||
setEndTime(newStart + currentDuration);
|
||||
setCurrentPreviewTime(newStart);
|
||||
seekToTime(newStart);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragType(null);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
seekToTime(startTime);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle track click (move range center to click position with animation)
|
||||
const handleTrackClick = (e: React.MouseEvent) => {
|
||||
if (disabled || !isVideoReady || dragType) return;
|
||||
|
||||
// Ignore clicks on the range itself
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.character-time-selector__range')) return;
|
||||
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clickTime = (x / rect.width) * duration;
|
||||
|
||||
const currentDuration = endTime - startTime;
|
||||
const halfDuration = currentDuration / 2;
|
||||
|
||||
// Center the range at click position
|
||||
let newStart = clickTime - halfDuration;
|
||||
newStart = Math.max(0, Math.min(duration - currentDuration, newStart));
|
||||
|
||||
setStartTime(newStart);
|
||||
setEndTime(newStart + currentDuration);
|
||||
setCurrentPreviewTime(newStart);
|
||||
seekToTime(newStart);
|
||||
};
|
||||
|
||||
// Handle confirm - round to integers for API
|
||||
const handleConfirm = () => {
|
||||
onConfirm(Math.round(startTime), Math.round(endTime));
|
||||
};
|
||||
|
||||
// Format time display
|
||||
const formatTime = (time: number): string => {
|
||||
return time.toFixed(1) + 's';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`character-time-selector ${dragType ? 'is-dragging' : ''}`}>
|
||||
{/* Hidden video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
style={{ display: 'none' }}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
onLoadedMetadata={handleMetadataLoaded}
|
||||
onSeeked={handleSeeked}
|
||||
onCanPlay={handleCanPlay}
|
||||
/>
|
||||
|
||||
{/* Hidden canvas */}
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
|
||||
{/* Frame preview */}
|
||||
<div className="character-time-selector__preview">
|
||||
{isLoading ? (
|
||||
<div className="character-time-selector__loading">
|
||||
<div className="character-time-selector__spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : frameImage ? (
|
||||
<img
|
||||
src={frameImage}
|
||||
alt="Video frame"
|
||||
className="character-time-selector__frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="character-time-selector__placeholder">
|
||||
视频帧加载失败
|
||||
</div>
|
||||
)}
|
||||
<div className="character-time-selector__time-badge">
|
||||
{formatTime(currentPreviewTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline track */}
|
||||
<div
|
||||
className="character-time-selector__timeline"
|
||||
ref={trackRef}
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{/* Background track */}
|
||||
<div className="character-time-selector__timeline-bg" />
|
||||
|
||||
{/* Time markers */}
|
||||
<div className="character-time-selector__markers">
|
||||
{Array.from({ length: Math.ceil(duration) + 1 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="character-time-selector__marker"
|
||||
style={{ left: `${(i / duration) * 100}%` }}
|
||||
>
|
||||
<span>{i}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Draggable range */}
|
||||
<div
|
||||
className={`character-time-selector__range ${dragType ? 'dragging' : ''}`}
|
||||
style={{
|
||||
left: `${rangeLeftPercent}%`,
|
||||
width: `${rangeWidthPercent}%`,
|
||||
}}
|
||||
>
|
||||
{/* Left handle - drag to resize */}
|
||||
<div
|
||||
className="character-time-selector__handle character-time-selector__handle--left"
|
||||
onMouseDown={handleLeftMouseDown}
|
||||
/>
|
||||
{/* Center area - drag to move */}
|
||||
<div
|
||||
className="character-time-selector__range-content"
|
||||
onMouseDown={handleCenterMouseDown}
|
||||
>
|
||||
<span className="character-time-selector__range-label">
|
||||
{formatTime(startTime)} - {formatTime(endTime)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right handle - drag to resize */}
|
||||
<div
|
||||
className="character-time-selector__handle character-time-selector__handle--right"
|
||||
onMouseDown={handleRightMouseDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info row */}
|
||||
<div className="character-time-selector__info">
|
||||
<span>API参数: {Math.round(startTime)}, {Math.round(endTime)}</span>
|
||||
<span>时长: {rangeDuration.toFixed(1)}s</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="character-time-selector__actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={disabled}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || !isVideoReady}
|
||||
>
|
||||
创建角色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterTimeRangeSelector;
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Character Time Range Selector Styles
|
||||
* Video timeline style for character extraction
|
||||
* Optimized for smooth dragging and transitions
|
||||
*/
|
||||
|
||||
.character-time-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
// Disable transitions during drag for immediate feedback
|
||||
&.is-dragging {
|
||||
.character-time-selector__range {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 180px;
|
||||
max-height: 240px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #333;
|
||||
border-top-color: var(--td-brand-color, #0052d9);
|
||||
border-radius: 50%;
|
||||
animation: character-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
&__frame {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__time-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
// Timeline track
|
||||
&__timeline {
|
||||
position: relative;
|
||||
height: 52px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&__timeline-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
#2a2a2a 0%,
|
||||
#1e1e1e 40%,
|
||||
#1a1a1a 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Time markers
|
||||
&__markers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
// Draggable range - smooth CSS transitions
|
||||
&__range {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
z-index: 2;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(59, 130, 246, 0.35) 0%,
|
||||
rgba(59, 130, 246, 0.2) 100%
|
||||
);
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
// Smooth transition for click-to-move
|
||||
transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 0.15s ease;
|
||||
will-change: left, width;
|
||||
|
||||
&:hover:not(.dragging) {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(59, 130, 246, 0.45) 0%,
|
||||
rgba(59, 130, 246, 0.3) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(59, 130, 246, 0.5) 0%,
|
||||
rgba(59, 130, 246, 0.35) 100%
|
||||
);
|
||||
// Disable transitions during active drag
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// Handles - visible bars on left and right
|
||||
&__handle {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
width: 12px;
|
||||
background: #3b82f6;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
transition: background 0.15s ease,
|
||||
transform 0.15s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
// Inner grip lines
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 1px;
|
||||
box-shadow: -3px 0 0 rgba(255, 255, 255, 0.3),
|
||||
3px 0 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #60a5fa;
|
||||
transform: scaleX(1.15);
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #2563eb;
|
||||
transform: scaleX(1.2);
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.dragging & {
|
||||
background: #60a5fa;
|
||||
}
|
||||
}
|
||||
|
||||
// Range content - center area for moving
|
||||
&__range-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 18px;
|
||||
cursor: grab;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__range-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
// Info row
|
||||
&__info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
padding: 0 4px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
// Actions
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--td-component-border, #e7e7e7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes character-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Character Component Styles
|
||||
*
|
||||
* Styles for Sora character creation and management UI
|
||||
*/
|
||||
|
||||
// Character Create Dialog
|
||||
.character-create-dialog {
|
||||
.t-dialog__body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__source-info {
|
||||
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
word-break: break-all;
|
||||
|
||||
&--prompt {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&--id {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__time-range {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&-input {
|
||||
flex: 1;
|
||||
|
||||
.t-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-separator {
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
}
|
||||
|
||||
&-hint {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&-error {
|
||||
font-size: 12px;
|
||||
color: var(--td-error-color, #e34d59);
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--td-component-border, #e7e7e7);
|
||||
}
|
||||
|
||||
&__warning {
|
||||
padding: 8px 12px;
|
||||
background: var(--td-warning-color-1, #fff8e6);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--td-warning-color-7, #8a5a00);
|
||||
}
|
||||
}
|
||||
|
||||
// Character Card
|
||||
.character-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-border, #e7e7e7);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--td-shadow-1, 0 1px 4px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
&--loading {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-color: var(--td-error-color-3, #f5b7b7);
|
||||
background: var(--td-error-color-1, #fff0f0);
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img,
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-brand-color, #0052d9);
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&-copy {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__username-copy {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__source {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
|
||||
&--processing {
|
||||
color: var(--td-brand-color, #0052d9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
color: var(--td-error-color, #e34d59);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Character List
|
||||
.character-list {
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
}
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
font-size: 14px;
|
||||
|
||||
&-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Character Mention Popup
|
||||
.character-mention-popup {
|
||||
max-height: 132px; // Show ~3 items (3 * 40px + padding)
|
||||
overflow-y: auto;
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-border, #e7e7e7);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--td-shadow-2, 0 2px 8px rgba(0, 0, 0, 0.12));
|
||||
|
||||
// Custom scrollbar for better UX
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--td-scrollbar-color, #d9d9d9);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&--selected {
|
||||
background: var(--td-bg-color-secondarycontainer-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
height: 1px;
|
||||
background: var(--td-component-border, #e7e7e7);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
color: var(--td-brand-color, #0052d9);
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-secondarycontainer-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// Character Selector in Video Dialog
|
||||
.character-selector {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--td-bg-color-secondarycontainer, #f5f5f5);
|
||||
border: 1px solid var(--td-component-border, #e7e7e7);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color, #0052d9);
|
||||
background: var(--td-brand-color-light, #e8f3ff);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--td-brand-color, #0052d9);
|
||||
background: var(--td-brand-color-light, #e8f3ff);
|
||||
color: var(--td-brand-color, #0052d9);
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Character Components Export
|
||||
*
|
||||
* Exports all character-related components for use in other parts of the app.
|
||||
*/
|
||||
|
||||
export { CharacterCard } from './CharacterCard';
|
||||
export type { CharacterCardProps } from './CharacterCard';
|
||||
|
||||
export { CharacterList } from './CharacterList';
|
||||
export type { CharacterListProps } from './CharacterList';
|
||||
|
||||
export { CharacterSelector } from './CharacterSelector';
|
||||
export type { CharacterSelectorProps } from './CharacterSelector';
|
||||
|
||||
export { CharacterCreateDialog } from './CharacterCreateDialog';
|
||||
export type { CharacterCreateDialogProps } from './CharacterCreateDialog';
|
||||
|
||||
export { CharacterMentionPopup } from './CharacterMentionPopup';
|
||||
export type { CharacterMentionPopupProps } from './CharacterMentionPopup';
|
||||
|
||||
export { CharacterTimeRangeSelector } from './CharacterTimeRangeSelector';
|
||||
export type { CharacterTimeRangeSelectorProps } from './CharacterTimeRangeSelector';
|
||||
@@ -17,6 +17,7 @@ export const IMAGE_MODEL_VIP_OPTIONS = [
|
||||
|
||||
// Additional models - shown in "More" section
|
||||
export const IMAGE_MODEL_MORE_OPTIONS = [
|
||||
{ label: 'gpt-image-1.5', value: 'gpt-image-1.5' },
|
||||
{ label: 'gemini-3-pro-image-preview (nano-banana-2)', value: 'gemini-3-pro-image-preview' },
|
||||
{ label: 'gemini-2.5-flash-image (nano-banana)', value: 'gemini-2.5-flash-image' },
|
||||
{ label: 'gemini-3-pro-image-preview-hd (nano-banana-2-hd)', value: 'gemini-3-pro-image-preview-hd' },
|
||||
@@ -110,7 +111,7 @@ export const SettingsDialog = ({
|
||||
<div>
|
||||
您可以从以下地址获取 API Key(新建令牌渠道分组选择default):
|
||||
<br />
|
||||
<a href="https://api.tu-zi.com/token" target="_blank" rel="noopener noreferrer"
|
||||
<a href="https://api.tu-zi.com/token" target="_blank" rel="noopener noreferrer"
|
||||
style={{color: '#0052d9', textDecoration: 'none'}}>
|
||||
https://api.tu-zi.com/token
|
||||
</a>
|
||||
@@ -184,4 +185,4 @@ export const SettingsDialog = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { useTaskQueue } from '../../hooks/useTaskQueue';
|
||||
import { TaskType, TaskStatus } from '../../types/task.types';
|
||||
import { Task, TaskType, TaskStatus } from '../../types/task.types';
|
||||
import { useDrawnix, DialogType } from '../../hooks/use-drawnix';
|
||||
import { insertImageFromUrl } from '../../data/image';
|
||||
import { insertVideoFromUrl } from '../../data/video';
|
||||
@@ -17,6 +17,8 @@ import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from 'tdesign-icons-rea
|
||||
import { downloadMediaFile, downloadFromBlob, sanitizeFilename } from '../../utils/download-utils';
|
||||
import { mediaCacheService } from '../../services/media-cache-service';
|
||||
import { useMediaUrl } from '../../hooks/useMediaCache';
|
||||
import { CharacterCreateDialog } from '../character/CharacterCreateDialog';
|
||||
import { supportsCharacterExtraction, isSora2VideoId } from '../../types/character.types';
|
||||
import './dialog-task-list.scss';
|
||||
|
||||
export interface DialogTaskListProps {
|
||||
@@ -47,6 +49,8 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
const [taskToDelete, setTaskToDelete] = useState<string | null>(null);
|
||||
const [previewTaskId, setPreviewTaskId] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
// Character extraction dialog state
|
||||
const [characterDialogTask, setCharacterDialogTask] = useState<Task | null>(null);
|
||||
|
||||
// Fuzzy match helper: all tokens must be present in concatenated fields
|
||||
const taskMatchesQuery = (task: any, query: string) => {
|
||||
@@ -220,6 +224,14 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle extract character action
|
||||
const handleExtractCharacter = (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
setCharacterDialogTask(task);
|
||||
}
|
||||
};
|
||||
|
||||
// Get completed tasks with results for navigation
|
||||
const completedTasksWithResults = useMemo(() => {
|
||||
return filteredTasks.filter(
|
||||
@@ -337,6 +349,7 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
onInsert={handleInsert}
|
||||
onEdit={handleEdit}
|
||||
onPreviewOpen={() => handlePreviewOpen(task.id)}
|
||||
onExtractCharacter={handleExtractCharacter}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -362,7 +375,7 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
width="90vw"
|
||||
header={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{previewedTask.type === TaskType.IMAGE ? '图片预览' : '视频预览'}</span>
|
||||
<span>{previewedTask.type === TaskType.IMAGE ? '图片预览' : previewedTask.type === TaskType.CHARACTER ? '角色预览' : '视频预览'}</span>
|
||||
{previewInfo && (
|
||||
<span style={{ fontSize: '14px', color: '#757575', fontWeight: 'normal' }}>
|
||||
{previewInfo.currentIndex + 1} / {previewInfo.total}
|
||||
@@ -398,6 +411,17 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Character Create Dialog */}
|
||||
<CharacterCreateDialog
|
||||
visible={!!characterDialogTask}
|
||||
task={characterDialogTask}
|
||||
onClose={() => setCharacterDialogTask(null)}
|
||||
onCreateComplete={(characterId) => {
|
||||
console.log('Character created:', characterId);
|
||||
setCharacterDialogTask(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -414,7 +438,7 @@ const PreviewContent: React.FC<{ task: any }> = ({ task }) => {
|
||||
|
||||
return (
|
||||
<div className="task-preview-content">
|
||||
{task.type === TaskType.IMAGE ? (
|
||||
{task.type === TaskType.IMAGE || task.type === TaskType.CHARACTER ? (
|
||||
<img
|
||||
key={task.id}
|
||||
src={url}
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Tag, Tooltip } from 'tdesign-react';
|
||||
import { ImageIcon, VideoIcon, DeleteIcon, RefreshIcon, DownloadIcon, EditIcon, SaveIcon, CheckCircleFilledIcon } from 'tdesign-icons-react';
|
||||
import { ImageIcon, VideoIcon, DeleteIcon, RefreshIcon, DownloadIcon, EditIcon, SaveIcon, CheckCircleFilledIcon, UserIcon } from 'tdesign-icons-react';
|
||||
import { Task, TaskStatus, TaskType } from '../../types/task.types';
|
||||
import { formatDateTime, formatTaskDuration } from '../../utils/task-utils';
|
||||
import { formatRetryDelay } from '../../utils/retry-utils';
|
||||
import { useMediaCache, useMediaUrl } from '../../hooks/useMediaCache';
|
||||
import { supportsCharacterExtraction, isSora2VideoId } from '../../types/character.types';
|
||||
import { RetryImage } from '../retry-image';
|
||||
import './task-queue.scss';
|
||||
|
||||
@@ -30,6 +31,8 @@ export interface TaskItemProps {
|
||||
onPreviewOpen?: () => void;
|
||||
/** Callback when edit button is clicked */
|
||||
onEdit?: (taskId: string) => void;
|
||||
/** Callback when extract character button is clicked */
|
||||
onExtractCharacter?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,12 +90,26 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
onInsert,
|
||||
onPreviewOpen,
|
||||
onEdit,
|
||||
onExtractCharacter,
|
||||
}) => {
|
||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const isCompleted = task.status === TaskStatus.COMPLETED;
|
||||
const isFailed = task.status === TaskStatus.FAILED;
|
||||
|
||||
// Media cache hook
|
||||
// Check if task supports character extraction (Sora-2 completed video tasks)
|
||||
// Note: Storyboard mode videos do not support character extraction
|
||||
const isStoryboardVideo = task.params.storyboard?.enabled === true;
|
||||
const canExtractCharacter =
|
||||
isCompleted &&
|
||||
task.type === TaskType.VIDEO &&
|
||||
isSora2VideoId(task.remoteId) &&
|
||||
supportsCharacterExtraction(task.params.model) &&
|
||||
!isStoryboardVideo;
|
||||
|
||||
// Check if this is a character task
|
||||
const isCharacterTask = task.type === TaskType.CHARACTER;
|
||||
|
||||
// Media cache hook (skip for character tasks)
|
||||
const {
|
||||
isCaching,
|
||||
isCached,
|
||||
@@ -101,7 +118,7 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
deleteCache,
|
||||
} = useMediaCache(
|
||||
task.id,
|
||||
task.result?.url,
|
||||
isCharacterTask ? undefined : task.result?.url,
|
||||
task.type === TaskType.IMAGE ? 'image' : 'video',
|
||||
task.params.prompt
|
||||
);
|
||||
@@ -212,10 +229,15 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
{/* Title - Always visible */}
|
||||
<div className="task-item__title">
|
||||
<div className="task-item__type-icon">
|
||||
{task.type === TaskType.IMAGE ? <ImageIcon /> : <VideoIcon />}
|
||||
{task.type === TaskType.IMAGE ? <ImageIcon /> :
|
||||
task.type === TaskType.CHARACTER ? <UserIcon /> : <VideoIcon />}
|
||||
</div>
|
||||
<div className="task-item__prompt" title={task.params.prompt}>
|
||||
{task.params.prompt}
|
||||
{isCharacterTask ? (
|
||||
isCompleted && task.result?.characterUsername
|
||||
? `@${task.result.characterUsername}`
|
||||
: '角色创建中...'
|
||||
) : task.params.prompt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,6 +271,30 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Character params: model, video id, time range */}
|
||||
{isCharacterTask && (
|
||||
<>
|
||||
{task.params.model && (
|
||||
<Tag variant="outline">
|
||||
{task.params.model}
|
||||
</Tag>
|
||||
)}
|
||||
{task.params.sourceVideoTaskId && (
|
||||
<Tooltip content={task.params.sourceVideoTaskId}>
|
||||
<Tag variant="outline">
|
||||
{task.params.sourceVideoTaskId.length > 20
|
||||
? `${task.params.sourceVideoTaskId.slice(0, 20)}...`
|
||||
: task.params.sourceVideoTaskId}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.params.characterTimestamps && (
|
||||
<Tag variant="outline">
|
||||
{task.params.characterTimestamps}s
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status in same line */}
|
||||
<Tag theme={getStatusTagTheme(task.status)} variant="light">
|
||||
@@ -358,10 +404,10 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Preview Image/Video */}
|
||||
{isCompleted && mediaUrl && (
|
||||
{/* Center: Preview Image/Video/Character */}
|
||||
{isCompleted && (mediaUrl || isCharacterTask) && (
|
||||
<div className="task-item__preview" data-track="task_click_preview" onClick={onPreviewOpen}>
|
||||
{task.type === TaskType.IMAGE ? (
|
||||
{task.type === TaskType.IMAGE && mediaUrl ? (
|
||||
<RetryImage
|
||||
src={mediaUrl}
|
||||
alt="Generated"
|
||||
@@ -381,11 +427,24 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : isCharacterTask && task.result?.characterProfileUrl ? (
|
||||
<div className="task-item__character-preview">
|
||||
<RetryImage
|
||||
src={task.result.characterProfileUrl}
|
||||
alt={`@${task.result.characterUsername}`}
|
||||
maxRetries={5}
|
||||
fallback={
|
||||
<div className="task-item__character-fallback">
|
||||
<UserIcon size="32px" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : mediaUrl ? (
|
||||
<video src={mediaUrl} />
|
||||
)}
|
||||
) : null}
|
||||
{/* Cache indicator */}
|
||||
{isFromCache && (
|
||||
{isFromCache && !isCharacterTask && (
|
||||
<div className="task-item__cache-badge">
|
||||
<CheckCircleFilledIcon />
|
||||
<span>已缓存</span>
|
||||
@@ -422,8 +481,8 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Insert button for completed tasks */}
|
||||
{isCompleted && task.result?.url && (
|
||||
{/* Insert button for completed tasks (not for character tasks) */}
|
||||
{isCompleted && task.result?.url && !isCharacterTask && (
|
||||
<Button
|
||||
size="small"
|
||||
theme="primary"
|
||||
@@ -434,8 +493,8 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Download button for completed tasks */}
|
||||
{isCompleted && task.result?.url && (
|
||||
{/* Download button for completed tasks (not for character tasks) */}
|
||||
{isCompleted && task.result?.url && !isCharacterTask && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
@@ -447,8 +506,8 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cache button for completed tasks */}
|
||||
{isCompleted && task.result?.url && (
|
||||
{/* Cache button for completed tasks (not for character tasks) */}
|
||||
{isCompleted && task.result?.url && !isCharacterTask && (
|
||||
<Tooltip content={isCached ? '点击删除缓存' : '缓存到本地,URL过期后仍可使用'}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -464,16 +523,34 @@ export const TaskItem: React.FC<TaskItemProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Edit button for all tasks */}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
icon={<EditIcon />}
|
||||
data-track="task_click_edit"
|
||||
onClick={() => onEdit?.(task.id)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{/* Edit button for image/video tasks (not for character tasks) */}
|
||||
{!isCharacterTask && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
icon={<EditIcon />}
|
||||
data-track="task_click_edit"
|
||||
onClick={() => onEdit?.(task.id)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Extract character button for Sora-2 completed video tasks */}
|
||||
{canExtractCharacter && (
|
||||
<Tooltip content="从视频中提取角色,用于后续视频生成">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
theme="warning"
|
||||
icon={<UserIcon />}
|
||||
data-track="task_click_extract_character"
|
||||
onClick={() => onExtractCharacter?.(task.id)}
|
||||
>
|
||||
角色
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* TaskQueuePanel Component
|
||||
*
|
||||
*
|
||||
* Side panel that displays all tasks in the queue.
|
||||
* Supports filtering by status and provides batch operations.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Button, Tabs, Dialog, MessagePlugin, Input, Radio } from 'tdesign-react';
|
||||
import { DeleteIcon, SearchIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react';
|
||||
import { DeleteIcon, SearchIcon, ChevronLeftIcon, ChevronRightIcon, UserIcon } from 'tdesign-icons-react';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { useTaskQueue } from '../../hooks/useTaskQueue';
|
||||
import { Task, TaskType, TaskStatus } from '../../types/task.types';
|
||||
@@ -18,6 +18,9 @@ import { insertVideoFromUrl } from '../../data/video';
|
||||
import { downloadMediaFile, downloadFromBlob, sanitizeFilename } from '../../utils/download-utils';
|
||||
import { mediaCacheService } from '../../services/media-cache-service';
|
||||
import { SideDrawer } from '../side-drawer';
|
||||
import { CharacterCreateDialog } from '../character/CharacterCreateDialog';
|
||||
import { CharacterList } from '../character/CharacterList';
|
||||
import { useCharacters } from '../../hooks/useCharacters';
|
||||
import './task-queue.scss';
|
||||
|
||||
const { TabPanel } = Tabs;
|
||||
@@ -44,7 +47,7 @@ const PreviewContent: React.FC<{ task: Task }> = ({ task }) => {
|
||||
|
||||
return (
|
||||
<div className="task-preview-content">
|
||||
{task.type === TaskType.IMAGE ? (
|
||||
{task.type === TaskType.IMAGE || task.type === TaskType.CHARACTER ? (
|
||||
<img
|
||||
key={task.id}
|
||||
src={url}
|
||||
@@ -88,14 +91,20 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
} = useTaskQueue();
|
||||
|
||||
const { board, openDialog } = useDrawnix();
|
||||
const { characters } = useCharacters();
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearType, setClearType] = useState<'completed' | 'failed'>('completed');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video' | 'character'>('all');
|
||||
const [previewTaskId, setPreviewTaskId] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [taskToDelete, setTaskToDelete] = useState<string | null>(null);
|
||||
// Character extraction dialog state
|
||||
const [characterDialogTask, setCharacterDialogTask] = useState<Task | null>(null);
|
||||
|
||||
// Check if showing characters view
|
||||
const isCharacterView = typeFilter === 'character';
|
||||
|
||||
// Initialize media cache status on component mount
|
||||
useEffect(() => {
|
||||
@@ -127,7 +136,7 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (typeFilter !== 'all') {
|
||||
if (typeFilter !== 'all' && typeFilter !== 'character') {
|
||||
tasksToFilter = tasksToFilter.filter(task =>
|
||||
task.type === (typeFilter === 'image' ? TaskType.IMAGE : TaskType.VIDEO)
|
||||
);
|
||||
@@ -280,6 +289,15 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
onTaskAction?.('edit', taskId);
|
||||
};
|
||||
|
||||
// Handle extract character action
|
||||
const handleExtractCharacter = (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
setCharacterDialogTask(task);
|
||||
onTaskAction?.('extractCharacter', taskId);
|
||||
}
|
||||
};
|
||||
|
||||
// Get completed tasks with results for navigation
|
||||
const completedTasksWithResults = useMemo(() => {
|
||||
return filteredTasks.filter(
|
||||
@@ -364,39 +382,50 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
<div className="task-queue-panel__filters">
|
||||
<RadioGroup
|
||||
value={typeFilter}
|
||||
onChange={(value) => setTypeFilter(value as 'all' | 'image' | 'video')}
|
||||
onChange={(value) => setTypeFilter(value as 'all' | 'image' | 'video' | 'character')}
|
||||
size="small"
|
||||
variant="default-filled"
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="image">图片</Radio.Button>
|
||||
<Radio.Button value="video">视频</Radio.Button>
|
||||
<Radio.Button value="character">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<UserIcon size="14px" />
|
||||
角色 ({characters.length})
|
||||
</span>
|
||||
</Radio.Button>
|
||||
</RadioGroup>
|
||||
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
placeholder="搜索 Prompt..."
|
||||
clearable
|
||||
prefixIcon={<SearchIcon />}
|
||||
size="small"
|
||||
style={{ width: '180px' }}
|
||||
/>
|
||||
|
||||
<div className="task-queue-panel__actions">
|
||||
{failedTasks.length > 0 && (
|
||||
<Button
|
||||
{/* Hide search and actions when viewing characters */}
|
||||
{!isCharacterView && (
|
||||
<>
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(value) => setSearchText(value)}
|
||||
placeholder="搜索 Prompt..."
|
||||
clearable
|
||||
prefixIcon={<SearchIcon />}
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="danger"
|
||||
icon={<DeleteIcon />}
|
||||
data-track="task_click_clear_failed"
|
||||
onClick={() => handleClear('failed')}
|
||||
>
|
||||
清除失败
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
style={{ width: '180px' }}
|
||||
/>
|
||||
|
||||
<div className="task-queue-panel__actions">
|
||||
{failedTasks.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
theme="danger"
|
||||
icon={<DeleteIcon />}
|
||||
data-track="task_click_clear_failed"
|
||||
onClick={() => handleClear('failed')}
|
||||
>
|
||||
清除失败
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -416,28 +445,38 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
className="task-queue-panel"
|
||||
contentClassName="task-queue-panel__content"
|
||||
>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="task-queue-panel__empty">
|
||||
<div className="task-queue-panel__empty-icon">📋</div>
|
||||
<div className="task-queue-panel__empty-text">
|
||||
{activeTab === 'all' ? '暂无任务' : `暂无${activeTab === 'active' ? '生成中' : activeTab === 'completed' ? '已完成' : activeTab === 'failed' ? '失败' : '已取消'}任务`}
|
||||
</div>
|
||||
</div>
|
||||
{isCharacterView ? (
|
||||
/* Character List View */
|
||||
<CharacterList
|
||||
showHeader={false}
|
||||
title=""
|
||||
/>
|
||||
) : (
|
||||
<div className="task-queue-panel__list">
|
||||
{filteredTasks.map(task => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRetry={handleRetry}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
onInsert={handleInsert}
|
||||
onEdit={handleEdit}
|
||||
onPreviewOpen={() => handlePreviewOpen(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
/* Task List View */
|
||||
filteredTasks.length === 0 ? (
|
||||
<div className="task-queue-panel__empty">
|
||||
<div className="task-queue-panel__empty-icon">📋</div>
|
||||
<div className="task-queue-panel__empty-text">
|
||||
{activeTab === 'all' ? '暂无任务' : `暂无${activeTab === 'active' ? '生成中' : activeTab === 'completed' ? '已完成' : activeTab === 'failed' ? '失败' : '已取消'}任务`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="task-queue-panel__list">
|
||||
{filteredTasks.map(task => (
|
||||
<TaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onRetry={handleRetry}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
onInsert={handleInsert}
|
||||
onEdit={handleEdit}
|
||||
onPreviewOpen={() => handlePreviewOpen(task.id)}
|
||||
onExtractCharacter={handleExtractCharacter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</SideDrawer>
|
||||
|
||||
@@ -471,7 +510,7 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
width="90vw"
|
||||
header={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{previewedTask.type === TaskType.IMAGE ? '图片预览' : '视频预览'}</span>
|
||||
<span>{previewedTask.type === TaskType.IMAGE ? '图片预览' : previewedTask.type === TaskType.CHARACTER ? '角色预览' : '视频预览'}</span>
|
||||
{previewInfo && (
|
||||
<span style={{ fontSize: '14px', color: '#757575', fontWeight: 'normal' }}>
|
||||
{previewInfo.currentIndex + 1} / {previewInfo.total}
|
||||
@@ -507,6 +546,23 @@ export const TaskQueuePanel: React.FC<TaskQueuePanelProps> = ({
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Character Create Dialog */}
|
||||
<CharacterCreateDialog
|
||||
visible={!!characterDialogTask}
|
||||
task={characterDialogTask}
|
||||
onClose={() => setCharacterDialogTask(null)}
|
||||
onCreateStart={() => {
|
||||
// Start indicator (API call begins)
|
||||
console.log('Character creation started');
|
||||
}}
|
||||
onCreateComplete={(characterId) => {
|
||||
console.log('Character created:', characterId);
|
||||
// Close dialog and switch to character view after API succeeds
|
||||
setCharacterDialogTask(null);
|
||||
setTypeFilter('character');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -15,15 +15,27 @@ import {
|
||||
savePromptToHistory as savePromptToHistoryUtil,
|
||||
VideoModelOptions,
|
||||
MultiImageUpload,
|
||||
StoryboardEditor,
|
||||
} from './shared';
|
||||
import { geminiSettings } from '../../utils/settings-manager';
|
||||
import { useTaskQueue } from '../../hooks/useTaskQueue';
|
||||
import { TaskType } from '../../types/task.types';
|
||||
import { MessagePlugin, Select } from 'tdesign-react';
|
||||
import { DialogTaskList } from '../task-queue/DialogTaskList';
|
||||
import type { VideoModel, UploadedVideoImage } from '../../types/video.types';
|
||||
import { getVideoModelConfig, getDefaultModelParams } from '../../constants/video-model-config';
|
||||
import type { VideoModel, UploadedVideoImage, StoryboardScene } from '../../types/video.types';
|
||||
import {
|
||||
getVideoModelConfig,
|
||||
getDefaultModelParams,
|
||||
supportsStoryboardMode,
|
||||
getStoryboardModeConfig,
|
||||
} from '../../constants/video-model-config';
|
||||
import { VIDEO_MODEL_OPTIONS } from '../settings-dialog/settings-dialog';
|
||||
import {
|
||||
formatStoryboardPrompt,
|
||||
parseStoryboardPrompt,
|
||||
isStoryboardPrompt,
|
||||
validateSceneDurations,
|
||||
} from '../../utils/storyboard-utils';
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +98,15 @@ const AIVideoGeneration = ({
|
||||
return [];
|
||||
});
|
||||
|
||||
// Storyboard mode state
|
||||
const [storyboardEnabled, setStoryboardEnabled] = useState(false);
|
||||
const [storyboardScenes, setStoryboardScenes] = useState<StoryboardScene[]>([]);
|
||||
const storyboardConfig = React.useMemo(
|
||||
() => getStoryboardModeConfig(currentModel),
|
||||
[currentModel]
|
||||
);
|
||||
const modelSupportsStoryboard = supportsStoryboardMode(currentModel);
|
||||
|
||||
// Use generation history from task queue
|
||||
const { videoHistory } = useGenerationHistory();
|
||||
|
||||
@@ -134,6 +155,11 @@ const AIVideoGeneration = ({
|
||||
setSize(defaultParams.size);
|
||||
// Clear uploaded images when model changes (different upload requirements)
|
||||
setUploadedImages([]);
|
||||
// Disable storyboard mode if new model doesn't support it
|
||||
if (!supportsStoryboardMode(currentModel)) {
|
||||
setStoryboardEnabled(false);
|
||||
setStoryboardScenes([]);
|
||||
}
|
||||
}, [currentModel, defaultParams, isEditMode]);
|
||||
|
||||
// Handle initial props - use ref to track if we've processed these props before
|
||||
@@ -212,6 +238,9 @@ const AIVideoGeneration = ({
|
||||
setSize(newDefaults.size);
|
||||
// Clear manual edit mode
|
||||
setIsManualEdit(false);
|
||||
// Clear storyboard mode
|
||||
setStoryboardEnabled(false);
|
||||
setStoryboardScenes([]);
|
||||
window.dispatchEvent(new CustomEvent('ai-video-clear'));
|
||||
};
|
||||
|
||||
@@ -232,12 +261,43 @@ const AIVideoGeneration = ({
|
||||
|
||||
// 标记为手动编辑模式,防止 props 的 useEffect 覆盖我们的更改
|
||||
setIsManualEdit(true);
|
||||
|
||||
|
||||
// 标记为编辑模式,防止模型变化时重置参数
|
||||
setIsEditMode(true);
|
||||
|
||||
// 直接更新表单状态
|
||||
setPrompt(task.params.prompt || '');
|
||||
// 更新模型选择(通过本地 state 和全局设置)- 先设置模型
|
||||
if (task.params.model) {
|
||||
console.log('Updating model to:', task.params.model);
|
||||
setCurrentModel(task.params.model as VideoModel);
|
||||
const settings = geminiSettings.get();
|
||||
geminiSettings.update({
|
||||
...settings,
|
||||
videoModelName: task.params.model
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否有故事场景配置
|
||||
if (task.params.storyboard?.enabled && task.params.storyboard?.scenes) {
|
||||
console.log('Restoring storyboard mode:', task.params.storyboard);
|
||||
setStoryboardEnabled(true);
|
||||
setStoryboardScenes(task.params.storyboard.scenes);
|
||||
setPrompt(''); // 故事场景模式下清空普通提示词
|
||||
} else {
|
||||
// 尝试从提示词解析故事场景格式
|
||||
const prompt = task.params.prompt || '';
|
||||
const parsedScenes = parseStoryboardPrompt(prompt);
|
||||
if (parsedScenes && parsedScenes.length > 0) {
|
||||
console.log('Parsed storyboard from prompt:', parsedScenes);
|
||||
setStoryboardEnabled(true);
|
||||
setStoryboardScenes(parsedScenes);
|
||||
setPrompt('');
|
||||
} else {
|
||||
// 普通模式
|
||||
setStoryboardEnabled(false);
|
||||
setStoryboardScenes([]);
|
||||
setPrompt(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新视频参数
|
||||
if (task.params.seconds !== undefined) {
|
||||
@@ -261,24 +321,28 @@ const AIVideoGeneration = ({
|
||||
setUploadedImages([]);
|
||||
}
|
||||
|
||||
// 更新模型选择(通过本地 state 和全局设置)
|
||||
if (task.params.model) {
|
||||
console.log('Updating model to:', task.params.model);
|
||||
setCurrentModel(task.params.model as VideoModel);
|
||||
const settings = geminiSettings.get();
|
||||
geminiSettings.update({
|
||||
...settings,
|
||||
videoModelName: task.params.model
|
||||
});
|
||||
}
|
||||
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleGenerate = async (count: number = 1) => {
|
||||
if (!prompt.trim()) {
|
||||
setError(language === 'zh' ? '请输入视频描述' : 'Please enter video description');
|
||||
return;
|
||||
// 验证输入
|
||||
if (storyboardEnabled) {
|
||||
// 故事场景模式验证
|
||||
const validation = validateSceneDurations(
|
||||
storyboardScenes,
|
||||
parseFloat(duration),
|
||||
storyboardConfig.minSceneDuration
|
||||
);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error || '场景配置无效');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 普通模式验证
|
||||
if (!prompt.trim()) {
|
||||
setError(language === 'zh' ? '请输入视频描述' : 'Please enter video description');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -306,6 +370,11 @@ const AIVideoGeneration = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 构建最终提示词
|
||||
const finalPrompt = storyboardEnabled
|
||||
? formatStoryboardPrompt(storyboardScenes)
|
||||
: prompt.trim();
|
||||
|
||||
// 批量生成逻辑
|
||||
const batchTaskIds: string[] = [];
|
||||
const batchId = count > 1 ? `video_batch_${Date.now()}` : undefined;
|
||||
@@ -313,12 +382,20 @@ const AIVideoGeneration = ({
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 创建任务参数(包含新的 duration, size, uploadedImages)
|
||||
const taskParams = {
|
||||
prompt: prompt.trim(),
|
||||
prompt: finalPrompt,
|
||||
model: currentModel,
|
||||
seconds: duration,
|
||||
size: size,
|
||||
// 保存上传的图片(已转换为可序列化的格式)
|
||||
uploadedImages: convertedImages,
|
||||
// 故事场景配置(用于编辑恢复)
|
||||
...(storyboardEnabled && {
|
||||
storyboard: {
|
||||
enabled: true,
|
||||
scenes: storyboardScenes,
|
||||
totalDuration: parseFloat(duration),
|
||||
},
|
||||
}),
|
||||
// 批量生成信息
|
||||
...(batchId && {
|
||||
batchId,
|
||||
@@ -348,11 +425,13 @@ const AIVideoGeneration = ({
|
||||
);
|
||||
|
||||
// 保存提示词到历史记录
|
||||
savePromptToHistory(prompt);
|
||||
savePromptToHistory(finalPrompt);
|
||||
|
||||
// 清空表单(保留模型选择和尺寸设置)
|
||||
setPrompt('');
|
||||
setUploadedImages([]);
|
||||
setStoryboardEnabled(false);
|
||||
setStoryboardScenes([]);
|
||||
setError(null);
|
||||
// Clear manual edit mode after generating
|
||||
setIsManualEdit(false);
|
||||
@@ -438,15 +517,33 @@ const AIVideoGeneration = ({
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
|
||||
<PromptInput
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
presetPrompts={presetPrompts}
|
||||
language={language}
|
||||
type="video"
|
||||
disabled={isGenerating}
|
||||
onError={setError}
|
||||
/>
|
||||
{/* Storyboard mode editor (only for supported models) */}
|
||||
{modelSupportsStoryboard && (
|
||||
<StoryboardEditor
|
||||
enabled={storyboardEnabled}
|
||||
onEnabledChange={setStoryboardEnabled}
|
||||
totalDuration={parseFloat(duration)}
|
||||
maxScenes={storyboardConfig.maxScenes}
|
||||
minSceneDuration={storyboardConfig.minSceneDuration}
|
||||
scenes={storyboardScenes}
|
||||
onScenesChange={setStoryboardScenes}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Normal prompt input (hidden when storyboard mode is enabled) */}
|
||||
{!storyboardEnabled && (
|
||||
<PromptInput
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
presetPrompts={presetPrompts}
|
||||
language={language}
|
||||
type="video"
|
||||
disabled={isGenerating}
|
||||
onError={setError}
|
||||
videoProvider={modelConfig.provider}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorDisplay error={error} />
|
||||
</div>
|
||||
@@ -456,7 +553,7 @@ const AIVideoGeneration = ({
|
||||
type="video"
|
||||
isGenerating={isGenerating}
|
||||
hasGenerated={false}
|
||||
canGenerate={!!prompt.trim()}
|
||||
canGenerate={storyboardEnabled ? storyboardScenes.length > 0 : !!prompt.trim()}
|
||||
onGenerate={handleGenerate}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { getPromptExample } from './ai-generation-utils';
|
||||
import { CharacterMentionPopup } from '../../character/CharacterMentionPopup';
|
||||
import { useMention } from '../../../hooks/useMention';
|
||||
|
||||
interface PromptInputProps {
|
||||
prompt: string;
|
||||
@@ -9,6 +11,10 @@ interface PromptInputProps {
|
||||
type: 'image' | 'video';
|
||||
disabled?: boolean;
|
||||
onError?: (error: string | null) => void;
|
||||
/** Whether to enable character @ mention feature */
|
||||
enableMention?: boolean;
|
||||
/** Video model provider (sora, veo, etc.) - used to determine if @ mention should be enabled */
|
||||
videoProvider?: 'sora' | 'veo' | string;
|
||||
}
|
||||
|
||||
export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
@@ -18,11 +24,29 @@ export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
language,
|
||||
type,
|
||||
disabled = false,
|
||||
onError
|
||||
onError,
|
||||
enableMention = true,
|
||||
videoProvider,
|
||||
}) => {
|
||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use mention hook for @ functionality
|
||||
// Only enable for video type with Sora provider (@ mention is a Sora-specific feature)
|
||||
const isMentionEnabled = enableMention && type === 'video' && videoProvider === 'sora';
|
||||
const {
|
||||
mentionState,
|
||||
textareaRef,
|
||||
handleTextChange,
|
||||
handleKeyDown,
|
||||
handleCharacterSelect,
|
||||
closeMentionPopup,
|
||||
} = useMention({
|
||||
enabled: isMentionEnabled,
|
||||
onPromptChange,
|
||||
prompt,
|
||||
});
|
||||
|
||||
// 点击外部关闭弹窗
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -43,6 +67,21 @@ export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
setIsPresetOpen(false); // 点击提示词后关闭弹窗
|
||||
};
|
||||
|
||||
// Handle textarea change
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart || 0;
|
||||
handleTextChange(value, cursorPos);
|
||||
onError?.(null);
|
||||
}, [handleTextChange, onError]);
|
||||
|
||||
// Close mention popup when type changes
|
||||
useEffect(() => {
|
||||
if (type !== 'video') {
|
||||
closeMentionPopup();
|
||||
}
|
||||
}, [type, closeMentionPopup]);
|
||||
|
||||
return (
|
||||
<div className="form-field">
|
||||
<div className="form-label-with-icon">
|
||||
@@ -84,16 +123,28 @@ export const PromptInput: React.FC<PromptInputProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="form-textarea"
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
onPromptChange(e.target.value);
|
||||
onError?.(null);
|
||||
}}
|
||||
placeholder={getPromptExample(language, type)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={getPromptExample(language, type, videoProvider)}
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Character mention popup - rendered in portal style with fixed position */}
|
||||
{isMentionEnabled && (
|
||||
<CharacterMentionPopup
|
||||
visible={mentionState.visible}
|
||||
query={mentionState.query}
|
||||
position={mentionState.position}
|
||||
showBelow={mentionState.showBelow}
|
||||
selectedIndex={mentionState.selectedIndex}
|
||||
onSelect={handleCharacterSelect}
|
||||
onClose={closeMentionPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* ScenePromptInput Component
|
||||
*
|
||||
* Textarea input for scene prompts with @ mention support.
|
||||
* Used in StoryboardEditor for individual scene prompt editing.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { CharacterMentionPopup } from '../../character/CharacterMentionPopup';
|
||||
import { useMention } from '../../../hooks/useMention';
|
||||
|
||||
interface ScenePromptInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
/** Whether to enable @ mention feature */
|
||||
enableMention?: boolean;
|
||||
}
|
||||
|
||||
export const ScenePromptInput: React.FC<ScenePromptInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
enableMention = true,
|
||||
}) => {
|
||||
const {
|
||||
mentionState,
|
||||
textareaRef,
|
||||
handleTextChange,
|
||||
handleKeyDown,
|
||||
handleCharacterSelect,
|
||||
closeMentionPopup,
|
||||
} = useMention({
|
||||
enabled: enableMention,
|
||||
onPromptChange: onChange,
|
||||
prompt: value,
|
||||
});
|
||||
|
||||
// Handle textarea change
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const cursorPos = e.target.selectionStart || 0;
|
||||
handleTextChange(newValue, cursorPos);
|
||||
}, [handleTextChange]);
|
||||
|
||||
// Auto focus
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [autoFocus, textareaRef]);
|
||||
|
||||
return (
|
||||
<div className="scene-prompt-input">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="scene-prompt-input__textarea"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
{/* Character mention popup */}
|
||||
{enableMention && (
|
||||
<CharacterMentionPopup
|
||||
visible={mentionState.visible}
|
||||
query={mentionState.query}
|
||||
position={mentionState.position}
|
||||
showBelow={mentionState.showBelow}
|
||||
selectedIndex={mentionState.selectedIndex}
|
||||
onSelect={handleCharacterSelect}
|
||||
onClose={closeMentionPopup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenePromptInput;
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* StoryboardEditor Styles
|
||||
* Clean and minimal design for storyboard scene editing
|
||||
* Inspired by storyflow-lite with timeline visualization
|
||||
*/
|
||||
|
||||
.storyboard-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-container, #ffffff);
|
||||
border: 1px solid var(--td-component-stroke, #e7e7e7);
|
||||
border-radius: 12px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.t-switch {
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__title-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary, #000000);
|
||||
}
|
||||
|
||||
&__title-desc {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary, #666666);
|
||||
}
|
||||
|
||||
&__new-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #f59e0b;
|
||||
background: #fef3c7;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary, #000000);
|
||||
}
|
||||
|
||||
&__section-hint {
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder, #999999);
|
||||
}
|
||||
|
||||
// Scene cards with timeline
|
||||
&__scenes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0; // No gap - timeline connects scenes
|
||||
position: relative;
|
||||
width: 100%;
|
||||
// Use table layout to force width constraints
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Scene wrapper with timeline elements
|
||||
&__scene-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
padding-left: 32px; // Space for timeline
|
||||
min-height: 44px;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
// Force width constraint using calc
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
&.is-selected {
|
||||
.storyboard-editor__scene--expanded {
|
||||
animation: sceneExpand 0.25s ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline connector line
|
||||
&__timeline-line {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--td-component-stroke, #e7e7e7) 0%,
|
||||
var(--td-component-stroke, #e7e7e7) 100%
|
||||
);
|
||||
|
||||
&.is-last {
|
||||
bottom: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline node
|
||||
&__timeline-node {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 14px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--td-bg-color-container, #ffffff);
|
||||
border: 2px solid var(--td-component-stroke, #dcdcdc);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: transparent;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
|
||||
&.is-selected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
top: 12px;
|
||||
border-color: var(--td-brand-color, #f39c12);
|
||||
background: var(--td-brand-color, #f39c12);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(243, 156, 18, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Base scene card styles
|
||||
&__scene {
|
||||
flex: 1;
|
||||
min-width: 0; // Critical: allow flex child to shrink below content size
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden; // Prevent content overflow
|
||||
}
|
||||
|
||||
// Compact scene card - use CSS Grid for reliable text truncation
|
||||
&__scene--compact {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--td-bg-color-secondarycontainer, #f9f9f9);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-secondarycontainer-hover, #f3f3f3);
|
||||
}
|
||||
}
|
||||
|
||||
&__scene-index {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--td-component-stroke, #e7e7e7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-secondary, #666666);
|
||||
}
|
||||
|
||||
&__scene-preview {
|
||||
// Grid item with minmax(0, 1fr) behavior - allows shrinking
|
||||
min-width: 0; // Critical for text-overflow in grid
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__scene-preview-text {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-primary, #333333);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
// Single line ellipsis
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__scene-duration-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--td-text-color-secondary, #666666);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__scene-arrow {
|
||||
color: var(--td-text-color-placeholder, #999999);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&__scene--compact:hover &__scene-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
// Expanded scene card
|
||||
&__scene--expanded {
|
||||
padding: 14px;
|
||||
background: var(--td-bg-color-container, #ffffff);
|
||||
border: 1px solid var(--td-brand-color, #f39c12);
|
||||
box-shadow: 0 4px 16px rgba(243, 156, 18, 0.12);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__scene-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__scene-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__scene-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
&__active-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--td-brand-color, #f39c12);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&__scene-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__time-icon {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-placeholder, #999999);
|
||||
}
|
||||
|
||||
// Duration select - TDesign Select dropdown style
|
||||
&__duration-select {
|
||||
width: 90px !important;
|
||||
|
||||
.t-input {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Center the selected value text
|
||||
.t-input__inner {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Duration popup style (rendered in portal)
|
||||
&__duration-popup {
|
||||
max-height: 120px;
|
||||
|
||||
.t-select-option {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
min-height: 24px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__scene-delete {
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s ease, color 0.15s ease;
|
||||
margin-left: 4px;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--td-error-color, #e34d59);
|
||||
}
|
||||
}
|
||||
|
||||
&__scene-prompt {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Add scene button
|
||||
&__add-btn {
|
||||
margin-top: 4px;
|
||||
margin-left: 32px; // Align with scene cards
|
||||
width: calc(100% - 32px) !important; // Account for left margin
|
||||
border: 1px dashed var(--td-component-stroke, #dcdcdc) !important;
|
||||
color: var(--td-text-color-secondary, #666666) !important;
|
||||
background: transparent !important;
|
||||
transition: all 0.2s ease !important;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--td-brand-color, #f39c12) !important;
|
||||
color: var(--td-brand-color, #f39c12) !important;
|
||||
background: rgba(243, 156, 18, 0.04) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(243, 156, 18, 0.08) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// TDesign component overrides
|
||||
.t-switch {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ScenePromptInput styles
|
||||
.scene-prompt-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--td-text-color-primary, #000000);
|
||||
background: var(--td-bg-color-secondarycontainer, #f9f9f9);
|
||||
border: 1px solid var(--td-component-stroke, #e7e7e7);
|
||||
border-radius: 8px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--td-text-color-placeholder, #b8b8b8);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--td-brand-color, #f39c12);
|
||||
box-shadow: 0 0 0 2px rgba(243, 156, 18, 0.1);
|
||||
background: var(--td-bg-color-container, #ffffff);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--td-text-color-placeholder, #b8b8b8);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes sceneExpand {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* StoryboardEditor Component
|
||||
*
|
||||
* Multi-scene editor for storyboard mode in video generation.
|
||||
* Supports adding/removing scenes, duration control, and prompt input.
|
||||
* Inspired by storyflow-lite design with timeline visualization.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { Button, Switch, Select } from 'tdesign-react';
|
||||
import { AddIcon, DeleteIcon, TimeIcon, ChevronRightIcon } from 'tdesign-icons-react';
|
||||
import type { StoryboardScene } from '../../../types/video.types';
|
||||
import { createEmptyScene } from '../../../utils/storyboard-utils';
|
||||
import { ScenePromptInput } from './ScenePromptInput';
|
||||
import './StoryboardEditor.scss';
|
||||
|
||||
interface StoryboardEditorProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
totalDuration: number;
|
||||
maxScenes: number;
|
||||
minSceneDuration: number;
|
||||
scenes: StoryboardScene[];
|
||||
onScenesChange: (scenes: StoryboardScene[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const StoryboardEditor: React.FC<StoryboardEditorProps> = ({
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
totalDuration,
|
||||
maxScenes,
|
||||
minSceneDuration,
|
||||
scenes,
|
||||
onScenesChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
// Selected scene state for expanded view
|
||||
const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
|
||||
const scenesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-select first scene when enabled
|
||||
useEffect(() => {
|
||||
if (enabled && scenes.length > 0 && !selectedSceneId) {
|
||||
setSelectedSceneId(scenes[0].id);
|
||||
}
|
||||
}, [enabled, scenes, selectedSceneId]);
|
||||
|
||||
// Handle adding a new scene - preserve existing durations, allocate from remaining time
|
||||
const handleAddScene = useCallback(() => {
|
||||
if (scenes.length >= maxScenes) return;
|
||||
|
||||
// Calculate total used duration by existing scenes
|
||||
const usedDuration = scenes.reduce((sum, scene) => sum + scene.duration, 0);
|
||||
const remainingDuration = totalDuration - usedDuration;
|
||||
|
||||
// New scene gets half of remaining duration (minimum is minSceneDuration)
|
||||
let newSceneDuration = Math.max(minSceneDuration, remainingDuration / 2);
|
||||
// Round to 1 decimal place
|
||||
newSceneDuration = parseFloat(newSceneDuration.toFixed(1));
|
||||
|
||||
// If not enough remaining time, use minimum duration
|
||||
if (newSceneDuration < minSceneDuration) {
|
||||
newSceneDuration = minSceneDuration;
|
||||
}
|
||||
|
||||
// Add new scene with calculated duration (keep existing scenes unchanged)
|
||||
const newScene = createEmptyScene(scenes.length + 1, newSceneDuration);
|
||||
onScenesChange([...scenes, newScene]);
|
||||
|
||||
// Auto-select new scene and scroll to it
|
||||
setSelectedSceneId(newScene.id);
|
||||
setTimeout(() => {
|
||||
scenesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
}, [scenes, maxScenes, totalDuration, minSceneDuration, onScenesChange]);
|
||||
|
||||
// Handle removing a scene - preserve existing durations, only update order
|
||||
const handleRemoveScene = useCallback(
|
||||
(sceneId: string) => {
|
||||
if (scenes.length <= 1) return;
|
||||
|
||||
const filteredScenes = scenes.filter(s => s.id !== sceneId);
|
||||
|
||||
// Only update order, preserve existing durations
|
||||
const updatedScenes = filteredScenes.map((scene, index) => ({
|
||||
...scene,
|
||||
order: index + 1,
|
||||
}));
|
||||
|
||||
onScenesChange(updatedScenes);
|
||||
|
||||
// Select previous or first scene
|
||||
if (selectedSceneId === sceneId) {
|
||||
setSelectedSceneId(updatedScenes[0]?.id || null);
|
||||
}
|
||||
},
|
||||
[scenes, onScenesChange, selectedSceneId]
|
||||
);
|
||||
|
||||
// Calculate the maximum available duration for a specific scene
|
||||
const getMaxDurationForScene = useCallback(
|
||||
(sceneId: string) => {
|
||||
// Sum of all other scenes' durations
|
||||
const otherScenesDuration = scenes
|
||||
.filter(s => s.id !== sceneId)
|
||||
.reduce((sum, s) => sum + s.duration, 0);
|
||||
// Max available = total - others, but at least minSceneDuration
|
||||
return Math.max(minSceneDuration, totalDuration - otherScenesDuration);
|
||||
},
|
||||
[scenes, totalDuration, minSceneDuration]
|
||||
);
|
||||
|
||||
// Generate duration options for a specific scene (respecting max available)
|
||||
// Use 0.1s step for full range
|
||||
const getDurationOptionsForScene = useCallback(
|
||||
(sceneId: string) => {
|
||||
const maxAvailable = getMaxDurationForScene(sceneId);
|
||||
const options = [];
|
||||
const step = 0.1;
|
||||
|
||||
// Generate options from minSceneDuration to maxAvailable with 0.1s step
|
||||
for (let i = minSceneDuration; i <= maxAvailable + 0.001; i += step) {
|
||||
const value = parseFloat(i.toFixed(1));
|
||||
if (value <= maxAvailable) {
|
||||
options.push({
|
||||
label: `${value}`,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
[minSceneDuration, getMaxDurationForScene]
|
||||
);
|
||||
|
||||
// Handle scene duration change
|
||||
const handleDurationChange = useCallback(
|
||||
(sceneId: string, value: number | string) => {
|
||||
const newDuration = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(newDuration)) return;
|
||||
|
||||
// Calculate max available for this scene
|
||||
const maxAvailable = getMaxDurationForScene(sceneId);
|
||||
|
||||
const updatedScenes = scenes.map(scene => {
|
||||
if (scene.id === sceneId) {
|
||||
// Clamp duration: min is minSceneDuration, max is maxAvailable
|
||||
const clampedDuration = Math.max(
|
||||
minSceneDuration,
|
||||
Math.min(newDuration, maxAvailable)
|
||||
);
|
||||
// Round to 1 decimal place for cleaner display
|
||||
const rounded = parseFloat(clampedDuration.toFixed(1));
|
||||
return { ...scene, duration: rounded };
|
||||
}
|
||||
return scene;
|
||||
});
|
||||
|
||||
onScenesChange(updatedScenes);
|
||||
},
|
||||
[scenes, minSceneDuration, getMaxDurationForScene, onScenesChange]
|
||||
);
|
||||
|
||||
// Handle scene prompt change
|
||||
const handlePromptChange = useCallback(
|
||||
(sceneId: string, newPrompt: string) => {
|
||||
const updatedScenes = scenes.map(scene => {
|
||||
if (scene.id === sceneId) {
|
||||
return { ...scene, prompt: newPrompt };
|
||||
}
|
||||
return scene;
|
||||
});
|
||||
onScenesChange(updatedScenes);
|
||||
},
|
||||
[scenes, onScenesChange]
|
||||
);
|
||||
|
||||
// Handle toggle enabled
|
||||
const handleToggleEnabled = useCallback(
|
||||
(value: boolean) => {
|
||||
onEnabledChange(value);
|
||||
if (value && scenes.length === 0) {
|
||||
// Initialize with first scene when enabled
|
||||
const firstScene = createEmptyScene(1, totalDuration);
|
||||
onScenesChange([firstScene]);
|
||||
setSelectedSceneId(firstScene.id);
|
||||
}
|
||||
},
|
||||
[scenes, totalDuration, onEnabledChange, onScenesChange]
|
||||
);
|
||||
|
||||
// Handle scene selection
|
||||
const handleSelectScene = useCallback((sceneId: string) => {
|
||||
setSelectedSceneId(sceneId);
|
||||
}, []);
|
||||
|
||||
const canAddMore = scenes.length < maxScenes;
|
||||
|
||||
return (
|
||||
<div className="storyboard-editor">
|
||||
{/* Toggle switch header */}
|
||||
<div className="storyboard-editor__header">
|
||||
<div className="storyboard-editor__toggle">
|
||||
<Switch
|
||||
value={enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
/>
|
||||
<div className="storyboard-editor__title">
|
||||
<span className="storyboard-editor__title-text">故事场景模式</span>
|
||||
<span className="storyboard-editor__title-desc">
|
||||
定义多个场景及其时长
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{enabled && (
|
||||
<span className="storyboard-editor__new-badge">NEW</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scenes list */}
|
||||
{enabled && (
|
||||
<div className="storyboard-editor__content">
|
||||
{/* Section title */}
|
||||
<div className="storyboard-editor__section-header">
|
||||
<span className="storyboard-editor__section-title">场景列表</span>
|
||||
<span className="storyboard-editor__section-hint">
|
||||
共 {scenes.length} 个场景,已用 {scenes.reduce((sum, s) => sum + s.duration, 0).toFixed(1)} 秒 / 总时长 {totalDuration} 秒
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scene cards with timeline */}
|
||||
<div className="storyboard-editor__scenes">
|
||||
{scenes.map((scene, index) => {
|
||||
const isSelected = selectedSceneId === scene.id;
|
||||
const isLast = index === scenes.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={scene.id}
|
||||
className={`storyboard-editor__scene-wrapper ${isSelected ? 'is-selected' : ''}`}
|
||||
>
|
||||
{/* Timeline connector line */}
|
||||
<div className={`storyboard-editor__timeline-line ${isLast ? 'is-last' : ''}`} />
|
||||
|
||||
{/* Timeline node */}
|
||||
<div className={`storyboard-editor__timeline-node ${isSelected ? 'is-selected' : ''}`}>
|
||||
{isSelected ? index + 1 : ''}
|
||||
</div>
|
||||
|
||||
{/* Scene card - Compact view */}
|
||||
{!isSelected && (
|
||||
<div
|
||||
className="storyboard-editor__scene storyboard-editor__scene--compact"
|
||||
onClick={() => handleSelectScene(scene.id)}
|
||||
>
|
||||
<span className="storyboard-editor__scene-index">{index + 1}</span>
|
||||
<div className="storyboard-editor__scene-preview">
|
||||
<p className="storyboard-editor__scene-preview-text">
|
||||
{scene.prompt || '暂无场景描述...'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="storyboard-editor__scene-duration-badge">
|
||||
<TimeIcon size="12px" />
|
||||
<span>{scene.duration}s</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="storyboard-editor__scene-arrow" size="14px" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scene card - Expanded view */}
|
||||
{isSelected && (
|
||||
<div className="storyboard-editor__scene storyboard-editor__scene--expanded">
|
||||
<div className="storyboard-editor__scene-header">
|
||||
<div className="storyboard-editor__scene-info">
|
||||
<span className="storyboard-editor__scene-label">
|
||||
<span className="storyboard-editor__active-indicator" />
|
||||
场景 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="storyboard-editor__scene-controls">
|
||||
<TimeIcon className="storyboard-editor__time-icon" />
|
||||
<Select
|
||||
value={parseFloat(scene.duration.toFixed(1))}
|
||||
onChange={value => handleDurationChange(scene.id, value as number)}
|
||||
disabled={disabled}
|
||||
className="storyboard-editor__duration-select"
|
||||
size="small"
|
||||
options={getDurationOptionsForScene(scene.id)}
|
||||
filterable
|
||||
creatable
|
||||
popupProps={{ overlayClassName: 'storyboard-editor__duration-popup' }}
|
||||
/>
|
||||
{scenes.length > 1 && (
|
||||
<Button
|
||||
theme="default"
|
||||
variant="text"
|
||||
size="small"
|
||||
icon={<DeleteIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveScene(scene.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="storyboard-editor__scene-delete"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="storyboard-editor__scene-prompt">
|
||||
<ScenePromptInput
|
||||
value={scene.prompt}
|
||||
onChange={value => handlePromptChange(scene.id, value)}
|
||||
placeholder={`描述场景 ${index + 1} 的内容,输入 @ 可引用角色`}
|
||||
disabled={disabled}
|
||||
autoFocus={true}
|
||||
enableMention={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scenesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Add scene button */}
|
||||
{canAddMore && (
|
||||
<Button
|
||||
theme="default"
|
||||
variant="dashed"
|
||||
size="medium"
|
||||
icon={<AddIcon />}
|
||||
onClick={handleAddScene}
|
||||
disabled={disabled}
|
||||
className="storyboard-editor__add-btn"
|
||||
block
|
||||
>
|
||||
添加场景
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoryboardEditor;
|
||||
@@ -132,12 +132,22 @@ export const useGenerationState = (type: 'image' | 'video') => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getPromptExample = (language: 'zh' | 'en', type: 'image' | 'video') => {
|
||||
export const getPromptExample = (
|
||||
language: 'zh' | 'en',
|
||||
type: 'image' | 'video',
|
||||
videoProvider?: 'sora' | 'veo' | string
|
||||
) => {
|
||||
if (type === 'image') {
|
||||
return language === 'zh'
|
||||
? '一只可爱的小猫坐在窗台上,阳光透过窗户洒在它的毛发上,背景是温馨的家居环境'
|
||||
: 'A cute kitten sitting on a windowsill, with sunlight streaming through the window onto its fur, with a cozy home environment in the background';
|
||||
} else {
|
||||
// For Sora models, include @ mention hint in the placeholder
|
||||
if (videoProvider === 'sora') {
|
||||
return language === 'zh'
|
||||
? '描述视频内容,输入 @ 可引用已创建的角色,如 @username 在海边漫步...'
|
||||
: 'Describe video content, type @ to reference created characters, e.g. @username walking on the beach...';
|
||||
}
|
||||
return language === 'zh'
|
||||
? '生成一个美丽的日出场景,阳光从山峰后缓缓升起,云朵轻柔地飘动'
|
||||
: 'Generate a beautiful sunrise scene where the sun slowly rises from behind mountains with clouds gently floating';
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from './preload-utils';
|
||||
export * from './size-constants';
|
||||
export * from './VideoModelOptions';
|
||||
export * from './MultiImageUpload';
|
||||
export * from './AspectRatioSelector';
|
||||
export * from './AspectRatioSelector';
|
||||
export * from './StoryboardEditor';
|
||||
@@ -38,6 +38,15 @@ export const PROVIDER_NAMES: Record<ModelProvider, string> = {
|
||||
/** Predefined chat models */
|
||||
export const CHAT_MODELS: ChatModel[] = [
|
||||
// OpenAI Models
|
||||
{
|
||||
id: 'gpt-image-1.5',
|
||||
name: 'GPT Image 1.5',
|
||||
description: 'OpenAI 最新图像生成模型',
|
||||
provider: ModelProvider.OPENAI,
|
||||
badges: ['NEW', 'Multimodal'],
|
||||
maxTokens: 128000,
|
||||
supportsVision: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-image',
|
||||
name: 'GPT-4o Image',
|
||||
@@ -48,13 +57,28 @@ export const CHAT_MODELS: ChatModel[] = [
|
||||
supportsVision: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT-5.2',
|
||||
description: '最新旗舰模型',
|
||||
provider: ModelProvider.OPENAI,
|
||||
badges: ['NEW'],
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2-all',
|
||||
name: 'GPT-5.2 All',
|
||||
description: '全功能版本',
|
||||
provider: ModelProvider.OPENAI,
|
||||
badges: ['NEW'],
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
description: '旗舰模型',
|
||||
provider: ModelProvider.OPENAI,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-thinking',
|
||||
name: 'GPT-5.1 Thinking',
|
||||
|
||||
@@ -17,7 +17,7 @@ export const VIDEO_MODEL_CONFIGS: Record<VideoModel, VideoModelConfig> = {
|
||||
id: 'sora-2',
|
||||
label: 'Sora 2',
|
||||
provider: 'sora',
|
||||
description: '10s/15s 默认标清',
|
||||
description: '10s/15s 默认标清,支持故事场景模式',
|
||||
durationOptions: [
|
||||
{ label: '10秒', value: '10' },
|
||||
{ label: '15秒', value: '15' },
|
||||
@@ -33,12 +33,17 @@ export const VIDEO_MODEL_CONFIGS: Record<VideoModel, VideoModelConfig> = {
|
||||
mode: 'reference',
|
||||
labels: ['参考图'],
|
||||
},
|
||||
storyboardMode: {
|
||||
supported: true,
|
||||
maxScenes: 15,
|
||||
minSceneDuration: 0.1,
|
||||
},
|
||||
},
|
||||
'sora-2-pro': {
|
||||
id: 'sora-2-pro',
|
||||
label: 'Sora 2 Pro',
|
||||
provider: 'sora',
|
||||
description: '10s/15s 默认高清,25s 默认标清',
|
||||
description: '10s/15s/25s 高清,支持故事场景模式',
|
||||
durationOptions: [
|
||||
{ label: '10秒', value: '10' },
|
||||
{ label: '15秒', value: '15' },
|
||||
@@ -57,6 +62,11 @@ export const VIDEO_MODEL_CONFIGS: Record<VideoModel, VideoModelConfig> = {
|
||||
mode: 'reference',
|
||||
labels: ['参考图'],
|
||||
},
|
||||
storyboardMode: {
|
||||
supported: true,
|
||||
maxScenes: 15,
|
||||
minSceneDuration: 0.1,
|
||||
},
|
||||
},
|
||||
|
||||
// Veo models
|
||||
@@ -208,3 +218,23 @@ export function getImageUploadLabels(model: VideoModel): string[] {
|
||||
const config = VIDEO_MODEL_CONFIGS[model];
|
||||
return config.imageUpload.labels || ['参考图'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model supports storyboard mode
|
||||
*/
|
||||
export function supportsStoryboardMode(model: VideoModel): boolean {
|
||||
const config = VIDEO_MODEL_CONFIGS[model];
|
||||
return config.storyboardMode?.supported ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storyboard mode configuration for a model
|
||||
*/
|
||||
export function getStoryboardModeConfig(model: VideoModel) {
|
||||
const config = VIDEO_MODEL_CONFIGS[model];
|
||||
return config.storyboardMode ?? {
|
||||
supported: false,
|
||||
maxScenes: 15,
|
||||
minSceneDuration: 0.1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* useCharacters Hook
|
||||
*
|
||||
* React hook for managing Sora characters.
|
||||
* Provides character list state, creation, deletion, and status management.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { characterStorageService } from '../services/character-storage-service';
|
||||
import { characterAPIService } from '../services/character-api-service';
|
||||
import type {
|
||||
SoraCharacter,
|
||||
CharacterStatus,
|
||||
CreateCharacterParams,
|
||||
} from '../types/character.types';
|
||||
|
||||
interface UseCharactersReturn {
|
||||
/** All characters */
|
||||
characters: SoraCharacter[];
|
||||
/** Characters that are completed */
|
||||
completedCharacters: SoraCharacter[];
|
||||
/** Characters that are being created */
|
||||
pendingCharacters: SoraCharacter[];
|
||||
/** Whether initial load is in progress */
|
||||
isLoading: boolean;
|
||||
/** Create a new character from a video task */
|
||||
createCharacter: (params: CreateCharacterParams) => Promise<SoraCharacter | null>;
|
||||
/** Delete a character */
|
||||
deleteCharacter: (id: string) => Promise<boolean>;
|
||||
/** Refresh character list from storage */
|
||||
refreshCharacters: () => Promise<void>;
|
||||
/** Get a character by ID */
|
||||
getCharacterById: (id: string) => SoraCharacter | undefined;
|
||||
/** Check if a task already has a character being created */
|
||||
isCreatingCharacterForTask: (taskId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing Sora characters
|
||||
*/
|
||||
export function useCharacters(): UseCharactersReturn {
|
||||
const [characters, setCharacters] = useState<SoraCharacter[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Track active polling for characters
|
||||
const pollingRef = useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
// Subscribe to character storage changes
|
||||
useEffect(() => {
|
||||
const subscription = characterStorageService
|
||||
.getCharacters$()
|
||||
.subscribe(chars => {
|
||||
setCharacters(chars);
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Initialize storage
|
||||
characterStorageService.init();
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Resume polling for pending characters on mount
|
||||
useEffect(() => {
|
||||
const resumePendingCharacters = async () => {
|
||||
const pending = characterStorageService.getPendingCharacters();
|
||||
console.log('[useCharacters] Found', pending.length, 'pending characters to resume');
|
||||
|
||||
for (const character of pending) {
|
||||
if (!pollingRef.current.get(character.id)) {
|
||||
resumePollingForCharacter(character);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for initial load
|
||||
if (!isLoading) {
|
||||
resumePendingCharacters();
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
/**
|
||||
* Resume polling for a pending character
|
||||
*/
|
||||
const resumePollingForCharacter = useCallback(async (character: SoraCharacter) => {
|
||||
if (pollingRef.current.get(character.id)) {
|
||||
console.log('[useCharacters] Already polling for:', character.id);
|
||||
return;
|
||||
}
|
||||
|
||||
pollingRef.current.set(character.id, true);
|
||||
console.log('[useCharacters] Resuming polling for:', character.id);
|
||||
|
||||
try {
|
||||
const result = await characterAPIService.resumePolling(character.id, {
|
||||
onStatusChange: async (status: CharacterStatus) => {
|
||||
await characterStorageService.updateCharacterStatus(character.id, status);
|
||||
},
|
||||
});
|
||||
|
||||
// Update with full character info
|
||||
await characterStorageService.updateCharacter(character.id, {
|
||||
username: result.username,
|
||||
profilePictureUrl: result.profile_picture_url,
|
||||
permalink: result.permalink,
|
||||
status: 'completed' as CharacterStatus,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
|
||||
console.log('[useCharacters] Character completed:', result.username);
|
||||
} catch (error) {
|
||||
console.error('[useCharacters] Character polling failed:', error);
|
||||
await characterStorageService.updateCharacterStatus(
|
||||
character.id,
|
||||
'failed' as CharacterStatus,
|
||||
(error as Error).message
|
||||
);
|
||||
} finally {
|
||||
pollingRef.current.delete(character.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new character from a video task
|
||||
*/
|
||||
const createCharacter = useCallback(async (
|
||||
params: CreateCharacterParams
|
||||
): Promise<SoraCharacter | null> => {
|
||||
try {
|
||||
console.log('[useCharacters] Creating character from:', params.videoTaskId);
|
||||
|
||||
// Create character via API
|
||||
const createResponse = await characterAPIService.createCharacter(params);
|
||||
const characterId = createResponse.id;
|
||||
|
||||
// Create initial character record
|
||||
const character: SoraCharacter = {
|
||||
id: characterId,
|
||||
username: '', // Will be filled when completed
|
||||
profilePictureUrl: '', // Will be filled when completed
|
||||
sourceTaskId: params.localTaskId || '',
|
||||
sourceVideoId: params.videoTaskId,
|
||||
sourcePrompt: params.sourcePrompt,
|
||||
characterTimestamps: params.characterTimestamps,
|
||||
status: 'processing' as CharacterStatus,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
// Save to storage
|
||||
await characterStorageService.saveCharacter(character);
|
||||
|
||||
// Start polling in background
|
||||
pollingRef.current.set(characterId, true);
|
||||
|
||||
characterAPIService.resumePolling(characterId, {
|
||||
onStatusChange: async (status: CharacterStatus) => {
|
||||
await characterStorageService.updateCharacterStatus(characterId, status);
|
||||
},
|
||||
}).then(async (result) => {
|
||||
// Update with full character info
|
||||
await characterStorageService.updateCharacter(characterId, {
|
||||
username: result.username,
|
||||
profilePictureUrl: result.profile_picture_url,
|
||||
permalink: result.permalink,
|
||||
status: 'completed' as CharacterStatus,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
console.log('[useCharacters] Character completed:', result.username);
|
||||
}).catch(async (error) => {
|
||||
console.error('[useCharacters] Character creation failed:', error);
|
||||
await characterStorageService.updateCharacterStatus(
|
||||
characterId,
|
||||
'failed' as CharacterStatus,
|
||||
(error as Error).message
|
||||
);
|
||||
}).finally(() => {
|
||||
pollingRef.current.delete(characterId);
|
||||
});
|
||||
|
||||
return character;
|
||||
} catch (error) {
|
||||
console.error('[useCharacters] Failed to create character:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a character
|
||||
*/
|
||||
const deleteCharacter = useCallback(async (id: string): Promise<boolean> => {
|
||||
// Stop any active polling
|
||||
pollingRef.current.delete(id);
|
||||
|
||||
return characterStorageService.deleteCharacter(id);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh character list from storage
|
||||
*/
|
||||
const refreshCharacters = useCallback(async (): Promise<void> => {
|
||||
await characterStorageService.init();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a character by ID
|
||||
*/
|
||||
const getCharacterById = useCallback((id: string): SoraCharacter | undefined => {
|
||||
return characters.find(c => c.id === id);
|
||||
}, [characters]);
|
||||
|
||||
/**
|
||||
* Check if a task already has a character being created
|
||||
*/
|
||||
const isCreatingCharacterForTask = useCallback((taskId: string): boolean => {
|
||||
return characters.some(
|
||||
c => c.sourceTaskId === taskId &&
|
||||
(c.status === 'pending' || c.status === 'processing')
|
||||
);
|
||||
}, [characters]);
|
||||
|
||||
// Computed values
|
||||
const completedCharacters = characters.filter(c => c.status === 'completed');
|
||||
const pendingCharacters = characters.filter(
|
||||
c => c.status === 'pending' || c.status === 'processing'
|
||||
);
|
||||
|
||||
return {
|
||||
characters,
|
||||
completedCharacters,
|
||||
pendingCharacters,
|
||||
isLoading,
|
||||
createCharacter,
|
||||
deleteCharacter,
|
||||
refreshCharacters,
|
||||
getCharacterById,
|
||||
isCreatingCharacterForTask,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* useMention Hook
|
||||
*
|
||||
* Reusable hook for @ mention functionality in textarea inputs.
|
||||
* Handles mention detection, popup positioning, keyboard navigation, and character selection.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, RefObject } from 'react';
|
||||
import { useCharacters } from './useCharacters';
|
||||
import type { SoraCharacter } from '../types/character.types';
|
||||
|
||||
export interface MentionState {
|
||||
visible: boolean;
|
||||
query: string;
|
||||
position: { top: number; left: number };
|
||||
showBelow: boolean;
|
||||
startIndex: number;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export interface UseMentionOptions {
|
||||
/** Whether mention feature is enabled */
|
||||
enabled?: boolean;
|
||||
/** Callback when prompt value changes */
|
||||
onPromptChange: (value: string) => void;
|
||||
/** Current prompt value */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface UseMentionReturn {
|
||||
/** Current mention state */
|
||||
mentionState: MentionState;
|
||||
/** Set mention state */
|
||||
setMentionState: React.Dispatch<React.SetStateAction<MentionState>>;
|
||||
/** Ref to attach to textarea */
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
/** Completed characters list */
|
||||
completedCharacters: SoraCharacter[];
|
||||
/** Handle text change with mention detection */
|
||||
handleTextChange: (value: string, cursorPos: number) => void;
|
||||
/** Handle keyboard navigation */
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
/** Handle character selection */
|
||||
handleCharacterSelect: (character: SoraCharacter) => void;
|
||||
/** Close mention popup */
|
||||
closeMentionPopup: () => void;
|
||||
/** Calculate mention position for a given cursor index */
|
||||
calculateMentionPosition: (cursorIndex: number) => { top: number; left: number; showBelow: boolean };
|
||||
}
|
||||
|
||||
const initialMentionState: MentionState = {
|
||||
visible: false,
|
||||
query: '',
|
||||
position: { top: 0, left: 0 },
|
||||
showBelow: false,
|
||||
startIndex: -1,
|
||||
selectedIndex: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* useMention - Hook for @ mention functionality
|
||||
*/
|
||||
export function useMention({
|
||||
enabled = true,
|
||||
onPromptChange,
|
||||
prompt,
|
||||
}: UseMentionOptions): UseMentionReturn {
|
||||
const [mentionState, setMentionState] = useState<MentionState>(initialMentionState);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { completedCharacters } = useCharacters();
|
||||
|
||||
// Calculate mention popup position using mirror div technique
|
||||
const calculateMentionPosition = useCallback((cursorIndex: number) => {
|
||||
if (!textareaRef.current) return { top: 0, left: 0, showBelow: false };
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
|
||||
// Create a mirror div that matches textarea styling
|
||||
const mirror = document.createElement('div');
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
|
||||
// Copy relevant styles to mirror
|
||||
const stylesToCopy = [
|
||||
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
|
||||
'letterSpacing', 'lineHeight', 'textTransform',
|
||||
'wordSpacing', 'whiteSpace',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
||||
'boxSizing',
|
||||
];
|
||||
|
||||
mirror.style.position = 'absolute';
|
||||
mirror.style.top = '0';
|
||||
mirror.style.left = '-9999px';
|
||||
mirror.style.visibility = 'hidden';
|
||||
mirror.style.whiteSpace = 'pre-wrap';
|
||||
mirror.style.overflowWrap = 'break-word';
|
||||
mirror.style.overflow = 'hidden';
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
|
||||
stylesToCopy.forEach(style => {
|
||||
(mirror.style as any)[style] = computedStyle.getPropertyValue(
|
||||
style.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
// Get text before cursor
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorIndex);
|
||||
|
||||
// Create content with cursor marker
|
||||
const textNode = document.createTextNode(textBeforeCursor);
|
||||
const cursorSpan = document.createElement('span');
|
||||
cursorSpan.textContent = '\u200B'; // Zero-width space as cursor marker
|
||||
|
||||
mirror.appendChild(textNode);
|
||||
mirror.appendChild(cursorSpan);
|
||||
|
||||
// Get cursor position
|
||||
const cursorRect = cursorSpan.getBoundingClientRect();
|
||||
const mirrorRect = mirror.getBoundingClientRect();
|
||||
|
||||
// Calculate offset within textarea
|
||||
const cursorOffsetTop = cursorRect.top - mirrorRect.top;
|
||||
const cursorOffsetLeft = cursorRect.left - mirrorRect.left;
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
// Account for textarea scroll and get line height
|
||||
const scrollTop = textarea.scrollTop;
|
||||
const lineHeight = parseInt(computedStyle.lineHeight) || 20;
|
||||
|
||||
// Calculate position relative to viewport
|
||||
let top = rect.top + cursorOffsetTop - scrollTop;
|
||||
let left = rect.left + cursorOffsetLeft;
|
||||
|
||||
// Ensure popup doesn't go off-screen horizontally
|
||||
const popupWidth = 240;
|
||||
const viewportWidth = window.innerWidth;
|
||||
if (left + popupWidth > viewportWidth - 16) {
|
||||
left = viewportWidth - popupWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
|
||||
// Check if there's enough space above for the popup
|
||||
const popupHeight = 220;
|
||||
const showBelow = top < popupHeight + 16;
|
||||
|
||||
if (showBelow) {
|
||||
// Show below cursor - add line height to position below current line
|
||||
top = top + lineHeight + 4;
|
||||
} else {
|
||||
// Show above cursor - move up a bit more for better visual spacing
|
||||
top = top - 48;
|
||||
}
|
||||
|
||||
return { top, left, showBelow };
|
||||
}, []);
|
||||
|
||||
// Handle @ mention detection
|
||||
const handleMentionDetection = useCallback((value: string, cursorPos: number) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the last @ before cursor
|
||||
const textBeforeCursor = value.substring(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if @ is at the start or after a space
|
||||
const charBeforeAt = lastAtIndex > 0 ? value[lastAtIndex - 1] : ' ';
|
||||
if (!/\s/.test(charBeforeAt) && lastAtIndex !== 0) {
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get query after @
|
||||
const query = textBeforeCursor.substring(lastAtIndex + 1);
|
||||
|
||||
// Check if query contains space (mention ended)
|
||||
if (query.includes(' ')) {
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Show mention popup
|
||||
const positionResult = calculateMentionPosition(cursorPos);
|
||||
setMentionState({
|
||||
visible: true,
|
||||
query,
|
||||
position: { top: positionResult.top, left: positionResult.left },
|
||||
showBelow: positionResult.showBelow,
|
||||
startIndex: lastAtIndex,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
}, [enabled, calculateMentionPosition]);
|
||||
|
||||
// Handle character selection
|
||||
const handleCharacterSelect = useCallback((character: SoraCharacter) => {
|
||||
if (mentionState.startIndex === -1) return;
|
||||
|
||||
const beforeMention = prompt.substring(0, mentionState.startIndex);
|
||||
const cursorPos = textareaRef.current?.selectionStart || prompt.length;
|
||||
const afterMention = prompt.substring(cursorPos);
|
||||
|
||||
// Replace @query with @username
|
||||
const newPrompt = `${beforeMention}@${character.username} ${afterMention}`;
|
||||
onPromptChange(newPrompt);
|
||||
|
||||
// Close mention popup
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
|
||||
// Focus textarea and set cursor after the inserted username
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
const newCursorPos = beforeMention.length + character.username.length + 2; // +2 for @ and space
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
}, 0);
|
||||
}, [mentionState.startIndex, prompt, onPromptChange]);
|
||||
|
||||
// Handle keyboard navigation in mention popup
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.visible) return;
|
||||
|
||||
// Filter characters by query for navigation
|
||||
const filteredCharacters = mentionState.query
|
||||
? completedCharacters.filter(c =>
|
||||
c.username.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
)
|
||||
: completedCharacters;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setMentionState(prev => ({
|
||||
...prev,
|
||||
selectedIndex: Math.min(prev.selectedIndex + 1, filteredCharacters.length - 1),
|
||||
}));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setMentionState(prev => ({
|
||||
...prev,
|
||||
selectedIndex: Math.max(prev.selectedIndex - 1, 0),
|
||||
}));
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
if (filteredCharacters.length > 0) {
|
||||
e.preventDefault();
|
||||
handleCharacterSelect(filteredCharacters[mentionState.selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
break;
|
||||
}
|
||||
}, [mentionState, completedCharacters, handleCharacterSelect]);
|
||||
|
||||
// Handle text change with mention detection
|
||||
const handleTextChange = useCallback((value: string, cursorPos: number) => {
|
||||
onPromptChange(value);
|
||||
handleMentionDetection(value, cursorPos);
|
||||
}, [onPromptChange, handleMentionDetection]);
|
||||
|
||||
// Close mention popup
|
||||
const closeMentionPopup = useCallback(() => {
|
||||
setMentionState(prev => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mentionState,
|
||||
setMentionState,
|
||||
textareaRef,
|
||||
completedCharacters,
|
||||
handleTextChange,
|
||||
handleKeyDown,
|
||||
handleCharacterSelect,
|
||||
closeMentionPopup,
|
||||
calculateMentionPosition,
|
||||
};
|
||||
}
|
||||
|
||||
export default useMention;
|
||||
@@ -8,7 +8,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { taskQueueService } from '../services/task-queue-service';
|
||||
import { generationAPIService } from '../services/generation-api-service';
|
||||
import { characterAPIService } from '../services/character-api-service';
|
||||
import { characterStorageService } from '../services/character-storage-service';
|
||||
import { Task, TaskStatus, TaskType } from '../types/task.types';
|
||||
import { CharacterStatus } from '../types/character.types';
|
||||
import { isTaskTimeout } from '../utils/task-utils';
|
||||
import { shouldRetry, getNextRetryTime } from '../utils/retry-utils';
|
||||
|
||||
@@ -205,10 +208,141 @@ export function useTaskExecutor(): void {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to execute a character task
|
||||
const executeCharacterTask = async (task: Task) => {
|
||||
const taskId = task.id;
|
||||
|
||||
// Prevent duplicate execution
|
||||
if (executingTasksRef.current.has(taskId)) {
|
||||
console.log(`[TaskExecutor] Character task ${taskId} is already executing`);
|
||||
return;
|
||||
}
|
||||
|
||||
executingTasksRef.current.add(taskId);
|
||||
console.log(`[TaskExecutor] Starting character task ${taskId}`);
|
||||
|
||||
try {
|
||||
// Update status to processing
|
||||
taskQueueService.updateTaskStatus(taskId, TaskStatus.PROCESSING);
|
||||
|
||||
const { sourceVideoTaskId, characterTimestamps, model, prompt } = task.params;
|
||||
|
||||
if (!sourceVideoTaskId) {
|
||||
throw new Error('缺少源视频任务ID');
|
||||
}
|
||||
|
||||
// Create character via API with polling
|
||||
const result = await characterAPIService.createCharacterWithPolling(
|
||||
{
|
||||
videoTaskId: sourceVideoTaskId,
|
||||
characterTimestamps,
|
||||
localTaskId: task.params.sourceLocalTaskId,
|
||||
sourcePrompt: prompt,
|
||||
sourceModel: model,
|
||||
},
|
||||
{
|
||||
onStatusChange: (status: CharacterStatus) => {
|
||||
console.log(`[TaskExecutor] Character ${taskId} status: ${status}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
// Save character to storage
|
||||
await characterStorageService.saveCharacter({
|
||||
id: result.characterId,
|
||||
username: result.username,
|
||||
profilePictureUrl: result.profile_picture_url,
|
||||
permalink: result.permalink,
|
||||
sourceTaskId: task.params.sourceLocalTaskId || '',
|
||||
sourceVideoId: sourceVideoTaskId,
|
||||
sourcePrompt: prompt,
|
||||
characterTimestamps,
|
||||
status: 'completed' as CharacterStatus,
|
||||
createdAt: task.createdAt,
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Mark task as completed with character info
|
||||
taskQueueService.updateTaskStatus(taskId, TaskStatus.COMPLETED, {
|
||||
result: {
|
||||
url: result.profile_picture_url,
|
||||
format: 'character',
|
||||
size: 0,
|
||||
characterUsername: result.username,
|
||||
characterProfileUrl: result.profile_picture_url,
|
||||
characterPermalink: result.permalink,
|
||||
},
|
||||
remoteId: result.characterId,
|
||||
});
|
||||
|
||||
console.log(`[TaskExecutor] Character task ${taskId} completed: @${result.username}`);
|
||||
} catch (error: any) {
|
||||
if (!isActive) return;
|
||||
|
||||
console.error(`[TaskExecutor] Character task ${taskId} failed:`, error);
|
||||
|
||||
const updatedTask = taskQueueService.getTask(taskId);
|
||||
if (!updatedTask) return;
|
||||
|
||||
const errorCode = error.httpStatus ? `HTTP_${error.httpStatus}` : (error.name || 'ERROR');
|
||||
const errorMessage = getFriendlyErrorMessage(error);
|
||||
const originalErrorInfo = error.fullResponse || error.apiErrorBody || error.message || String(error);
|
||||
const errorDetails = {
|
||||
originalError: originalErrorInfo,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Check if we should retry
|
||||
if (shouldRetry(updatedTask)) {
|
||||
const nextRetryAt = getNextRetryTime(updatedTask);
|
||||
|
||||
taskQueueService.updateTaskStatus(taskId, TaskStatus.RETRYING, {
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
details: errorDetails,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[TaskExecutor] Character task ${taskId} will retry`);
|
||||
|
||||
if (nextRetryAt) {
|
||||
const delay = nextRetryAt - Date.now();
|
||||
setTimeout(() => {
|
||||
if (!isActive) return;
|
||||
const t = taskQueueService.getTask(taskId);
|
||||
if (t && t.status === TaskStatus.RETRYING) {
|
||||
taskQueueService.updateTaskStatus(taskId, TaskStatus.PENDING);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
} else {
|
||||
taskQueueService.updateTaskStatus(taskId, TaskStatus.FAILED, {
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
details: errorDetails,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[TaskExecutor] Character task ${taskId} failed permanently`);
|
||||
}
|
||||
} finally {
|
||||
executingTasksRef.current.delete(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to execute a single task
|
||||
const executeTask = async (task: Task) => {
|
||||
const taskId = task.id;
|
||||
|
||||
// Check if this is a character task
|
||||
if (task.type === TaskType.CHARACTER) {
|
||||
return executeCharacterTask(task);
|
||||
}
|
||||
|
||||
// Check if this is a resumable video task
|
||||
if (task.type === TaskType.VIDEO && task.remoteId && task.status === TaskStatus.PROCESSING) {
|
||||
return resumeVideoTask(task);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Character API Service
|
||||
*
|
||||
* Handles Sora-2 character creation and query API calls.
|
||||
* Characters are extracted from completed Sora-2 video tasks.
|
||||
*/
|
||||
|
||||
import { geminiSettings } from '../utils/settings-manager';
|
||||
import {
|
||||
getCharacterModel,
|
||||
type CreateCharacterParams,
|
||||
type CharacterCreateResponse,
|
||||
type CharacterQueryResponse,
|
||||
type CharacterPollingOptions,
|
||||
type CharacterStatus,
|
||||
} from '../types/character.types';
|
||||
|
||||
/**
|
||||
* Character API Service
|
||||
* Manages character creation with async polling
|
||||
*/
|
||||
class CharacterAPIService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = 'https://api.tu-zi.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a character from a Sora-2 video task
|
||||
* @param params - Character creation parameters
|
||||
* @returns Character creation response with ID
|
||||
*/
|
||||
async createCharacter(params: CreateCharacterParams): Promise<CharacterCreateResponse> {
|
||||
const settings = geminiSettings.get();
|
||||
const apiKey = settings.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('API Key 未配置,请先配置 API Key');
|
||||
}
|
||||
|
||||
// Get character model based on source video model
|
||||
const characterModel = getCharacterModel(params.sourceModel);
|
||||
|
||||
console.log('[CharacterAPI] Creating character from video:', params.videoTaskId);
|
||||
console.log('[CharacterAPI] Source model:', params.sourceModel, '-> Character model:', characterModel);
|
||||
console.log('[CharacterAPI] Timestamps:', params.characterTimestamps || 'default');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('character_from_task', params.videoTaskId);
|
||||
formData.append('model', characterModel);
|
||||
|
||||
if (params.characterTimestamps) {
|
||||
// Note: API parameter is character_timestamps (not chacter_timestamps)
|
||||
formData.append('character_timestamps', params.characterTimestamps);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/videos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[CharacterAPI] Create failed:', response.status, errorText);
|
||||
const error = new Error(`角色创建失败: ${response.status} - ${errorText}`);
|
||||
(error as any).apiErrorBody = errorText;
|
||||
(error as any).httpStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[CharacterAPI] Character created:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query character status and information
|
||||
* @param characterId - Character ID (format: sora-2-character:ch_xxx)
|
||||
* @returns Character information
|
||||
*/
|
||||
async queryCharacter(characterId: string): Promise<CharacterQueryResponse> {
|
||||
const settings = geminiSettings.get();
|
||||
const apiKey = settings.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('API Key 未配置');
|
||||
}
|
||||
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 2000;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/v1/videos/${characterId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[CharacterAPI] Query failed:', response.status, errorText);
|
||||
|
||||
// Check if it's a "not ready" error (character still being processed)
|
||||
if (response.status === 404 || response.status === 202) {
|
||||
throw new Error('CHARACTER_PROCESSING');
|
||||
}
|
||||
|
||||
const error = new Error(`角色查询失败: ${response.status} - ${errorText}`);
|
||||
(error as any).apiErrorBody = errorText;
|
||||
(error as any).httpStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[CharacterAPI] Character query result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
const isNetworkError = error instanceof TypeError &&
|
||||
(error.message.includes('Failed to fetch') || error.message.includes('network'));
|
||||
|
||||
if (isNetworkError && attempt < maxRetries) {
|
||||
console.warn(`[CharacterAPI] Network error on attempt ${attempt}/${maxRetries}, retrying...`);
|
||||
await this.sleep(retryDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('角色查询失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create character and poll until completion
|
||||
* @param params - Character creation parameters
|
||||
* @param options - Polling options
|
||||
* @returns Completed character information
|
||||
*/
|
||||
async createCharacterWithPolling(
|
||||
params: CreateCharacterParams,
|
||||
options: CharacterPollingOptions = {}
|
||||
): Promise<CharacterQueryResponse & { characterId: string }> {
|
||||
const {
|
||||
interval = 3000,
|
||||
maxAttempts = 60, // 3 minutes at 3s interval
|
||||
onStatusChange,
|
||||
} = options;
|
||||
|
||||
// Create character
|
||||
console.log('[CharacterAPI] Submitting character creation...');
|
||||
const createResponse = await this.createCharacter(params);
|
||||
const characterId = createResponse.id;
|
||||
console.log('[CharacterAPI] Character creation submitted:', characterId);
|
||||
|
||||
// Notify status change
|
||||
if (onStatusChange) {
|
||||
onStatusChange('processing' as CharacterStatus);
|
||||
}
|
||||
|
||||
// Poll for completion
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
await this.sleep(interval);
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
console.log(`[CharacterAPI] Polling attempt ${attempts}/${maxAttempts}...`);
|
||||
const result = await this.queryCharacter(characterId);
|
||||
|
||||
// Character is ready when we get username and profile_picture_url
|
||||
if (result.username && result.profile_picture_url) {
|
||||
console.log('[CharacterAPI] Character ready:', result.username);
|
||||
if (onStatusChange) {
|
||||
onStatusChange('completed' as CharacterStatus);
|
||||
}
|
||||
return { ...result, characterId };
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's a processing error, continue polling
|
||||
if ((error as Error).message === 'CHARACTER_PROCESSING') {
|
||||
console.log('[CharacterAPI] Character still processing...');
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, check if we should continue
|
||||
console.warn('[CharacterAPI] Query error:', (error as Error).message);
|
||||
|
||||
// If it's the last attempt, throw the error
|
||||
if (attempts >= maxAttempts) {
|
||||
if (onStatusChange) {
|
||||
onStatusChange('failed' as CharacterStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (onStatusChange) {
|
||||
onStatusChange('failed' as CharacterStatus);
|
||||
}
|
||||
throw new Error('角色创建超时,请稍后重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume polling for an existing character
|
||||
* Used to recover from page refresh
|
||||
* @param characterId - Character ID to poll
|
||||
* @param options - Polling options
|
||||
* @returns Character information
|
||||
*/
|
||||
async resumePolling(
|
||||
characterId: string,
|
||||
options: CharacterPollingOptions = {}
|
||||
): Promise<CharacterQueryResponse> {
|
||||
const { onStatusChange } = options;
|
||||
|
||||
console.log('[CharacterAPI] Resuming poll for character:', characterId);
|
||||
|
||||
// Check immediate status
|
||||
try {
|
||||
const result = await this.queryCharacter(characterId);
|
||||
|
||||
if (result.username && result.profile_picture_url) {
|
||||
console.log('[CharacterAPI] Character already ready:', result.username);
|
||||
if (onStatusChange) {
|
||||
onStatusChange('completed' as CharacterStatus);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).message !== 'CHARACTER_PROCESSING') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
return this.pollUntilComplete(characterId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for character completion
|
||||
* @private
|
||||
*/
|
||||
private async pollUntilComplete(
|
||||
characterId: string,
|
||||
options: CharacterPollingOptions = {}
|
||||
): Promise<CharacterQueryResponse> {
|
||||
const {
|
||||
interval = 3000,
|
||||
maxAttempts = 60,
|
||||
onStatusChange,
|
||||
} = options;
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
await this.sleep(interval);
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
console.log(`[CharacterAPI] Poll attempt ${attempts}/${maxAttempts}...`);
|
||||
const result = await this.queryCharacter(characterId);
|
||||
|
||||
if (result.username && result.profile_picture_url) {
|
||||
console.log('[CharacterAPI] Character ready:', result.username);
|
||||
if (onStatusChange) {
|
||||
onStatusChange('completed' as CharacterStatus);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'CHARACTER_PROCESSING') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
if (onStatusChange) {
|
||||
onStatusChange('failed' as CharacterStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onStatusChange) {
|
||||
onStatusChange('failed' as CharacterStatus);
|
||||
}
|
||||
throw new Error('角色创建超时,请稍后重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const characterAPIService = new CharacterAPIService();
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Character Avatar Cache Service
|
||||
*
|
||||
* Caches character profile pictures to IndexedDB to prevent
|
||||
* broken images when the original URL expires.
|
||||
*/
|
||||
|
||||
import localforage from 'localforage';
|
||||
|
||||
// Storage key prefix
|
||||
const STORAGE_PREFIX = 'avatar-';
|
||||
|
||||
// Initialize localforage instance for avatar cache
|
||||
const avatarStore = localforage.createInstance({
|
||||
name: 'drawnix',
|
||||
storeName: 'character-avatars',
|
||||
description: 'Character avatar cache',
|
||||
});
|
||||
|
||||
/**
|
||||
* Cached avatar data
|
||||
*/
|
||||
export interface CachedAvatar {
|
||||
/** Character ID */
|
||||
characterId: string;
|
||||
/** Avatar blob data */
|
||||
blob: Blob;
|
||||
/** MIME type */
|
||||
mimeType: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Cache timestamp */
|
||||
cachedAt: number;
|
||||
/** Original URL (for reference) */
|
||||
originalUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Character Avatar Cache Service
|
||||
* Singleton service for caching character profile pictures
|
||||
*/
|
||||
class CharacterAvatarCacheService {
|
||||
// In-memory object URL cache (to avoid creating multiple URLs for same blob)
|
||||
private objectUrlCache: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Cache a character avatar from URL
|
||||
* @param characterId - Character ID
|
||||
* @param url - Profile picture URL
|
||||
* @returns true if cached successfully
|
||||
*/
|
||||
async cacheAvatar(characterId: string, url: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if already cached
|
||||
const existing = await this.getCachedAvatar(characterId);
|
||||
if (existing) {
|
||||
console.log('[AvatarCache] Avatar already cached for:', characterId);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[AvatarCache] Caching avatar for:', characterId);
|
||||
|
||||
// Use Image + Canvas approach to handle CORS issues
|
||||
const blob = await this.fetchImageAsBlob(url);
|
||||
if (!blob) {
|
||||
throw new Error('Failed to fetch avatar image');
|
||||
}
|
||||
|
||||
const mimeType = blob.type || 'image/png';
|
||||
|
||||
// Store in IndexedDB
|
||||
const cachedAvatar: CachedAvatar = {
|
||||
characterId,
|
||||
blob,
|
||||
mimeType,
|
||||
size: blob.size,
|
||||
cachedAt: Date.now(),
|
||||
originalUrl: url,
|
||||
};
|
||||
|
||||
await avatarStore.setItem(`${STORAGE_PREFIX}${characterId}`, cachedAvatar);
|
||||
console.log('[AvatarCache] Avatar cached successfully:', characterId, `(${blob.size} bytes)`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to cache avatar:', characterId, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch image as blob using Image + Canvas to avoid CORS issues
|
||||
* @param url - Image URL
|
||||
* @returns Blob or null if failed
|
||||
*/
|
||||
private async fetchImageAsBlob(url: string): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
} catch (error) {
|
||||
console.warn('[AvatarCache] Canvas draw failed:', error);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.warn('[AvatarCache] Image load failed for:', url);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
// Add cache buster to avoid stale cache
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached avatar data
|
||||
* @param characterId - Character ID
|
||||
* @returns Cached avatar data or null
|
||||
*/
|
||||
async getCachedAvatar(characterId: string): Promise<CachedAvatar | null> {
|
||||
try {
|
||||
const cached = await avatarStore.getItem<CachedAvatar>(`${STORAGE_PREFIX}${characterId}`);
|
||||
return cached || null;
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to get cached avatar:', characterId, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached avatar URL (creates object URL from blob)
|
||||
* @param characterId - Character ID
|
||||
* @returns Object URL or null if not cached
|
||||
*/
|
||||
async getCachedUrl(characterId: string): Promise<string | null> {
|
||||
// Check in-memory cache first
|
||||
const cachedUrl = this.objectUrlCache.get(characterId);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
// Get from IndexedDB
|
||||
const cached = await this.getCachedAvatar(characterId);
|
||||
if (cached?.blob) {
|
||||
const url = URL.createObjectURL(cached.blob);
|
||||
this.objectUrlCache.set(characterId, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar URL with fallback
|
||||
* Returns cached URL if available, otherwise returns original URL
|
||||
* @param characterId - Character ID
|
||||
* @param originalUrl - Original profile picture URL
|
||||
* @returns URL to use for display
|
||||
*/
|
||||
async getAvatarUrl(characterId: string, originalUrl: string): Promise<string> {
|
||||
// Try cached URL first
|
||||
const cachedUrl = await this.getCachedUrl(characterId);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
// Return original URL (and trigger background cache)
|
||||
this.cacheAvatar(characterId, originalUrl).catch(() => {
|
||||
// Ignore cache errors
|
||||
});
|
||||
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if avatar is cached
|
||||
* @param characterId - Character ID
|
||||
* @returns true if cached
|
||||
*/
|
||||
async isCached(characterId: string): Promise<boolean> {
|
||||
const cached = await this.getCachedAvatar(characterId);
|
||||
return cached !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached avatar
|
||||
* @param characterId - Character ID
|
||||
* @returns true if deleted successfully
|
||||
*/
|
||||
async deleteCache(characterId: string): Promise<boolean> {
|
||||
try {
|
||||
// Revoke object URL if exists
|
||||
const cachedUrl = this.objectUrlCache.get(characterId);
|
||||
if (cachedUrl) {
|
||||
URL.revokeObjectURL(cachedUrl);
|
||||
this.objectUrlCache.delete(characterId);
|
||||
}
|
||||
|
||||
await avatarStore.removeItem(`${STORAGE_PREFIX}${characterId}`);
|
||||
console.log('[AvatarCache] Avatar cache deleted:', characterId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to delete avatar cache:', characterId, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached avatars
|
||||
* @returns true if cleared successfully
|
||||
*/
|
||||
async clearAll(): Promise<boolean> {
|
||||
try {
|
||||
// Revoke all object URLs
|
||||
this.objectUrlCache.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.objectUrlCache.clear();
|
||||
|
||||
await avatarStore.clear();
|
||||
console.log('[AvatarCache] All avatar caches cleared');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to clear all caches:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total cache size
|
||||
* @returns Total size in bytes
|
||||
*/
|
||||
async getTotalCacheSize(): Promise<number> {
|
||||
try {
|
||||
let totalSize = 0;
|
||||
await avatarStore.iterate<CachedAvatar, void>((value) => {
|
||||
if (value?.size) {
|
||||
totalSize += value.size;
|
||||
}
|
||||
});
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to get cache size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache count
|
||||
* @returns Number of cached avatars
|
||||
*/
|
||||
async getCacheCount(): Promise<number> {
|
||||
try {
|
||||
return await avatarStore.length();
|
||||
} catch (error) {
|
||||
console.error('[AvatarCache] Failed to get cache count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup - revoke object URLs on service destroy
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.objectUrlCache.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
this.objectUrlCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const characterAvatarCacheService = new CharacterAvatarCacheService();
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Character Storage Service
|
||||
*
|
||||
* Handles persistent storage of Sora characters using IndexedDB (via localforage).
|
||||
* Provides CRUD operations and state persistence for characters.
|
||||
*/
|
||||
|
||||
import localforage from 'localforage';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import type { SoraCharacter, CharacterStatus } from '../types/character.types';
|
||||
import { characterAvatarCacheService } from './character-avatar-cache-service';
|
||||
|
||||
// Storage key for characters
|
||||
const STORAGE_KEY = 'sora-characters';
|
||||
|
||||
// Initialize localforage instance for characters
|
||||
const characterStore = localforage.createInstance({
|
||||
name: 'drawnix',
|
||||
storeName: 'characters',
|
||||
description: 'Sora character storage',
|
||||
});
|
||||
|
||||
/**
|
||||
* Character Storage Service
|
||||
* Manages character persistence with IndexedDB
|
||||
*/
|
||||
class CharacterStorageService {
|
||||
private characters$ = new BehaviorSubject<SoraCharacter[]>([]);
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize on first access
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service and load characters from storage
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
const characters = await this.loadFromStorage();
|
||||
this.characters$.next(characters);
|
||||
this.initialized = true;
|
||||
console.log('[CharacterStorage] Initialized with', characters.length, 'characters');
|
||||
} catch (error) {
|
||||
console.error('[CharacterStorage] Failed to initialize:', error);
|
||||
this.characters$.next([]);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observable for character list
|
||||
*/
|
||||
getCharacters$(): Observable<SoraCharacter[]> {
|
||||
return this.characters$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current character list
|
||||
*/
|
||||
getCharacters(): SoraCharacter[] {
|
||||
return this.characters$.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get characters by status
|
||||
*/
|
||||
getCharactersByStatus(status: CharacterStatus): SoraCharacter[] {
|
||||
return this.getCharacters().filter(c => c.status === status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a character by ID
|
||||
*/
|
||||
getCharacterById(id: string): SoraCharacter | undefined {
|
||||
return this.getCharacters().find(c => c.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get characters by source task ID
|
||||
*/
|
||||
getCharactersBySourceTask(taskId: string): SoraCharacter[] {
|
||||
return this.getCharacters().filter(c => c.sourceTaskId === taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new character
|
||||
*/
|
||||
async saveCharacter(character: SoraCharacter): Promise<void> {
|
||||
const characters = this.getCharacters();
|
||||
const existingIndex = characters.findIndex(c => c.id === character.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing
|
||||
characters[existingIndex] = character;
|
||||
} else {
|
||||
// Add new
|
||||
characters.push(character);
|
||||
}
|
||||
|
||||
await this.saveToStorage(characters);
|
||||
this.characters$.next([...characters]);
|
||||
console.log('[CharacterStorage] Character saved:', character.id);
|
||||
|
||||
// Cache avatar if character is completed and has profile picture
|
||||
if (character.status === 'completed' && character.profilePictureUrl) {
|
||||
characterAvatarCacheService.cacheAvatar(character.id, character.profilePictureUrl)
|
||||
.catch(err => console.warn('[CharacterStorage] Failed to cache avatar:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a character
|
||||
*/
|
||||
async updateCharacter(
|
||||
id: string,
|
||||
updates: Partial<SoraCharacter>
|
||||
): Promise<SoraCharacter | null> {
|
||||
const characters = this.getCharacters();
|
||||
const index = characters.findIndex(c => c.id === id);
|
||||
|
||||
if (index < 0) {
|
||||
console.warn('[CharacterStorage] Character not found:', id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedCharacter = { ...characters[index], ...updates };
|
||||
characters[index] = updatedCharacter;
|
||||
|
||||
await this.saveToStorage(characters);
|
||||
this.characters$.next([...characters]);
|
||||
console.log('[CharacterStorage] Character updated:', id);
|
||||
|
||||
// Cache avatar if character becomes completed and has profile picture
|
||||
if (updatedCharacter.status === 'completed' && updatedCharacter.profilePictureUrl) {
|
||||
characterAvatarCacheService.cacheAvatar(id, updatedCharacter.profilePictureUrl)
|
||||
.catch(err => console.warn('[CharacterStorage] Failed to cache avatar:', err));
|
||||
}
|
||||
|
||||
return updatedCharacter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update character status
|
||||
*/
|
||||
async updateCharacterStatus(
|
||||
id: string,
|
||||
status: CharacterStatus,
|
||||
error?: string
|
||||
): Promise<SoraCharacter | null> {
|
||||
const updates: Partial<SoraCharacter> = { status };
|
||||
|
||||
if (status === 'completed') {
|
||||
updates.completedAt = Date.now();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
updates.error = error;
|
||||
}
|
||||
|
||||
return this.updateCharacter(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a character
|
||||
*/
|
||||
async deleteCharacter(id: string): Promise<boolean> {
|
||||
const characters = this.getCharacters();
|
||||
const index = characters.findIndex(c => c.id === id);
|
||||
|
||||
if (index < 0) {
|
||||
console.warn('[CharacterStorage] Character not found for deletion:', id);
|
||||
return false;
|
||||
}
|
||||
|
||||
characters.splice(index, 1);
|
||||
|
||||
await this.saveToStorage(characters);
|
||||
this.characters$.next([...characters]);
|
||||
console.log('[CharacterStorage] Character deleted:', id);
|
||||
|
||||
// Clean up avatar cache
|
||||
characterAvatarCacheService.deleteCache(id)
|
||||
.catch(err => console.warn('[CharacterStorage] Failed to delete avatar cache:', err));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all characters from a source task
|
||||
*/
|
||||
async deleteCharactersBySourceTask(taskId: string): Promise<number> {
|
||||
const characters = this.getCharacters();
|
||||
const originalLength = characters.length;
|
||||
const filtered = characters.filter(c => c.sourceTaskId !== taskId);
|
||||
|
||||
if (filtered.length === originalLength) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await this.saveToStorage(filtered);
|
||||
this.characters$.next(filtered);
|
||||
|
||||
const deletedCount = originalLength - filtered.length;
|
||||
console.log('[CharacterStorage] Deleted', deletedCount, 'characters from task:', taskId);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all characters
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
await this.saveToStorage([]);
|
||||
this.characters$.next([]);
|
||||
console.log('[CharacterStorage] All characters cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending characters that need to resume polling
|
||||
*/
|
||||
getPendingCharacters(): SoraCharacter[] {
|
||||
return this.getCharacters().filter(
|
||||
c => c.status === 'pending' || c.status === 'processing'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load characters from storage
|
||||
* @private
|
||||
*/
|
||||
private async loadFromStorage(): Promise<SoraCharacter[]> {
|
||||
try {
|
||||
const data = await characterStore.getItem<SoraCharacter[]>(STORAGE_KEY);
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.error('[CharacterStorage] Load error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save characters to storage
|
||||
* @private
|
||||
*/
|
||||
private async saveToStorage(characters: SoraCharacter[]): Promise<void> {
|
||||
try {
|
||||
await characterStore.setItem(STORAGE_KEY, characters);
|
||||
} catch (error) {
|
||||
console.error('[CharacterStorage] Save error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const characterStorageService = new CharacterStorageService();
|
||||
@@ -81,6 +81,20 @@ class VideoAPIService {
|
||||
throw new Error('API Key 未配置,请先配置 API Key');
|
||||
}
|
||||
|
||||
// Log request parameters
|
||||
console.log('[VideoAPI] ========== Video Generation Request ==========');
|
||||
console.log('[VideoAPI] Request params:', {
|
||||
model: params.model,
|
||||
prompt: params.prompt,
|
||||
seconds: params.seconds,
|
||||
size: params.size,
|
||||
inputReferencesCount: params.inputReferences?.length || 0,
|
||||
hasLegacyInputReference: !!params.inputReference,
|
||||
});
|
||||
console.log('[VideoAPI] Full prompt:');
|
||||
console.log(params.prompt);
|
||||
console.log('[VideoAPI] ===============================================');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('model', params.model);
|
||||
formData.append('prompt', params.prompt);
|
||||
@@ -142,6 +156,19 @@ class VideoAPIService {
|
||||
}
|
||||
}
|
||||
|
||||
// Log FormData summary before sending
|
||||
console.log('[VideoAPI] FormData summary:');
|
||||
const formDataEntries: Record<string, string> = {};
|
||||
formData.forEach((value, key) => {
|
||||
if (value instanceof Blob) {
|
||||
formDataEntries[key] = `[Blob: ${value.size} bytes, type: ${value.type}]`;
|
||||
} else {
|
||||
formDataEntries[key] = String(value);
|
||||
}
|
||||
});
|
||||
console.log('[VideoAPI] FormData entries:', formDataEntries);
|
||||
console.log('[VideoAPI] Sending request to:', `${this.baseUrl}/v1/videos`);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/videos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -165,7 +192,7 @@ class VideoAPIService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query video generation status
|
||||
* Query video generation status (with network retry)
|
||||
*/
|
||||
async queryVideoStatus(videoId: string): Promise<VideoQueryResponse> {
|
||||
const settings = geminiSettings.get();
|
||||
@@ -175,25 +202,49 @@ class VideoAPIService {
|
||||
throw new Error('API Key 未配置');
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v1/videos/${videoId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 2000; // 2 seconds
|
||||
let lastError: Error | null = null;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[VideoAPI] Query failed:', response.status, errorText);
|
||||
const error = new Error(`视频状态查询失败: ${response.status} - ${errorText}`);
|
||||
(error as any).apiErrorBody = errorText;
|
||||
(error as any).httpStatus = response.status;
|
||||
throw error;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/v1/videos/${videoId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[VideoAPI] Query failed:', response.status, errorText);
|
||||
const error = new Error(`视频状态查询失败: ${response.status} - ${errorText}`);
|
||||
(error as any).apiErrorBody = errorText;
|
||||
(error as any).httpStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[VideoAPI] Query response:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
const isNetworkError = error instanceof TypeError &&
|
||||
(error.message.includes('Failed to fetch') || error.message.includes('network'));
|
||||
|
||||
if (isNetworkError && attempt < maxRetries) {
|
||||
console.warn(`[VideoAPI] Network error on attempt ${attempt}/${maxRetries}, retrying in ${retryDelay}ms...`);
|
||||
await this.sleep(retryDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-network error or last attempt, throw immediately
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[VideoAPI] Query response:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
// Should not reach here, but just in case
|
||||
throw lastError || new Error('视频状态查询失败');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Sora Character Types
|
||||
*
|
||||
* Type definitions for Sora-2 character creation and management.
|
||||
* Characters can be extracted from completed Sora-2 video tasks
|
||||
* and reused in future video generations via @username mentions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Character creation status
|
||||
*/
|
||||
export enum CharacterStatus {
|
||||
/** Character creation request submitted, waiting for processing */
|
||||
PENDING = 'pending',
|
||||
/** Character is being processed by the API */
|
||||
PROCESSING = 'processing',
|
||||
/** Character creation completed successfully */
|
||||
COMPLETED = 'completed',
|
||||
/** Character creation failed */
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* Sora character interface
|
||||
* Represents a character extracted from a Sora-2 video
|
||||
*/
|
||||
export interface SoraCharacter {
|
||||
/** Character ID from API (format: sora-2-character:ch_xxx) */
|
||||
id: string;
|
||||
/** Username for @mention in prompts */
|
||||
username: string;
|
||||
/** Character profile picture URL */
|
||||
profilePictureUrl: string;
|
||||
/** Permalink to character profile (optional) */
|
||||
permalink?: string;
|
||||
|
||||
// Source information
|
||||
/** Local task ID that the character was extracted from */
|
||||
sourceTaskId: string;
|
||||
/** Remote video ID (format: sora-2:task_xxx) */
|
||||
sourceVideoId: string;
|
||||
/** Source video prompt (for reference) */
|
||||
sourcePrompt?: string;
|
||||
|
||||
// Creation parameters
|
||||
/** Time range used for extraction (format: "start,end") */
|
||||
characterTimestamps?: string;
|
||||
|
||||
// Status tracking
|
||||
/** Current character status */
|
||||
status: CharacterStatus;
|
||||
/** Creation timestamp (Unix milliseconds) */
|
||||
createdAt: number;
|
||||
/** Completion timestamp (Unix milliseconds) */
|
||||
completedAt?: number;
|
||||
/** Error message if creation failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for creating a character
|
||||
*/
|
||||
export interface CreateCharacterParams {
|
||||
/** Source video task ID (format: sora-2:task_xxx) */
|
||||
videoTaskId: string;
|
||||
/** Time range for character extraction (format: "start,end", e.g., "0,3") */
|
||||
characterTimestamps?: string;
|
||||
/** Local task ID for reference */
|
||||
localTaskId?: string;
|
||||
/** Source prompt for reference */
|
||||
sourcePrompt?: string;
|
||||
/** Source video model (sora-2 or sora-2-pro) - used to determine character model */
|
||||
sourceModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from character creation API
|
||||
*/
|
||||
export interface CharacterCreateResponse {
|
||||
/** Character ID (format: sora-2-character:ch_xxx) */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from character query API
|
||||
*/
|
||||
export interface CharacterQueryResponse {
|
||||
/** Character ID */
|
||||
id: string;
|
||||
/** Username for @mention */
|
||||
username: string;
|
||||
/** Profile page URL */
|
||||
permalink: string;
|
||||
/** Profile picture URL */
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Character polling options
|
||||
*/
|
||||
export interface CharacterPollingOptions {
|
||||
/** Polling interval in milliseconds (default: 3000) */
|
||||
interval?: number;
|
||||
/** Maximum polling attempts (default: 60 = 3 minutes at 3s interval) */
|
||||
maxAttempts?: number;
|
||||
/** Callback when status changes */
|
||||
onStatusChange?: (status: CharacterStatus) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Character event types
|
||||
*/
|
||||
export type CharacterEventType =
|
||||
| 'characterCreated'
|
||||
| 'characterUpdated'
|
||||
| 'characterDeleted'
|
||||
| 'characterCompleted'
|
||||
| 'characterFailed';
|
||||
|
||||
/**
|
||||
* Character event
|
||||
*/
|
||||
export interface CharacterEvent {
|
||||
/** Event type */
|
||||
type: CharacterEventType;
|
||||
/** The character that triggered the event */
|
||||
character: SoraCharacter;
|
||||
/** Timestamp when the event occurred */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates time range for character extraction
|
||||
* @param start - Start time in seconds
|
||||
* @param end - End time in seconds
|
||||
* @param videoDuration - Total video duration in seconds
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validateCharacterTimestamps(
|
||||
start: number,
|
||||
end: number,
|
||||
videoDuration?: number
|
||||
): { valid: boolean; error?: string } {
|
||||
const duration = end - start;
|
||||
|
||||
if (start < 0) {
|
||||
return { valid: false, error: '开始时间不能为负数' };
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
return { valid: false, error: '结束时间必须大于开始时间' };
|
||||
}
|
||||
|
||||
if (duration < 1) {
|
||||
return { valid: false, error: '时间范围至少需要 1 秒' };
|
||||
}
|
||||
|
||||
if (duration > 3) {
|
||||
return { valid: false, error: '时间范围不能超过 3 秒' };
|
||||
}
|
||||
|
||||
if (videoDuration !== undefined && end > videoDuration) {
|
||||
return { valid: false, error: `结束时间不能超过视频时长 ${videoDuration} 秒` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats time range for API request
|
||||
* @param start - Start time in seconds
|
||||
* @param end - End time in seconds
|
||||
* @returns Formatted string "start,end"
|
||||
*/
|
||||
export function formatCharacterTimestamps(start: number, end: number): string {
|
||||
return `${start},${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses time range string
|
||||
* @param timestamps - Time range string "start,end"
|
||||
* @returns Parsed start and end times, or null if invalid
|
||||
*/
|
||||
export function parseCharacterTimestamps(
|
||||
timestamps: string
|
||||
): { start: number; end: number } | null {
|
||||
const parts = timestamps.split(',');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = parseFloat(parts[0]);
|
||||
const end = parseFloat(parts[1]);
|
||||
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a video model supports character extraction
|
||||
* @param model - Video model name
|
||||
* @returns true if the model supports character extraction
|
||||
*/
|
||||
export function supportsCharacterExtraction(model?: string): boolean {
|
||||
if (!model) return false;
|
||||
return model === 'sora-2' || model === 'sora-2-pro';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a remote video ID is from Sora-2 (including sora-2-pro)
|
||||
* @param remoteId - Remote video ID
|
||||
* @returns true if the ID is from Sora-2 or Sora-2-Pro
|
||||
*/
|
||||
export function isSora2VideoId(remoteId?: string): boolean {
|
||||
if (!remoteId) return false;
|
||||
return remoteId.startsWith('sora-2:') || remoteId.startsWith('sora-2-pro:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the character model for a source video model
|
||||
* @param sourceModel - Source video model (sora-2 or sora-2-pro)
|
||||
* @returns Character model name
|
||||
*/
|
||||
export function getCharacterModel(sourceModel?: string): string {
|
||||
if (sourceModel === 'sora-2-pro') {
|
||||
return 'sora-2-pro-character';
|
||||
}
|
||||
// Default to sora-2-character
|
||||
return 'sora-2-character';
|
||||
}
|
||||
@@ -33,7 +33,9 @@ export enum TaskType {
|
||||
/** Image generation task */
|
||||
IMAGE = 'image',
|
||||
/** Video generation task */
|
||||
VIDEO = 'video'
|
||||
VIDEO = 'video',
|
||||
/** Character extraction task */
|
||||
CHARACTER = 'character'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,12 @@ export interface GenerationParams {
|
||||
model?: string;
|
||||
/** Random seed for reproducible generation */
|
||||
seed?: number;
|
||||
/** Source video task ID for character extraction */
|
||||
sourceVideoTaskId?: string;
|
||||
/** Time range for character extraction (format: "start,end") */
|
||||
characterTimestamps?: string;
|
||||
/** Local task ID of source video */
|
||||
sourceLocalTaskId?: string;
|
||||
/** Additional parameters for specific generation types */
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -78,6 +86,12 @@ export interface TaskResult {
|
||||
duration?: number;
|
||||
/** Video thumbnail URL (video only) */
|
||||
thumbnailUrl?: string;
|
||||
/** Character username for @mention (character only) */
|
||||
characterUsername?: string;
|
||||
/** Character profile picture URL (character only) */
|
||||
characterProfileUrl?: string;
|
||||
/** Character permalink (character only) */
|
||||
characterPermalink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface ImageUploadConfig {
|
||||
required?: boolean; // Whether image upload is required
|
||||
}
|
||||
|
||||
// Storyboard mode configuration for models
|
||||
export interface StoryboardModeConfig {
|
||||
supported: boolean; // Whether model supports storyboard mode
|
||||
maxScenes: number; // Maximum scenes allowed (default 5)
|
||||
minSceneDuration: number; // Minimum duration per scene in seconds
|
||||
}
|
||||
|
||||
// Video model configuration
|
||||
export interface VideoModelConfig {
|
||||
id: VideoModel;
|
||||
@@ -55,6 +62,8 @@ export interface VideoModelConfig {
|
||||
defaultSize: string;
|
||||
// Image upload configuration
|
||||
imageUpload: ImageUploadConfig;
|
||||
// Storyboard mode configuration (optional, only for supported models)
|
||||
storyboardMode?: StoryboardModeConfig;
|
||||
}
|
||||
|
||||
// Uploaded image with slot info
|
||||
@@ -66,6 +75,21 @@ export interface UploadedVideoImage {
|
||||
file?: File; // Original file object
|
||||
}
|
||||
|
||||
// Storyboard scene definition
|
||||
export interface StoryboardScene {
|
||||
id: string; // Unique scene ID
|
||||
duration: number; // Duration in seconds (e.g., 7.5)
|
||||
prompt: string; // Scene prompt/description
|
||||
order: number; // Scene order (1-5)
|
||||
}
|
||||
|
||||
// Storyboard configuration
|
||||
export interface StoryboardConfig {
|
||||
enabled: boolean; // Whether storyboard mode is enabled
|
||||
scenes: StoryboardScene[]; // Scene list (1-5 scenes)
|
||||
totalDuration: number; // Total video duration from model (10/15/25)
|
||||
}
|
||||
|
||||
// Video generation parameters (extended)
|
||||
export interface VideoGenerationParams {
|
||||
model: VideoModel;
|
||||
@@ -76,4 +100,6 @@ export interface VideoGenerationParams {
|
||||
inputReferences?: UploadedVideoImage[];
|
||||
// Legacy single image support
|
||||
inputReference?: string;
|
||||
// Storyboard mode configuration
|
||||
storyboard?: StoryboardConfig;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Storyboard Mode Utilities
|
||||
*
|
||||
* Utility functions for managing storyboard scenes in video generation.
|
||||
* Supports duration calculation, validation, and prompt formatting.
|
||||
*/
|
||||
|
||||
import type { StoryboardScene } from '../types/video.types';
|
||||
|
||||
/**
|
||||
* Generate a unique scene ID
|
||||
*/
|
||||
export function generateSceneId(): string {
|
||||
return `scene-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new empty scene
|
||||
*/
|
||||
export function createEmptyScene(order: number, duration: number): StoryboardScene {
|
||||
return {
|
||||
id: generateSceneId(),
|
||||
order,
|
||||
duration,
|
||||
prompt: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to 2 decimal places (avoids floating point precision issues)
|
||||
* Example: 1.875 -> 1.88, 7.5 -> 7.5, 10 -> 10
|
||||
*/
|
||||
function roundToTwoDecimals(value: number): number {
|
||||
return parseFloat(value.toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum scene duration constant
|
||||
*/
|
||||
const MIN_SCENE_DURATION = 0.1;
|
||||
|
||||
/**
|
||||
* Calculate default scene durations using halving strategy
|
||||
*
|
||||
* Strategy:
|
||||
* - 1 scene: total duration
|
||||
* - 2 scenes: half each (e.g., 15s -> [7.5, 7.5])
|
||||
* - 3+ scenes: first scene gets half, then each new scene halves the last scene's time
|
||||
* (e.g., 15s with 3 scenes -> [7.5, 3.75, 3.75])
|
||||
*
|
||||
* Rules:
|
||||
* - Keep up to 2 decimal places
|
||||
* - When calculated duration approaches MIN_SCENE_DURATION (0.1s), use MIN_SCENE_DURATION
|
||||
* - All subsequent scenes after hitting minimum also get MIN_SCENE_DURATION
|
||||
*
|
||||
* @param totalDuration Total video duration in seconds
|
||||
* @param sceneCount Number of scenes
|
||||
* @returns Array of durations for each scene
|
||||
*/
|
||||
export function calculateDefaultSceneDurations(
|
||||
totalDuration: number,
|
||||
sceneCount: number
|
||||
): number[] {
|
||||
if (sceneCount <= 0) return [];
|
||||
if (sceneCount === 1) return [totalDuration];
|
||||
|
||||
const durations: number[] = [];
|
||||
let remaining = totalDuration;
|
||||
let hitMinimum = false;
|
||||
|
||||
for (let i = 0; i < sceneCount; i++) {
|
||||
if (i === sceneCount - 1) {
|
||||
// Last scene gets all remaining time (but not less than minimum)
|
||||
const finalDuration = Math.max(MIN_SCENE_DURATION, roundToTwoDecimals(remaining));
|
||||
durations.push(finalDuration);
|
||||
} else if (hitMinimum) {
|
||||
// Once we hit minimum, all subsequent scenes get minimum duration
|
||||
durations.push(MIN_SCENE_DURATION);
|
||||
remaining -= MIN_SCENE_DURATION;
|
||||
} else {
|
||||
// Each scene gets half of remaining time
|
||||
const duration = remaining / 2;
|
||||
const rounded = roundToTwoDecimals(duration);
|
||||
|
||||
// Check if we're approaching minimum duration
|
||||
if (rounded <= MIN_SCENE_DURATION) {
|
||||
hitMinimum = true;
|
||||
durations.push(MIN_SCENE_DURATION);
|
||||
remaining -= MIN_SCENE_DURATION;
|
||||
} else {
|
||||
durations.push(rounded);
|
||||
remaining -= rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return durations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate equal scene durations
|
||||
*
|
||||
* Alternative strategy: Distribute time equally among all scenes.
|
||||
*
|
||||
* @param totalDuration Total video duration in seconds
|
||||
* @param sceneCount Number of scenes
|
||||
* @returns Array of equal durations
|
||||
*/
|
||||
export function calculateEqualSceneDurations(
|
||||
totalDuration: number,
|
||||
sceneCount: number
|
||||
): number[] {
|
||||
if (sceneCount <= 0) return [];
|
||||
const duration = Math.round((totalDuration / sceneCount) * 10) / 10;
|
||||
return Array(sceneCount).fill(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate scene durations
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function validateSceneDurations(
|
||||
scenes: StoryboardScene[],
|
||||
totalDuration: number,
|
||||
minDuration: number = 1
|
||||
): ValidationResult {
|
||||
if (scenes.length === 0) {
|
||||
return { valid: false, error: '至少需要一个场景' };
|
||||
}
|
||||
|
||||
// Check each scene's duration is within valid range
|
||||
for (const scene of scenes) {
|
||||
if (scene.duration < minDuration) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `场景 ${scene.order} 时长不能少于 ${minDuration} 秒`,
|
||||
};
|
||||
}
|
||||
if (scene.duration > totalDuration) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `场景 ${scene.order} 时长不能超过 ${totalDuration} 秒`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty prompts
|
||||
const emptyPromptScene = scenes.find(s => !s.prompt.trim());
|
||||
if (emptyPromptScene) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `场景 ${emptyPromptScene.order} 需要填写提示词`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate remaining duration available for new scenes
|
||||
*/
|
||||
export function calculateRemainingDuration(
|
||||
scenes: StoryboardScene[],
|
||||
totalDuration: number
|
||||
): number {
|
||||
const used = scenes.reduce((acc, s) => acc + s.duration, 0);
|
||||
return Math.round((totalDuration - used) * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format storyboard scenes into API prompt format
|
||||
*
|
||||
* Output format:
|
||||
* Shot 1:
|
||||
* duration: 7.5sec
|
||||
* Scene: 飞机起飞
|
||||
*
|
||||
* Shot 2:
|
||||
* duration: 7.5sec
|
||||
* Scene: 飞机降落
|
||||
*/
|
||||
export function formatStoryboardPrompt(scenes: StoryboardScene[]): string {
|
||||
return scenes
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(
|
||||
(scene, index) =>
|
||||
`Shot ${index + 1}:\nduration: ${scene.duration}sec\nScene: ${scene.prompt.trim()}`
|
||||
)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storyboard prompt back into scenes (for edit recovery)
|
||||
*
|
||||
* @param prompt Formatted storyboard prompt
|
||||
* @returns Array of scenes or null if not a valid storyboard prompt
|
||||
*/
|
||||
export function parseStoryboardPrompt(prompt: string): StoryboardScene[] | null {
|
||||
const shotRegex =
|
||||
/Shot\s+(\d+):\s*\n\s*duration:\s*([\d.]+)\s*sec\s*\n\s*Scene:\s*([^\n]+(?:\n(?!Shot\s+\d+:)[^\n]*)*)/gi;
|
||||
|
||||
const scenes: StoryboardScene[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = shotRegex.exec(prompt)) !== null) {
|
||||
const order = parseInt(match[1], 10);
|
||||
const duration = parseFloat(match[2]);
|
||||
const scenePrompt = match[3].trim();
|
||||
|
||||
scenes.push({
|
||||
id: generateSceneId(),
|
||||
order,
|
||||
duration,
|
||||
prompt: scenePrompt,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by order
|
||||
return scenes.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a prompt is in storyboard format
|
||||
*/
|
||||
export function isStoryboardPrompt(prompt: string): boolean {
|
||||
return /Shot\s+\d+:\s*\n\s*duration:\s*[\d.]+\s*sec/i.test(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redistribute durations when a scene is added or removed
|
||||
*
|
||||
* @param scenes Current scenes
|
||||
* @param totalDuration Total video duration
|
||||
* @param addedSceneIndex Index where new scene will be added (-1 for append)
|
||||
* @returns Updated scenes with redistributed durations
|
||||
*/
|
||||
export function redistributeDurationsOnAdd(
|
||||
scenes: StoryboardScene[],
|
||||
totalDuration: number,
|
||||
addedSceneIndex: number = -1
|
||||
): StoryboardScene[] {
|
||||
const newSceneCount = scenes.length + 1;
|
||||
const newDurations = calculateDefaultSceneDurations(totalDuration, newSceneCount);
|
||||
|
||||
// Create new scene
|
||||
const newScene = createEmptyScene(
|
||||
addedSceneIndex >= 0 ? addedSceneIndex + 1 : newSceneCount,
|
||||
newDurations[newDurations.length - 1]
|
||||
);
|
||||
|
||||
// Insert new scene and update orders
|
||||
const updatedScenes = [...scenes];
|
||||
if (addedSceneIndex >= 0 && addedSceneIndex < scenes.length) {
|
||||
updatedScenes.splice(addedSceneIndex, 0, newScene);
|
||||
} else {
|
||||
updatedScenes.push(newScene);
|
||||
}
|
||||
|
||||
// Update durations and orders
|
||||
return updatedScenes.map((scene, index) => ({
|
||||
...scene,
|
||||
order: index + 1,
|
||||
duration: newDurations[index],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update scene order numbers after deletion
|
||||
*/
|
||||
export function reorderScenes(scenes: StoryboardScene[]): StoryboardScene[] {
|
||||
return scenes
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((scene, index) => ({
|
||||
...scene,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration usage percentage for display
|
||||
*/
|
||||
export function getDurationUsagePercent(
|
||||
scenes: StoryboardScene[],
|
||||
totalDuration: number
|
||||
): number {
|
||||
const used = scenes.reduce((acc, s) => acc + s.duration, 0);
|
||||
return Math.round((used / totalDuration) * 100);
|
||||
}
|
||||
@@ -72,7 +72,14 @@ export function validateGenerationParams(
|
||||
errors.push('Duration must not exceed 60 seconds');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Validate character-specific fields
|
||||
if (type === TaskType.CHARACTER) {
|
||||
if (!params.sourceVideoTaskId) {
|
||||
errors.push('Source video task ID is required for character extraction');
|
||||
}
|
||||
}
|
||||
|
||||
if (params.seed !== undefined) {
|
||||
if (typeof params.seed !== 'number' || !Number.isInteger(params.seed)) {
|
||||
errors.push('Seed must be an integer');
|
||||
@@ -87,12 +94,12 @@ export function validateGenerationParams(
|
||||
|
||||
/**
|
||||
* Validates a task type value
|
||||
*
|
||||
*
|
||||
* @param type - The task type to validate
|
||||
* @returns True if the type is valid, false otherwise
|
||||
*/
|
||||
export function isValidTaskType(type: string): type is TaskType {
|
||||
return type === TaskType.IMAGE || type === TaskType.VIDEO;
|
||||
return type === TaskType.IMAGE || type === TaskType.VIDEO || type === TaskType.CHARACTER;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user