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 && ( +
+
+