Merge branch 'dev' into prod

This commit is contained in:
moux1024 2025-10-12 04:18:32 +08:00
commit 1ccb69e747
13 changed files with 822 additions and 628 deletions

View File

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

View File

@ -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

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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 积分 */}

View File

@ -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>

View File

@ -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

View File

@ -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',

View File

@ -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: ()=>{}
}); });
}} }}
/> />

View 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} />
);
}

View File

@ -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'>

View File

@ -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={() => {