forked from 77media/video-flow
Merge branch 'dev' into prod
This commit is contained in:
commit
1ccb69e747
@ -307,6 +307,39 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-textarea, .mobile-input {
|
.mobile-textarea,
|
||||||
|
.mobile-input {
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar:hover {
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.2) rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit browsers (Chrome, Safari) */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(156, 163, 175, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar:hover::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(156, 163, 175, 0.7);
|
||||||
}
|
}
|
||||||
@ -77,6 +77,19 @@ function HomeModule5() {
|
|||||||
}[]
|
}[]
|
||||||
>(() => {
|
>(() => {
|
||||||
return plans.map((plan) => {
|
return plans.map((plan) => {
|
||||||
|
const rawDescription = plan.description ?? '';
|
||||||
|
let creditsText: string = rawDescription;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawDescription) as { period: 'month' | 'year'; content: string }[];
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const match = parsed.find((item) => item && item.period === billingType);
|
||||||
|
if (match && typeof match.content === 'string') {
|
||||||
|
creditsText = match.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, keep original string
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
title: plan.display_name || plan.name,
|
title: plan.display_name || plan.name,
|
||||||
price:
|
price:
|
||||||
@ -86,7 +99,7 @@ function HomeModule5() {
|
|||||||
originalPrice: plan.price_month / 100,
|
originalPrice: plan.price_month / 100,
|
||||||
monthlyPrice: billingType === "month" ? 0 : Math.round(plan.price_year / 12) / 100,
|
monthlyPrice: billingType === "month" ? 0 : Math.round(plan.price_year / 12) / 100,
|
||||||
discountMsg: `Saves $${(plan.price_month * 12 - plan.price_year) / 100} by billing yearly!`,
|
discountMsg: `Saves $${(plan.price_month * 12 - plan.price_year) / 100} by billing yearly!`,
|
||||||
credits: plan.description,
|
credits: creditsText,
|
||||||
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
||||||
issubscribed: plan.is_subscribed,
|
issubscribed: plan.is_subscribed,
|
||||||
features: plan.features || [],
|
features: plan.features || [],
|
||||||
@ -150,9 +163,26 @@ function HomeModule5() {
|
|||||||
xl:text-[3.375rem] xl:leading-[110%] xl:mb-[1.5rem]
|
xl:text-[3.375rem] xl:leading-[110%] xl:mb-[1.5rem]
|
||||||
2xl:text-[3.5rem] 2xl:leading-[110%] 2xl:mb-[1.5rem]"
|
2xl:text-[3.5rem] 2xl:leading-[110%] 2xl:mb-[1.5rem]"
|
||||||
>
|
>
|
||||||
Pick a plan and make it yours
|
CREATE FOR $0.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-white font-normal text-center
|
||||||
|
/* 移动端字体 */
|
||||||
|
text-[1rem] leading-[140%]
|
||||||
|
/* 平板字体 */
|
||||||
|
sm:text-[1.25rem] sm:leading-[140%]
|
||||||
|
/* 小屏笔记本字体 */
|
||||||
|
md:text-[1.5rem] md:leading-[140%]
|
||||||
|
/* 大屏笔记本字体 */
|
||||||
|
lg:text-[1.6rem] lg:leading-[140%]
|
||||||
|
/* 桌面端字体 */
|
||||||
|
xl:text-[1.7rem] xl:leading-[140%]
|
||||||
|
/* 大屏显示器字体 */
|
||||||
|
2xl:text-[1.8rem] 2xl:leading-[140%]"
|
||||||
|
>
|
||||||
|
Remove watermark with Credits.
|
||||||
|
</p>
|
||||||
{/* 计费切换 */}
|
{/* 计费切换 */}
|
||||||
<div
|
<div
|
||||||
className="flex bg-black rounded-full border border-white/20
|
className="flex bg-black rounded-full border border-white/20
|
||||||
|
|||||||
@ -267,7 +267,7 @@ export default function SharePage(): JSX.Element {
|
|||||||
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
|
||||||
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
<span className="rounded px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after your friend activates their account.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You will receive <strong>500 credits</strong> after your friends successfully sign in.</p>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@ -275,33 +275,23 @@ export default function SharePage(): JSX.Element {
|
|||||||
<div data-alt="rules-col" className="rounded-md">
|
<div data-alt="rules-col" className="rounded-md">
|
||||||
<h2 data-alt="section-title" className="text-lg font-medium text-white mb-4">MovieFlow Credits Rewards Program</h2>
|
<h2 data-alt="section-title" className="text-lg font-medium text-white mb-4">MovieFlow Credits Rewards Program</h2>
|
||||||
<div className='p-4 space-y-4'>
|
<div className='p-4 space-y-4'>
|
||||||
<p className="text-sm">Welcome to MovieFlow! Our Credits Program is designed to reward your growth and contributions. Credits can be redeemed for premium templates, effects, and membership time.</p>
|
<p className="text-sm">
|
||||||
|
Welcome to MovieFlow! Our Credits Program is designed to reward your growth and contributions.
|
||||||
|
<br />
|
||||||
|
Credits can be redeemed for <strong>watermark</strong> removal.
|
||||||
|
<br />
|
||||||
|
In the future, credits may be used to redeem advanced template features.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<h2 className="text-medium font-medium text-white">How to Earn Credits?</h2>
|
<h2 className="text-medium font-medium text-white">How to Earn Credits?</h2>
|
||||||
|
|
||||||
<div className="reward-section welcome">
|
<div className="reward-section welcome">
|
||||||
<h3 className="text-medium font-medium text-white">Welcome Bonus</h3>
|
|
||||||
<p className='text-sm'>All <strong>new users</strong> receive a bonus of <span className="credit-amount">500 credits</span> upon successful registration!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="reward-section invite">
|
|
||||||
<h3 className="text-medium font-medium text-white">Invite & Earn</h3>
|
<h3 className="text-medium font-medium text-white">Invite & Earn</h3>
|
||||||
<p className='text-sm'>Invite friends to join using your unique referral link. Both you and your friend will get <span className="credit-amount">500 credits</span> once they successfully sign up.</p>
|
<p className='text-sm'>Invite friends to join using your unique referral link.</p>
|
||||||
|
<p className='text-sm'>You will get <strong>500 credits</strong> once they successfully sign in.</p>
|
||||||
<div className="highlight">
|
<br />
|
||||||
<p className='text-sm'>If your invited friend completes their first purchase, you will receive a <strong>bonus equal to 20% of the credits</strong> they earn from that purchase.</p>
|
<p className='text-sm'>When your invited friends complete their first purchase, you will receive <strong>a 20% share of the credits</strong> they earn from that purchase.</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="reward-section login">
|
|
||||||
<h3 className="text-medium font-medium text-white">Daily Login</h3>
|
|
||||||
<p className='text-sm'>Starting the day after registration, log in daily to claim <span className="credit-amount">100 credits</span>.</p>
|
|
||||||
<p className='text-sm'>This reward can be claimed for <strong>7 consecutive days</strong>.</p>
|
|
||||||
|
|
||||||
<div className="note">
|
|
||||||
<p className='text-sm'><strong>Please note:</strong> Daily login credits will <strong>reset</strong> automatically on the 8th day, so remember to use them in time!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -570,7 +570,7 @@ export const H5TemplateDrawer = ({
|
|||||||
data-alt="items-section-title"
|
data-alt="items-section-title"
|
||||||
className="text-base font-semibold text-white mb-3"
|
className="text-base font-semibold text-white mb-3"
|
||||||
>
|
>
|
||||||
input Configuration
|
Input Configuration
|
||||||
</h3>
|
</h3>
|
||||||
<textarea
|
<textarea
|
||||||
data-alt="h5-template-free-input-top"
|
data-alt="h5-template-free-input-top"
|
||||||
|
|||||||
@ -284,9 +284,9 @@ export const PcTemplateModal = ({
|
|||||||
// 故事编辑器渲染
|
// 故事编辑器渲染
|
||||||
const storyEditorRender = () => {
|
const storyEditorRender = () => {
|
||||||
return selectedTemplate ? (
|
return selectedTemplate ? (
|
||||||
<div className="relative h-full">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
{/* 模板信息头部 - 增加顶部空间 */}
|
{/* 模板信息头部 - 增加顶部空间 */}
|
||||||
<div className="flex gap-3 py-4 border-b border-white/[0.1] h-[300px]">
|
<div className="flex gap-3 py-4 border-b border-white/[0.1] max-h-[300px]">
|
||||||
{/* 左侧图片 */}
|
{/* 左侧图片 */}
|
||||||
<div className="w-1/4">
|
<div className="w-1/4">
|
||||||
<Image
|
<Image
|
||||||
@ -321,415 +321,415 @@ export const PcTemplateModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 角色配置区域 */}
|
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
{selectedTemplate?.storyRole &&
|
{/* 角色配置区域 */}
|
||||||
selectedTemplate.storyRole.length > 0 && (
|
{selectedTemplate?.storyRole &&
|
||||||
<div className="pt-2 border-t border-white/10">
|
selectedTemplate.storyRole.length > 0 && (
|
||||||
<h3
|
<div className="pt-2 border-t border-white/10">
|
||||||
data-alt="roles-section-title"
|
<h3
|
||||||
className="text-lg font-semibold text-white mb-4"
|
data-alt="roles-section-title"
|
||||||
>
|
className="text-lg font-semibold text-white mb-2"
|
||||||
Character Configuration
|
>
|
||||||
</h3>
|
Character Configuration
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
</h3>
|
||||||
{selectedTemplate.storyRole.map((role, index) => (
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
<div
|
{selectedTemplate.storyRole.map((role, index) => (
|
||||||
key={index}
|
<div
|
||||||
data-alt={`role-field-${index}`}
|
key={index}
|
||||||
className="flex flex-col items-center space-y-3"
|
data-alt={`role-field-${index}`}
|
||||||
>
|
className="flex flex-col items-center space-y-3"
|
||||||
{/* 图片容器 */}
|
>
|
||||||
<div className="relative group">
|
{/* 图片容器 */}
|
||||||
<Tooltip
|
<div className="relative group">
|
||||||
title={
|
<Tooltip
|
||||||
<div className="relative">
|
title={
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
value={role.role_description || ""}
|
type="text"
|
||||||
onChange={(e) => {
|
value={role.role_description || ""}
|
||||||
// 更新角色的描述字段
|
onChange={(e) => {
|
||||||
const updatedTemplate = {
|
// 更新角色的描述字段
|
||||||
...selectedTemplate!,
|
const updatedTemplate = {
|
||||||
storyRole: selectedTemplate!.storyRole.map(
|
...selectedTemplate!,
|
||||||
(r) =>
|
storyRole: selectedTemplate!.storyRole.map(
|
||||||
r.role_name === role.role_name
|
(r) =>
|
||||||
? {
|
r.role_name === role.role_name
|
||||||
...r,
|
? {
|
||||||
role_description: e.target.value,
|
...r,
|
||||||
}
|
role_description: e.target.value,
|
||||||
: r
|
}
|
||||||
),
|
: r
|
||||||
};
|
),
|
||||||
setSelectedTemplate(updatedTemplate);
|
};
|
||||||
}}
|
setSelectedTemplate(updatedTemplate);
|
||||||
placeholder={role.user_tips}
|
|
||||||
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
{/* AI生成按钮 */}
|
|
||||||
<ActionButton
|
|
||||||
isCreating={isRoleGenerating[role.role_name] || false}
|
|
||||||
handleCreateVideo={async () => {
|
|
||||||
if (
|
|
||||||
role.role_description &&
|
|
||||||
role.role_description.trim()
|
|
||||||
) {
|
|
||||||
setIsRoleGenerating(prev => ({...prev, [role.role_name]: true}));
|
|
||||||
try {
|
|
||||||
await handleRoleFieldBlur(
|
|
||||||
role.role_name,
|
|
||||||
role.role_description.trim()
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsRoleGenerating(prev => ({...prev, [role.role_name]: false}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setInputVisible((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[role.role_name]: false,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
icon={<Sparkles className="w-4 h-4" />}
|
placeholder={role.user_tips}
|
||||||
width="w-8"
|
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
height="h-8"
|
|
||||||
disabled={isRoleGenerating[role.role_name] || false}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
{/* AI生成按钮 */}
|
||||||
|
<ActionButton
|
||||||
|
isCreating={isRoleGenerating[role.role_name] || false}
|
||||||
|
handleCreateVideo={async () => {
|
||||||
|
if (
|
||||||
|
role.role_description &&
|
||||||
|
role.role_description.trim()
|
||||||
|
) {
|
||||||
|
setIsRoleGenerating(prev => ({...prev, [role.role_name]: true}));
|
||||||
|
try {
|
||||||
|
await handleRoleFieldBlur(
|
||||||
|
role.role_name,
|
||||||
|
role.role_description.trim()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsRoleGenerating(prev => ({...prev, [role.role_name]: false}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInputVisible((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[role.role_name]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
width="w-8"
|
||||||
|
height="h-8"
|
||||||
|
disabled={isRoleGenerating[role.role_name] || false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
placement="top"
|
||||||
placement="top"
|
classNames={{
|
||||||
classNames={{
|
root: "max-w-none",
|
||||||
root: "max-w-none",
|
}}
|
||||||
}}
|
open={inputVisible[role.role_name]}
|
||||||
open={inputVisible[role.role_name]}
|
onOpenChange={(visible) =>
|
||||||
onOpenChange={(visible) =>
|
setInputVisible((prev) => ({
|
||||||
setInputVisible((prev) => ({
|
...prev,
|
||||||
...prev,
|
[role.role_name]: visible,
|
||||||
[role.role_name]: visible,
|
}))
|
||||||
}))
|
}
|
||||||
}
|
trigger="contextMenu"
|
||||||
trigger="contextMenu"
|
styles={{ root: { zIndex: 1000 } }}
|
||||||
styles={{ root: { zIndex: 1000 } }}
|
|
||||||
>
|
|
||||||
{/* 图片 */}
|
|
||||||
<div
|
|
||||||
data-alt={`role-thumbnail-${index}`}
|
|
||||||
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
|
|
||||||
>
|
>
|
||||||
<Image
|
{/* 图片 */}
|
||||||
src={role.photo_url || "/assets/empty_video.png"}
|
<div
|
||||||
alt={role.role_name}
|
data-alt={`role-thumbnail-${index}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
|
||||||
preview={{
|
|
||||||
mask: null,
|
|
||||||
maskClassName: "hidden",
|
|
||||||
}}
|
|
||||||
fallback="/assets/empty_video.png"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 角色名称 - 图片下方 */}
|
|
||||||
<div className="text-center mt-2">
|
|
||||||
<span className="text-white text-sm font-medium">
|
|
||||||
{role.role_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 按钮组 - 右上角 */}
|
|
||||||
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
|
||||||
{/* AI生成按钮 */}
|
|
||||||
<Tooltip title="AI generate image" placement="top">
|
|
||||||
<button
|
|
||||||
data-alt={`role-ai-button-${index}`}
|
|
||||||
onClick={() =>
|
|
||||||
setInputVisible((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[role.role_name]: !prev[role.role_name],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Image
|
||||||
</button>
|
src={role.photo_url || "/assets/empty_video.png"}
|
||||||
|
alt={role.role_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
preview={{
|
||||||
|
mask: null,
|
||||||
|
maskClassName: "hidden",
|
||||||
|
}}
|
||||||
|
fallback="/assets/empty_video.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* 上传按钮 */}
|
{/* 角色名称 - 图片下方 */}
|
||||||
<Upload
|
<div className="text-center mt-2">
|
||||||
name="roleImage"
|
<span className="text-white text-sm font-medium">
|
||||||
showUploadList={false}
|
{role.role_name}
|
||||||
beforeUpload={(file) => {
|
</span>
|
||||||
const isImage = file.type.startsWith("image/");
|
</div>
|
||||||
if (!isImage) {
|
|
||||||
console.error("只能上传图片文件");
|
{/* 按钮组 - 右上角 */}
|
||||||
return false;
|
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||||
}
|
{/* AI生成按钮 */}
|
||||||
const isLt5M = file.size / 1024 / 1024 < 5;
|
<Tooltip title="AI generate image" placement="top">
|
||||||
if (!isLt5M) {
|
|
||||||
console.error("图片大小不能超过5MB");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
customRequest={async ({
|
|
||||||
file,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
}: RcCustomRequestOptions) => {
|
|
||||||
try {
|
|
||||||
const fileObj = file as File;
|
|
||||||
const uploadedUrl = await uploadFile(
|
|
||||||
fileObj,
|
|
||||||
(progress: number) => {
|
|
||||||
console.log(`上传进度: ${progress}%`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
await AvatarAndAnalyzeFeatures(
|
|
||||||
uploadedUrl,
|
|
||||||
role.role_name
|
|
||||||
);
|
|
||||||
onSuccess?.(uploadedUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("角色图片上传失败:", error);
|
|
||||||
onError?.(error as Error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title="upload your image" placement="top">
|
|
||||||
<button
|
<button
|
||||||
data-alt={`role-upload-button-${index}`}
|
data-alt={`role-ai-button-${index}`}
|
||||||
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
onClick={() =>
|
||||||
|
setInputVisible((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[role.role_name]: !prev[role.role_name],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
>
|
>
|
||||||
<UploadOutlined className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Upload>
|
|
||||||
|
{/* 上传按钮 */}
|
||||||
|
<Upload
|
||||||
|
name="roleImage"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const isImage = file.type.startsWith("image/");
|
||||||
|
if (!isImage) {
|
||||||
|
console.error("只能上传图片文件");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
console.error("图片大小不能超过5MB");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
customRequest={async ({
|
||||||
|
file,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: RcCustomRequestOptions) => {
|
||||||
|
try {
|
||||||
|
const fileObj = file as File;
|
||||||
|
const uploadedUrl = await uploadFile(
|
||||||
|
fileObj,
|
||||||
|
(progress: number) => {
|
||||||
|
console.log(`上传进度: ${progress}%`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await AvatarAndAnalyzeFeatures(
|
||||||
|
uploadedUrl,
|
||||||
|
role.role_name
|
||||||
|
);
|
||||||
|
onSuccess?.(uploadedUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("角色图片上传失败:", error);
|
||||||
|
onError?.(error as Error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="upload your image" placement="top">
|
||||||
|
<button
|
||||||
|
data-alt={`role-upload-button-${index}`}
|
||||||
|
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 道具配置区域 */}
|
{/* 道具配置区域 */}
|
||||||
{selectedTemplate?.storyItem &&
|
{selectedTemplate?.storyItem &&
|
||||||
selectedTemplate.storyItem.length > 0 && (
|
selectedTemplate.storyItem.length > 0 && (
|
||||||
<div className="pt-2 border-t border-white/10">
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<h3
|
||||||
|
data-alt="items-section-title"
|
||||||
|
className="text-lg font-semibold text-white mb-4"
|
||||||
|
>
|
||||||
|
props Configuration
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{selectedTemplate.storyItem.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
data-alt={`item-field-${index}`}
|
||||||
|
className="flex flex-col items-center space-y-3"
|
||||||
|
>
|
||||||
|
{/* 图片容器 */}
|
||||||
|
<div className="relative group">
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.item_description || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
// 更新道具的描述字段
|
||||||
|
const updatedTemplate = {
|
||||||
|
...selectedTemplate!,
|
||||||
|
storyItem: selectedTemplate!.storyItem.map(
|
||||||
|
(i) =>
|
||||||
|
i.item_name === item.item_name
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
item_description: e.target.value,
|
||||||
|
}
|
||||||
|
: i
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setSelectedTemplate(updatedTemplate);
|
||||||
|
}}
|
||||||
|
placeholder="Enter description for AI image generation..."
|
||||||
|
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
{/* AI生成按钮 */}
|
||||||
|
<ActionButton
|
||||||
|
isCreating={isItemGenerating[item.item_name] || false}
|
||||||
|
handleCreateVideo={async () => {
|
||||||
|
if (
|
||||||
|
item.item_description &&
|
||||||
|
item.item_description.trim()
|
||||||
|
) {
|
||||||
|
setIsItemGenerating(prev => ({...prev, [item.item_name]: true}));
|
||||||
|
try {
|
||||||
|
await handleItemFieldBlur(
|
||||||
|
item.item_name,
|
||||||
|
item.item_description.trim()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsItemGenerating(prev => ({...prev, [item.item_name]: false}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInputVisible((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.item_name]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
width="w-8"
|
||||||
|
height="h-8"
|
||||||
|
disabled={isItemGenerating[item.item_name] || false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
classNames={{
|
||||||
|
root: "max-w-none",
|
||||||
|
}}
|
||||||
|
open={inputVisible[item.item_name]}
|
||||||
|
onOpenChange={(visible) =>
|
||||||
|
setInputVisible((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.item_name]: visible,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
trigger="contextMenu"
|
||||||
|
styles={{ root: { zIndex: 1000 } }}
|
||||||
|
>
|
||||||
|
{/* 图片 */}
|
||||||
|
<div
|
||||||
|
data-alt={`item-thumbnail-${index}`}
|
||||||
|
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.photo_url || "/assets/empty_video.png"}
|
||||||
|
alt={item.item_name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
preview={{
|
||||||
|
mask: null,
|
||||||
|
maskClassName: "hidden",
|
||||||
|
}}
|
||||||
|
fallback="/assets/empty_video.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 道具名称 - 图片下方 */}
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{item.item_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按钮组 - 右上角 */}
|
||||||
|
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||||
|
{/* AI生成按钮 */}
|
||||||
|
<Tooltip title="AI generate image" placement="top">
|
||||||
|
<button
|
||||||
|
data-alt={`item-ai-button-${index}`}
|
||||||
|
onClick={() =>
|
||||||
|
setInputVisible((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.item_name]: !prev[item.item_name],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 上传按钮 */}
|
||||||
|
<Upload
|
||||||
|
name="itemImage"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const isImage = file.type.startsWith("image/");
|
||||||
|
if (!isImage) {
|
||||||
|
console.error("只能上传图片文件");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
console.error("图片大小不能超过5MB");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
customRequest={async ({
|
||||||
|
file,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: RcCustomRequestOptions) => {
|
||||||
|
try {
|
||||||
|
const fileObj = file as File;
|
||||||
|
const uploadedUrl = await uploadFile(
|
||||||
|
fileObj,
|
||||||
|
(progress: number) => {
|
||||||
|
console.log(`上传进度: ${progress}%`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
updateItemImage(item.item_name, uploadedUrl);
|
||||||
|
onSuccess?.(uploadedUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("道具图片上传失败:", error);
|
||||||
|
onError?.(error as Error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="upload your image" placement="top">
|
||||||
|
<button
|
||||||
|
data-alt={`item-upload-button-${index}`}
|
||||||
|
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
|
>
|
||||||
|
<UploadOutlined className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/** 自由输入文字 */}
|
||||||
|
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
||||||
|
<div className="pt-2 pb-6 h-full flex flex-col">
|
||||||
<h3
|
<h3
|
||||||
data-alt="items-section-title"
|
data-alt="items-section-title"
|
||||||
className="text-lg font-semibold text-white mb-4"
|
className="text-lg font-semibold text-white mb-2"
|
||||||
>
|
>
|
||||||
props Configuration
|
Input Configuration
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<textarea
|
||||||
{selectedTemplate.storyItem.map((item, index) => (
|
data-alt="pc-template-free-input-top"
|
||||||
<div
|
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
||||||
key={index}
|
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
||||||
data-alt={`item-field-${index}`}
|
className="w-full min-h-[50px] max-h-[150px] flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
||||||
className="flex flex-col items-center space-y-3"
|
onChange={(e) => {
|
||||||
>
|
// 更新自由输入文字字段
|
||||||
{/* 图片容器 */}
|
const updatedTemplate = {
|
||||||
<div className="relative group">
|
...selectedTemplate!,
|
||||||
<Tooltip
|
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
||||||
title={
|
...item,
|
||||||
<div className="relative">
|
free_input_text: e.target.value
|
||||||
<input
|
})),
|
||||||
type="text"
|
};
|
||||||
value={item.item_description || ""}
|
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
||||||
onChange={(e) => {
|
}}
|
||||||
// 更新道具的描述字段
|
/>
|
||||||
const updatedTemplate = {
|
|
||||||
...selectedTemplate!,
|
|
||||||
storyItem: selectedTemplate!.storyItem.map(
|
|
||||||
(i) =>
|
|
||||||
i.item_name === item.item_name
|
|
||||||
? {
|
|
||||||
...i,
|
|
||||||
item_description: e.target.value,
|
|
||||||
}
|
|
||||||
: i
|
|
||||||
),
|
|
||||||
};
|
|
||||||
setSelectedTemplate(updatedTemplate);
|
|
||||||
}}
|
|
||||||
placeholder="Enter description for AI image generation..."
|
|
||||||
className="w-[30rem] px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
||||||
{/* AI生成按钮 */}
|
|
||||||
<ActionButton
|
|
||||||
isCreating={isItemGenerating[item.item_name] || false}
|
|
||||||
handleCreateVideo={async () => {
|
|
||||||
if (
|
|
||||||
item.item_description &&
|
|
||||||
item.item_description.trim()
|
|
||||||
) {
|
|
||||||
setIsItemGenerating(prev => ({...prev, [item.item_name]: true}));
|
|
||||||
try {
|
|
||||||
await handleItemFieldBlur(
|
|
||||||
item.item_name,
|
|
||||||
item.item_description.trim()
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsItemGenerating(prev => ({...prev, [item.item_name]: false}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setInputVisible((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.item_name]: false,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
icon={<Sparkles className="w-4 h-4" />}
|
|
||||||
width="w-8"
|
|
||||||
height="h-8"
|
|
||||||
disabled={isItemGenerating[item.item_name] || false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
placement="top"
|
|
||||||
classNames={{
|
|
||||||
root: "max-w-none",
|
|
||||||
}}
|
|
||||||
open={inputVisible[item.item_name]}
|
|
||||||
onOpenChange={(visible) =>
|
|
||||||
setInputVisible((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.item_name]: visible,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
trigger="contextMenu"
|
|
||||||
styles={{ root: { zIndex: 1000 } }}
|
|
||||||
>
|
|
||||||
{/* 图片 */}
|
|
||||||
<div
|
|
||||||
data-alt={`item-thumbnail-${index}`}
|
|
||||||
className="w-24 h-24 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer hover:scale-105 transition-all duration-200"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={item.photo_url || "/assets/empty_video.png"}
|
|
||||||
alt={item.item_name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
preview={{
|
|
||||||
mask: null,
|
|
||||||
maskClassName: "hidden",
|
|
||||||
}}
|
|
||||||
fallback="/assets/empty_video.png"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 道具名称 - 图片下方 */}
|
|
||||||
<div className="text-center mt-2">
|
|
||||||
<span className="text-white text-sm font-medium">
|
|
||||||
{item.item_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 按钮组 - 右上角 */}
|
|
||||||
<div className="absolute -top-8 left-[1.2rem] flex gap-3 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
|
||||||
{/* AI生成按钮 */}
|
|
||||||
<Tooltip title="AI generate image" placement="top">
|
|
||||||
<button
|
|
||||||
data-alt={`item-ai-button-${index}`}
|
|
||||||
onClick={() =>
|
|
||||||
setInputVisible((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.item_name]: !prev[item.item_name],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* 上传按钮 */}
|
|
||||||
<Upload
|
|
||||||
name="itemImage"
|
|
||||||
showUploadList={false}
|
|
||||||
beforeUpload={(file) => {
|
|
||||||
const isImage = file.type.startsWith("image/");
|
|
||||||
if (!isImage) {
|
|
||||||
console.error("只能上传图片文件");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const isLt5M = file.size / 1024 / 1024 < 5;
|
|
||||||
if (!isLt5M) {
|
|
||||||
console.error("图片大小不能超过5MB");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
customRequest={async ({
|
|
||||||
file,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
}: RcCustomRequestOptions) => {
|
|
||||||
try {
|
|
||||||
const fileObj = file as File;
|
|
||||||
const uploadedUrl = await uploadFile(
|
|
||||||
fileObj,
|
|
||||||
(progress: number) => {
|
|
||||||
console.log(`上传进度: ${progress}%`);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
updateItemImage(item.item_name, uploadedUrl);
|
|
||||||
onSuccess?.(uploadedUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("道具图片上传失败:", error);
|
|
||||||
onError?.(error as Error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip title="upload your image" placement="top">
|
|
||||||
<button
|
|
||||||
data-alt={`item-upload-button-${index}`}
|
|
||||||
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center transition-all duration-200 hover:scale-110 shadow-lg"
|
|
||||||
>
|
|
||||||
<UploadOutlined className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</Upload>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/** 自由输入文字 */}
|
|
||||||
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
|
|
||||||
<div className="py-2 flex-1 flex flex-col" style={{
|
|
||||||
height: 'calc(70vh - 300px - 8rem)'
|
|
||||||
}}>
|
|
||||||
<h3
|
|
||||||
data-alt="items-section-title"
|
|
||||||
className="text-lg font-semibold text-white mb-4"
|
|
||||||
>
|
|
||||||
input Configuration
|
|
||||||
</h3>
|
|
||||||
<textarea
|
|
||||||
data-alt="pc-template-free-input-top"
|
|
||||||
value={selectedTemplate?.freeInput[0].free_input_text || ""}
|
|
||||||
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
|
|
||||||
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
|
|
||||||
onChange={(e) => {
|
|
||||||
// 更新自由输入文字字段
|
|
||||||
const updatedTemplate = {
|
|
||||||
...selectedTemplate!,
|
|
||||||
freeInput: selectedTemplate!.freeInput.map((item) => ({
|
|
||||||
...item,
|
|
||||||
free_input_text: e.target.value
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
|
<div className=" absolute -bottom-8 right-0 w-full flex items-center justify-end gap-2">
|
||||||
{/** 自由输入文字 */}
|
{/** 自由输入文字 */}
|
||||||
@ -813,7 +813,7 @@ export const PcTemplateModal = ({
|
|||||||
|
|
||||||
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
|
<div className="flex gap-4 pb-8 flex-1 overflow-y-hidden">
|
||||||
{templateListRender()}
|
{templateListRender()}
|
||||||
<div className="flex-1">{storyEditorRender()}</div>
|
<div className="flex-1 relative">{storyEditorRender()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlobalLoad>
|
</GlobalLoad>
|
||||||
|
|||||||
@ -380,7 +380,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Sign-in entry */}
|
{/* Sign-in entry */}
|
||||||
<div>
|
{/* <div>
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||||
onClick={() => handleSignin()}
|
onClick={() => handleSignin()}
|
||||||
@ -388,7 +388,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
>
|
>
|
||||||
<CalendarDays className="h-3 w-3" />
|
<CalendarDays className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI 积分 */}
|
{/* AI 积分 */}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { MouseEvent } from 'react';
|
||||||
import { Loader2, Download } from 'lucide-react';
|
import { Loader2, Download } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import './style/create-to-video2.css';
|
import './style/create-to-video2.css';
|
||||||
@ -11,7 +12,9 @@ import cover_image1 from '@/public/assets/cover_image3.jpg';
|
|||||||
import cover_image2 from '@/public/assets/cover_image_shu.jpg';
|
import cover_image2 from '@/public/assets/cover_image_shu.jpg';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Tooltip, Button } from 'antd';
|
import { Tooltip, Button } from 'antd';
|
||||||
import { downloadVideo, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
||||||
|
import { showDownloadOptionsModal } from '@/components/pages/work-flow/download-options-modal';
|
||||||
|
import { post } from '@/api/request';
|
||||||
import Masonry from 'react-masonry-css';
|
import Masonry from 'react-masonry-css';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
@ -263,6 +266,34 @@ export default function CreateToVideo2() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderProjectCard = (project: MovieProject): JSX.Element => {
|
const renderProjectCard = (project: MovieProject): JSX.Element => {
|
||||||
|
const handleDownloadClick = async (e: MouseEvent, project: MovieProject) => {
|
||||||
|
console.log(project);
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
showDownloadOptionsModal({
|
||||||
|
currentVideoIndex: 0,
|
||||||
|
totalVideos: 1,
|
||||||
|
isCurrentVideoFailed: false,
|
||||||
|
isFinalStage: true,
|
||||||
|
projectId: project.project_id,
|
||||||
|
onDownloadCurrent: async (withWatermark: boolean) => {
|
||||||
|
setIsLoadingDownloadBtn(true);
|
||||||
|
try {
|
||||||
|
const json: any = await post('/movie/download_video', {
|
||||||
|
project_id: project.project_id,
|
||||||
|
watermark: withWatermark
|
||||||
|
});
|
||||||
|
const url = json?.data?.download_url as string | undefined;
|
||||||
|
if (url) {
|
||||||
|
await downloadVideo(url);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDownloadBtn(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDownloadAll: ()=>{}
|
||||||
|
});
|
||||||
|
};
|
||||||
// 根据 aspect_ratio 计算纵横比
|
// 根据 aspect_ratio 计算纵横比
|
||||||
const getAspectRatio = () => {
|
const getAspectRatio = () => {
|
||||||
switch (project.aspect_ratio) {
|
switch (project.aspect_ratio) {
|
||||||
@ -320,12 +351,7 @@ export default function CreateToVideo2() {
|
|||||||
{(project.final_video_url || project.final_simple_video_url) && (
|
{(project.final_video_url || project.final_simple_video_url) && (
|
||||||
<div className="absolute top-1 right-1">
|
<div className="absolute top-1 right-1">
|
||||||
<Tooltip placement="top" title="Download">
|
<Tooltip placement="top" title="Download">
|
||||||
<Button size="small" type="text" disabled={isLoadingDownloadBtn} className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15" onClick={async (e) => {
|
<Button size="small" type="text" disabled={isLoadingDownloadBtn} className="w-[2.5rem] h-[2.5rem] rounded-full items-center justify-center p-0 hidden group-hover:flex transition-all duration-300 hover:bg-white/15" onClick={(e) => handleDownloadClick(e, project)}>
|
||||||
e.stopPropagation(); // 阻止事件冒泡
|
|
||||||
setIsLoadingDownloadBtn(true);
|
|
||||||
await downloadVideo(project.final_video_url || project.final_simple_video_url);
|
|
||||||
setIsLoadingDownloadBtn(false);
|
|
||||||
}}>
|
|
||||||
{isLoadingDownloadBtn ? <Loader2 className="w-4 h-4 animate-spin text-white" /> : <Download className="w-4 h-4 text-white" />}
|
{isLoadingDownloadBtn ? <Loader2 className="w-4 h-4 animate-spin text-white" /> : <Download className="w-4 h-4 text-white" />}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -1223,6 +1223,19 @@ function HomeModule5() {
|
|||||||
}[]
|
}[]
|
||||||
>(() => {
|
>(() => {
|
||||||
return plans.map((plan) => {
|
return plans.map((plan) => {
|
||||||
|
const rawDescription = plan.description ?? '';
|
||||||
|
let creditsText: string = rawDescription;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawDescription) as { period: 'month' | 'year'; content: string }[];
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const match = parsed.find((item) => item && item.period === billingType);
|
||||||
|
if (match && typeof match.content === 'string') {
|
||||||
|
creditsText = match.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, keep original string
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
title: plan.display_name || plan.name,
|
title: plan.display_name || plan.name,
|
||||||
price:
|
price:
|
||||||
@ -1232,7 +1245,7 @@ function HomeModule5() {
|
|||||||
originalPrice: plan.price_month / 100,
|
originalPrice: plan.price_month / 100,
|
||||||
monthlyPrice: billingType === "month" ? 0 : Math.round(plan.price_year / 12) / 100,
|
monthlyPrice: billingType === "month" ? 0 : Math.round(plan.price_year / 12) / 100,
|
||||||
discountMsg: `Saves $${(plan.price_month * 12 - plan.price_year) / 100} by billing yearly!`,
|
discountMsg: `Saves $${(plan.price_month * 12 - plan.price_year) / 100} by billing yearly!`,
|
||||||
credits: plan.description,
|
credits: creditsText,
|
||||||
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
buttonText: plan.is_free ? "Try For Free" : "Subscribe Now",
|
||||||
issubscribed: plan.is_subscribed,
|
issubscribed: plan.is_subscribed,
|
||||||
features: plan.features || [],
|
features: plan.features || [],
|
||||||
@ -1302,9 +1315,26 @@ function HomeModule5() {
|
|||||||
/* 大屏显示器字体 */
|
/* 大屏显示器字体 */
|
||||||
2xl:text-[3.5rem] 2xl:leading-[110%] 2xl:mb-[1.5rem]"
|
2xl:text-[3.5rem] 2xl:leading-[110%] 2xl:mb-[1.5rem]"
|
||||||
>
|
>
|
||||||
Pick a plan and make it yours
|
CREATE FOR $0.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-white font-normal text-center
|
||||||
|
/* 移动端字体 */
|
||||||
|
text-[1rem] leading-[140%]
|
||||||
|
/* 平板字体 */
|
||||||
|
sm:text-[1.25rem] sm:leading-[140%]
|
||||||
|
/* 小屏笔记本字体 */
|
||||||
|
md:text-[1.5rem] md:leading-[140%]
|
||||||
|
/* 大屏笔记本字体 */
|
||||||
|
lg:text-[1.6rem] lg:leading-[140%]
|
||||||
|
/* 桌面端字体 */
|
||||||
|
xl:text-[1.7rem] xl:leading-[140%]
|
||||||
|
/* 大屏显示器字体 */
|
||||||
|
2xl:text-[1.8rem] 2xl:leading-[140%]"
|
||||||
|
>
|
||||||
|
Remove watermark with Credits.
|
||||||
|
</p>
|
||||||
{/* 计费切换 */}
|
{/* 计费切换 */}
|
||||||
<div
|
<div
|
||||||
className="flex bg-black rounded-full border border-white/20
|
className="flex bg-black rounded-full border border-white/20
|
||||||
|
|||||||
@ -181,6 +181,7 @@ const UsageView: React.FC = () => {
|
|||||||
const formatSource = useCallback((source: string | undefined) => {
|
const formatSource = useCallback((source: string | undefined) => {
|
||||||
if (!source) return '-';
|
if (!source) return '-';
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
video_download: 'Video Download',
|
||||||
video_generation: 'Video Generation',
|
video_generation: 'Video Generation',
|
||||||
manual_admin: 'Manual (Admin)',
|
manual_admin: 'Manual (Admin)',
|
||||||
subscription: 'Subscription',
|
subscription: 'Subscription',
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
|||||||
import ScriptLoading from './script-loading';
|
import ScriptLoading from './script-loading';
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
||||||
|
import { post } from '@/api/request';
|
||||||
import { Drawer } from 'antd';
|
import { Drawer } from 'antd';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import error_image from '@/public/assets/error.webp';
|
import error_image from '@/public/assets/error.webp';
|
||||||
import { createRoot, Root } from 'react-dom/client';
|
import { showDownloadOptionsModal } from './download-options-modal';
|
||||||
|
|
||||||
interface H5MediaViewerProps {
|
interface H5MediaViewerProps {
|
||||||
/** 任务对象,包含各阶段数据 */
|
/** 任务对象,包含各阶段数据 */
|
||||||
@ -51,143 +53,6 @@ interface H5MediaViewerProps {
|
|||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadOptionsModalProps {
|
|
||||||
onDownloadCurrent: () => void;
|
|
||||||
onDownloadAll: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
currentVideoIndex: number;
|
|
||||||
totalVideos: number;
|
|
||||||
/** 当前视频是否生成失败 */
|
|
||||||
isCurrentVideoFailed: boolean;
|
|
||||||
/** 是否为最终视频阶段 */
|
|
||||||
isFinalStage?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownloadOptionsModal(props: DownloadOptionsModalProps) {
|
|
||||||
const { onDownloadCurrent, onDownloadAll, onClose, currentVideoIndex, totalVideos, isCurrentVideoFailed, isFinalStage = false } = props;
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const originalOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = originalOverflow;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
data-alt="download-options-overlay"
|
|
||||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-alt="download-options-modal"
|
|
||||||
className="relative w-11/12 max-w-sm rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl p-6 text-white"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="download-options-title"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
data-alt="close-button"
|
|
||||||
className="absolute top-4 right-4 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 border border-white/10 flex items-center justify-center text-white transition-all active:scale-95"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
<div data-alt="modal-header" className="flex flex-col items-center text-center gap-2">
|
|
||||||
<div data-alt="modal-icon" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-lg text-purple-400">
|
|
||||||
<Download />
|
|
||||||
</div>
|
|
||||||
<h3 id="download-options-title" data-alt="modal-title" className="text-base font-semibold">
|
|
||||||
Download Options
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-alt="modal-body" className="mt-4">
|
|
||||||
<p data-alt="modal-description" className="text-sm text-white/80 text-center">
|
|
||||||
Choose your download preference
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!isCurrentVideoFailed && (
|
|
||||||
<div data-alt="stats-info" className="mt-3 rounded-lg bg-white/5 border border-white/10 p-3 text-center">
|
|
||||||
<div className="text-xs text-white/60">Current video</div>
|
|
||||||
<div className="text-sm font-medium">{currentVideoIndex + 1} / {totalVideos}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-alt="modal-actions" className="mt-6 space-y-3">
|
|
||||||
{!isCurrentVideoFailed && (
|
|
||||||
<button
|
|
||||||
data-alt="download-current-button"
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-gradient-to-br from-purple-600/80 to-purple-700/80 hover:from-purple-500/80 hover:to-purple-600/80 text-white font-medium transition-all flex items-center justify-center gap-2"
|
|
||||||
onClick={() => {
|
|
||||||
onDownloadCurrent();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
{isFinalStage ? 'Download Final Video' : 'Download Current Video'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
data-alt="download-all-button"
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg bg-gradient-to-br from-purple-500/60 to-purple-600/60 hover:from-purple-500/80 hover:to-purple-600/80 text-white font-medium transition-all flex items-center justify-center gap-2 border border-purple-400/30"
|
|
||||||
onClick={() => {
|
|
||||||
onDownloadAll();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowDownWideNarrow size={16} />
|
|
||||||
Download All Videos ({totalVideos})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a download options modal with glass morphism style.
|
|
||||||
* @param {DownloadOptionsModalProps} options - download options and callbacks.
|
|
||||||
*/
|
|
||||||
function showDownloadOptionsModal(options: Omit<DownloadOptionsModalProps, 'onClose'>): void {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mount = document.createElement('div');
|
|
||||||
mount.setAttribute('data-alt', 'download-options-modal-root');
|
|
||||||
document.body.appendChild(mount);
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
try {
|
|
||||||
root = createRoot(mount);
|
|
||||||
} catch {
|
|
||||||
if (mount.parentNode) {
|
|
||||||
mount.parentNode.removeChild(mount);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
try {
|
|
||||||
root?.unmount();
|
|
||||||
} finally {
|
|
||||||
if (mount.parentNode) {
|
|
||||||
mount.parentNode.removeChild(mount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
root.render(
|
|
||||||
<DownloadOptionsModal {...options} onClose={close} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 面向 H5 的媒体预览组件。
|
* 面向 H5 的媒体预览组件。
|
||||||
* - 除剧本阶段外,统一使用 antd Carousel 展示 图片/视频。
|
* - 除剧本阶段外,统一使用 antd Carousel 展示 图片/视频。
|
||||||
@ -221,7 +86,8 @@ export function H5MediaViewer({
|
|||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
|
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
|
||||||
const [isEdgeBrowser, setIsEdgeBrowser] = useState<boolean>(false);
|
const [isEdgeBrowser, setIsEdgeBrowser] = useState<boolean>(false);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const episodeId = searchParams.get('episodeId') || '';
|
||||||
/** 解析形如 "16:9" 的比例字符串 */
|
/** 解析形如 "16:9" 的比例字符串 */
|
||||||
const parseAspect = (input?: string): { w: number; h: number } => {
|
const parseAspect = (input?: string): { w: number; h: number } => {
|
||||||
const parts = (typeof input === 'string' ? input.split(':') : []);
|
const parts = (typeof input === 'string' ? input.split(':') : []);
|
||||||
@ -611,18 +477,19 @@ export function H5MediaViewer({
|
|||||||
currentVideoIndex: hasFinalVideo ? activeIndex + 1 : activeIndex,
|
currentVideoIndex: hasFinalVideo ? activeIndex + 1 : activeIndex,
|
||||||
totalVideos,
|
totalVideos,
|
||||||
isCurrentVideoFailed,
|
isCurrentVideoFailed,
|
||||||
onDownloadCurrent: async () => {
|
projectId: episodeId,
|
||||||
if (hasUrl) {
|
videoId: current?.video_id,
|
||||||
await downloadVideo(current.urls[0]);
|
onDownloadCurrent: async (withWatermark: boolean) => {
|
||||||
}
|
if (!current?.video_id) return;
|
||||||
},
|
const json: any = await post('/movie/download_video', {
|
||||||
onDownloadAll: async () => {
|
project_id: episodeId,
|
||||||
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
|
video_id: current.video_id,
|
||||||
if (hasFinalVideo) {
|
watermark: withWatermark
|
||||||
all.push(taskObject.final.url);
|
});
|
||||||
}
|
const url = json?.data?.download_url as string | undefined;
|
||||||
await downloadAllVideos(all);
|
if (url) await downloadVideo(url);
|
||||||
},
|
},
|
||||||
|
onDownloadAll: ()=>{}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -660,18 +527,16 @@ export function H5MediaViewer({
|
|||||||
totalVideos,
|
totalVideos,
|
||||||
isCurrentVideoFailed: false,
|
isCurrentVideoFailed: false,
|
||||||
isFinalStage: true,
|
isFinalStage: true,
|
||||||
onDownloadCurrent: async () => {
|
projectId: episodeId,
|
||||||
if (finalUrl) {
|
onDownloadCurrent: async (withWatermark: boolean) => {
|
||||||
await downloadVideo(finalUrl);
|
const json: any = await post('/movie/download_video', {
|
||||||
}
|
project_id: episodeId,
|
||||||
},
|
watermark: withWatermark
|
||||||
onDownloadAll: async () => {
|
});
|
||||||
const all = (taskObject.videos?.data ?? []).flatMap((v: any) => v?.urls ?? []);
|
const url = json?.data?.download_url as string | undefined;
|
||||||
if (finalUrl) {
|
if (url) await downloadVideo(url);
|
||||||
all.push(finalUrl);
|
|
||||||
}
|
|
||||||
await downloadAllVideos(all);
|
|
||||||
},
|
},
|
||||||
|
onDownloadAll: ()=>{}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
182
components/pages/work-flow/download-options-modal.tsx
Normal file
182
components/pages/work-flow/download-options-modal.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Checkbox } from 'antd';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { X, Download, ArrowDownWideNarrow } from 'lucide-react';
|
||||||
|
import { post } from '@/api/request';
|
||||||
|
|
||||||
|
interface DownloadOptionsModalProps {
|
||||||
|
onDownloadCurrent: (withWatermark: boolean) => void;
|
||||||
|
onDownloadAll: (withWatermark: boolean) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
currentVideoIndex: number;
|
||||||
|
totalVideos: number;
|
||||||
|
/** 当前视频是否生成失败 */
|
||||||
|
isCurrentVideoFailed: boolean;
|
||||||
|
/** 是否为最终视频阶段 */
|
||||||
|
isFinalStage?: boolean;
|
||||||
|
/** 项目ID */
|
||||||
|
projectId?: string;
|
||||||
|
/** 视频ID(分镜视频可用) */
|
||||||
|
videoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download options modal component with glass morphism style.
|
||||||
|
* @param {DownloadOptionsModalProps} props - modal properties.
|
||||||
|
*/
|
||||||
|
function DownloadOptionsModal(props: DownloadOptionsModalProps) {
|
||||||
|
const { onDownloadCurrent, onDownloadAll, onClose, currentVideoIndex, totalVideos, isCurrentVideoFailed, isFinalStage = false, projectId, videoId } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [withWatermark, setWithWatermark] = useState<boolean>(true);
|
||||||
|
const [baseAmount, setBaseAmount] = useState<number>(0);
|
||||||
|
const [isCheckingBalance, setIsCheckingBalance] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const originalOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听水印选择变化,请求价格信息
|
||||||
|
useEffect(() => {
|
||||||
|
let aborted = false;
|
||||||
|
const checkBalance = async () => {
|
||||||
|
try {
|
||||||
|
if (!aborted) setIsCheckingBalance(true);
|
||||||
|
const json: any = await post('/movie/download_video', {
|
||||||
|
project_id: projectId,
|
||||||
|
video_id: videoId,
|
||||||
|
watermark: withWatermark,
|
||||||
|
check_balance: true
|
||||||
|
});
|
||||||
|
const amount = json?.data?.base_amount;
|
||||||
|
if (!aborted) setBaseAmount(Number.isFinite(amount) ? Number(amount) : 0);
|
||||||
|
} catch {
|
||||||
|
if (!aborted) setBaseAmount(0);
|
||||||
|
} finally {
|
||||||
|
if (!aborted) setIsCheckingBalance(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void checkBalance();
|
||||||
|
return () => {
|
||||||
|
aborted = true;
|
||||||
|
};
|
||||||
|
}, [withWatermark]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
data-alt="download-options-overlay"
|
||||||
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/60"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-alt="download-options-modal"
|
||||||
|
className="relative w-11/12 max-w-sm rounded-2xl bg-white/5 border border-white/10 backdrop-blur-xl shadow-2xl p-6 text-white"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="download-options-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
data-alt="close-button"
|
||||||
|
className="absolute top-4 right-4 w-6 h-6 rounded-full bg-white/10 hover:bg-white/20 border border-white/10 flex items-center justify-center text-white transition-all active:scale-95"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<div data-alt="modal-header" className="flex flex-col items-center text-center gap-2">
|
||||||
|
<div data-alt="modal-icon" className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-lg text-purple-400">
|
||||||
|
<Download />
|
||||||
|
</div>
|
||||||
|
<h3 id="download-options-title" data-alt="modal-title" className="text-base font-semibold">
|
||||||
|
Download Options
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-alt="modal-body" className="mt-4">
|
||||||
|
<p data-alt="modal-description" className="text-sm text-white/80 text-center">
|
||||||
|
Choose your download preference
|
||||||
|
</p>
|
||||||
|
<div data-alt="price-indicator" className="mt-2 text-center text-sm font-medium">
|
||||||
|
{!withWatermark && baseAmount && baseAmount !== 0 ? (
|
||||||
|
<span className="text-red-400">-{baseAmount} credits</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-400">free</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div data-alt="watermark-select" className="mt-3 flex items-center justify-center">
|
||||||
|
<div data-alt="watermark-toggle" className="inline-flex items-center gap-2 text-sm text-white/90">
|
||||||
|
<Checkbox
|
||||||
|
data-alt="watermark-checkbox"
|
||||||
|
checked={!withWatermark}
|
||||||
|
onChange={(e) => setWithWatermark(!e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>without watermark</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stats-info hidden temporarily due to no batch billing support */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-alt="modal-actions" className="mt-6 space-y-3">
|
||||||
|
<button
|
||||||
|
data-alt="download-single-button"
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg bg-gradient-to-br from-purple-600/80 to-purple-700/80 hover:from-purple-500/80 hover:to-purple-600/80 text-white font-medium transition-all flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
disabled={isCheckingBalance}
|
||||||
|
onClick={() => {
|
||||||
|
onDownloadCurrent(withWatermark);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
Download Video
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a download options modal with glass morphism style.
|
||||||
|
* @param {DownloadOptionsModalProps} options - download options and callbacks.
|
||||||
|
*/
|
||||||
|
export function showDownloadOptionsModal(options: Omit<DownloadOptionsModalProps, 'onClose'>): void {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mount = document.createElement('div');
|
||||||
|
mount.setAttribute('data-alt', 'download-options-modal-root');
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
try {
|
||||||
|
root = createRoot(mount);
|
||||||
|
} catch {
|
||||||
|
if (mount.parentNode) {
|
||||||
|
mount.parentNode.removeChild(mount);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
try {
|
||||||
|
root?.unmount();
|
||||||
|
} finally {
|
||||||
|
if (mount.parentNode) {
|
||||||
|
mount.parentNode.removeChild(mount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<DownloadOptionsModal {...options} onClose={close} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,6 +3,7 @@
|
|||||||
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, PictureInPicture2, PenTool } from 'lucide-react';
|
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, PictureInPicture2, PenTool } from 'lucide-react';
|
||||||
|
import { showDownloadOptionsModal } from './download-options-modal';
|
||||||
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||||
@ -12,9 +13,11 @@ import ScriptLoading from './script-loading';
|
|||||||
import { TaskObject } from '@/api/DTO/movieEdit';
|
import { TaskObject } from '@/api/DTO/movieEdit';
|
||||||
import { Button, Tooltip } from 'antd';
|
import { Button, Tooltip } from 'antd';
|
||||||
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
|
||||||
|
import { post } from '@/api/request';
|
||||||
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
|
||||||
import { EditPoint as EditPointType } from './video-edit/types';
|
import { EditPoint as EditPointType } from './video-edit/types';
|
||||||
import { isVideoModificationEnabled } from '@/lib/server-config';
|
import { isVideoModificationEnabled } from '@/lib/server-config';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import error_image from '@/public/assets/error.webp';
|
import error_image from '@/public/assets/error.webp';
|
||||||
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
|
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
|
||||||
|
|
||||||
@ -89,7 +92,8 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
||||||
// 控制钢笔图标显示的状态 - 参考谷歌登录按钮的实现
|
// 控制钢笔图标显示的状态 - 参考谷歌登录按钮的实现
|
||||||
const [showVideoModification, setShowVideoModification] = useState(false);
|
const [showVideoModification, setShowVideoModification] = useState(false);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const episodeId = searchParams.get('episodeId') || '';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSmartChatBoxOpen) {
|
if (isSmartChatBoxOpen) {
|
||||||
const videoContentWidth = videoContentRef.current?.clientWidth ?? 0;
|
const videoContentWidth = videoContentRef.current?.clientWidth ?? 0;
|
||||||
@ -505,21 +509,36 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
onClick={() => handleEditClick('3', 'final')}
|
onClick={() => handleEditClick('3', 'final')}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 下载所有视频按钮 */}
|
|
||||||
<Tooltip placement="top" title="Download all videos">
|
|
||||||
<GlassIconButton icon={ArrowDownWideNarrow} size='sm' loading={isLoadingDownloadAllVideosBtn} onClick={ async () => {
|
|
||||||
setIsLoadingDownloadAllVideosBtn(true);
|
|
||||||
await downloadAllVideos(taskObject.videos.data.flatMap((video: any) => video.urls));
|
|
||||||
setIsLoadingDownloadAllVideosBtn(false);
|
|
||||||
}} />
|
|
||||||
</Tooltip>
|
|
||||||
{/* 下载按钮 */}
|
{/* 下载按钮 */}
|
||||||
<Tooltip placement="top" title="Download video">
|
<Tooltip placement="top" title="Download">
|
||||||
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
|
<GlassIconButton
|
||||||
setIsLoadingDownloadBtn(true);
|
icon={Download}
|
||||||
await downloadVideo(taskObject.final.url);
|
size='sm'
|
||||||
setIsLoadingDownloadBtn(false);
|
onClick={() => {
|
||||||
}} />
|
const totalVideos = taskObject.videos.data.filter((video: any) => video.urls && video.urls.length > 0).length;
|
||||||
|
showDownloadOptionsModal({
|
||||||
|
currentVideoIndex: 0,
|
||||||
|
totalVideos: totalVideos + 1,
|
||||||
|
isCurrentVideoFailed: false,
|
||||||
|
isFinalStage: true,
|
||||||
|
projectId: episodeId || '',
|
||||||
|
onDownloadCurrent: async (withWatermark: boolean) => {
|
||||||
|
setIsLoadingDownloadBtn(true);
|
||||||
|
try {
|
||||||
|
const json: any = await post('/movie/download_video', {
|
||||||
|
project_id: episodeId,
|
||||||
|
watermark: withWatermark
|
||||||
|
});
|
||||||
|
const url = json?.data?.download_url as string | undefined;
|
||||||
|
if (url) await downloadVideo(url);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDownloadBtn(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDownloadAll: () => {}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{showGotoCutButton && (
|
{showGotoCutButton && (
|
||||||
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
||||||
@ -648,15 +667,41 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip placement="top" title="Download video">
|
<Tooltip placement="top" title="Download">
|
||||||
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
|
<GlassIconButton
|
||||||
const currentVideo = taskObject.videos.data[currentSketchIndex];
|
icon={Download}
|
||||||
if (currentVideo && currentVideo.urls && currentVideo.urls.length > 0) {
|
size='sm'
|
||||||
setIsLoadingDownloadBtn(true);
|
onClick={() => {
|
||||||
await downloadVideo(currentVideo.urls[0]);
|
const currentVideo = taskObject.videos.data[currentSketchIndex];
|
||||||
setIsLoadingDownloadBtn(false);
|
const totalVideos = taskObject.videos.data.filter((video: any) => video.urls && video.urls.length > 0).length;
|
||||||
}
|
const isCurrentVideoFailed = currentVideo.video_status === 2;
|
||||||
}} />
|
|
||||||
|
showDownloadOptionsModal({
|
||||||
|
currentVideoIndex: currentSketchIndex,
|
||||||
|
totalVideos: taskObject.final.url ? totalVideos + 1 : totalVideos,
|
||||||
|
isCurrentVideoFailed: isCurrentVideoFailed,
|
||||||
|
isFinalStage: false,
|
||||||
|
projectId: episodeId,
|
||||||
|
videoId: currentVideo?.video_id,
|
||||||
|
onDownloadCurrent: async (withWatermark: boolean) => {
|
||||||
|
if (!currentVideo?.video_id) return;
|
||||||
|
setIsLoadingDownloadBtn(true);
|
||||||
|
try {
|
||||||
|
const json: any = await post('/movie/download_video', {
|
||||||
|
project_id: episodeId,
|
||||||
|
video_id: currentVideo.video_id,
|
||||||
|
watermark: withWatermark
|
||||||
|
});
|
||||||
|
const url = json?.data?.download_url as string | undefined;
|
||||||
|
if (url) await downloadVideo(url);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDownloadBtn(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDownloadAll: () => {}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -681,14 +726,6 @@ export const MediaViewer = React.memo(function MediaViewer({
|
|||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* 下载所有视频按钮 */}
|
|
||||||
<Tooltip placement="top" title="Download all videos">
|
|
||||||
<GlassIconButton icon={ArrowDownWideNarrow} size='sm' loading={isLoadingDownloadAllVideosBtn} onClick={ async () => {
|
|
||||||
setIsLoadingDownloadAllVideosBtn(true);
|
|
||||||
await downloadAllVideos(taskObject.videos.data.flatMap((video: any) => video.urls));
|
|
||||||
setIsLoadingDownloadAllVideosBtn(false);
|
|
||||||
}} />
|
|
||||||
</Tooltip>
|
|
||||||
{/* 跳转剪辑按钮 */}
|
{/* 跳转剪辑按钮 */}
|
||||||
{showGotoCutButton && (
|
{showGotoCutButton && (
|
||||||
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
||||||
|
|||||||
@ -177,13 +177,13 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
|||||||
{addThemeTag.map((item, index) => (
|
{addThemeTag.map((item, index) => (
|
||||||
<div key={index} className={`flex items-center gap-1 px-2 rounded-full ${Object.values(ThemeTagBgColor)[index]}`}>
|
<div key={index} className={`flex items-center gap-1 px-2 rounded-full ${Object.values(ThemeTagBgColor)[index]}`}>
|
||||||
<span className={`text-sm px-2 py-1 rounded-md`}>{item}</span>
|
<span className={`text-sm px-2 py-1 rounded-md`}>{item}</span>
|
||||||
<X className="w-4 h-4 cursor-pointer text-blue-500/80" onClick={() =>
|
{/* <X className="w-4 h-4 cursor-pointer text-blue-500/80" onClick={() =>
|
||||||
handleThemeTagChange(addThemeTag.filter(v => v !== item))
|
handleThemeTagChange(addThemeTag.filter(v => v !== item))
|
||||||
} />
|
} /> */}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 主题标签更改 */}
|
{/* 主题标签更改 */}
|
||||||
<div className='flex items-center gap-1'>
|
{/* <div className='flex items-center gap-1'>
|
||||||
<div className='w-[10rem]'>
|
<div className='w-[10rem]'>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
dropdownId="theme-type"
|
dropdownId="theme-type"
|
||||||
@ -197,7 +197,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
@ -212,7 +212,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
>
|
>
|
||||||
<SquarePen
|
{/* <SquarePen
|
||||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 提示权限不够
|
// 提示权限不够
|
||||||
@ -220,7 +220,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
|
|||||||
return;
|
return;
|
||||||
handleEditBlock(block);
|
handleEditBlock(block);
|
||||||
}}
|
}}
|
||||||
/>
|
/> */}
|
||||||
<Copy
|
<Copy
|
||||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user