video-flow-b/components/ChatInputBox/PcPhotoStoryModal.tsx
2025-09-22 21:18:08 +08:00

359 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
};