diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 40796d5..f31f35d 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -239,53 +239,6 @@ const getAssetLabel = (asset: ProjectAsset) => { const getAssetSourceValue = (asset: ProjectAsset) => String(asset.storage_key || asset.cdn_url || '').trim(); -const extractPrivateUrlFromDownloadPath = (value: string) => { - const normalized = String(value || '').trim(); - if (!normalized) return ''; - - try { - const parsed = new URL(normalized, 'http://localhost'); - const privateUrl = parsed.searchParams.get('privateUrl'); - return String(privateUrl || '').trim(); - } catch (error) { - console.error('Failed to parse download URL:', error); - return ''; - } -}; - -const extractS3ObjectKey = (value: string) => { - const normalized = String(value || '').trim(); - if (!normalized) return ''; - - try { - const parsed = new URL(normalized); - const hostname = String(parsed.hostname || '').toLowerCase(); - if ( - !hostname.includes('amazonaws.com') && - !hostname.includes('cloudfront.net') - ) - return ''; - const decodedPath = decodeURIComponent( - String(parsed.pathname || '').replace(/^\/+/, ''), - ); - if (!decodedPath) return ''; - - const pathParts = decodedPath.split('/').filter(Boolean); - if (pathParts.length <= 1) return decodedPath; - - const firstPart = pathParts[0]; - const isLikelyStoragePrefix = /^[a-f0-9]{24,64}$/i.test(firstPart); - if (isLikelyStoragePrefix) { - return pathParts.slice(1).join('/'); - } - - return decodedPath; - } catch (error) { - console.error('Failed to parse S3 asset URL:', error); - return ''; - } -}; - const formatDurationNote = (durationSec?: number | string | null) => { const parsed = Number(durationSec); if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown'; @@ -310,19 +263,8 @@ const resolveAssetPlaybackUrl = (value?: string) => { if (normalized.startsWith('/file/download')) return `${baseURLApi}${normalized}`; - if (normalized.startsWith('http://') || normalized.startsWith('https://')) { - const downloadPrivateUrl = extractPrivateUrlFromDownloadPath(normalized); - if (downloadPrivateUrl) { - return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(downloadPrivateUrl)}`; - } - - const s3ObjectKey = extractS3ObjectKey(normalized); - if (s3ObjectKey) { - return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(s3ObjectKey)}`; - } - + if (normalized.startsWith('http://') || normalized.startsWith('https://')) return normalized; - } const normalizedPrivateUrl = normalized.replace(/^\/+/, ''); return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; @@ -2364,7 +2306,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const video = transitionVideoRef.current; if (!transitionPreview || !video) return; - let fallbackTimer: ReturnType | null = null; + let startWatchdogTimer: ReturnType | null = null; + let finishTimer: ReturnType | null = null; + let hardTimeoutTimer: ReturnType | null = null; + let previewBlobUrl: string | null = null; + let didFinish = false; + let didStartPlayback = false; + + const sourceCandidateRaw = + transitionPreview.reverseMode === 'separate' + ? transitionPreview.reverseVideoUrl || '' + : transitionPreview.videoUrl; + const sourceUrl = resolveAssetPlaybackUrl(sourceCandidateRaw); const cleanupReverseFrame = () => { if (reverseAnimationFrame.current !== null) { @@ -2373,8 +2326,74 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { } }; - const finishPreview = () => { + const clearTimers = () => { + if (startWatchdogTimer) clearTimeout(startWatchdogTimer); + if (finishTimer) clearTimeout(finishTimer); + if (hardTimeoutTimer) clearTimeout(hardTimeoutTimer); + startWatchdogTimer = null; + finishTimer = null; + hardTimeoutTimer = null; + }; + + const cleanupPreviewBlobUrl = () => { + if (!previewBlobUrl) return; + URL.revokeObjectURL(previewBlobUrl); + previewBlobUrl = null; + }; + + const shouldLoadTransitionViaBlob = (candidateUrl: string) => { + try { + const parsedUrl = new URL(candidateUrl, window.location.origin); + const isSameOrigin = parsedUrl.origin === window.location.origin; + if (!isSameOrigin) return false; + return ( + parsedUrl.pathname === '/api/file/download' || + parsedUrl.pathname === '/file/download' + ); + } catch (error) { + console.error('Transition preview URL parsing failed:', { + candidateUrl, + error, + }); + return false; + } + }; + + const buildBlobRequestUrl = (candidateUrl: string) => { + if (candidateUrl.startsWith('/api/')) { + return candidateUrl.replace(/^\/api(?=\/)/, ''); + } + return candidateUrl; + }; + + const resolvePlayableTransitionSource = async () => { + cleanupPreviewBlobUrl(); + + if (!shouldLoadTransitionViaBlob(sourceUrl)) { + return sourceUrl; + } + + const token = + typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; + const requestUrl = buildBlobRequestUrl(sourceUrl); + const response = await axios.get(requestUrl, { + responseType: 'blob', + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + + previewBlobUrl = URL.createObjectURL(response.data); + return previewBlobUrl; + }; + + const finishPreview = (reason: string) => { + if (didFinish) return; + didFinish = true; + clearTimers(); cleanupReverseFrame(); + video.pause(); + video.removeAttribute('src'); + video.load(); + cleanupPreviewBlobUrl(); setTransitionPreview(null); setPendingNavigationPageId((pendingPageId) => { const nextPageId = String(pendingPageId || '').trim(); @@ -2386,25 +2405,59 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { } return ''; }); + console.info('Transition preview finished:', { + reason, + src: video.currentSrc || sourceUrl || '', + }); }; - const configuredDurationMs = - (transitionPreview.durationSec && transitionPreview.durationSec > 0 - ? transitionPreview.durationSec - : 0.7) * 1000; + const configuredDurationSec = Number(transitionPreview.durationSec); + const getMediaErrorDetails = () => { + if (!video.error) return null; + const mediaError = video.error as MediaError & { message?: string }; + return { + code: mediaError.code, + message: mediaError.message || '', + }; + }; + + const logTransitionIssue = (reason: string, error?: unknown) => { + console.error('Transition preview issue:', { + reason, + src: video.currentSrc || sourceUrl || '', + readyState: video.readyState, + networkState: video.networkState, + duration: video.duration, + configuredDurationSec: transitionPreview.durationSec, + reverseMode: transitionPreview.reverseMode, + mediaError: getMediaErrorDetails(), + error, + }); + }; + + const scheduleFinishByDuration = (durationSec: number) => { + if (!Number.isFinite(durationSec) || durationSec <= 0 || finishTimer) { + return; + } + finishTimer = setTimeout(() => { + finishPreview('duration-timer'); + }, durationSec * 1000 + 200); + }; const runReversePreview = () => { cleanupReverseFrame(); const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration - : Math.max(configuredDurationMs / 1000, 0.7); + : Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 + ? configuredDurationSec + : 0.7; const reverseSeconds = - transitionPreview.durationSec && transitionPreview.durationSec > 0 - ? transitionPreview.durationSec + Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 + ? configuredDurationSec : duration; const reverseMs = Math.max(reverseSeconds * 1000, 400); - const reverseRate = duration / reverseSeconds; + const reverseRate = reverseSeconds > 0 ? duration / reverseSeconds : 1; const startTime = performance.now(); video.pause(); video.currentTime = duration; @@ -2415,7 +2468,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { video.currentTime = nextTime; if (elapsed >= reverseMs || nextTime <= 0.001) { - finishPreview(); + finishPreview('reverse-complete'); return; } @@ -2425,30 +2478,145 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { reverseAnimationFrame.current = requestAnimationFrame(step); }; + const attemptPlay = () => { + if (transitionPreview.reverseMode === 'reverse') return; + video + .play() + .catch((playError) => { + logTransitionIssue('play-failed', playError); + }); + }; + + const loadSourceCandidate = async () => { + didStartPlayback = false; + + if (startWatchdogTimer) { + clearTimeout(startWatchdogTimer); + } + + try { + const playableSourceUrl = await resolvePlayableTransitionSource(); + if (didFinish) return; + + video.pause(); + cleanupReverseFrame(); + video.src = playableSourceUrl; + video.currentTime = 0; + video.load(); + + if (transitionPreview.reverseMode !== 'reverse') { + attemptPlay(); + } + + startWatchdogTimer = setTimeout(() => { + if (didStartPlayback || didFinish) return; + logTransitionIssue('playback-start-slow'); + attemptPlay(); + }, 12000); + } catch (error) { + logTransitionIssue('source-prepare-failed', error); + finishPreview('source-prepare-failed'); + } + }; + + if (!sourceUrl) { + logTransitionIssue('missing-source'); + finishPreview('missing-source'); + return () => { + cleanupReverseFrame(); + }; + } + const onLoadedMetadata = () => { - if (transitionPreview.reverseMode === 'reverse') { + if (didFinish) return; + if (transitionPreview.reverseMode === 'reverse' && !didStartPlayback) { + didStartPlayback = true; + if (startWatchdogTimer) { + clearTimeout(startWatchdogTimer); + startWatchdogTimer = null; + } runReversePreview(); return; } video.currentTime = 0; - video.play().catch((playError) => { - console.error('Transition preview playback failed:', playError); - fallbackTimer = setTimeout(finishPreview, configuredDurationMs); - }); + attemptPlay(); }; - const onEnded = () => finishPreview(); + const onCanPlay = () => { + if (didFinish) return; + attemptPlay(); + }; + + const onPlaying = () => { + if (didFinish) return; + didStartPlayback = true; + if (startWatchdogTimer) { + clearTimeout(startWatchdogTimer); + startWatchdogTimer = null; + } + + if (transitionPreview.reverseMode !== 'reverse') { + const mediaDurationSec = Number(video.duration); + const durationSec = + Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 + ? configuredDurationSec + : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 + ? mediaDurationSec + : NaN; + if (Number.isFinite(durationSec) && durationSec > 0) { + scheduleFinishByDuration(durationSec); + } + } + }; + + const onEnded = () => finishPreview('ended'); + + const onPlaybackError = (eventName: string, error?: unknown) => { + if (didFinish) return; + logTransitionIssue(eventName, error); + finishPreview(eventName); + }; + + const onError = () => onPlaybackError('video-error'); + const onAbort = () => onPlaybackError('video-abort'); + const onStalled = () => { + if (didFinish) return; + logTransitionIssue('video-stalled'); + }; video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('canplay', onCanPlay); + video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); - fallbackTimer = setTimeout(finishPreview, configuredDurationMs + 500); + video.addEventListener('error', onError); + video.addEventListener('abort', onAbort); + video.addEventListener('stalled', onStalled); + + hardTimeoutTimer = setTimeout(() => { + if (didFinish) return; + logTransitionIssue('hard-timeout'); + finishPreview('hard-timeout'); + }, 45000); + + void loadSourceCandidate(); return () => { video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('canplay', onCanPlay); + video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); + video.removeEventListener('error', onError); + video.removeEventListener('abort', onAbort); + video.removeEventListener('stalled', onStalled); + clearTimers(); cleanupReverseFrame(); - if (fallbackTimer) clearTimeout(fallbackTimer); + cleanupPreviewBlobUrl(); + if (!didFinish) { + video.pause(); + video.removeAttribute('src'); + video.load(); + } }; }, [transitionPreview]); @@ -3668,42 +3836,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { {transitionPreview && ( -
+
)}