forked from 77media/video-flow
359 lines
15 KiB
TypeScript
359 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { Modal, Tooltip, Popconfirm, Upload, Dropdown } from "antd";
|
||
import { ImagePlay, Sparkles, Trash2 } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import GlobalLoad from "../common/GlobalLoad";
|
||
import { ActionButton } from "../common/ActionButton";
|
||
import { HighlightEditor } from "../common/HighlightEditor";
|
||
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
|
||
import { useLoadScriptText } from "@/app/service/domain/service";
|
||
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
|
||
|
||
type ConfigOptions = {
|
||
mode: "auto" | "manual";
|
||
resolution: "720p" | "1080p" | "4k";
|
||
language: string;
|
||
videoDuration: string;
|
||
};
|
||
|
||
export const PcPhotoStoryModal = ({
|
||
isCreating,
|
||
setIsCreating,
|
||
isPhotoCreating,
|
||
setIsPhotoCreating,
|
||
isOpen,
|
||
onClose,
|
||
configOptions = {
|
||
mode: "auto",
|
||
resolution: "720p",
|
||
language: "english",
|
||
videoDuration: "1min",
|
||
},
|
||
}: {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
isCreating: boolean;
|
||
setIsCreating: (value: boolean) => void;
|
||
isPhotoCreating: boolean;
|
||
setIsPhotoCreating: (value: boolean) => void;
|
||
configOptions?: ConfigOptions;
|
||
}) => {
|
||
const {
|
||
activeImageUrl,
|
||
storyContent,
|
||
potentialGenres,
|
||
selectedCategory,
|
||
isLoading,
|
||
hasAnalyzed,
|
||
taskProgress,
|
||
updateStoryType,
|
||
updateStoryContent,
|
||
updateCharacterName,
|
||
resetImageStory,
|
||
triggerFileSelection,
|
||
avatarComputed,
|
||
uploadAndAnalyzeImage,
|
||
setCharactersAnalysis,
|
||
originalUserDescription,
|
||
actionMovie,
|
||
uploadCharacterAvatarAndAnalyzeFeatures,
|
||
} = useImageStoryServiceHook();
|
||
const { loadingText } = useLoadScriptText(isLoading);
|
||
const [localLoading, setLocalLoading] = useState(0);
|
||
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
|
||
const router = useRouter();
|
||
const taskProgressRef = useRef(taskProgress);
|
||
const [cursorPosition, setCursorPosition] = useState(0);
|
||
|
||
const handleCursorPositionChange = (position: number) => {
|
||
setCursorPosition(position);
|
||
};
|
||
|
||
useEffect(() => {
|
||
taskProgressRef.current = taskProgress;
|
||
}, [taskProgress]);
|
||
|
||
const handleClose = () => {
|
||
onClose();
|
||
};
|
||
|
||
const handleImageUpload = async (e: any) => {
|
||
const target = e.target as HTMLImageElement;
|
||
if (!(target.tagName == "IMG" || e.target.dataset.alt == "image-upload-area")) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
try {
|
||
await triggerFileSelection();
|
||
} catch (error) {
|
||
console.error("Failed to upload image:", error);
|
||
}
|
||
};
|
||
|
||
const handleConfirm = async () => {
|
||
try {
|
||
setIsCreating(true);
|
||
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
||
if (!User.id) {
|
||
console.error("用户未登录");
|
||
return;
|
||
}
|
||
const episodeResponse = await actionMovie(
|
||
String(User.id),
|
||
configOptions.mode as "auto" | "manual",
|
||
configOptions.resolution as "720p" | "1080p" | "4k",
|
||
configOptions.language,
|
||
aspectUI as AspectRatioValue
|
||
);
|
||
if (!episodeResponse) return;
|
||
const episodeId = episodeResponse.project_id;
|
||
router.push(`/movies/work-flow?episodeId=${episodeId}`);
|
||
handleClose();
|
||
} catch (error) {
|
||
setIsCreating(false);
|
||
console.error("创建电影项目失败:", error);
|
||
}
|
||
};
|
||
|
||
const handleAnalyzeImage = async () => {
|
||
if (isPhotoCreating || isLoading) return;
|
||
setIsPhotoCreating(true);
|
||
let timeout = 100;
|
||
let timer: NodeJS.Timeout;
|
||
timer = setInterval(() => {
|
||
const currentProgress = taskProgressRef.current;
|
||
setLocalLoading((prev) => {
|
||
if (prev >= currentProgress && currentProgress != 0) {
|
||
return currentProgress;
|
||
}
|
||
return prev + 0.1;
|
||
});
|
||
}, timeout);
|
||
try {
|
||
await uploadAndAnalyzeImage();
|
||
} catch (error) {
|
||
console.error("分析图片失败:", error);
|
||
setIsPhotoCreating(false);
|
||
} finally {
|
||
clearInterval(timer);
|
||
setLocalLoading(0);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
open={isOpen}
|
||
onCancel={handleClose}
|
||
footer={null}
|
||
width="80%"
|
||
maskClosable={false}
|
||
style={{ maxWidth: "1000px", marginTop: "10vh" }}
|
||
className="photo-story-modal bg-white/[0.08] backdrop-blur-[20px] [&_.ant-modal-content]:bg-white/[0.00]"
|
||
closeIcon={
|
||
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
|
||
<span className="text-white/70 text-lg leading-none flex items-center justify-center">×</span>
|
||
</div>
|
||
}
|
||
>
|
||
<GlobalLoad show={localLoading > 0} progress={localLoading}>
|
||
<div className="rounded-2xl">
|
||
<div className="flex items-center gap-3 p-2 border-b border-white/[0.1]">
|
||
<ImagePlay className="w-6 h-6 text-blue-400" />
|
||
<h2 className="text-xl font-bold text-white">Movie Generation from Image</h2>
|
||
</div>
|
||
<div className="w-full bg-white/[0.04] border border-white/[0.1] rounded-xl p-4 mt-2">
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex-shrink-0">
|
||
<div
|
||
data-alt="image-upload-area"
|
||
className={`w-32 h-32 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
|
||
activeImageUrl
|
||
? "border-2 border-white/20 bg-white/[0.05]"
|
||
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05] hover:scale-105"
|
||
}`}
|
||
onClick={handleImageUpload}
|
||
>
|
||
{activeImageUrl ? (
|
||
<div className="relative w-full h-full">
|
||
<img
|
||
src={activeImageUrl}
|
||
alt="Story inspiration"
|
||
className="w-full h-full object-contain rounded-lg bg-white/[0.05]"
|
||
/>
|
||
<Popconfirm
|
||
title="Clear all content"
|
||
description="Are you sure you want to clear all content? This action cannot be undone."
|
||
onConfirm={() => {
|
||
resetImageStory();
|
||
}}
|
||
okText="Yes"
|
||
cancelText="No"
|
||
showCancel={false}
|
||
okType="default"
|
||
placement="top"
|
||
classNames={{
|
||
root: "text-white event-pointer",
|
||
body: "text-white border rounded-lg bg-white/[0.04] [&_.ant-popconfirm-description]:!text-white [&_.ant-popconfirm-title]:!text-white [&_.ant-btn]:!text-white",
|
||
}}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}}
|
||
className="absolute top-1 right-1 w-4 h-4 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-md flex items-center justify-center text-white/90 hover:text-white border border-white/20 hover:border-white/30 transition-all duration-200 z-10 shadow-sm hover:shadow-md"
|
||
data-alt="clear-all-button"
|
||
>
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</Popconfirm>
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-white/60">
|
||
<Upload className="w-6 h-6 mx-auto mb-1 opacity-50" />
|
||
<p className="text-xs">Upload</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
|
||
{hasAnalyzed && avatarComputed.length > 0 && (
|
||
<div className="flex gap-2 n justify-start">
|
||
{avatarComputed.map((avatar, index) => (
|
||
<div key={`${avatar.name}-${index}`} className="flex flex-col items-center">
|
||
<div className="relative w-20 h-20 rounded-sm overflow-hidden bg-white/[0.05] border border-white/[0.1] mb-2 group cursor-pointer">
|
||
<img
|
||
src={avatar.url}
|
||
alt={avatar.name}
|
||
className="w-full h-full object-contain bg-white/[0.05]"
|
||
onError={(e) => {
|
||
const target = e.target as HTMLImageElement;
|
||
target.src = activeImageUrl;
|
||
}}
|
||
/>
|
||
<Tooltip title="Remove this character from the movie" placement="top">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setCharactersAnalysis((charactersAnalysis) => {
|
||
const updatedCharacters = charactersAnalysis.filter((char) => char.role_name !== avatar.name);
|
||
return updatedCharacters;
|
||
});
|
||
const updatedStory = storyContent
|
||
.replace(new RegExp(`<role[^>]*>${avatar.name}<\/role>`, "g"), "")
|
||
.replace(new RegExp(`\\b${avatar.name}\\b`, "g"), "")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
updateStoryContent(updatedStory);
|
||
}}
|
||
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/[0.4] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
|
||
>
|
||
<Trash2 className="w-2.5 h-2.5" />
|
||
</button>
|
||
</Tooltip>
|
||
<Tooltip title="Click to upload new image for this character" placement="top">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
uploadCharacterAvatarAndAnalyzeFeatures(avatar.name).catch((error) => {
|
||
console.error("上传人物头像失败:", error);
|
||
});
|
||
}}
|
||
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
|
||
>
|
||
<Upload className="w-4 h-4 text-white" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
<div className="relative group">
|
||
<input
|
||
type="text"
|
||
defaultValue={avatar.name}
|
||
onBlur={(e) => {
|
||
const newName = e.target.value.trim();
|
||
if (newName && newName !== avatar.name) {
|
||
updateCharacterName(avatar.name, newName);
|
||
}
|
||
}}
|
||
className="w-16 text-center text-sm text-white/80 bg-transparent border-none outline-none focus:ring-1 focus:ring-blue-400/50 rounded px-1 py-0.5 transition-all duration-200"
|
||
style={{ textAlign: "center" }}
|
||
/>
|
||
<div className="absolute inset-0 border border-transparent group-hover:border-white/20 rounded transition-all duration-200 pointer-events-none"></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{hasAnalyzed && potentialGenres.length > 0 && (
|
||
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
|
||
<div className="flex gap-2">
|
||
{[...potentialGenres].map((genre) => (
|
||
<button
|
||
key={genre}
|
||
onClick={() => updateStoryType(genre)}
|
||
className={`px-3 py-1.5 text-xs rounded-lg transition-all duration-200 whitespace-nowrap ${
|
||
selectedCategory === genre
|
||
? "bg-blue-500/20 border border-blue-500/40 text-blue-300"
|
||
: "bg-white/[0.05] border border-white/[0.1] text-white/60 hover:bg-white/[0.08] hover:text-white/80"
|
||
}`}
|
||
>
|
||
{genre}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{originalUserDescription && (
|
||
<div className="mt-2 text-sm text-white/30 italic">Your Provided Text:{originalUserDescription}</div>
|
||
)}
|
||
<div className="flex items-start gap-4 mt-2 relative">
|
||
<HighlightEditor
|
||
content={storyContent}
|
||
onContentChange={updateStoryContent}
|
||
onCursorPositionChange={handleCursorPositionChange}
|
||
cursorPosition={cursorPosition}
|
||
type={"role"}
|
||
placeholder="Share your creative ideas about the image and let AI create a movie story for you..."
|
||
/>
|
||
<div className="absolute bottom-1 right-0 flex gap-2 items-center">
|
||
{/* 横/竖屏选择 */}
|
||
<AspectRatioSelector
|
||
value={aspectUI}
|
||
onChange={setAspectUI}
|
||
placement="top"
|
||
/>
|
||
{!hasAnalyzed ? (
|
||
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
|
||
<ActionButton
|
||
isCreating={isLoading || isPhotoCreating}
|
||
handleCreateVideo={handleAnalyzeImage}
|
||
icon={<Sparkles className="w-5 h-5" />}
|
||
disabled={isLoading || isPhotoCreating}
|
||
/>
|
||
</Tooltip>
|
||
) : (
|
||
<Tooltip title="Confirm story creation" placement="top">
|
||
<ActionButton
|
||
isCreating={isLoading}
|
||
handleCreateVideo={handleConfirm}
|
||
icon={<ImagePlay className="w-5 h-5" />}
|
||
disabled={isCreating}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</GlobalLoad>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
|