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

406 lines
16 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Drawer, Popconfirm, Tooltip, 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;
};
/**
* 移动端/平板端全屏抽屉版本:基于 PcPhotoStoryModal 的功能与交互
* - 顶部固定标题栏
* - 中间可滚动内容
* - 底部固定操作条
* - 人物头像采用网格布局,避免横向滚动误触返回手势
*
* @param {boolean} isOpen - 是否打开抽屉
* @param {() => void} onClose - 关闭回调
* @param {boolean} isCreating - 是否正在创建视频
* @param {(v: boolean) => void} setIsCreating - 设置创建状态
* @param {boolean} isPhotoCreating - 是否正在分析图片
* @param {(v: boolean) => void} setIsPhotoCreating - 设置分析状态
* @param {ConfigOptions} configOptions - 配置项,默认与 PC 版一致
*/
export const H5PhotoStoryDrawer = ({
isMobile,
isOpen,
onClose,
isCreating,
setIsCreating,
isPhotoCreating,
setIsPhotoCreating,
configOptions = {
mode: "auto",
resolution: "720p",
language: "english",
videoDuration: "1min",
},
}: {
isMobile: boolean;
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 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) {
// 保持静默失败,避免打断用户
}
};
const handleConfirm = async () => {
try {
setIsCreating(true);
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
setIsCreating(false);
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}`);
onClose();
} catch (error) {
setIsCreating(false);
}
};
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) {
setIsPhotoCreating(false);
} finally {
clearInterval(timer);
setLocalLoading(0);
}
};
return (
<Drawer
open={isOpen}
placement="left"
width={isMobile ? "80%" : "40%"}
maskClosable={true}
closable={false}
onClose={onClose}
className="h5-photo-story-drawer [&_.ant-drawer-body]:!p-0 bg-white/[0.02]"
styles={{
body: {
height: "100svh",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
},
}}
>
<GlobalLoad show={localLoading > 0} progress={localLoading}>
<div data-alt="drawer-content" className="flex flex-col h-[100svh] backdrop-blur-[16px]">
<div data-alt="drawer-header" className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="flex items-center gap-2">
<ImagePlay className="w-4 h-4 text-blue-400" />
<h2 className="text-medium font-bold text-white">Movie Generation from Image</h2>
</div>
</div>
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
<div className="p-4">
{/* 上传卡片 */}
<div className="w-full">
<div className="flex items-start gap-3">
<div className="shrink-0">
<div
data-alt="image-upload-area"
className={`w-28 h-28 md:w-32 md: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]"
}`}
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-5 h-5 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-md flex items-center justify-center text-white/90 border border-white/20 transition-all duration-200 z-10"
data-alt="clear-all-button"
aria-label="clear-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</Popconfirm>
</div>
) : (
<div className="text-center text-white/70">
<Upload className="w-6 h-6 mx-auto mb-1 opacity-60" />
<span className="text-xs">Upload</span>
</div>
)}
</div>
</div>
<div className="flex-1" />
</div>
</div>
{/* 人物头像网格 */}
{hasAnalyzed && avatarComputed.length > 0 && (
<div className="mt-4" data-alt="avatar-grid">
<div className="grid grid-cols-4 gap-3 md:grid-cols-4">
{avatarComputed.map((avatar, index) => (
<div key={`${avatar.name}-${index}`} className="flex flex-col items-center">
<div className="relative w-16 h-16 md:w-20 md: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/40 border border-black/20 text-white rounded-full flex items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 z-10"
aria-label="remove-character"
>
<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(() => {});
}}
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center"
aria-label="replace-character-avatar"
>
<Upload className="w-4 h-4 text-white" />
</button>
</Tooltip>
</div>
<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-full max-w-[72px] md:max-w-[80px] text-center text-xs md: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"
/>
</div>
))}
</div>
</div>
)}
{/* 题材标签(自动换行,避免横向滚动) */}
{hasAnalyzed && potentialGenres.length > 0 && (
<div className="mt-4" data-alt="genre-tags">
<div className="flex flex-wrap 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 ${
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"
}`}
aria-pressed={selectedCategory === genre}
>
{genre}
</button>
))}
</div>
</div>
)}
{/* 用户原始文本信息 */}
{originalUserDescription && (
<div className="mt-3 text-xs text-white/40 italic" data-alt="user-original-text">
Your Provided Text: {originalUserDescription}
</div>
)}
{/* 文本编辑器 */}
<div className="mt-3 relative" data-alt="editor-section">
<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..."
className="pr-0"
/>
</div>
</div>
</div>
<div data-alt="bottom-action-bar" className="sticky bottom-0 left-0 right-0 backdrop-blur border-t border-white/10 px-3 py-2">
<div className="flex items-center justify-end gap-2">
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/>
{!hasAnalyzed ? (
<Tooltip title={activeImageUrl ? "Analyze image content" : "Please upload an image first"} placement="top">
<div>
<ActionButton
isCreating={isLoading || isPhotoCreating}
handleCreateVideo={handleAnalyzeImage}
icon={<Sparkles className="w-4 h-4" />}
disabled={isLoading || isPhotoCreating}
width="w-10"
height="h-10"
/>
</div>
</Tooltip>
) : (
<Tooltip title="Confirm story creation" placement="top">
<div>
<ActionButton
isCreating={isLoading}
handleCreateVideo={handleConfirm}
icon={<ImagePlay className="w-4 h-4" />}
disabled={isCreating}
width="w-10"
height="h-10"
/>
</div>
</Tooltip>
)}
</div>
</div>
</div>
</GlobalLoad>
</Drawer>
);
};
export default H5PhotoStoryDrawer;