diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index a121d66..1304809 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -76,12 +76,16 @@ type NavigationElementType = Extract< 'navigation_next' | 'navigation_prev' >; +type NavigationButtonKind = 'forward' | 'back'; + type CanvasElement = { id: string; type: CanvasElementType; label: string; xPercent: number; yPercent: number; + appearDelaySec?: number; + appearDurationSec?: number | null; iconUrl?: string; galleryCards?: GalleryCard[]; carouselSlides?: CarouselSlide[]; @@ -92,6 +96,7 @@ type CanvasElement = { descriptionTitle?: string; descriptionText?: string; navLabel?: string; + navType?: NavigationButtonKind; targetPageId?: string; transitionVideoUrl?: string; transitionReverseMode?: 'auto_reverse' | 'separate_video'; @@ -164,6 +169,19 @@ const parseJsonObject = (value?: unknown, fallback?: T): T => { const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); +const normalizeAppearDelaySec = (value: unknown) => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return Number(parsed); +}; + +const normalizeAppearDurationSec = (value: unknown) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return Number(parsed); +}; + const createLocalId = () => { if (typeof window !== 'undefined' && window.crypto?.randomUUID) { return window.crypto.randomUUID(); @@ -228,6 +246,18 @@ const extractS3ObjectKey = (value: string) => { } }; +const formatDurationNote = (durationSec?: number | string | null) => { + const parsed = Number(durationSec); + if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown'; + + const totalSeconds = Math.round(parsed); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) return `Duration: ${seconds}s`; + return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`; +}; + const resolveAssetPlaybackUrl = (value?: string) => { const normalized = String(value || '').trim(); if (!normalized) return ''; @@ -258,6 +288,87 @@ const resolveAssetPlaybackUrl = (value?: string) => { return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; }; +const readMediaDuration = ( + playbackUrl: string, + mediaType: 'video' | 'audio', +): Promise => + new Promise((resolve) => { + const mediaElement = + mediaType === 'video' + ? document.createElement('video') + : document.createElement('audio'); + + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata); + mediaElement.removeEventListener('error', onError); + mediaElement.removeEventListener('abort', onError); + if (timeoutId) clearTimeout(timeoutId); + mediaElement.pause(); + mediaElement.removeAttribute('src'); + mediaElement.load(); + }; + + const onLoadedMetadata = () => { + const duration = Number(mediaElement.duration); + cleanup(); + if (Number.isFinite(duration) && duration > 0) { + resolve(duration); + return; + } + resolve(null); + }; + + const onError = () => { + cleanup(); + resolve(null); + }; + + timeoutId = setTimeout(() => { + cleanup(); + resolve(null); + }, 12000); + + mediaElement.preload = 'metadata'; + mediaElement.crossOrigin = 'anonymous'; + mediaElement.addEventListener('loadedmetadata', onLoadedMetadata); + mediaElement.addEventListener('error', onError); + mediaElement.addEventListener('abort', onError); + mediaElement.src = playbackUrl; + mediaElement.load(); + }); + +const resolveDurationWithFallback = async ( + source: string, + mediaType: 'video' | 'audio', +) => { + const playbackUrl = resolveAssetPlaybackUrl(source); + if (!playbackUrl) return null; + + const directDuration = await readMediaDuration(playbackUrl, mediaType); + if (Number.isFinite(directDuration) && Number(directDuration) > 0) { + return Number(directDuration); + } + + try { + const response = await axios.get(playbackUrl, { responseType: 'blob' }); + const blobUrl = URL.createObjectURL(response.data); + try { + const blobDuration = await readMediaDuration(blobUrl, mediaType); + if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) { + return Number(blobDuration); + } + return null; + } finally { + URL.revokeObjectURL(blobUrl); + } + } catch (error) { + console.error('Failed to fetch media for duration probing:', error); + return null; + } +}; + const isBackgroundImageAsset = (asset: ProjectAsset) => { if (asset.type) return asset.type === 'background_image'; const normalizedName = String(asset.name || '').toLowerCase(); @@ -306,6 +417,14 @@ const isNavigationElementType = ( const getNavigationButtonLabel = (type: NavigationElementType) => type === 'navigation_next' ? 'Forward' : 'Back'; +const getNavigationButtonKind = (type: NavigationElementType): NavigationButtonKind => + type === 'navigation_prev' ? 'back' : 'forward'; + +const getNavigationTypeFromKind = ( + kind: NavigationButtonKind, +): NavigationElementType => + kind === 'back' ? 'navigation_prev' : 'navigation_next'; + const createDefaultElement = ( type: CanvasElementType, index: number, @@ -316,6 +435,8 @@ const createDefaultElement = ( label: labelByType[type], xPercent: clamp(12 + index * 4, 5, 80), yPercent: clamp(16 + index * 6, 8, 85), + appearDelaySec: 0, + appearDurationSec: null, }; if (type === 'gallery') { @@ -360,6 +481,7 @@ const createDefaultElement = ( return { ...base, navLabel: getNavigationButtonLabel(type), + navType: getNavigationButtonKind(type), iconUrl: '', transitionReverseMode: 'auto_reverse', transitionDurationSec: 0.7, @@ -452,6 +574,9 @@ const ConstructorPage = () => { const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); + const [resolvedDurationBySource, setResolvedDurationBySource] = useState< + Record + >({}); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 }); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -470,6 +595,7 @@ const ConstructorPage = () => { const transitionVideoRef = useRef(null); const reverseAnimationFrame = useRef(null); const didSetInitialCanvasFocus = useRef(false); + const durationProbeInFlightRef = useRef>(new Set()); const activePage = useMemo( () => pages.find((item) => item.id === activePageId) || null, @@ -481,6 +607,7 @@ const ConstructorPage = () => { ); const allowedNavigationTypes = useMemo(() => { if (pages.length <= 1) return ['navigation_next']; + if (activePageIndex < 0) return ['navigation_next', 'navigation_prev']; if (activePageIndex <= 0) return ['navigation_next']; if (activePageIndex >= pages.length - 1) return ['navigation_prev']; return ['navigation_next', 'navigation_prev']; @@ -512,6 +639,7 @@ const ConstructorPage = () => { return { ...element, type: nextType, + navType: getNavigationButtonKind(nextType), label: hasDefaultLabel ? labelByType[nextType] : element.label, navLabel: hasDefaultNavLabel ? nextButtonLabel : element.navLabel, }; @@ -615,6 +743,112 @@ const ConstructorPage = () => { })), [assets], ); + const getKnownDurationForSource = useCallback( + (source?: string) => { + const normalizedSource = String(source || '').trim(); + if (!normalizedSource) return null; + + const resolvedDuration = resolvedDurationBySource[normalizedSource]; + if (Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0) { + return Number(resolvedDuration); + } + + return null; + }, + [resolvedDurationBySource], + ); + + const durationProbeTargets = useMemo< + Array<{ source: string; mediaType: 'video' | 'audio' }> + >(() => { + const targets: Array<{ source: string; mediaType: 'video' | 'audio' }> = []; + + if (backgroundVideoUrl) { + targets.push({ source: backgroundVideoUrl, mediaType: 'video' }); + } + + if (backgroundAudioUrl) { + targets.push({ source: backgroundAudioUrl, mediaType: 'audio' }); + } + + if ( + selectedElement && + (selectedElement.type === 'video_player' || + selectedElement.type === 'audio_player') && + selectedElement.mediaUrl + ) { + targets.push({ + source: selectedElement.mediaUrl, + mediaType: selectedElement.type === 'video_player' ? 'video' : 'audio', + }); + } + + return targets; + }, [backgroundAudioUrl, backgroundVideoUrl, selectedElement]); + + useEffect(() => { + let isCancelled = false; + + durationProbeTargets.forEach(({ source, mediaType }) => { + const normalizedSource = String(source || '').trim(); + if (!normalizedSource) return; + + if (getKnownDurationForSource(normalizedSource)) return; + + const probeKey = `${mediaType}:${normalizedSource}`; + if (durationProbeInFlightRef.current.has(probeKey)) return; + durationProbeInFlightRef.current.add(probeKey); + + resolveDurationWithFallback(normalizedSource, mediaType) + .then((duration) => { + if (isCancelled) return; + setResolvedDurationBySource((prev) => ({ + ...prev, + [normalizedSource]: + Number.isFinite(duration) && Number(duration) > 0 + ? Number(duration) + : null, + })); + }) + .catch((error) => { + console.error('Failed to resolve media duration:', error); + if (isCancelled) return; + setResolvedDurationBySource((prev) => ({ + ...prev, + [normalizedSource]: null, + })); + }) + .finally(() => { + durationProbeInFlightRef.current.delete(probeKey); + }); + }); + + return () => { + isCancelled = true; + }; + }, [durationProbeTargets, getKnownDurationForSource]); + + const backgroundVideoDurationNote = useMemo( + () => formatDurationNote(getKnownDurationForSource(backgroundVideoUrl)), + [backgroundVideoUrl, getKnownDurationForSource], + ); + const backgroundAudioDurationNote = useMemo( + () => formatDurationNote(getKnownDurationForSource(backgroundAudioUrl)), + [backgroundAudioUrl, getKnownDurationForSource], + ); + const selectedMediaDurationNote = useMemo(() => { + if ( + !selectedElement || + (selectedElement.type !== 'video_player' && + selectedElement.type !== 'audio_player') + ) { + return 'Duration: unknown'; + } + + return formatDurationNote( + getKnownDurationForSource(selectedElement.mediaUrl || ''), + ); + }, [getKnownDurationForSource, selectedElement]); useEffect(() => { if (newTransitionVideoUrl) return; @@ -762,6 +996,8 @@ const ConstructorPage = () => { label: labelByType[item.type as CanvasElementType], xPercent: clamp(Number(item.xPercent || 0), 0, 100), yPercent: clamp(Number(item.yPercent || 0), 0, 100), + appearDelaySec: normalizeAppearDelaySec(item.appearDelaySec), + appearDurationSec: normalizeAppearDurationSec(item.appearDurationSec), galleryCards: Array.isArray(item.galleryCards) ? item.galleryCards.map((card: any, index: number) => ({ id: String(card?.id || createLocalId()), @@ -799,6 +1035,12 @@ const ConstructorPage = () => { ? item.descriptionText : '', navLabel: typeof item.navLabel === 'string' ? item.navLabel : '', + navType: + item.navType === 'back' || item.navType === 'forward' + ? item.navType + : isNavigationElementType(item.type as CanvasElementType) + ? getNavigationButtonKind(item.type as NavigationElementType) + : undefined, targetPageId: typeof item.targetPageId === 'string' ? item.targetPageId : '', transitionVideoUrl: @@ -1861,6 +2103,9 @@ const ConstructorPage = () => { ))} +

+ {backgroundVideoDurationNote} +

)} @@ -1883,6 +2128,9 @@ const ConstructorPage = () => { ))} +

+ {backgroundAudioDurationNote} +

)} @@ -1952,17 +2200,61 @@ const ConstructorPage = () => { )} {selectedElement && ( -
- - - updateSelectedElement({ label: event.target.value }) - } - /> +
+
+ + + updateSelectedElement({ label: event.target.value }) + } + /> +
+
+ + + updateSelectedElement({ + appearDelaySec: normalizeAppearDelaySec( + event.target.value, + ), + }) + } + /> +
+
+ + + updateSelectedElement({ + appearDurationSec: normalizeAppearDurationSec( + event.target.value, + ), + }) + } + /> +

+ Leave empty for unlimited. +

+
)} @@ -1972,17 +2264,22 @@ const ConstructorPage = () => {
@@ -2092,6 +2397,9 @@ const ConstructorPage = () => { ))} +

+ {selectedMediaDurationNote} +