diff --git a/apps/web/public/version.json b/apps/web/public/version.json index fc0f8ad5..14a18e8e 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -2,4 +2,4 @@ "version": "0.3.2", "buildTime": "2025-12-23T08:04:24.882Z", "gitCommit": "unknown" -} \ No newline at end of file +} diff --git a/docs/features/sora-character-feature.md b/docs/features/sora-character-feature.md new file mode 100644 index 00000000..e6747fb8 --- /dev/null +++ b/docs/features/sora-character-feature.md @@ -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. **角色分享** - 跨设备同步角色(需后端支持) diff --git a/packages/drawnix/src/components/character/CharacterAvatar.tsx b/packages/drawnix/src/components/character/CharacterAvatar.tsx new file mode 100644 index 00000000..48cfb892 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterAvatar.tsx @@ -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 = ({ + characterId, + profilePictureUrl, + alt = 'Character', + className, + onClick, +}) => { + const [imageUrl, setImageUrl] = useState(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 ( +
+ +
+ ); + } + + return ( + {alt} + ); +}; + +export default CharacterAvatar; diff --git a/packages/drawnix/src/components/character/CharacterCard.tsx b/packages/drawnix/src/components/character/CharacterCard.tsx new file mode 100644 index 00000000..6c018974 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterCard.tsx @@ -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 = ({ + 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 ( + + + + ); + } + return ( +
+ +
+ ); + }; + + // Render status + const renderStatus = () => { + if (isProcessing) { + return ( +
+ + 创建中... +
+ ); + } + if (isFailed) { + return ( +
+ 创建失败: {character.error || '未知错误'} +
+ ); + } + return null; + }; + + if (compact) { + // Compact mode for inline display + return ( +
+
{renderAvatar()}
+ + {character.username || '创建中...'} + +
+ ); + } + + return ( +
+ {/* Avatar */} +
{renderAvatar()}
+ + {/* Info */} +
+ {/* Username with copy button */} + {isCompleted && character.username ? ( + +
+ @{character.username} + +
+
+ ) : ( +
+ {isProcessing ? '创建中...' : '角色'} +
+ )} + + {/* Character ID */} + {character.id && ( + +
{character.id}
+
+ )} + + {/* Status */} + {renderStatus()} +
+ + {/* Actions */} +
+ +
+ + {/* Image Viewer for avatar - render in portal with high z-index to ensure it's on top */} + {character.profilePictureUrl && imageViewerVisible && createPortal( + setImageViewerVisible(false)} + images={[character.profilePictureUrl]} + title={`@${character.username || 'Character'}`} + zIndex={8000} + />, + document.body + )} +
+ ); +}; + +export default CharacterCard; diff --git a/packages/drawnix/src/components/character/CharacterCreateDialog.tsx b/packages/drawnix/src/components/character/CharacterCreateDialog.tsx new file mode 100644 index 00000000..7bfa84ef --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterCreateDialog.tsx @@ -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 = ({ + 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 ( + +
+ 从视频中提取角色,创建后可通过 @username 在提示词中引用 +
+ + {/* Already creating warning */} + {alreadyCreating && ( +
+ 此视频已有一个角色正在创建中 +
+ )} + + {/* Video frame time range selector */} + +
+ ); +}; + +export default CharacterCreateDialog; diff --git a/packages/drawnix/src/components/character/CharacterList.tsx b/packages/drawnix/src/components/character/CharacterList.tsx new file mode 100644 index 00000000..27996391 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterList.tsx @@ -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 = ({ + onSelect, + completedOnly = false, + maxItems, + showHeader = true, + title = '角色列表', +}) => { + const { + characters, + completedCharacters, + pendingCharacters, + isLoading, + deleteCharacter, + refreshCharacters, + } = useCharacters(); + + const [deleteConfirmId, setDeleteConfirmId] = React.useState(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 ( +
+ + 加载中... +
+ ); + } + + // Empty state + if (displayCharacters.length === 0) { + return ( +
+ {showHeader && ( +
+

{title}

+
+ )} +
+
+ +
+
暂无角色
+
+ 从已完成的 Sora-2 视频中提取角色 +
+
+
+ ); + } + + return ( +
+ {showHeader && ( +
+

+ {title} + {pendingCharacters.length > 0 && ( + + ({pendingCharacters.length} 创建中) + + )} +

+
+ )} + +
+ {displayCharacters.map((character) => ( + + ))} +
+ + {/* Delete Confirmation Dialog */} + setDeleteConfirmId(null)} + onConfirm={confirmDelete} + onCancel={() => setDeleteConfirmId(null)} + > + 确定要删除此角色吗?删除后将无法在提示词中使用该角色。 + +
+ ); +}; + +export default CharacterList; diff --git a/packages/drawnix/src/components/character/CharacterMentionPopup.tsx b/packages/drawnix/src/components/character/CharacterMentionPopup.tsx new file mode 100644 index 00000000..60710b75 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterMentionPopup.tsx @@ -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 = ({ + visible, + query, + position, + showBelow = false, + selectedIndex, + onSelect, + onClose, +}) => { + const { completedCharacters, isLoading } = useCharacters(); + const popupRef = useRef(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 ( +
+
加载中...
+
+ ); + } + + if (filteredCharacters.length === 0) { + return ( +
+
+ {query ? `未找到 "${query}" 相关角色` : '暂无可用角色'} +
+
+ ); + } + + return ( +
+ {filteredCharacters.map((character, index) => ( +
handleCharacterClick(character)} + data-track="character_click_mention" + > +
+ +
+ + @{character.username} + +
+ ))} +
+ ); +}; + +export default CharacterMentionPopup; diff --git a/packages/drawnix/src/components/character/CharacterSelector.tsx b/packages/drawnix/src/components/character/CharacterSelector.tsx new file mode 100644 index 00000000..8dea6ae1 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterSelector.tsx @@ -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 = ({ + 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 ( +
+
+ + 角色 + + + +
+
暂无可用角色
+
+ ); + } + + return ( +
+
+ + 角色 + + (选中后会自动添加到提示词) + +
+ +
+ {visibleCharacters.map((character) => ( + +
handleCharacterClick(character)} + data-track="character_click_select" + > +
+ {character.profilePictureUrl ? ( + {character.username} + ) : ( + + )} +
+ @{character.username} +
+
+ ))} + + {hasMore && ( + +
+ +{completedCharacters.length - maxVisible} +
+
+ )} +
+ + {selectedIds.length > 0 && ( +
+ 已选择: {getSelectedUsernames()} +
+ )} +
+ ); +}; + +export default CharacterSelector; diff --git a/packages/drawnix/src/components/character/CharacterTimeRangeSelector.tsx b/packages/drawnix/src/components/character/CharacterTimeRangeSelector.tsx new file mode 100644 index 00000000..090ab2a4 --- /dev/null +++ b/packages/drawnix/src/components/character/CharacterTimeRangeSelector.tsx @@ -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 = ({ + videoUrl, + videoDuration, + onConfirm, + onCancel, + disabled = false, +}) => { + const videoRef = useRef(null); + const canvasRef = useRef(null); + const trackRef = useRef(null); + const frameUpdateRef = useRef(null); + const lastSeekTimeRef = useRef(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(''); + 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 ( +
+ {/* Hidden video element */} +