From 7155c5b6c210918bdf3b9eb1bf5509f4cd577494 Mon Sep 17 00:00:00 2001
From: gongchengtu <1817063960@qq.com>
Date: Wed, 17 Dec 2025 23:58:11 +0800
Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Sora-2=20?=
=?UTF-8?q?=E6=95=85=E4=BA=8B=E5=9C=BA=E6=99=AF=E6=A8=A1=E5=BC=8F=E6=94=AF?=
=?UTF-8?q?=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 StoryboardEditor 组件,支持多场景编辑
- 扩展 video.types.ts 添加场景相关类型定义
- 扩展 video-model-config.ts 为 sora-2/sora-2-pro 添加故事场景支持
- 新增 storyboard-utils.ts 工具函数(场景验证、提示词格式化等)
- 集成到 ai-video-generation.tsx,支持场景模式切换
- 添加视频 API 请求日志打印
---
apps/web/public/version.json | 2 +-
.../settings-dialog/settings-dialog.tsx | 5 +-
.../ttd-dialog/ai-video-generation.tsx | 158 ++++++++--
.../ttd-dialog/shared/StoryboardEditor.scss | 286 ++++++++++++++++++
.../ttd-dialog/shared/StoryboardEditor.tsx | 215 +++++++++++++
.../src/components/ttd-dialog/shared/index.ts | 3 +-
packages/drawnix/src/constants/CHAT_MODELS.ts | 28 +-
.../src/constants/video-model-config.ts | 34 ++-
.../drawnix/src/services/video-api-service.ts | 27 ++
packages/drawnix/src/types/video.types.ts | 26 ++
.../drawnix/src/utils/storyboard-utils.ts | 260 ++++++++++++++++
11 files changed, 1005 insertions(+), 39 deletions(-)
create mode 100644 packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
create mode 100644 packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
create mode 100644 packages/drawnix/src/utils/storyboard-utils.ts
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/packages/drawnix/src/components/settings-dialog/settings-dialog.tsx b/packages/drawnix/src/components/settings-dialog/settings-dialog.tsx
index bccba844..521ff87f 100644
--- a/packages/drawnix/src/components/settings-dialog/settings-dialog.tsx
+++ b/packages/drawnix/src/components/settings-dialog/settings-dialog.tsx
@@ -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 = ({
您可以从以下地址获取 API Key(新建令牌渠道分组选择default):
-
https://api.tu-zi.com/token
@@ -184,4 +185,4 @@ export const SettingsDialog = ({
);
-};
\ No newline at end of file
+};
diff --git a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx
index 2945067e..9228b074 100644
--- a/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx
+++ b/packages/drawnix/src/components/ttd-dialog/ai-video-generation.tsx
@@ -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
([]);
+ 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,32 @@ const AIVideoGeneration = ({
disabled={isGenerating}
/>
-
+ {/* Storyboard mode editor (only for supported models) */}
+ {modelSupportsStoryboard && (
+
+ )}
+
+ {/* Normal prompt input (hidden when storyboard mode is enabled) */}
+ {!storyboardEnabled && (
+
+ )}
@@ -456,7 +552,7 @@ const AIVideoGeneration = ({
type="video"
isGenerating={isGenerating}
hasGenerated={false}
- canGenerate={!!prompt.trim()}
+ canGenerate={storyboardEnabled ? storyboardScenes.length > 0 : !!prompt.trim()}
onGenerate={handleGenerate}
onReset={handleReset}
/>
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
new file mode 100644
index 00000000..3ca29631
--- /dev/null
+++ b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
@@ -0,0 +1,286 @@
+/**
+ * StoryboardEditor Styles
+ * Clean and minimal design for storyboard scene editing
+ */
+
+.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 - minimal style
+ &__scenes {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__scene {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px;
+ background: var(--td-bg-color-secondarycontainer, #f9f9f9);
+ border-radius: 8px;
+ border: none;
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background: var(--td-bg-color-secondarycontainer-hover, #f3f3f3);
+ }
+ }
+
+ &__scene-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__scene-info {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ &__scene-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--td-text-color-primary, #1a1a1a);
+ }
+
+ &__scene-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &__time-icon {
+ font-size: 14px;
+ color: var(--td-text-color-placeholder, #999999);
+ }
+
+ // Duration input - simple underline style
+ &__duration-input {
+ width: 40px !important;
+ display: inline-flex;
+
+ input {
+ width: 100%;
+ height: 24px;
+ padding: 0 2px;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ color: var(--td-text-color-primary, #000000);
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--td-component-stroke, #dcdcdc);
+ border-radius: 0;
+ outline: none;
+ transition: border-color 0.15s ease;
+
+ &:focus {
+ border-bottom-color: var(--td-brand-color, #0052d9);
+ }
+
+ &:hover {
+ border-bottom-color: var(--td-text-color-secondary, #999999);
+ }
+ }
+
+ // Override TDesign input styles
+ .t-input {
+ height: 24px;
+ padding: 0;
+ background: transparent !important;
+ border: none !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ }
+
+ .t-input__wrap {
+ border: none !important;
+ border-bottom: 1px solid var(--td-component-stroke, #dcdcdc) !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ transition: border-color 0.15s ease;
+
+ &:hover {
+ border-bottom-color: var(--td-text-color-secondary, #999999) !important;
+ }
+ }
+
+ .t-input.t-is-focused .t-input__wrap,
+ .t-input__wrap:focus-within {
+ border-bottom-color: var(--td-brand-color, #0052d9) !important;
+ }
+
+ .t-input__inner {
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ color: var(--td-text-color-primary, #000000);
+ background: transparent;
+ padding: 0 2px;
+ }
+ }
+
+ &__duration-unit {
+ font-size: 13px;
+ color: var(--td-text-color-secondary, #666666);
+ margin-left: 2px;
+ }
+
+ &__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 {
+ .t-textarea {
+ background: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+
+ &:hover,
+ &.t-is-focused {
+ border: none !important;
+ box-shadow: none !important;
+ }
+ }
+
+ .t-textarea__inner {
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--td-text-color-primary, #000000);
+ resize: none;
+ background: transparent;
+ border: none;
+ padding: 0;
+ min-height: 44px;
+
+ &:focus {
+ outline: none;
+ box-shadow: none;
+ }
+
+ &::placeholder {
+ color: var(--td-text-color-placeholder, #b8b8b8);
+ }
+ }
+ }
+
+ // Add scene button - clean style without purple background
+ &__add-btn {
+ margin-top: 4px;
+ border: 1px dashed var(--td-component-stroke, #dcdcdc) !important;
+ color: var(--td-text-color-secondary, #666666) !important;
+ background: transparent !important;
+
+ &:hover {
+ border-color: var(--td-text-color-secondary, #999999) !important;
+ color: var(--td-text-color-primary, #333333) !important;
+ background: transparent !important;
+ }
+
+ &:active {
+ background: rgba(0, 0, 0, 0.02) !important;
+ }
+ }
+
+ // TDesign component overrides
+ .t-switch {
+ flex-shrink: 0;
+ }
+
+ // Override textarea globally in this component
+ .t-textarea {
+ border: none !important;
+ box-shadow: none !important;
+
+ &:hover,
+ &:focus,
+ &.t-is-focused {
+ border: none !important;
+ box-shadow: none !important;
+ }
+ }
+}
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
new file mode 100644
index 00000000..76b4aa9f
--- /dev/null
+++ b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
@@ -0,0 +1,215 @@
+/**
+ * StoryboardEditor Component
+ *
+ * Multi-scene editor for storyboard mode in video generation.
+ * Supports adding/removing scenes, duration control, and prompt input.
+ */
+
+import React, { useCallback } from 'react';
+import { Button, Input, Textarea, Switch } from 'tdesign-react';
+import { AddIcon, DeleteIcon, TimeIcon } from 'tdesign-icons-react';
+import type { StoryboardScene } from '../../../types/video.types';
+import {
+ createEmptyScene,
+ reorderScenes,
+} from '../../../utils/storyboard-utils';
+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 = ({
+ enabled,
+ onEnabledChange,
+ totalDuration,
+ maxScenes,
+ minSceneDuration,
+ scenes,
+ onScenesChange,
+ disabled = false,
+}) => {
+ // Handle adding a new scene with default duration (same as total)
+ const handleAddScene = useCallback(() => {
+ if (scenes.length >= maxScenes) return;
+
+ const newSceneCount = scenes.length + 1;
+ // New scene defaults to totalDuration (each scene can be up to totalDuration)
+ const newScene = createEmptyScene(newSceneCount, totalDuration);
+ onScenesChange([...scenes, newScene]);
+ }, [scenes, maxScenes, totalDuration, onScenesChange]);
+
+ // Handle removing a scene
+ const handleRemoveScene = useCallback(
+ (sceneId: string) => {
+ if (scenes.length <= 1) return;
+
+ const filteredScenes = scenes.filter(s => s.id !== sceneId);
+ const reordered = reorderScenes(filteredScenes);
+ onScenesChange(reordered);
+ },
+ [scenes, onScenesChange]
+ );
+
+ // Handle scene duration change - each scene can be 0.1 to totalDuration
+ const handleDurationChange = useCallback(
+ (sceneId: string, value: string) => {
+ const newDuration = parseFloat(value);
+ if (isNaN(newDuration)) return;
+
+ const updatedScenes = scenes.map(scene => {
+ if (scene.id === sceneId) {
+ // Clamp duration: min is minSceneDuration, max is totalDuration
+ const clampedDuration = Math.max(
+ minSceneDuration,
+ Math.min(newDuration, totalDuration)
+ );
+ // Round to 1 decimal place
+ return { ...scene, duration: Math.round(clampedDuration * 10) / 10 };
+ }
+ return scene;
+ });
+
+ onScenesChange(updatedScenes);
+ },
+ [scenes, totalDuration, minSceneDuration, 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]);
+ }
+ },
+ [scenes, totalDuration, onEnabledChange, onScenesChange]
+ );
+
+ const canAddMore = scenes.length < maxScenes;
+
+ return (
+
+ {/* Toggle switch header */}
+
+
+
+
+ 故事场景模式
+
+ 定义多个场景及其时长
+
+
+
+ {enabled && (
+
NEW
+ )}
+
+
+ {/* Scenes list */}
+ {enabled && (
+
+ {/* Section title */}
+
+ 场景列表
+
+ 每个场景时长不超过 {totalDuration} 秒
+
+
+
+ {/* Scene cards */}
+
+ {scenes.map((scene, index) => (
+
+
+
+
+ 场景 {index + 1}
+
+
+
+
+ handleDurationChange(scene.id, value as string)}
+ disabled={disabled}
+ className="storyboard-editor__duration-input"
+ align="center"
+ />
+ 秒
+ {scenes.length > 1 && (
+ }
+ onClick={() => handleRemoveScene(scene.id)}
+ disabled={disabled}
+ className="storyboard-editor__scene-delete"
+ />
+ )}
+
+
+
+ ))}
+
+
+ {/* Add scene button */}
+ {canAddMore && (
+
}
+ onClick={handleAddScene}
+ disabled={disabled}
+ className="storyboard-editor__add-btn"
+ block
+ >
+ 添加场景
+
+ )}
+
+ )}
+
+ );
+};
+
+export default StoryboardEditor;
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/index.ts b/packages/drawnix/src/components/ttd-dialog/shared/index.ts
index 7e709469..823661fc 100644
--- a/packages/drawnix/src/components/ttd-dialog/shared/index.ts
+++ b/packages/drawnix/src/components/ttd-dialog/shared/index.ts
@@ -10,4 +10,5 @@ export * from './preload-utils';
export * from './size-constants';
export * from './VideoModelOptions';
export * from './MultiImageUpload';
-export * from './AspectRatioSelector';
\ No newline at end of file
+export * from './AspectRatioSelector';
+export * from './StoryboardEditor';
\ No newline at end of file
diff --git a/packages/drawnix/src/constants/CHAT_MODELS.ts b/packages/drawnix/src/constants/CHAT_MODELS.ts
index bbc934cd..f180fca4 100644
--- a/packages/drawnix/src/constants/CHAT_MODELS.ts
+++ b/packages/drawnix/src/constants/CHAT_MODELS.ts
@@ -38,6 +38,15 @@ export const PROVIDER_NAMES: Record = {
/** 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',
diff --git a/packages/drawnix/src/constants/video-model-config.ts b/packages/drawnix/src/constants/video-model-config.ts
index 48fb814c..90a69286 100644
--- a/packages/drawnix/src/constants/video-model-config.ts
+++ b/packages/drawnix/src/constants/video-model-config.ts
@@ -17,7 +17,7 @@ export const VIDEO_MODEL_CONFIGS: Record = {
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 = {
mode: 'reference',
labels: ['参考图'],
},
+ storyboardMode: {
+ supported: true,
+ maxScenes: 5,
+ minSceneDuration: 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 = {
mode: 'reference',
labels: ['参考图'],
},
+ storyboardMode: {
+ supported: true,
+ maxScenes: 5,
+ minSceneDuration: 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: 5,
+ minSceneDuration: 1,
+ };
+}
diff --git a/packages/drawnix/src/services/video-api-service.ts b/packages/drawnix/src/services/video-api-service.ts
index e0f1a234..802120a7 100644
--- a/packages/drawnix/src/services/video-api-service.ts
+++ b/packages/drawnix/src/services/video-api-service.ts
@@ -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 = {};
+ formData.forEach((value, key) => {
+ if (value instanceof Blob) {
+ formDataEntries[key] = `[Blob: ${value.size} bytes, type: ${value.type}]`;
+ } else {
+ formDataEntries[key] = key === 'prompt' ? `${String(value).substring(0, 100)}...` : 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: {
diff --git a/packages/drawnix/src/types/video.types.ts b/packages/drawnix/src/types/video.types.ts
index a2dfe8dd..b4cd391d 100644
--- a/packages/drawnix/src/types/video.types.ts
+++ b/packages/drawnix/src/types/video.types.ts
@@ -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;
}
diff --git a/packages/drawnix/src/utils/storyboard-utils.ts b/packages/drawnix/src/utils/storyboard-utils.ts
new file mode 100644
index 00000000..22ba2e93
--- /dev/null
+++ b/packages/drawnix/src/utils/storyboard-utils.ts
@@ -0,0 +1,260 @@
+/**
+ * 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: '',
+ };
+}
+
+/**
+ * Calculate default scene durations using halving strategy
+ *
+ * Strategy: Each subsequent scene gets half of the remaining time.
+ * Example for 15s with 3 scenes: [7.5, 3.75, 3.75]
+ *
+ * @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;
+
+ for (let i = 0; i < sceneCount; i++) {
+ if (i === sceneCount - 1) {
+ // Last scene gets all remaining time
+ durations.push(Math.round(remaining * 10) / 10);
+ } else {
+ // Each scene gets half of remaining time
+ const duration = remaining / 2;
+ durations.push(Math.round(duration * 10) / 10);
+ remaining -= duration;
+ }
+ }
+
+ 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);
+}
From dc909860f1a135af63c59fe63f3bb3e9c5b5704e Mon Sep 17 00:00:00 2001
From: gongchengtu <1817063960@qq.com>
Date: Thu, 18 Dec 2025 15:56:43 +0800
Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=E6=95=85=E4=BA=8B=E5=9C=BA?=
=?UTF-8?q?=E6=99=AF=E6=A8=A1=E5=BC=8F=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ttd-dialog/shared/StoryboardEditor.scss | 117 ++++++------------
.../ttd-dialog/shared/StoryboardEditor.tsx | 55 +++++---
.../src/constants/video-model-config.ts | 6 +-
.../drawnix/src/services/video-api-service.ts | 60 ++++++---
.../drawnix/src/utils/storyboard-utils.ts | 26 +++-
5 files changed, 145 insertions(+), 119 deletions(-)
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
index 3ca29631..4158254f 100644
--- a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
+++ b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.scss
@@ -129,70 +129,41 @@
color: var(--td-text-color-placeholder, #999999);
}
- // Duration input - simple underline style
+ // Duration input - native number input with underline style
&__duration-input {
- width: 40px !important;
- display: inline-flex;
+ width: 48px;
+ height: 26px;
+ padding: 0 4px;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ color: var(--td-text-color-primary, #000000);
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--td-component-stroke, #dcdcdc);
+ border-radius: 0;
+ outline: none;
+ transition: border-color 0.15s ease;
+ appearance: textfield; // Hide spinner
+ -moz-appearance: textfield; // Firefox compatibility
- input {
- width: 100%;
- height: 24px;
- padding: 0 2px;
- font-size: 13px;
- font-weight: 500;
- text-align: center;
- color: var(--td-text-color-primary, #000000);
- background: transparent;
- border: none;
- border-bottom: 1px solid var(--td-component-stroke, #dcdcdc);
- border-radius: 0;
- outline: none;
- transition: border-color 0.15s ease;
-
- &:focus {
- border-bottom-color: var(--td-brand-color, #0052d9);
- }
-
- &:hover {
- border-bottom-color: var(--td-text-color-secondary, #999999);
- }
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
}
- // Override TDesign input styles
- .t-input {
- height: 24px;
- padding: 0;
- background: transparent !important;
- border: none !important;
- border-radius: 0 !important;
- box-shadow: none !important;
+ &:focus {
+ border-bottom-color: var(--td-brand-color, #0052d9);
}
- .t-input__wrap {
- border: none !important;
- border-bottom: 1px solid var(--td-component-stroke, #dcdcdc) !important;
- border-radius: 0 !important;
- box-shadow: none !important;
- background: transparent !important;
- transition: border-color 0.15s ease;
-
- &:hover {
- border-bottom-color: var(--td-text-color-secondary, #999999) !important;
- }
+ &:hover:not(:disabled) {
+ border-bottom-color: var(--td-text-color-secondary, #999999);
}
- .t-input.t-is-focused .t-input__wrap,
- .t-input__wrap:focus-within {
- border-bottom-color: var(--td-brand-color, #0052d9) !important;
- }
-
- .t-input__inner {
- font-size: 13px;
- font-weight: 500;
- text-align: center;
- color: var(--td-text-color-primary, #000000);
- background: transparent;
- padding: 0 2px;
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
}
}
@@ -216,14 +187,20 @@
&__scene-prompt {
.t-textarea {
- background: transparent !important;
- border: none !important;
+ background: var(--td-bg-color-container, #ffffff) !important;
+ border: 1px solid var(--td-component-stroke, #e7e7e7) !important;
+ border-radius: 8px !important;
box-shadow: none !important;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
- &:hover,
- &.t-is-focused {
- border: none !important;
- box-shadow: none !important;
+ &:hover {
+ border-color: var(--td-text-color-placeholder, #b8b8b8) !important;
+ }
+
+ &.t-is-focused,
+ &:focus-within {
+ border-color: var(--td-brand-color, #f39c12) !important;
+ box-shadow: 0 0 0 2px rgba(243, 156, 18, 0.1) !important;
}
}
@@ -234,8 +211,8 @@
resize: none;
background: transparent;
border: none;
- padding: 0;
- min-height: 44px;
+ padding: 10px 12px;
+ min-height: 52px;
&:focus {
outline: none;
@@ -271,16 +248,4 @@
flex-shrink: 0;
}
- // Override textarea globally in this component
- .t-textarea {
- border: none !important;
- box-shadow: none !important;
-
- &:hover,
- &:focus,
- &.t-is-focused {
- border: none !important;
- box-shadow: none !important;
- }
- }
}
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
index 76b4aa9f..cd0d01b4 100644
--- a/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
+++ b/packages/drawnix/src/components/ttd-dialog/shared/StoryboardEditor.tsx
@@ -6,12 +6,12 @@
*/
import React, { useCallback } from 'react';
-import { Button, Input, Textarea, Switch } from 'tdesign-react';
+import { Button, Textarea, Switch } from 'tdesign-react';
import { AddIcon, DeleteIcon, TimeIcon } from 'tdesign-icons-react';
import type { StoryboardScene } from '../../../types/video.types';
import {
createEmptyScene,
- reorderScenes,
+ calculateDefaultSceneDurations,
} from '../../../utils/storyboard-utils';
import './StoryboardEditor.scss';
@@ -37,29 +37,46 @@ export const StoryboardEditor: React.FC = ({
onScenesChange,
disabled = false,
}) => {
- // Handle adding a new scene with default duration (same as total)
+ // Handle adding a new scene - recalculate all durations using halving strategy
const handleAddScene = useCallback(() => {
if (scenes.length >= maxScenes) return;
const newSceneCount = scenes.length + 1;
- // New scene defaults to totalDuration (each scene can be up to totalDuration)
- const newScene = createEmptyScene(newSceneCount, totalDuration);
- onScenesChange([...scenes, newScene]);
+ const newDurations = calculateDefaultSceneDurations(totalDuration, newSceneCount);
+
+ // Update existing scenes with new durations and add new scene
+ const updatedScenes = scenes.map((scene, index) => ({
+ ...scene,
+ duration: newDurations[index],
+ }));
+
+ // Add new scene with its calculated duration
+ const newScene = createEmptyScene(newSceneCount, newDurations[newSceneCount - 1]);
+ onScenesChange([...updatedScenes, newScene]);
}, [scenes, maxScenes, totalDuration, onScenesChange]);
- // Handle removing a scene
+ // Handle removing a scene - recalculate all durations
const handleRemoveScene = useCallback(
(sceneId: string) => {
if (scenes.length <= 1) return;
const filteredScenes = scenes.filter(s => s.id !== sceneId);
- const reordered = reorderScenes(filteredScenes);
- onScenesChange(reordered);
+ const newSceneCount = filteredScenes.length;
+ const newDurations = calculateDefaultSceneDurations(totalDuration, newSceneCount);
+
+ // Update durations and reorder
+ const updatedScenes = filteredScenes.map((scene, index) => ({
+ ...scene,
+ order: index + 1,
+ duration: newDurations[index],
+ }));
+
+ onScenesChange(updatedScenes);
},
- [scenes, onScenesChange]
+ [scenes, totalDuration, onScenesChange]
);
- // Handle scene duration change - each scene can be 0.1 to totalDuration
+ // Handle scene duration change - keep up to 2 decimal places
const handleDurationChange = useCallback(
(sceneId: string, value: string) => {
const newDuration = parseFloat(value);
@@ -72,8 +89,9 @@ export const StoryboardEditor: React.FC = ({
minSceneDuration,
Math.min(newDuration, totalDuration)
);
- // Round to 1 decimal place
- return { ...scene, duration: Math.round(clampedDuration * 10) / 10 };
+ // Round to 2 decimal places using toFixed to avoid floating point issues
+ const rounded = parseFloat(clampedDuration.toFixed(2));
+ return { ...scene, duration: rounded };
}
return scene;
});
@@ -158,12 +176,15 @@ export const StoryboardEditor: React.FC = ({
-
handleDurationChange(scene.id, value as string)}
+
handleDurationChange(scene.id, e.target.value)}
disabled={disabled}
className="storyboard-editor__duration-input"
- align="center"
+ step="0.01"
+ min={minSceneDuration}
+ max={totalDuration}
/>
秒
{scenes.length > 1 && (
diff --git a/packages/drawnix/src/constants/video-model-config.ts b/packages/drawnix/src/constants/video-model-config.ts
index 90a69286..13777f13 100644
--- a/packages/drawnix/src/constants/video-model-config.ts
+++ b/packages/drawnix/src/constants/video-model-config.ts
@@ -36,7 +36,7 @@ export const VIDEO_MODEL_CONFIGS: Record
= {
storyboardMode: {
supported: true,
maxScenes: 5,
- minSceneDuration: 1,
+ minSceneDuration: 0.1,
},
},
'sora-2-pro': {
@@ -65,7 +65,7 @@ export const VIDEO_MODEL_CONFIGS: Record = {
storyboardMode: {
supported: true,
maxScenes: 5,
- minSceneDuration: 1,
+ minSceneDuration: 0.1,
},
},
@@ -235,6 +235,6 @@ export function getStoryboardModeConfig(model: VideoModel) {
return config.storyboardMode ?? {
supported: false,
maxScenes: 5,
- minSceneDuration: 1,
+ minSceneDuration: 0.1,
};
}
diff --git a/packages/drawnix/src/services/video-api-service.ts b/packages/drawnix/src/services/video-api-service.ts
index 802120a7..4fb095d1 100644
--- a/packages/drawnix/src/services/video-api-service.ts
+++ b/packages/drawnix/src/services/video-api-service.ts
@@ -163,7 +163,7 @@ class VideoAPIService {
if (value instanceof Blob) {
formDataEntries[key] = `[Blob: ${value.size} bytes, type: ${value.type}]`;
} else {
- formDataEntries[key] = key === 'prompt' ? `${String(value).substring(0, 100)}...` : String(value);
+ formDataEntries[key] = String(value);
}
});
console.log('[VideoAPI] FormData entries:', formDataEntries);
@@ -192,7 +192,7 @@ class VideoAPIService {
}
/**
- * Query video generation status
+ * Query video generation status (with network retry)
*/
async queryVideoStatus(videoId: string): Promise {
const settings = geminiSettings.get();
@@ -202,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('视频状态查询失败');
}
/**
diff --git a/packages/drawnix/src/utils/storyboard-utils.ts b/packages/drawnix/src/utils/storyboard-utils.ts
index 22ba2e93..037d31f8 100644
--- a/packages/drawnix/src/utils/storyboard-utils.ts
+++ b/packages/drawnix/src/utils/storyboard-utils.ts
@@ -26,11 +26,26 @@ export function createEmptyScene(order: number, duration: number): StoryboardSce
};
}
+/**
+ * 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));
+}
+
/**
* Calculate default scene durations using halving strategy
*
- * Strategy: Each subsequent scene gets half of the remaining time.
- * Example for 15s with 3 scenes: [7.5, 3.75, 3.75]
+ * 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
+ * - Use floor for rounding (e.g., 1.875 -> 1.87)
*
* @param totalDuration Total video duration in seconds
* @param sceneCount Number of scenes
@@ -49,12 +64,13 @@ export function calculateDefaultSceneDurations(
for (let i = 0; i < sceneCount; i++) {
if (i === sceneCount - 1) {
// Last scene gets all remaining time
- durations.push(Math.round(remaining * 10) / 10);
+ durations.push(roundToTwoDecimals(remaining));
} else {
// Each scene gets half of remaining time
const duration = remaining / 2;
- durations.push(Math.round(duration * 10) / 10);
- remaining -= duration;
+ const rounded = roundToTwoDecimals(duration);
+ durations.push(rounded);
+ remaining -= rounded;
}
}
From 26aefb0f7d42f98cf6c6496ea1e43de847c2c1f0 Mon Sep 17 00:00:00 2001
From: gongchengtu <1817063960@qq.com>
Date: Mon, 22 Dec 2025 12:19:52 +0800
Subject: [PATCH 03/13] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Sora-2=20?=
=?UTF-8?q?=E8=A7=92=E8=89=B2=E6=8F=90=E5=8F=96=E4=B8=8E=E5=A4=8D=E7=94=A8?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
实现从已完成的 Sora-2 视频任务中提取角色,支持在后续视频生成中通过 @username 方式引用。
主要内容:
- 新增角色类型定义和 API 服务
- 实现角色存储服务(IndexedDB)
- 添加角色创建对话框和时间范围选择器
- 实现 PromptInput 中的 @ 提及功能
- 在 TaskItem 中添加「提取角色」按钮
---
docs/features/sora-character-feature.md | 384 +++++++++++++++
.../components/character/CharacterCard.tsx | 173 +++++++
.../character/CharacterCreateDialog.tsx | 139 ++++++
.../components/character/CharacterList.tsx | 170 +++++++
.../character/CharacterMentionPopup.tsx | 154 ++++++
.../character/CharacterSelector.tsx | 153 ++++++
.../character/CharacterTimeRangeSelector.tsx | 411 ++++++++++++++++
.../character/character-time-selector.scss | 290 ++++++++++++
.../src/components/character/character.scss | 438 ++++++++++++++++++
.../drawnix/src/components/character/index.ts | 23 +
.../src/components/task-queue/TaskItem.tsx | 29 +-
.../components/task-queue/TaskQueuePanel.tsx | 158 +++++--
.../ttd-dialog/shared/PromptInput.tsx | 202 +++++++-
packages/drawnix/src/hooks/useCharacters.ts | 238 ++++++++++
.../src/services/character-api-service.ts | 310 +++++++++++++
.../src/services/character-storage-service.ts | 243 ++++++++++
packages/drawnix/src/types/character.types.ts | 233 ++++++++++
17 files changed, 3690 insertions(+), 58 deletions(-)
create mode 100644 docs/features/sora-character-feature.md
create mode 100644 packages/drawnix/src/components/character/CharacterCard.tsx
create mode 100644 packages/drawnix/src/components/character/CharacterCreateDialog.tsx
create mode 100644 packages/drawnix/src/components/character/CharacterList.tsx
create mode 100644 packages/drawnix/src/components/character/CharacterMentionPopup.tsx
create mode 100644 packages/drawnix/src/components/character/CharacterSelector.tsx
create mode 100644 packages/drawnix/src/components/character/CharacterTimeRangeSelector.tsx
create mode 100644 packages/drawnix/src/components/character/character-time-selector.scss
create mode 100644 packages/drawnix/src/components/character/character.scss
create mode 100644 packages/drawnix/src/components/character/index.ts
create mode 100644 packages/drawnix/src/hooks/useCharacters.ts
create mode 100644 packages/drawnix/src/services/character-api-service.ts
create mode 100644 packages/drawnix/src/services/character-storage-service.ts
create mode 100644 packages/drawnix/src/types/character.types.ts
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/CharacterCard.tsx b/packages/drawnix/src/components/character/CharacterCard.tsx
new file mode 100644
index 00000000..18607ccc
--- /dev/null
+++ b/packages/drawnix/src/components/character/CharacterCard.tsx
@@ -0,0 +1,173 @@
+/**
+ * CharacterCard Component
+ *
+ * Displays a single character with avatar, username, and actions.
+ * Supports different states: processing, completed, failed.
+ */
+
+import React, { useCallback } from 'react';
+import { Button, Tooltip, MessagePlugin, Loading } from 'tdesign-react';
+import { DeleteIcon, CopyIcon, UserIcon } from 'tdesign-icons-react';
+import type { SoraCharacter } from '../../types/character.types';
+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 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]);
+
+ // Render avatar
+ const renderAvatar = () => {
+ if (character.profilePictureUrl) {
+ 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 ? '创建中...' : '角色'}
+
+ )}
+
+ {/* Source prompt */}
+ {character.sourcePrompt && (
+
+ {character.sourcePrompt}
+
+ )}
+
+ {/* Status */}
+ {renderStatus()}
+
+
+ {/* Actions */}
+
+
+ }
+ data-track="character_click_delete"
+ onClick={handleDelete}
+ />
+
+
+
+ );
+};
+
+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..6025c16e
--- /dev/null
+++ b/packages/drawnix/src/components/character/CharacterCreateDialog.tsx
@@ -0,0 +1,139 @@
+/**
+ * CharacterCreateDialog Component
+ *
+ * Dialog for creating a character from a Sora-2 video task.
+ * Uses video frame preview for time range selection.
+ */
+
+import React, { useState, useCallback, useMemo } from 'react';
+import { Dialog, MessagePlugin } from 'tdesign-react';
+import { useCharacters } from '../../hooks/useCharacters';
+import { formatCharacterTimestamps } from '../../types/character.types';
+import { useMediaUrl } from '../../hooks/useMediaCache';
+import { CharacterTimeRangeSelector } from './CharacterTimeRangeSelector';
+import type { Task } 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 { createCharacter, isCreatingCharacterForTask } = useCharacters();
+ const [isCreating, setIsCreating] = 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
+ const alreadyCreating = task ? isCreatingCharacterForTask(task.id) : false;
+
+ // Handle time range confirmation
+ const handleConfirm = useCallback(async (startTime: number, endTime: number) => {
+ if (!task || !task.remoteId) {
+ MessagePlugin.error('无效的任务数据');
+ return;
+ }
+
+ setIsCreating(true);
+ onCreateStart?.();
+
+ try {
+ const timestamps = formatCharacterTimestamps(startTime, endTime);
+
+ const character = await createCharacter({
+ videoTaskId: task.remoteId,
+ characterTimestamps: timestamps,
+ localTaskId: task.id,
+ sourcePrompt: task.params.prompt,
+ sourceModel: task.params.model,
+ });
+
+ if (character) {
+ MessagePlugin.success('角色创建已提交');
+ onCreateComplete?.(character.id);
+ onClose();
+ }
+ } catch (err) {
+ console.error('Failed to create character:', err);
+ const errorMessage = (err as Error).message || '角色创建失败';
+ MessagePlugin.error(errorMessage);
+ setIsCreating(false);
+ }
+ }, [task, createCharacter, onCreateStart, onCreateComplete, onClose]);
+
+ // Handle dialog close
+ const handleClose = () => {
+ if (!isCreating) {
+ onClose();
+ }
+ };
+
+ // Handle cancel from time selector
+ const handleCancel = () => {
+ if (!isCreating) {
+ onClose();
+ }
+ };
+
+ if (!task || !videoUrl) return null;
+
+ return (
+
+ );
+};
+
+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}
+ }
+ data-track="character_click_refresh"
+ onClick={handleRefresh}
+ />
+
+ )}
+
+
+
+
+
暂无角色
+
+ 从已完成的 Sora-2 视频中提取角色
+
+
+
+ );
+ }
+
+ return (
+
+ {showHeader && (
+
+
+ {title}
+ {pendingCharacters.length > 0 && (
+
+ ({pendingCharacters.length} 创建中)
+
+ )}
+
+ }
+ data-track="character_click_refresh"
+ onClick={handleRefresh}
+ />
+
+ )}
+
+
+ {displayCharacters.map((character) => (
+
+ ))}
+
+
+ {/* Delete Confirmation Dialog */}
+
+
+ );
+};
+
+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..3d0db024
--- /dev/null
+++ b/packages/drawnix/src/components/character/CharacterMentionPopup.tsx
@@ -0,0 +1,154 @@
+/**
+ * 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 { UserIcon } from 'tdesign-icons-react';
+import { useCharacters } from '../../hooks/useCharacters';
+import type { SoraCharacter } from '../../types/character.types';
+import './character.scss';
+
+export interface CharacterMentionPopupProps {
+ /** Whether the popup is visible */
+ visible: boolean;
+ /** Search query (characters after @) */
+ query: string;
+ /** Position of the popup */
+ position: { top: number; left: number };
+ /** 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,
+ 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 above the textarea, with transform to move up
+ const popupStyle: React.CSSProperties = {
+ position: 'fixed',
+ top: position.top,
+ left: position.left,
+ transform: 'translateY(-100%)', // Move above the reference point
+ zIndex: 10000,
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (filteredCharacters.length === 0) {
+ return (
+
+
+ {query ? `未找到 "${query}" 相关角色` : '暂无可用角色'}
+
+
+ );
+ }
+
+ return (
+
+ {filteredCharacters.map((character, index) => (
+
handleCharacterClick(character)}
+ data-track="character_click_mention"
+ >
+
+ {character.profilePictureUrl ? (
+

+ ) : (
+
+ )}
+
+
+ @{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}
+
+
+ ))}
+
+ {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..12102367
--- /dev/null
+++ b/packages/drawnix/src/components/character/CharacterTimeRangeSelector.tsx
@@ -0,0 +1,411 @@
+/**
+ * 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);
+ }
+ }, []);
+
+ // 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 */}
+
+
+ {/* Hidden canvas */}
+
+
+ {/* Frame preview */}
+
+ {isLoading ? (
+
+ ) : frameImage ? (
+

+ ) : (
+
+ 视频帧加载失败
+
+ )}
+
+ {formatTime(currentPreviewTime)}
+
+
+
+ {/* Timeline track */}
+
+ {/* Background track */}
+
+
+ {/* Time markers */}
+
+ {Array.from({ length: Math.ceil(duration) + 1 }, (_, i) => (
+
+ {i}s
+
+ ))}
+
+
+ {/* Draggable range */}
+
+ {/* Left handle - drag to resize */}
+
+ {/* Center area - drag to move */}
+
+
+ {formatTime(startTime)} - {formatTime(endTime)}
+
+
+ {/* Right handle - drag to resize */}
+
+
+
+
+ {/* Info row */}
+
+ API参数: {Math.round(startTime)}, {Math.round(endTime)}
+ 时长: {rangeDuration.toFixed(1)}s
+
+
+ {/* Actions */}
+
+
+
+
+
+ );
+};
+
+export default CharacterTimeRangeSelector;
diff --git a/packages/drawnix/src/components/character/character-time-selector.scss b/packages/drawnix/src/components/character/character-time-selector.scss
new file mode 100644
index 00000000..1dbaf60d
--- /dev/null
+++ b/packages/drawnix/src/components/character/character-time-selector.scss
@@ -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);
+ }
+}
diff --git a/packages/drawnix/src/components/character/character.scss b/packages/drawnix/src/components/character/character.scss
new file mode 100644
index 00000000..8bf06a41
--- /dev/null
+++ b/packages/drawnix/src/components/character/character.scss
@@ -0,0 +1,438 @@
+/**
+ * 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 {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ &-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;
+ }
+}
diff --git a/packages/drawnix/src/components/character/index.ts b/packages/drawnix/src/components/character/index.ts
new file mode 100644
index 00000000..13f1e71c
--- /dev/null
+++ b/packages/drawnix/src/components/character/index.ts
@@ -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';
diff --git a/packages/drawnix/src/components/task-queue/TaskItem.tsx b/packages/drawnix/src/components/task-queue/TaskItem.tsx
index 67cf5a21..fed81b7f 100644
--- a/packages/drawnix/src/components/task-queue/TaskItem.tsx
+++ b/packages/drawnix/src/components/task-queue/TaskItem.tsx
@@ -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,11 +90,19 @@ export const TaskItem: React.FC = ({
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;
+ // Check if task supports character extraction (Sora-2 completed video tasks)
+ const canExtractCharacter =
+ isCompleted &&
+ task.type === TaskType.VIDEO &&
+ isSora2VideoId(task.remoteId) &&
+ supportsCharacterExtraction(task.params.model);
+
// Media cache hook
const {
isCaching,
@@ -474,6 +485,22 @@ export const TaskItem: React.FC = ({
>
编辑
+
+ {/* Extract character button for Sora-2 completed video tasks */}
+ {canExtractCharacter && (
+
+ }
+ data-track="task_click_extract_character"
+ onClick={() => onExtractCharacter?.(task.id)}
+ >
+ 提取角色
+
+
+ )}
);
diff --git a/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx b/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx
index 53d59cc4..ccb97d9b 100644
--- a/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx
+++ b/packages/drawnix/src/components/task-queue/TaskQueuePanel.tsx
@@ -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 (
- {task.type === TaskType.IMAGE ? (
+ {task.type === TaskType.IMAGE || task.type === TaskType.CHARACTER ? (

= ({
} = useTaskQueue();
const { board, openDialog } = useDrawnix();
+ const { characters } = useCharacters();
const [activeTab, setActiveTab] = useState
('all');
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearType, setClearType] = useState<'completed' | 'failed'>('completed');
const [searchText, setSearchText] = useState('');
- const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video'>('all');
+ const [typeFilter, setTypeFilter] = useState<'all' | 'image' | 'video' | 'character'>('all');
const [previewTaskId, setPreviewTaskId] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [taskToDelete, setTaskToDelete] = useState(null);
+ // Character extraction dialog state
+ const [characterDialogTask, setCharacterDialogTask] = useState(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 = ({
}
// 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 = ({
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 = ({
setTypeFilter(value as 'all' | 'image' | 'video')}
+ onChange={(value) => setTypeFilter(value as 'all' | 'image' | 'video' | 'character')}
size="small"
variant="default-filled"
>
全部
图片
视频
+
+
+
+ 角色 ({characters.length})
+
+
-
setSearchText(value)}
- placeholder="搜索 Prompt..."
- clearable
- prefixIcon={
}
- size="small"
- style={{ width: '180px' }}
- />
-
-
- {failedTasks.length > 0 && (
-
- )}
-
+ style={{ width: '180px' }}
+ />
+
+
+ {failedTasks.length > 0 && (
+ }
+ data-track="task_click_clear_failed"
+ onClick={() => handleClear('failed')}
+ >
+ 清除失败
+
+ )}
+
+ >
+ )}
);
@@ -416,28 +445,38 @@ export const TaskQueuePanel: React.FC = ({
className="task-queue-panel"
contentClassName="task-queue-panel__content"
>
- {filteredTasks.length === 0 ? (
-
-
📋
-
- {activeTab === 'all' ? '暂无任务' : `暂无${activeTab === 'active' ? '生成中' : activeTab === 'completed' ? '已完成' : activeTab === 'failed' ? '失败' : '已取消'}任务`}
-
-
+ {isCharacterView ? (
+ /* Character List View */
+
) : (
-
- {filteredTasks.map(task => (
- handlePreviewOpen(task.id)}
- />
- ))}
-
+ /* Task List View */
+ filteredTasks.length === 0 ? (
+
+
📋
+
+ {activeTab === 'all' ? '暂无任务' : `暂无${activeTab === 'active' ? '生成中' : activeTab === 'completed' ? '已完成' : activeTab === 'failed' ? '失败' : '已取消'}任务`}
+
+
+ ) : (
+
+ {filteredTasks.map(task => (
+ handlePreviewOpen(task.id)}
+ onExtractCharacter={handleExtractCharacter}
+ />
+ ))}
+
+ )
)}
@@ -471,7 +510,7 @@ export const TaskQueuePanel: React.FC = ({
width="90vw"
header={
- {previewedTask.type === TaskType.IMAGE ? '图片预览' : '视频预览'}
+ {previewedTask.type === TaskType.IMAGE ? '图片预览' : previewedTask.type === TaskType.CHARACTER ? '角色预览' : '视频预览'}
{previewInfo && (
{previewInfo.currentIndex + 1} / {previewInfo.total}
@@ -507,6 +546,23 @@ export const TaskQueuePanel: React.FC = ({
)}
+
+ {/* Character Create Dialog */}
+ 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');
+ }}
+ />
>
);
};
diff --git a/packages/drawnix/src/components/ttd-dialog/shared/PromptInput.tsx b/packages/drawnix/src/components/ttd-dialog/shared/PromptInput.tsx
index 43a1ff03..a473e48f 100644
--- a/packages/drawnix/src/components/ttd-dialog/shared/PromptInput.tsx
+++ b/packages/drawnix/src/components/ttd-dialog/shared/PromptInput.tsx
@@ -1,5 +1,8 @@
-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 { useCharacters } from '../../../hooks/useCharacters';
+import type { SoraCharacter } from '../../../types/character.types';
interface PromptInputProps {
prompt: string;
@@ -9,6 +12,8 @@ interface PromptInputProps {
type: 'image' | 'video';
disabled?: boolean;
onError?: (error: string | null) => void;
+ /** Whether to enable character @ mention feature */
+ enableMention?: boolean;
}
export const PromptInput: React.FC = ({
@@ -18,10 +23,29 @@ export const PromptInput: React.FC = ({
language,
type,
disabled = false,
- onError
+ onError,
+ enableMention = true, // Enable by default for video
}) => {
const [isPresetOpen, setIsPresetOpen] = useState(false);
const containerRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ // Mention state
+ const [mentionState, setMentionState] = useState<{
+ visible: boolean;
+ query: string;
+ position: { top: number; left: number };
+ startIndex: number; // Position of @ in the prompt
+ selectedIndex: number;
+ }>({
+ visible: false,
+ query: '',
+ position: { top: 0, left: 0 },
+ startIndex: -1,
+ selectedIndex: 0,
+ });
+
+ const { completedCharacters } = useCharacters();
// 点击外部关闭弹窗
useEffect(() => {
@@ -43,6 +67,161 @@ export const PromptInput: React.FC = ({
setIsPresetOpen(false); // 点击提示词后关闭弹窗
};
+ // Calculate mention popup position - show above cursor using caret position
+ const calculateMentionPosition = useCallback((cursorIndex: number) => {
+ if (!textareaRef.current) return { top: 0, left: 0 };
+
+ const textarea = textareaRef.current;
+ const rect = textarea.getBoundingClientRect();
+
+ // Get text before cursor to calculate approximate position
+ const textBeforeCursor = textarea.value.substring(0, cursorIndex);
+ const lines = textBeforeCursor.split('\n');
+ const currentLineIndex = lines.length - 1;
+ const currentLineText = lines[currentLineIndex];
+
+ // Approximate character width (monospace assumption ~8px per char)
+ const charWidth = 8;
+ const lineHeight = 20; // Approximate line height
+
+ // Calculate x position based on current line length
+ const xOffset = Math.min(currentLineText.length * charWidth, rect.width - 200);
+
+ // Calculate y position based on line number (from top of textarea)
+ const yOffset = currentLineIndex * lineHeight;
+
+ // Position popup above the cursor line
+ return {
+ top: rect.top + yOffset - 8, // Above cursor, will use transform to move up
+ left: rect.left + Math.max(0, xOffset),
+ };
+ }, []);
+
+ // Handle @ mention detection
+ const handleMentionDetection = useCallback((value: string, cursorPos: number) => {
+ if (!enableMention || type !== 'video') {
+ 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
+ setMentionState({
+ visible: true,
+ query,
+ position: calculateMentionPosition(cursorPos),
+ startIndex: lastAtIndex,
+ selectedIndex: 0,
+ });
+ }, [enableMention, type, 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) => {
+ 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((e: React.ChangeEvent) => {
+ const value = e.target.value;
+ const cursorPos = e.target.selectionStart || 0;
+
+ onPromptChange(value);
+ onError?.(null);
+
+ // Detect @ mentions
+ handleMentionDetection(value, cursorPos);
+ }, [onPromptChange, onError, handleMentionDetection]);
+
+ // Close mention popup when type changes
+ useEffect(() => {
+ if (type !== 'video') {
+ setMentionState(prev => ({ ...prev, visible: false }));
+ }
+ }, [type]);
+
return (
@@ -84,16 +263,27 @@ export const PromptInput: React.FC
= ({