From f44bee50098853fa7a2176f9ae106ba5946dd4b5 Mon Sep 17 00:00:00 2001 From: moux1024 <403053463@qq.com> Date: Tue, 21 Oct 2025 01:02:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=96=B0=E6=A8=A1=E6=9D=BF=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/DTO/movie_start_dto.ts | 2 +- app/service/domain/Entities.ts | 1 + components/ChatInputBox/PcTemplateModal.tsx | 30 +- components/FamousTemplate.tsx | 94 ++---- components/HomeBanner.tsx | 50 +++- components/MyMovies.tsx | 2 +- components/common/TemplatePreviewModal.tsx | 121 ++++++++ .../CreateInput/PortraitAnimeSelector.tsx | 2 +- .../CreateInput/VideoCreationForm.tsx | 271 ++++++++++++++---- .../PhotoPreview/PhotoPreviewSection.tsx | 3 +- 10 files changed, 406 insertions(+), 170 deletions(-) create mode 100644 components/common/TemplatePreviewModal.tsx diff --git a/api/DTO/movie_start_dto.ts b/api/DTO/movie_start_dto.ts index 220fc95..2b4068f 100644 --- a/api/DTO/movie_start_dto.ts +++ b/api/DTO/movie_start_dto.ts @@ -354,7 +354,7 @@ export interface CreateMovieProjectV4Request { /** 视频时长 */ video_duration: string; /** 是否是图生 */ - is_image_to_video: boolean; + use_img2video: boolean; /** pcode编码 */ pcode: string; /** 角色简介数组 */ diff --git a/app/service/domain/Entities.ts b/app/service/domain/Entities.ts index 0a7c2bf..14180b4 100644 --- a/app/service/domain/Entities.ts +++ b/app/service/domain/Entities.ts @@ -146,6 +146,7 @@ export interface StoryTemplateEntity { template_id: string; /** 故事模板视频URL */ show_url: string; + pcode: string; /** 故事角色 */ storyRole: { /** 角色名 */ diff --git a/components/ChatInputBox/PcTemplateModal.tsx b/components/ChatInputBox/PcTemplateModal.tsx index a4902d9..2d636d6 100644 --- a/components/ChatInputBox/PcTemplateModal.tsx +++ b/components/ChatInputBox/PcTemplateModal.tsx @@ -5,6 +5,7 @@ import { Clapperboard, Sparkles, LayoutTemplate, + ArrowRight } from "lucide-react"; import { Modal, @@ -22,25 +23,6 @@ import { useRouter } from "next/navigation"; import { useUploadFile } from "@/app/service/domain/service"; import { ActionButton } from "../common/ActionButton"; import GlobalLoad from "../common/GlobalLoad"; -import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector"; - -/** - * 防抖函数 - * @param {Function} func - 需要防抖的函数 - * @param {number} wait - 等待时间(ms) - * @returns {Function} - 防抖后的函数 - */ -const debounce = (func: Function, wait: number) => { - let timeout: ReturnType; - return function executedFunction(...args: any[]) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; interface PcTemplateModalProps { isTemplateCreating: boolean; @@ -108,7 +90,6 @@ export const PcTemplateModal = ({ // 自由输入框布局 const [freeInputLayout, setFreeInputLayout] = useState('bottom'); const router = useRouter(); - const [aspectUI, setAspectUI] = useState("VIDEO_ASPECT_RATIO_LANDSCAPE"); const leftListRef = useRef(null); // 组件挂载时获取模板列表 useEffect(() => { @@ -228,7 +209,6 @@ export const PcTemplateModal = ({ configOptions.mode, configOptions.resolution, configOptions.language, - aspectUI as AspectRatioValue ); if (projectId) { @@ -755,16 +735,10 @@ export const PcTemplateModal = ({ /> )} - {/* 横/竖屏选择 */} - 0} handleCreateVideo={handleConfirm} - icon={} + icon={} disabled={isTemplateCreating || localLoading > 0} /> diff --git a/components/FamousTemplate.tsx b/components/FamousTemplate.tsx index 8a3bfd8..59ef256 100644 --- a/components/FamousTemplate.tsx +++ b/components/FamousTemplate.tsx @@ -3,6 +3,7 @@ import type React from "react" import { useEffect, useState } from "react" import { X } from "lucide-react" +import TemplatePreviewModal from "@/components/common/TemplatePreviewModal" import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal" import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService" @@ -21,7 +22,7 @@ const FamousTemplate: React.FC = () => { const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({}) const [activeTemplateId, setActiveTemplateId] = useState(null) const [isPreviewReady, setIsPreviewReady] = useState(false) - const [activeTab, setActiveTab] = useState<"all" | "music" | "animation" | "thriller">("all") + const [activeTab, setActiveTab] = useState<"all" | "music" | "animation" | "fantasy">("all") useEffect(() => { void getTemplateStoryList() @@ -42,15 +43,15 @@ const FamousTemplate: React.FC = () => { Hot Templates
- {(["all", "music", "animation", "thriller"] as const).map((tab) => ( + {(["all", "music", "animation", "fantasy"] as const).map((tab) => ( -
- )} - - - - + setActiveTemplateId(null)} + title={active.name} + description={active.generateText || active.name} + onPrimaryAction={() => { + setInitialTemplateId(active.id || active.template_id) + setIsModalOpen(true) + setActiveTemplateId(null) + }} + primaryLabel="Try it Free" + /> ) })()} diff --git a/components/HomeBanner.tsx b/components/HomeBanner.tsx index 6d24d36..90f0e7c 100644 --- a/components/HomeBanner.tsx +++ b/components/HomeBanner.tsx @@ -2,10 +2,12 @@ import { useEffect, useState, useRef } from "react"; import { fetchSettingByCode } from "@/api/serversetting"; -import { X, Eclipse } from "lucide-react"; +import { X, ChevronUp, ChevronDown } from "lucide-react"; import { ChatInputBox } from "@/components/ChatInputBox/ChatInputBox"; +import { VideoCreationForm } from '@/components/pages/create-video/CreateInput'; export const HOME_BANNER_CODE = "homeBanner"; +const HOME_BANNER_COLLAPSE_KEY = "homeBannerCollapsedDate"; /** CTA config for banner */ export interface HomeBannerCTAConfig { @@ -38,9 +40,22 @@ export default function HomeBanner() { const [error, setError] = useState(null); const [isFlying, setIsFlying] = useState(false); const autoCollapseTimerRef = useRef | null>(null); + const skipAutoCollapseRef = useRef(false); + + /** Returns YYYY-MM-DD for user's local timezone */ + const getLocalDateKey = () => { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + }; const handleDismiss = () => { setIsFlying(true); + try { + localStorage.setItem(HOME_BANNER_COLLAPSE_KEY, getLocalDateKey()); + } catch {} }; const handleBannerClick = () => { @@ -49,12 +64,27 @@ export default function HomeBanner() { } }; - // Auto collapse after mount (3s) + // Initialize from persisted state: keep collapsed for the rest of the day useEffect(() => { + try { + const saved = localStorage.getItem(HOME_BANNER_COLLAPSE_KEY); + if (saved && saved === getLocalDateKey()) { + setIsFlying(true); + skipAutoCollapseRef.current = true; + } + } catch {} + }, []); + + // Auto collapse after mount (2s) unless already collapsed today + useEffect(() => { + if (skipAutoCollapseRef.current) return; if (autoCollapseTimerRef.current) return; autoCollapseTimerRef.current = setTimeout(() => { setIsFlying(true); - }, 3000); + try { + localStorage.setItem(HOME_BANNER_COLLAPSE_KEY, getLocalDateKey()); + } catch {} + }, 2000); return () => { if (autoCollapseTimerRef.current) { clearTimeout(autoCollapseTimerRef.current); @@ -130,9 +160,9 @@ export default function HomeBanner() { {/* Banner overlay - stacked above */}
- + {isFlying ? ( - ) : null} {eyebrow ? ( @@ -208,8 +238,8 @@ export default function HomeBanner() {
{/* Base content - always present under the banner */} -
- +
+
); diff --git a/components/MyMovies.tsx b/components/MyMovies.tsx index f6acb7b..813d96b 100644 --- a/components/MyMovies.tsx +++ b/components/MyMovies.tsx @@ -62,7 +62,7 @@ const MyMovies: React.FC = () => { return (
-

My Movies

+

My Projects

All movies →
diff --git a/components/common/TemplatePreviewModal.tsx b/components/common/TemplatePreviewModal.tsx new file mode 100644 index 0000000..5b3ed8b --- /dev/null +++ b/components/common/TemplatePreviewModal.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; + +interface TemplatePreviewModalProps { + /** Control visibility */ + open: boolean; + /** Video URL to preview */ + videoUrl: string | null; + /** Close callback */ + onClose: () => void; + /** Optional title shown over the video */ + title?: string; + /** Optional description shown over the video */ + description?: string; + /** Optional primary action (e.g., Try it) */ + onPrimaryAction?: () => void; + /** Optional primary action label */ + primaryLabel?: string; +} + +/** + * Fullscreen, Tailwind-based template preview modal with video content. + * Overlays title/description if provided and supports an optional primary action. + */ +export function TemplatePreviewModal({ + open, + videoUrl, + onClose, + title, + description, + onPrimaryAction, + primaryLabel = 'Try it', +}: TemplatePreviewModalProps) { + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!open) { + setIsReady(false); + } + }, [open]); + + if (!open || !videoUrl) return null; + + return ( +
+
e.stopPropagation()} + > +
+ ); +} + +export default TemplatePreviewModal; + + diff --git a/components/pages/create-video/CreateInput/PortraitAnimeSelector.tsx b/components/pages/create-video/CreateInput/PortraitAnimeSelector.tsx index c61b476..3f12e21 100644 --- a/components/pages/create-video/CreateInput/PortraitAnimeSelector.tsx +++ b/components/pages/create-video/CreateInput/PortraitAnimeSelector.tsx @@ -33,7 +33,7 @@ export function PortraitAnimeSelector({ disabled = false, }: PortraitAnimeSelectorProps) { const [lastAnimeChoice, setLastAnimeChoice] = useState('STANDARD_V1_734684_116483'); - const [animeOptions, setAnimeOptions] = useState>([]) + const [animeOptions, setAnimeOptions] = useState>([]); useEffect(() => { let mounted = true; diff --git a/components/pages/create-video/CreateInput/VideoCreationForm.tsx b/components/pages/create-video/CreateInput/VideoCreationForm.tsx index f01d29e..54df7bf 100644 --- a/components/pages/create-video/CreateInput/VideoCreationForm.tsx +++ b/components/pages/create-video/CreateInput/VideoCreationForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { PhotoPreviewSection } from '../PhotoPreview'; import type { PhotoItem, PhotoType } from '../PhotoPreview/types'; import { @@ -11,17 +11,24 @@ import { ArrowRightOutlined, SettingOutlined, } from '@ant-design/icons'; -import { Dropdown } from 'antd'; +import { Dropdown, Popover } from 'antd'; import { ConfigPanel } from './ConfigPanel'; import { MobileConfigModal } from './MobileConfigModal'; import { AddItemModal } from './AddItemModal'; import { defaultConfig } from './config-options'; import type { ConfigOptions } from './config-options'; +import { Eye, Check, ArrowRight, X } from 'lucide-react'; +import TemplatePreviewModal from '@/components/common/TemplatePreviewModal'; +import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal" import { useDeviceType } from '@/hooks/useDeviceType'; import { MovieProjectService, MovieProjectMode } from '@/app/service/Interaction/MovieProjectService'; import type { CreateMovieProjectV4Request } from '@/api/DTO/movie_start_dto'; import { getCurrentUser } from '@/lib/auth'; import { useRouter } from 'next/navigation'; +import { useTemplateStoryServiceHook } from '@/app/service/Interaction/templateStoryService'; +import { StoryTemplateEntity } from '@/app/service/domain/Entities'; +import { useUploadFile } from '@/app/service/domain/service'; + export default function VideoCreationForm() { const [photos, setPhotos] = useState([]); const [inputText, setInputText] = useState(''); @@ -32,12 +39,58 @@ export default function VideoCreationForm() { const [editingIndex, setEditingIndex] = useState(null); const [replacingIndex, setReplacingIndex] = useState(null); const [isCreating, setIsCreating] = useState(false); + // Template modal states (align with FamousTemplate usage) + const [isModalOpen, setIsModalOpen] = useState(false); + const [initialTemplateId, setInitialTemplateId] = useState(undefined); + const [isTemplateCreating, setIsTemplateCreating] = useState(false); + const [isRoleGenerating, setIsRoleGenerating] = useState<{ [key: string]: boolean }>({}); + const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({}); + const [currentTemplate, setCurrentTemplate] = useState(null); + const [inputPlaceholder, setInputPlaceholder] = useState(''); + const [templateTitle, setTemplateTitle] = useState(''); + const [isMentionOpen, setIsMentionOpen] = useState(false); + const { isMobile, isDesktop } = useDeviceType(); const router = useRouter(); + + /** Template list for mention popover */ + const { templateStoryList, getTemplateStoryList } = useTemplateStoryServiceHook(); + const isTemplateSelected = useMemo(() => { + return templateStoryList.find(i => i.pcode === configOptions.pcode); + }, [configOptions.pcode, templateStoryList]); + useEffect(() => { + void getTemplateStoryList(); + }, [getTemplateStoryList]); + const characterInputRef = useRef(null); const sceneInputRef = useRef(null); const propInputRef = useRef(null); + const { uploadFile } = useUploadFile(); + + /** Clear current template related states */ + const clearTemplateSelection = () => { + handleConfigChange('pcode', ''); + setInputPlaceholder(''); + setTemplateTitle(''); + setPhotos([]); + }; + + /** Apply selected template to current form state */ + const applyTemplateSelection = (template: StoryTemplateEntity) => { + const characterPhotos = (template.storyRole || []).map((role, index) => ({ + url: role.photo_url, + type: 'character' as const, + id: `character-${Date.now()}-${index}`, + name: role.role_name, + description: role.role_description, + })).filter(p => p.url); + + setPhotos(prev => [...prev, ...characterPhotos]); + setInputPlaceholder(template.generateText || template.name); + setTemplateTitle(template.name); + handleConfigChange('pcode', template.pcode || ''); + }; /** Handle file upload */ const handleFileUpload = (event: React.ChangeEvent, type: PhotoType) => { @@ -47,24 +100,33 @@ export default function VideoCreationForm() { // Check if we're replacing an existing photo if (replacingIndex !== null) { const file = files[0]; - setPhotos(prevPhotos => { - const updatedPhotos = [...prevPhotos]; - updatedPhotos[replacingIndex] = { - ...updatedPhotos[replacingIndex], - url: URL.createObjectURL(file), - }; - return updatedPhotos; - }); - setReplacingIndex(null); + void (async () => { + try { + const uploadedUrl = await uploadFile(file); + setPhotos(prevPhotos => { + const updatedPhotos = [...prevPhotos]; + updatedPhotos[replacingIndex] = { + ...updatedPhotos[replacingIndex], + url: uploadedUrl, + }; + return updatedPhotos; + }); + } finally { + setReplacingIndex(null); + } + })(); } else { // Add new photos - const newPhotos: PhotoItem[] = Array.from(files).map((file, index) => ({ - url: URL.createObjectURL(file), - type, - id: `${type}-${Date.now()}-${index}`, - })); - - setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]); + void (async () => { + const fileArray = Array.from(files); + const uploadedUrls = await Promise.all(fileArray.map((file) => uploadFile(file))); + const newPhotos: PhotoItem[] = uploadedUrls.map((url, index) => ({ + url, + type, + id: `${type}-${Date.now()}-${index}`, + })); + setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]); + })(); } event.target.value = ''; @@ -131,33 +193,36 @@ export default function VideoCreationForm() { // Check if we're editing or adding if (typeof data.index === 'number' && editingIndex !== null) { // Edit mode - update existing photo - setPhotos(prevPhotos => { - const updatedPhotos = [...prevPhotos]; - const existingPhoto = updatedPhotos[editingIndex]; - - updatedPhotos[editingIndex] = { - ...existingPhoto, - // Only update URL if a new file was selected (not the dummy file) - ...(data.file.size > 0 && { url: URL.createObjectURL(data.file) }), + void (async () => { + let uploadedUrl: string | undefined; + if (data.file.size > 0) { + uploadedUrl = await uploadFile(data.file); + } + setPhotos(prevPhotos => { + const updatedPhotos = [...prevPhotos]; + const existingPhoto = updatedPhotos[editingIndex]; + updatedPhotos[editingIndex] = { + ...existingPhoto, + ...(uploadedUrl ? { url: uploadedUrl } : {}), + ...(data.name && { name: data.name }), + ...(data.description && { description: data.description }), + }; + return updatedPhotos; + }); + })(); + } else { + // Add mode - create new photo + void (async () => { + const uploadedUrl = await uploadFile(data.file); + const newPhoto: PhotoItem = { + url: uploadedUrl, + type: currentItemType, + id: `${currentItemType}-${Date.now()}`, ...(data.name && { name: data.name }), ...(data.description && { description: data.description }), }; - - return updatedPhotos; - }); - console.log('Updated item:', { ...data, type: currentItemType, index: editingIndex }); - } else { - // Add mode - create new photo - const newPhoto: PhotoItem = { - url: URL.createObjectURL(data.file), - type: currentItemType, - id: `${currentItemType}-${Date.now()}`, - ...(data.name && { name: data.name }), - ...(data.description && { description: data.description }), - }; - - setPhotos(prevPhotos => [...prevPhotos, newPhoto]); - console.log('Added item:', { ...data, type: currentItemType }); + setPhotos(prevPhotos => [...prevPhotos, newPhoto]); + })(); } }; @@ -193,7 +258,7 @@ export default function VideoCreationForm() { aspect_ratio: configOptions.aspect_ratio, expansion_mode: configOptions.expansion_mode, video_duration: configOptions.videoDuration, - is_image_to_video: photos.length > 0, + use_img2video: photos.length > 0, pcode: configOptions.pcode === 'portrait' ? '' : configOptions.pcode, }; @@ -257,9 +322,25 @@ export default function VideoCreationForm() { data-alt="content-container" className="flex-1 border border-white/10 rounded-3xl bg-gradient-to-br from-[#1a1a1a]/50 to-[#0a0a0a]/50 backdrop-blur-sm overflow-hidden flex flex-col" > + {templateTitle && ( +
+
+ {templateTitle} +
+ +
+ )} {/* Photo Preview Section - Top */} {photos.length > 0 && ( -
+
setInputText(e.target.value)} /> @@ -344,13 +425,75 @@ export default function VideoCreationForm() { onChange={(e) => handleFileUpload(e, 'prop')} /> - {/* Mention Button */} - +
+
+
+ ); + })} + + ) : ( +
No Avaliable Templates
+ )} + + } > - @ - + + {/* Configuration - Desktop: Full Panel, Mobile: Setting Icon */} {isDesktop ? ( @@ -416,6 +559,32 @@ export default function VideoCreationForm() { : undefined } /> + setIsModalOpen(false)} + initialTemplateId={initialTemplateId} + configOptions={{ mode: "auto", resolution: "720p", language: "english", videoDuration: "auto" }} + /> + setCurrentTemplate(null)} + title={currentTemplate?.name || ''} + description={currentTemplate?.generateText || currentTemplate?.name} + onPrimaryAction={() => { + if (!currentTemplate) return; + clearTemplateSelection(); + applyTemplateSelection(currentTemplate); + setCurrentTemplate(null) + }} + primaryLabel="Try it Free" + /> ); } diff --git a/components/pages/create-video/PhotoPreview/PhotoPreviewSection.tsx b/components/pages/create-video/PhotoPreview/PhotoPreviewSection.tsx index 83923d7..914baf3 100644 --- a/components/pages/create-video/PhotoPreview/PhotoPreviewSection.tsx +++ b/components/pages/create-video/PhotoPreview/PhotoPreviewSection.tsx @@ -93,11 +93,10 @@ export default function PhotoPreviewSection({ {/* Photo List Container with Horizontal Scroll */}
{photos.map((photo, index) => { const photoName = photo.name || `${getTypeLabel(photo.type)} ${index + 1}`; - return (