forked from 77media/video-flow
421 lines
18 KiB
TypeScript
421 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from 'react';
|
|
import { PhotoPreviewSection } from '../PhotoPreview';
|
|
import type { PhotoItem, PhotoType } from '../PhotoPreview/types';
|
|
import {
|
|
PlusOutlined,
|
|
UserOutlined,
|
|
CameraOutlined,
|
|
BulbOutlined,
|
|
ArrowRightOutlined,
|
|
SettingOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Dropdown } from 'antd';
|
|
import { ConfigPanel } from './ConfigPanel';
|
|
import { MobileConfigModal } from './MobileConfigModal';
|
|
import { AddItemModal } from './AddItemModal';
|
|
import { defaultConfig } from './config-options';
|
|
import type { ConfigOptions } from './config-options';
|
|
import { useDeviceType } from '@/hooks/useDeviceType';
|
|
import { MovieProjectService, MovieProjectMode } from '@/app/service/Interaction/MovieProjectService';
|
|
import type { CreateMovieProjectV4Request } from '@/api/DTO/movie_start_dto';
|
|
import { getCurrentUser } from '@/lib/auth';
|
|
|
|
export default function VideoCreationForm() {
|
|
const [photos, setPhotos] = useState<PhotoItem[]>([]);
|
|
const [inputText, setInputText] = useState('');
|
|
const [configOptions, setConfigOptions] = useState<ConfigOptions>(defaultConfig);
|
|
const [configModalVisible, setConfigModalVisible] = useState(false);
|
|
const [addItemModalVisible, setAddItemModalVisible] = useState(false);
|
|
const [currentItemType, setCurrentItemType] = useState<PhotoType | null>(null);
|
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
|
|
const { isMobile, isDesktop } = useDeviceType();
|
|
|
|
const characterInputRef = useRef<HTMLInputElement>(null);
|
|
const sceneInputRef = useRef<HTMLInputElement>(null);
|
|
const propInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
/** Handle file upload */
|
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>, type: PhotoType) => {
|
|
const files = event.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
// Check if we're replacing an existing photo
|
|
if (replacingIndex !== null) {
|
|
const file = files[0];
|
|
setPhotos(prevPhotos => {
|
|
const updatedPhotos = [...prevPhotos];
|
|
updatedPhotos[replacingIndex] = {
|
|
...updatedPhotos[replacingIndex],
|
|
url: URL.createObjectURL(file),
|
|
};
|
|
return updatedPhotos;
|
|
});
|
|
setReplacingIndex(null);
|
|
} else {
|
|
// Add new photos
|
|
const newPhotos: PhotoItem[] = Array.from(files).map((file, index) => ({
|
|
url: URL.createObjectURL(file),
|
|
type,
|
|
id: `${type}-${Date.now()}-${index}`,
|
|
}));
|
|
|
|
setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]);
|
|
}
|
|
|
|
event.target.value = '';
|
|
};
|
|
|
|
/** Handle configuration change */
|
|
const handleConfigChange = <K extends keyof ConfigOptions>(
|
|
key: K,
|
|
value: ConfigOptions[K]
|
|
) => {
|
|
setConfigOptions(prev => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
/** Handle editing a photo */
|
|
const handleEditPhoto = (index: number) => {
|
|
const photo = photos[index];
|
|
setCurrentItemType(photo.type);
|
|
setEditingIndex(index);
|
|
setAddItemModalVisible(true);
|
|
};
|
|
|
|
/** Handle replacing a photo */
|
|
const handleReplacePhoto = (index: number) => {
|
|
const photo = photos[index];
|
|
setReplacingIndex(index);
|
|
|
|
// Trigger the appropriate file input based on photo type
|
|
switch (photo.type) {
|
|
case 'character':
|
|
characterInputRef.current?.click();
|
|
break;
|
|
case 'scene':
|
|
sceneInputRef.current?.click();
|
|
break;
|
|
case 'prop':
|
|
propInputRef.current?.click();
|
|
break;
|
|
}
|
|
};
|
|
|
|
/** Handle adding photos by type */
|
|
const handleAddPhotoByType = (type: PhotoType) => {
|
|
// Reset replacing index to ensure we're adding, not replacing
|
|
setReplacingIndex(null);
|
|
|
|
// Trigger the appropriate file input
|
|
switch (type) {
|
|
case 'character':
|
|
characterInputRef.current?.click();
|
|
break;
|
|
case 'scene':
|
|
sceneInputRef.current?.click();
|
|
break;
|
|
case 'prop':
|
|
propInputRef.current?.click();
|
|
break;
|
|
}
|
|
};
|
|
|
|
/** Handle item submission from modal */
|
|
const handleItemSubmit = (data: { name: string; description: string; file: File; index?: number }) => {
|
|
if (!currentItemType) return;
|
|
|
|
// Check if we're editing or adding
|
|
if (typeof data.index === 'number' && editingIndex !== null) {
|
|
// Edit mode - update existing photo
|
|
setPhotos(prevPhotos => {
|
|
const updatedPhotos = [...prevPhotos];
|
|
const existingPhoto = updatedPhotos[editingIndex];
|
|
|
|
updatedPhotos[editingIndex] = {
|
|
...existingPhoto,
|
|
// Only update URL if a new file was selected (not the dummy file)
|
|
...(data.file.size > 0 && { url: URL.createObjectURL(data.file) }),
|
|
...(data.name && { name: data.name }),
|
|
...(data.description && { description: data.description }),
|
|
};
|
|
|
|
return updatedPhotos;
|
|
});
|
|
console.log('Updated item:', { ...data, type: currentItemType, index: editingIndex });
|
|
} else {
|
|
// Add mode - create new photo
|
|
const newPhoto: PhotoItem = {
|
|
url: URL.createObjectURL(data.file),
|
|
type: currentItemType,
|
|
id: `${currentItemType}-${Date.now()}`,
|
|
...(data.name && { name: data.name }),
|
|
...(data.description && { description: data.description }),
|
|
};
|
|
|
|
setPhotos(prevPhotos => [...prevPhotos, newPhoto]);
|
|
console.log('Added item:', { ...data, type: currentItemType });
|
|
}
|
|
};
|
|
|
|
/** Handle video creation */
|
|
const handleCreate = async () => {
|
|
if (isCreating) return;
|
|
|
|
if (!inputText.trim()) {
|
|
window.msg?.warning('Please enter your story description');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCreating(true);
|
|
|
|
const user = getCurrentUser();
|
|
if (!user?.id) {
|
|
window.msg?.error('Please login first');
|
|
return;
|
|
}
|
|
|
|
/** Separate photos by type */
|
|
const characterPhotos = photos.filter(p => p.type === 'character');
|
|
const scenePhotos = photos.filter(p => p.type === 'scene');
|
|
const propPhotos = photos.filter(p => p.type === 'prop');
|
|
|
|
/** Build request parameters */
|
|
const requestParams: CreateMovieProjectV4Request = {
|
|
script: inputText,
|
|
mode: configOptions.mode,
|
|
resolution: '720p',
|
|
language: configOptions.language,
|
|
aspect_ratio: configOptions.aspect_ratio,
|
|
expansion_mode: configOptions.expansion_mode,
|
|
is_image_to_video: photos.length > 0,
|
|
pcode: configOptions.pcode === 'portrait' ? '' : configOptions.pcode,
|
|
};
|
|
|
|
/** Add character briefs if exists */
|
|
if (characterPhotos.length > 0) {
|
|
requestParams.character_briefs = characterPhotos.map(photo => ({
|
|
character_name: photo.name || 'Character',
|
|
character_description: photo.description || '',
|
|
image_url: photo.url,
|
|
}));
|
|
}
|
|
|
|
/** Add scene briefs if exists */
|
|
if (scenePhotos.length > 0) {
|
|
requestParams.scene_briefs = scenePhotos.map(photo => ({
|
|
scene_name: photo.name || 'Scene',
|
|
scene_description: photo.description || '',
|
|
image_url: photo.url,
|
|
scene_type: 'custom',
|
|
}));
|
|
}
|
|
|
|
/** Add prop briefs if exists */
|
|
if (propPhotos.length > 0) {
|
|
requestParams.prop_briefs = propPhotos.map(photo => ({
|
|
prop_name: photo.name || 'Prop',
|
|
prop_description: photo.description || '',
|
|
image_url: photo.url,
|
|
prop_type: 'custom',
|
|
}));
|
|
}
|
|
|
|
console.log('Creating video with params:', requestParams);
|
|
|
|
/** Call MovieProjectService V4 API */
|
|
const result = await MovieProjectService.createProject<CreateMovieProjectV4Request>(
|
|
MovieProjectMode.V4,
|
|
requestParams
|
|
);
|
|
|
|
console.log('Video creation successful, project_id:', result.project_id);
|
|
window.msg?.success(`Video created successfully! Project ID: ${result.project_id}`);
|
|
|
|
/** TODO: Navigate to project detail page or next step */
|
|
// window.location.href = `/movies/${result.project_id}`;
|
|
} catch (error) {
|
|
console.error('Failed to create video:', error);
|
|
if (error instanceof Error && error.message !== '操作已取消') {
|
|
window.msg?.error(error.message || 'Failed to create video');
|
|
}
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div data-alt="video-creation-form" className="w-full h-full flex flex-col">
|
|
{/* Main Content Area with Border */}
|
|
<div
|
|
data-alt="content-container"
|
|
className="flex-1 border border-white/10 rounded-3xl bg-gradient-to-br from-[#1a1a1a]/50 to-[#0a0a0a]/50 backdrop-blur-sm overflow-hidden flex flex-col"
|
|
>
|
|
{/* Photo Preview Section - Top */}
|
|
{photos.length > 0 && (
|
|
<div data-alt="photo-preview-wrapper" className="p-4 pb-0">
|
|
<PhotoPreviewSection
|
|
photos={photos}
|
|
onEdit={handleEditPhoto}
|
|
onReplace={handleReplacePhoto}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text Input Area - Middle */}
|
|
<div data-alt="text-input-wrapper" className="flex-1 px-4 py-4">
|
|
<textarea
|
|
data-alt="main-text-input"
|
|
className="w-full h-full bg-transparent text-gray-300 text-base placeholder-gray-500 resize-none outline-none border-none"
|
|
placeholder="Share a topic, idea, or instructions with Video Agent to produce a full avatar video"
|
|
value={inputText}
|
|
onChange={(e) => setInputText(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Control Panel - Bottom */}
|
|
<div data-alt="control-panel" className="px-4 py-4 flex items-center justify-between gap-2">
|
|
{/* Left Side - Upload and Options */}
|
|
<div data-alt="left-controls" className="flex items-center gap-2">
|
|
{/* Upload Button with Dropdown */}
|
|
<Dropdown
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'character',
|
|
icon: <UserOutlined className="text-cyan-400" />,
|
|
label: <span className="text-gray-300">Character</span>,
|
|
onClick: () => handleAddPhotoByType('character'),
|
|
},
|
|
{
|
|
key: 'scene',
|
|
icon: <CameraOutlined className="text-cyan-400" />,
|
|
label: <span className="text-gray-300">Scene</span>,
|
|
onClick: () => handleAddPhotoByType('scene'),
|
|
},
|
|
{
|
|
key: 'prop',
|
|
icon: <BulbOutlined className="text-cyan-400" />,
|
|
label: <span className="text-gray-300">Prop</span>,
|
|
onClick: () => handleAddPhotoByType('prop'),
|
|
},
|
|
],
|
|
className: 'bg-[#1a1a1a] border border-white/10'
|
|
}}
|
|
trigger={['click']}
|
|
>
|
|
<button
|
|
data-alt="upload-button"
|
|
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
|
|
>
|
|
<PlusOutlined className="text-base font-bold" />
|
|
</button>
|
|
</Dropdown>
|
|
|
|
{/* Hidden file inputs */}
|
|
<input
|
|
ref={characterInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFileUpload(e, 'character')}
|
|
/>
|
|
<input
|
|
ref={sceneInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFileUpload(e, 'scene')}
|
|
/>
|
|
<input
|
|
ref={propInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => handleFileUpload(e, 'prop')}
|
|
/>
|
|
|
|
{/* Mention Button */}
|
|
<button
|
|
data-alt="mention-button"
|
|
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
|
|
>
|
|
<span className="text-base font-bold">@</span>
|
|
</button>
|
|
|
|
{/* Configuration - Desktop: Full Panel, Mobile: Setting Icon */}
|
|
{isDesktop ? (
|
|
<ConfigPanel
|
|
configOptions={configOptions}
|
|
onConfigChange={handleConfigChange}
|
|
isMobile={isMobile}
|
|
isDesktop={isDesktop}
|
|
/>
|
|
) : (
|
|
<button
|
|
data-alt="settings-button"
|
|
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
|
|
onClick={() => setConfigModalVisible(true)}
|
|
>
|
|
<SettingOutlined className="text-base" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Side - Create Button */}
|
|
<button
|
|
data-alt="create-button"
|
|
className="w-8 h-8 rounded-full bg-black hover:bg-gray-900 border border-white/20 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-white shadow-lg hover:shadow-cyan-400/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
onClick={handleCreate}
|
|
disabled={isCreating}
|
|
>
|
|
{isCreating ? (
|
|
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
|
) : (
|
|
<ArrowRightOutlined className="text-base font-bold" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Configuration Modal */}
|
|
<MobileConfigModal
|
|
visible={configModalVisible}
|
|
onClose={() => setConfigModalVisible(false)}
|
|
configOptions={configOptions}
|
|
onConfigChange={handleConfigChange}
|
|
/>
|
|
|
|
{/* Add Item Modal */}
|
|
<AddItemModal
|
|
visible={addItemModalVisible}
|
|
onClose={() => {
|
|
setAddItemModalVisible(false);
|
|
setCurrentItemType(null);
|
|
setEditingIndex(null);
|
|
}}
|
|
itemType={currentItemType}
|
|
onSubmit={handleItemSubmit}
|
|
editData={
|
|
editingIndex !== null && photos[editingIndex]
|
|
? {
|
|
index: editingIndex,
|
|
name: photos[editingIndex].name,
|
|
description: photos[editingIndex].description,
|
|
imageUrl: photos[editingIndex].url,
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|