diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index dc268a6..40796d5 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -392,7 +392,18 @@ const resolveDurationWithFallback = async ( } try { - const response = await axios.get(playbackUrl, { responseType: 'blob' }); + const requestUrl = + playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://') + ? playbackUrl + : playbackUrl.replace(/^\/api(?=\/)/, ''); + + const token = + typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; + const response = await axios.get(requestUrl, { + responseType: 'blob', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + const blobUrl = URL.createObjectURL(response.data); try { const blobDuration = await readMediaDuration(blobUrl, mediaType); @@ -538,7 +549,6 @@ const createDefaultElement = ( navType: getNavigationButtonKind(type), iconUrl: '', transitionReverseMode: 'auto_reverse', - transitionDurationSec: 0.7, }; } @@ -739,6 +749,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { useState('none'); const [transitionPreview, setTransitionPreview] = useState(null); + const [pendingNavigationPageId, setPendingNavigationPageId] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -748,7 +759,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const [newTransitionVideoUrl, setNewTransitionVideoUrl] = useState(''); const [newTransitionSupportsReverse, setNewTransitionSupportsReverse] = useState(true); - const [newTransitionDurationSec, setNewTransitionDurationSec] = useState(0.7); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const [resolvedDurationBySource, setResolvedDurationBySource] = useState< @@ -984,8 +994,28 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { }); } + if (newTransitionVideoUrl) { + targets.push({ source: newTransitionVideoUrl, mediaType: 'video' }); + } + + elements.forEach((element) => { + if (!isNavigationElementType(element.type)) return; + if (element.transitionVideoUrl) { + targets.push({ source: element.transitionVideoUrl, mediaType: 'video' }); + } + if (element.reverseVideoUrl) { + targets.push({ source: element.reverseVideoUrl, mediaType: 'video' }); + } + }); + return targets; - }, [backgroundAudioUrl, backgroundVideoUrl, selectedElement]); + }, [ + backgroundAudioUrl, + backgroundVideoUrl, + elements, + newTransitionVideoUrl, + selectedElement, + ]); useEffect(() => { let isCancelled = false; @@ -1050,6 +1080,19 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { getKnownDurationForSource(selectedElement.mediaUrl || ''), ); }, [getKnownDurationForSource, selectedElement]); + const newTransitionDurationNote = useMemo( + () => formatDurationNote(getKnownDurationForSource(newTransitionVideoUrl)), + [getKnownDurationForSource, newTransitionVideoUrl], + ); + const selectedTransitionDurationNote = useMemo(() => { + if (!selectedElement || !isNavigationElementType(selectedElement.type)) { + return 'Duration: unknown'; + } + + return formatDurationNote( + getKnownDurationForSource(selectedElement.transitionVideoUrl || ''), + ); + }, [getKnownDurationForSource, selectedElement]); useEffect(() => { if (newTransitionVideoUrl) return; @@ -1057,6 +1100,32 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { setNewTransitionVideoUrl(transitionVideoAssetOptions[0].value); }, [newTransitionVideoUrl, transitionVideoAssetOptions]); + useEffect(() => { + setElements((prev) => { + let hasChanges = false; + const next = prev.map((element) => { + if (!isNavigationElementType(element.type)) return element; + + const resolvedDuration = getKnownDurationForSource( + element.transitionVideoUrl || '', + ); + const nextDuration = + Number.isFinite(resolvedDuration) && Number(resolvedDuration) > 0 + ? Number(resolvedDuration) + : undefined; + if (element.transitionDurationSec === nextDuration) return element; + + hasChanges = true; + return { + ...element, + transitionDurationSec: nextDuration, + }; + }); + + return hasChanges ? next : prev; + }); + }, [getKnownDurationForSource]); + const loadData = useCallback(async () => { if (!projectId || !router.isReady || !isAuthReady) return; @@ -1643,7 +1712,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const sanitizedName = String(newTransitionName || '').trim() || `Transition ${Date.now().toString().slice(-4)}`; - const parsedDuration = Number(newTransitionDurationSec); + const resolvedDurationSec = getKnownDurationForSource(sanitizedVideoUrl); + if (!resolvedDurationSec) { + setErrorMessage( + 'Could not resolve transition video duration yet. Please wait a moment and try again.', + ); + return; + } try { setIsCreatingTransition(true); @@ -1659,10 +1734,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { video_url: sanitizedVideoUrl, audio_url: '', supports_reverse: Boolean(newTransitionSupportsReverse), - duration_sec: - Number.isFinite(parsedDuration) && parsedDuration > 0 - ? parsedDuration - : 0.7, + duration_sec: resolvedDurationSec, }; await axios.post('/transitions', { data: payload }); @@ -1680,7 +1752,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { } }, [ activePage?.environment, - newTransitionDurationSec, + getKnownDurationForSource, newTransitionName, newTransitionSupportsReverse, newTransitionVideoUrl, @@ -1924,6 +1996,44 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { element.navType === 'back' || element.type === 'navigation_prev' ? 'back' : 'forward'; + const configuredTargetId = String(element.targetPageId || '').trim(); + const fallbackTargetId = (() => { + const currentPageIndex = pages.findIndex( + (page) => page.id === activePageId, + ); + if (currentPageIndex < 0) return ''; + + const nextPageIndex = + direction === 'back' ? currentPageIndex - 1 : currentPageIndex + 1; + const nextPage = pages[nextPageIndex]; + return nextPage ? String(nextPage.id || '').trim() : ''; + })(); + const targetPageId = configuredTargetId || fallbackTargetId; + + if (!targetPageId) { + setErrorMessage('No target page available for this navigation button.'); + return; + } + + const hasPlayableTransition = + Boolean(element.transitionVideoUrl) && + !( + direction === 'back' && + element.transitionReverseMode === 'separate_video' && + !element.reverseVideoUrl + ); + + if (!hasPlayableTransition) { + setPendingNavigationPageId(''); + setTransitionPreview(null); + setActivePageId(targetPageId); + setSelectedElementId(''); + setSelectedMenuItem('none'); + setErrorMessage(''); + return; + } + + setPendingNavigationPageId(targetPageId); openTransitionPreviewForElement(element, direction); } return; @@ -2266,6 +2376,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const finishPreview = () => { cleanupReverseFrame(); setTransitionPreview(null); + setPendingNavigationPageId((pendingPageId) => { + const nextPageId = String(pendingPageId || '').trim(); + if (nextPageId) { + setActivePageId(nextPageId); + setSelectedElementId(''); + setSelectedMenuItem('none'); + setErrorMessage(''); + } + return ''; + }); }; const configuredDurationMs = @@ -2700,6 +2820,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ))} +

+ Transition duration is automatic from video metadata.{' '} + {newTransitionDurationNote} +

- - setNewTransitionDurationSec( - Number(event.target.value || 0.7), - ) - } - />