2025-10-20 21:29:20 +08:00

423 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';
import { useRouter } from 'next/navigation';
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 router = useRouter();
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,
video_duration: configOptions.videoDuration,
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}`);
router.push(`/movies/work-flow?episodeId=${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>
);
}