2025-10-22 20:36:02 +08:00

691 lines
34 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, useMemo } from 'react';
import { PhotoPreviewSection } from '../PhotoPreview';
import type { PhotoItem, PhotoType } from '../PhotoPreview/types';
import { ActionButton } from '@/components/common/ActionButton';
import {
SettingOutlined,
} from '@ant-design/icons';
import { Dropdown, Popover } 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 { CircleArrowRight, X, Clapperboard } from 'lucide-react';
import TemplatePreviewModal from '@/components/common/TemplatePreviewModal';
import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal"
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';
import { useTemplateStoryServiceHook } from '@/app/service/Interaction/templateStoryService';
import { StoryTemplateEntity } from '@/app/service/domain/Entities';
import { useUploadFile } from '@/app/service/domain/service';
import { useAppDispatch, useAppSelector } from '@/lib/store/hooks';
import { clearSelection } from '@/lib/store/creationTemplateSlice';
import { Tooltip } from "antd";
import { useTypewriterText } from '@/hooks/useTypewriterText';
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);
// Template modal states (align with FamousTemplate usage)
const [isModalOpen, setIsModalOpen] = useState(false);
const [initialTemplateId, setInitialTemplateId] = useState<string | undefined>(undefined);
const [isTemplateCreating, setIsTemplateCreating] = useState(false);
const [isRoleGenerating, setIsRoleGenerating] = useState<{ [key: string]: boolean }>({});
const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({});
const [currentTemplate, setCurrentTemplate] = useState<StoryTemplateEntity | null>(null);
const [generateText, setGenerateText] = useState<string>('');
const [templateTitle, setTemplateTitle] = useState<string>('');
const [isMentionOpen, setIsMentionOpen] = useState(false);
const { isMobile, isDesktop } = useDeviceType();
const router = useRouter();
/** Template list for mention popover */
const { templateStoryList, getTemplateStoryList } = useTemplateStoryServiceHook();
const isTemplateSelected = useMemo(() => {
return templateStoryList.find(i => i.pcode === configOptions.pcode);
}, [configOptions.pcode, templateStoryList]);
useEffect(() => {
void getTemplateStoryList();
}, [getTemplateStoryList]);
/** Clear inputText when currentTemplate changes */
useEffect(() => {
if (currentTemplate) {
setInputText('');
}
}, [currentTemplate]);
/** Determine if input textarea should be shown based on selected template's freeInput */
const shouldShowInput = useMemo(() => {
if (!isTemplateSelected) {
return true;
}
return isTemplateSelected.freeInput && isTemplateSelected.freeInput.length > 0;
}, [isTemplateSelected]);
/** Dynamic placeholder typing from template freeInput tips */
const placeholderList = useMemo(() => {
if (isTemplateSelected?.freeInput?.length) {
return (isTemplateSelected.freeInput
.map((i) => i.user_tips)
.filter(Boolean) as string[]);
}
return ['Describe the story you want to make...'];
}, [isTemplateSelected]);
const dynamicPlaceholder = useTypewriterText(placeholderList, {
typingMs: 36,
pauseMs: 2200,
resetMs: 300,
});
const characterInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
const propInputRef = useRef<HTMLInputElement>(null);
const mainTextInputRef = useRef<HTMLTextAreaElement>(null);
const { uploadFile } = useUploadFile();
const dispatch = useAppDispatch();
const selectedTemplateId = useAppSelector(state => state.creationTemplate.selectedTemplateId);
useEffect(() => {
setTimeout(() => {
mainTextInputRef.current?.focus();
}, 0);
}, [mainTextInputRef]);
/** Clear current template related states */
const clearTemplateSelection = () => {
handleConfigChange('pcode', '');
setGenerateText('');
setTemplateTitle('');
setPhotos([]);
};
/** Apply selected template to current form state */
const applyTemplateSelection = (template: StoryTemplateEntity) => {
const characterPhotos = (template.storyRole || []).map((role, index) => ({
url: role.photo_url,
type: 'character' as const,
id: `character-${Date.now()}-${index}`,
name: role.role_name,
description: role.role_description,
})).filter(p => p.url);
setPhotos(prev => [...prev, ...characterPhotos]);
setGenerateText(template.generateText || template.name);
setTemplateTitle(template.name);
handleConfigChange('pcode', template.pcode || '');
setTimeout(() => {
mainTextInputRef.current?.focus();
}, 0);
};
/** Apply template selected from global store (e.g., FamousTemplate) */
useEffect(() => {
if (!selectedTemplateId) return;
const selected = templateStoryList.find(t => (t.id || t.template_id) === selectedTemplateId);
if (!selected) return;
clearTemplateSelection();
applyTemplateSelection(selected as StoryTemplateEntity);
dispatch(clearSelection());
}, [selectedTemplateId, templateStoryList]);
/** 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];
void (async () => {
try {
const uploadedUrl = await uploadFile(file);
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
updatedPhotos[replacingIndex] = {
...updatedPhotos[replacingIndex],
url: uploadedUrl,
};
return updatedPhotos;
});
} finally {
setReplacingIndex(null);
}
})();
} else {
// Add new photos
void (async () => {
const fileArray = Array.from(files);
const uploadedUrls = await Promise.all(fileArray.map((file) => uploadFile(file)));
const newPhotos: PhotoItem[] = uploadedUrls.map((url, index) => ({
url,
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],
exclude?: boolean
) => {
if (exclude && key === 'pcode') {
clearTemplateSelection();
}
if (key === 'videoDuration') {
if (value === '8s') {
setConfigOptions(prev => ({ ...prev, expansion_mode: false }));
} else {
setConfigOptions(prev => ({ ...prev, expansion_mode: true }));
}
}
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
void (async () => {
let uploadedUrl: string | undefined;
if (data.file.size > 0) {
uploadedUrl = await uploadFile(data.file);
}
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
const existingPhoto = updatedPhotos[editingIndex];
updatedPhotos[editingIndex] = {
...existingPhoto,
...(uploadedUrl ? { url: uploadedUrl } : {}),
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
return updatedPhotos;
});
})();
} else {
// Add mode - create new photo
void (async () => {
const uploadedUrl = await uploadFile(data.file);
const newPhoto: PhotoItem = {
url: uploadedUrl,
type: currentItemType,
id: `${currentItemType}-${Date.now()}`,
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
setPhotos(prevPhotos => [...prevPhotos, newPhoto]);
})();
}
};
/** Handle video creation */
const handleCreate = async () => {
if (isCreating) return;
if (shouldShowInput && !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: isTemplateSelected ? 'unlimited' : configOptions.videoDuration,
use_img2video: 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);
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 pt-2 border border-white/10 rounded-3xl bg-gradient-to-br from-[#1a1a1a]/50 to-[#0a0a0a]/50 backdrop-blur-sm overflow-hidden flex flex-col"
>
{templateTitle && (
<div data-alt="template-title" className="px-4 flex items-center">
<div data-alt="template-title-text" className="text-white text-xs font-bold truncate">
{templateTitle}
</div>
<button
data-alt="clear-template"
className="ml-2 p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white"
onClick={() => {
clearTemplateSelection();
}}
>
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Photo Preview Section - Top */}
{photos.length > 0 && (
<div data-alt="photo-preview-wrapper" className="pt-2 pl-4 pb-0">
<PhotoPreviewSection
photos={photos}
onEdit={handleEditPhoto}
onReplace={handleReplacePhoto}
/>
</div>
)}
{/* Template Description */}
{generateText && (
<div data-alt="template-description-wrapper" className="px-4 pt-1">
<div data-alt="template-description-text" className="text-white/25 text-sm">{generateText}</div>
</div>
)}
{/* Text Input Area - Middle */}
{shouldShowInput && (
<div data-alt="text-input-wrapper" className="flex-1 flex px-4 py-2">
<textarea
data-alt="main-text-input"
ref={mainTextInputRef}
className="w-full h-full bg-transparent text-gray-300 text-base placeholder:italic placeholder-gray-400 resize-none outline-none border-none"
style={{
minHeight: '4.25rem'
}}
placeholder={dynamicPlaceholder}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
</div>
)}
{/* Control Panel - Bottom */}
<div data-alt="control-panel" className="pl-4 pr-2 py-2 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 with Popover (template list) */}
<Popover
placement="topLeft"
trigger={["click"]}
arrow={false}
open={isMentionOpen}
onOpenChange={(open) => setIsMentionOpen(open)}
content={
<div
data-alt="mention-popover-content"
className="w-64 max-h-64 overflow-y-auto"
>
{templateStoryList && templateStoryList.length > 0 ? (
<div className="grid grid-cols-1">
{templateStoryList.map((tpl) => {
const tplKey = (tpl as any).id || (tpl as any).template_id;
return (
<div
key={tplKey}
data-alt={`mention-template-item-${tplKey}`}
className="group relative px-3 py-2 rounded-lg text-gray-200 hover:bg-white/10 transition-all duration-300 text-sm overflow-hidden hover:h-[106px]"
>
{/* Background image on hover */}
{Array.isArray((tpl as any).image_url) && (tpl as any).image_url.length > 0 && (
<img
data-alt="mention-template-bg"
src={(tpl as any).image_url[0]}
alt=""
loading="lazy"
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover:opacity-100 transition-opacity duration-300"
/>
)}
{/* Dark overlay for readability on hover */}
<div
data-alt="mention-template-overlay"
className="pointer-events-none absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
/>
<div className={`relative z-20 flex items-center gap-3 cursor-pointer h-full ${tpl.pcode === configOptions.pcode ? 'text-custom-blue' : ''}`}
onClick={() => {
setIsMentionOpen(false);
const url = (tpl as any).show_url as string | undefined;
if (url) {
setCurrentTemplate(tpl as StoryTemplateEntity);
} else {
setInitialTemplateId(tplKey);
setIsModalOpen(true);
}
}}
>
<span className="truncate group-hover:hidden">{tpl.name}</span>
{/* Floating actions on hover, right-aligned and overlaying the name */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity z-30">
<button
data-alt={`mention-template-use-${tplKey}`}
className="px-0 py-1 text-xs rounded-full hover:text-custom-blue/80"
onClick={(e) => {
e.stopPropagation();
setIsMentionOpen(false);
clearTemplateSelection();
const selected = tpl as StoryTemplateEntity;
applyTemplateSelection(selected);
}}
>
<CircleArrowRight className="w-5 h-5" />
</button>
</div>
{/* Description at bottom on hover */}
<div
data-alt="mention-template-desc"
className="absolute top-0 left-0 right-0 bottom-0 text-xs text-white/90 opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 transition-all"
>
<div
data-alt="mention-template-desc-scroll"
className="overflow-hidden"
>
<div className="pr-5 line-clamp-5">{(tpl as any).generateText || tpl.name}</div>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div data-alt="mention-no-templates" className="text-gray-400 text-sm">No Avaliable Templates</div>
)}
</div>
}
>
<Tooltip placement="top" title="Inspiration Lab">
<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 ${isTemplateSelected ? 'text-yellow-500' : ''}`}
>
<span className="text-base font-bold">@</span>
</button>
</Tooltip>
</Popover>
{/* Configuration - Desktop: Full Panel, Mobile: Setting Icon */}
{isDesktop ? (
<ConfigPanel
configOptions={configOptions}
onConfigChange={handleConfigChange}
isMobile={isMobile}
isDesktop={isDesktop}
disableDuration={isTemplateSelected ? true : false}
/>
) : (
<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 */}
<ActionButton
isCreating={isCreating}
handleCreateVideo={handleCreate}
icon={<Clapperboard className="text-base font-bold w-5 h-5" />}
width="w-10"
height="h-10"
/>
</div>
</div>
{/* Mobile Configuration Modal */}
<MobileConfigModal
visible={configModalVisible}
onClose={() => setConfigModalVisible(false)}
configOptions={configOptions}
onConfigChange={handleConfigChange}
disableDuration={isTemplateSelected ? true : false}
/>
{/* 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
}
/>
<PcTemplateModal
isTemplateCreating={isTemplateCreating}
setIsTemplateCreating={setIsTemplateCreating}
isRoleGenerating={isRoleGenerating}
setIsRoleGenerating={setIsRoleGenerating}
isItemGenerating={isItemGenerating}
setIsItemGenerating={setIsItemGenerating}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
initialTemplateId={initialTemplateId}
configOptions={{ mode: "auto", resolution: "720p", language: "english", videoDuration: "auto" }}
/>
<TemplatePreviewModal
open={!!currentTemplate && !!currentTemplate.show_url}
videoUrl={currentTemplate?.show_url || ''}
onClose={() => setCurrentTemplate(null)}
title={currentTemplate?.name || ''}
description={currentTemplate?.generateText || currentTemplate?.name}
onPrimaryAction={() => {
if (!currentTemplate) return;
clearTemplateSelection();
applyTemplateSelection(currentTemplate);
setCurrentTemplate(null)
}}
primaryLabel="Try this"
/>
</div>
);
}