forked from 77media/video-flow
406 lines
16 KiB
TypeScript
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;
|
|
|
|
|