feat(ai-video): 支持无源图片的AI视频生成功能

- 修改视频生成API,使源图片变为可选参数
- 新增纯文本提示词生成视频的支持
- 更新AI视频生成对话框,移除图片上传的强制要求
- 调整默认提示词示例,更适合无图片场景
- 优化类型定义和错误处理逻辑
This commit is contained in:
liquid committed 2025-09-07 18:43:39 +08:00
1 parent 2e9f4c57fb
commit 01acd6bbc1
6 files changed
+130 -93

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.0.10",
"buildTime": "2025-09-06T15:47:03.510Z",
"buildTime": "2025-09-07T10:38:24.746Z",
"gitCommit": "unknown"
}
@@ -13,6 +13,7 @@ import {
FeltTipPenIcon,
ImageIcon,
AIImageIcon,
AIVideoIcon,
ExtraToolsIcon,
} from '../icons';
import { useBoard } from '@plait-board/react-board';
@@ -44,7 +45,7 @@ import {
} from '../../hooks/use-drawnix';
import { ExtraToolsButton } from './extra-tools/extra-tools-button';
import { addImage } from '../../utils/image';
import { useI18n } from '../../i18n';
import { useI18n, Translations } from '../../i18n';
export enum PopupKey {
'shape' = 'shape',
@@ -57,7 +58,7 @@ type AppToolButtonProps = {
name?: string;
icon: React.ReactNode;
pointer?: DrawnixPointerType;
key?: PopupKey | 'image' | 'ai-image' | 'extra-tools';
key?: PopupKey | 'image' | 'ai-image' | 'ai-video' | 'extra-tools';
};
const isBasicPointer = (pointer: string) => {
@@ -115,6 +116,11 @@ export const BUTTONS: AppToolButtonProps[] = [
titleKey: 'toolbar.aiImage',
key: 'ai-image',
},
{
icon: AIVideoIcon,
titleKey: 'toolbar.aiVideo',
key: 'ai-video',
},
{
icon: ExtraToolsIcon,
titleKey: 'toolbar.extraTools',
@@ -202,8 +208,8 @@ export const CreationToolbar = () => {
checkCurrentPointerIsFreehand(board)
}
icon={lastFreehandButton.icon}
title={lastFreehandButton.titleKey ? t(lastFreehandButton.titleKey) : 'Freehand'}
aria-label={lastFreehandButton.titleKey ? t(lastFreehandButton.titleKey) : 'Freehand'}
title={lastFreehandButton.titleKey ? t(lastFreehandButton.titleKey as keyof Translations) : 'Freehand'}
aria-label={lastFreehandButton.titleKey ? t(lastFreehandButton.titleKey as keyof Translations) : 'Freehand'}
onPointerDown={() => {
setFreehandOpen(!freehandOpen);
onPointerDown(lastFreehandButton.pointer!);
@@ -246,8 +252,8 @@ export const CreationToolbar = () => {
!PlaitBoard.isPointer(board, BasicShapes.text))
}
icon={button.icon}
title={button.titleKey ? t(button.titleKey) : 'Shape'}
aria-label={button.titleKey ? t(button.titleKey) : 'Shape'}
title={button.titleKey ? t(button.titleKey as keyof Translations) : 'Shape'}
aria-label={button.titleKey ? t(button.titleKey as keyof Translations) : 'Shape'}
onPointerDown={() => {
setShapeOpen(!shapeOpen);
}}
@@ -280,8 +286,8 @@ export const CreationToolbar = () => {
visible={true}
selected={arrowOpen || isArrowLinePointer(board)}
icon={button.icon}
title={button.titleKey ? t(button.titleKey) : ''}
aria-label={button.titleKey ? t(button.titleKey) : ''}
title={button.titleKey ? t(button.titleKey as keyof Translations) : ''}
aria-label={button.titleKey ? t(button.titleKey as keyof Translations) : ''}
onPointerDown={() => {
setArrowOpen(!arrowOpen);
}}
@@ -307,8 +313,8 @@ export const CreationToolbar = () => {
type="radio"
icon={button.icon}
checked={isChecked(button)}
title={button.titleKey ? t(button.titleKey) : ''}
aria-label={button.titleKey ? t(button.titleKey) : ''}
title={button.titleKey ? t(button.titleKey as keyof Translations) : ''}
aria-label={button.titleKey ? t(button.titleKey as keyof Translations) : ''}
onPointerDown={() => {
if (button.pointer && !isBasicPointer(button.pointer)) {
onPointerDown(button.pointer);
@@ -327,6 +333,9 @@ export const CreationToolbar = () => {
if (button.key === 'ai-image') {
openDialog(DialogType.aiImageGeneration);
}
if (button.key === 'ai-video') {
openDialog(DialogType.aiVideoGeneration);
}
}}
/>
);
@@ -15,7 +15,7 @@ import { splitRows } from '../../../utils/common';
import {
DrawnixPointerType,
} from '../../../hooks/use-drawnix';
import { useI18n } from '../../../i18n';
import { useI18n, Translations } from '../../../i18n';
export interface FreehandProps {
titleKey: string;
@@ -63,8 +63,8 @@ export const FreehandPanel: React.FC<FreehandPickerProps> = ({
size={'small'}
visible={true}
icon={freehand.icon}
title={t(freehand.titleKey)}
aria-label={t(freehand.titleKey)}
title={t(freehand.titleKey as keyof Translations)}
aria-label={t(freehand.titleKey as keyof Translations)}
onPointerDown={() => {
setCreationMode(board, BoardCreationMode.dnd);
BoardTransforms.updatePointerType(board, freehand.pointer);
@@ -137,9 +137,9 @@ const generateVideoThumbnail = async (videoUrl: string): Promise<string | undefi
const getPromptExample = (language: 'zh' | 'en') => {
if (language === 'zh') {
return `让图片中的小猫缓缓转头看向镜头,眼睛慢慢眨动,尾巴轻轻摆动`;
return `生成一个美丽的日出场景,阳光从山峰后缓缓升起,云朵轻柔地飘动`;
}
return `Make the cat in the image slowly turn its head towards the camera, blink its eyes slowly, and gently sway its tail`;
return `Generate a beautiful sunrise scene where the sun slowly rises from behind mountains with clouds gently floating`;
};
interface AIVideoGenerationProps {
@@ -439,25 +439,25 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
// 获取合并的预设提示词(用户历史 + 默认预设)
const getMergedPresetPrompts = () => {
// 默认预设提示词
// 默认预设提示词(适用于无源图片场景)
const defaultPrompts = language === 'zh' ? [
'让图片中的人物缓缓转头看向镜头,微微点头',
'图片中的物体轻轻摇摆,营造微风吹动的效果',
'让画面中的水面产生涟漪,水波荡漾',
'使图片中的花朵轻轻摇摆,花瓣偶尔飘落',
'让人物的头发在风中轻柔摆动',
'使背景中的树叶缓缓摇动,阳光斑驳',
'让动物的眼睛慢慢眨动,显得生动活泼',
'使画面产生轻微的景深变化,聚焦效果'
'生成一个美丽的日出场景,阳光从山峰后缓缓升起,云朵轻柔地飘动',
'创造一个森林中的场景,树叶在微风中轻轻摇摆,阳光斑驳',
'生成一个海边场景,海浪轻拍岸边,海鸟在空中盘旋',
'创建一个花园场景,花朵在微风中轻摆,蝴蝶翩翩起舞',
'生成一个雨后场景,水滴从树叶上缓缓滴落,彩虹出现在天空',
'创造一个雪花飘落的冬日场景,雪花轻柔地降落',
'生成一个星空场景,星星闪烁,云朵缓缓飘过月亮',
'创建一个溪流场景,清水在石头间潺潺流淌,鱼儿游过'
] : [
'Make the person in the image slowly turn their head towards the camera and nod slightly',
'Let objects in the image sway gently, creating a breeze effect',
'Make ripples appear on the water surface in the image',
'Let flowers in the image sway gently with petals occasionally falling',
'Make the person\'s hair move softly in the wind',
'Let leaves in the background move slowly with dappled sunlight',
'Make the animal\'s eyes blink slowly, appearing lively',
'Create subtle depth of field changes in the scene'
'Generate a beautiful sunrise scene where the sun slowly rises from behind mountains with clouds gently floating',
'Create a forest scene with leaves swaying gently in the breeze and dappled sunlight',
'Generate a beach scene with waves gently lapping the shore and seagulls soaring overhead',
'Create a garden scene with flowers swaying in the breeze and butterflies dancing',
'Generate a post-rain scene with water drops slowly falling from leaves and a rainbow in the sky',
'Create a winter scene with snowflakes gently falling from the sky',
'Generate a starry night scene with twinkling stars and clouds slowly drifting past the moon',
'Create a stream scene with clear water flowing gently between rocks and fish swimming by'
];
// 使用工具函数提取用户历史提示词
@@ -510,10 +510,11 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
return;
}
if (!uploadedImage) {
setError(language === 'zh' ? '请上传一张图片作为视频生成的源素材' : 'Please upload an image as source material for video generation');
return;
}
// 源图片现在是可选的
// if (!uploadedImage) {
// setError(language === 'zh' ? '请上传一张图片作为视频生成的源素材' : 'Please upload an image as source material for video generation');
// return;
// }
// 在生成开始时保存提示词(不管是否生成成功)
savePromptToHistory(prompt);
@@ -524,38 +525,43 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
try {
console.log('Using new Video Generation API...');
// 处理上传的图片
// 处理上传的图片(现在是可选的)
let imageInput;
if (uploadedImage instanceof File) {
// 注释掉图片压缩逻辑,直接使用原图
// try {
// // 将File转换为data URL
// const fileDataUrl = await new Promise<string>((resolve, reject) => {
// const reader = new FileReader();
// reader.onload = () => resolve(reader.result as string);
// reader.onerror = reject;
// reader.readAsDataURL(uploadedImage);
// });
//
// // 对base64图片进行压缩处理
// const compressedDataUrl = await compressImageUrl(fileDataUrl);
//
// // 将压缩后的data URL转换回File对象
// const response = await fetch(compressedDataUrl);
// const blob = await response.blob();
// const compressedFile = new File([blob], uploadedImage.name, { type: blob.type || uploadedImage.type });
//
// imageInput = { file: compressedFile };
// } catch (compressionError) {
// console.warn('Failed to compress uploaded image, using original:', compressionError);
// imageInput = { file: uploadedImage };
// }
// 直接使用原图,不进行压缩
imageInput = { file: uploadedImage };
if (uploadedImage) {
if (uploadedImage instanceof File) {
// 注释掉图片压缩逻辑,直接使用原图
// try {
// // 将File转换为data URL
// const fileDataUrl = await new Promise<string>((resolve, reject) => {
// const reader = new FileReader();
// reader.onload = () => resolve(reader.result as string);
// reader.onerror = reject;
// reader.readAsDataURL(uploadedImage);
// });
//
// // 对base64图片进行压缩处理
// const compressedDataUrl = await compressImageUrl(fileDataUrl);
//
// // 将压缩后的data URL转换回File对象
// const response = await fetch(compressedDataUrl);
// const blob = await response.blob();
// const compressedFile = new File([blob], uploadedImage.name, { type: blob.type || uploadedImage.type });
//
// imageInput = { file: compressedFile };
// } catch (compressionError) {
// console.warn('Failed to compress uploaded image, using original:', compressionError);
// imageInput = { file: uploadedImage };
// }
// 直接使用原图,不进行压缩
imageInput = { file: uploadedImage };
} else {
// 对于URL类型的图片,直接传递URL
imageInput = { url: uploadedImage.url };
}
} else {
// 对于URL类型的图片,直接传递URL
imageInput = { url: uploadedImage.url };
// 没有图片时传递 null
imageInput = null;
}
// 调用新的视频生成API(使用专用的视频客户端)
@@ -683,7 +689,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
if (!isGenerating && prompt.trim() && uploadedImage) {
if (!isGenerating && prompt.trim()) {
handleGenerate();
}
}
@@ -693,7 +699,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isGenerating, prompt, uploadedImage, handleGenerate]);
}, [isGenerating, prompt, handleGenerate]);
// 组件卸载时清理视频播放
useEffect(() => {
@@ -717,7 +723,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
{/* 图片上传 (只支持单张图片) */}
<div className="form-field">
<label className="form-label">
{language === 'zh' ? '源图片 (必需)' : 'Source Image (Required)'}
{language === 'zh' ? '源图片 (可选)' : 'Source Image (Optional)'}
</label>
<div className="unified-image-area">
{!uploadedImage ? (
@@ -879,7 +885,7 @@ const AIVideoGeneration = ({ initialPrompt = '', initialImage }: AIVideoGenerati
<div className="section-actions">
<button
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim() || !uploadedImage}
disabled={isGenerating || !prompt.trim()}
className={`action-button primary ${isGenerating ? 'loading' : ''}`}
>
{isGenerating
+3
View File
@@ -16,6 +16,7 @@ export interface Translations {
'toolbar.shape': string;
'toolbar.image': string;
'toolbar.aiImage': string;
'toolbar.aiVideo': string;
'toolbar.extraTools': string;
// Zoom controls
@@ -105,6 +106,7 @@ const translations: Record<Language, Translations> = {
'toolbar.shape': '形状',
'toolbar.image': '图片 — Cmd+U',
'toolbar.aiImage': 'AI 图像生成',
'toolbar.aiVideo': 'AI 视频生成',
'toolbar.extraTools': '更多工具',
// Zoom controls
@@ -191,6 +193,7 @@ const translations: Record<Language, Translations> = {
'toolbar.shape': 'Shape',
'toolbar.image': 'Image — Cmd+U',
'toolbar.aiImage': 'AI Image Generation',
'toolbar.aiVideo': 'AI Video Generation',
'toolbar.extraTools': 'Extra Tools',
// Zoom controls
+41 -22
View File
@@ -582,7 +582,7 @@ export async function generateImageWithGemini(
*/
export async function generateVideoWithGemini(
prompt: string,
image: ImageInput,
image: ImageInput | null,
options: VideoGenerationOptions = {}
): Promise<{
response: GeminiResponse;
@@ -597,24 +597,30 @@ export async function generateVideoWithGemini(
};
const validatedConfig = await validateAndEnsureConfig(config);
// 准备图片数据
// 准备图片数据(现在是可选的)
let imageContent;
try {
console.log('处理视频生成源图片...');
const imageData = await prepareImageData(image);
imageContent = {
type: 'image_url' as const,
image_url: {
url: imageData,
},
};
} catch (error) {
console.error('处理源图片时出错:', error);
throw error;
if (image) {
try {
console.log('处理视频生成源图片...');
const imageData = await prepareImageData(image);
imageContent = {
type: 'image_url' as const,
image_url: {
url: imageData,
},
};
console.log('视频生成源图片处理完成');
} catch (error) {
console.error('处理源图片时出错:', error);
throw error;
}
} else {
console.log('无源图片,使用纯文本生成视频');
}
// 构建视频生成专用的提示词
const videoPrompt = `Generate a video based on this image and description: "${prompt}"
// 构建视频生成专用的提示词(根据是否有图片使用不同提示词)
const videoPrompt = image
? `Generate a video based on this image and description: "${prompt}"
Requirements:
- Create a short video (3-5 seconds) based on the provided image
@@ -622,13 +628,26 @@ Requirements:
- Maintain the original image quality and style
- Return only the direct video URL in your response
Description: ${prompt}`
: `Generate a video based on this description: "${prompt}"
Requirements:
- Create a short video (3-5 seconds) based on the text description
- Generate realistic visual content that matches the description
- Use high quality rendering and smooth motion
- Return only the direct video URL in your response
Description: ${prompt}`;
// 构建消息内容
const contentList = [
{ type: 'text' as const, text: videoPrompt },
imageContent,
];
// 构建消息内容(只有在有图片时才包含图片)
const contentList = image && imageContent
? [
{ type: 'text' as const, text: videoPrompt },
imageContent,
]
: [
{ type: 'text' as const, text: videoPrompt },
];
const messages: GeminiMessage[] = [
{
@@ -768,7 +787,7 @@ export class GeminiClient {
/**
* 生成视频
*/
async generateVideo(prompt: string, image: ImageInput, options: VideoGenerationOptions = {}) {
async generateVideo(prompt: string, image: ImageInput | null, options: VideoGenerationOptions = {}) {
return generateVideoWithGemini(prompt, image, options);
}