Merge pull request #46 from ljquan/feat/image-video-update

feat: 添加 Sora-2 故事场景模式和角色提取复用功能
This commit is contained in:
ljquan authored and GitHub committed 2025-12-26 10:24:48 +08:00
commit eec1dc764a
37 files changed
+6057 -150

No files matched your search

+1 -1
View File
@@ -2,4 +2,4 @@
"version": "0.3.2",
"buildTime": "2025-12-23T08:04:24.882Z",
"gitCommit": "unknown"
}
}
+384
View File
@@ -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';
+26 -2
View File
@@ -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,
};
}
+238
View File
@@ -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,
};
}
+297
View File
@@ -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';
}
+15 -1
View File
@@ -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;
}
/**
+26
View File
@@ -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);
}
+10 -3
View File
@@ -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;
}
/**