Transition basic

This commit is contained in:
Flatlogic Bot 2026-03-19 12:41:43 +00:00
parent 07dccfbc37
commit 991ac75f32

View File

@ -239,53 +239,6 @@ const getAssetLabel = (asset: ProjectAsset) => {
const getAssetSourceValue = (asset: ProjectAsset) => const getAssetSourceValue = (asset: ProjectAsset) =>
String(asset.storage_key || asset.cdn_url || '').trim(); 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 formatDurationNote = (durationSec?: number | string | null) => {
const parsed = Number(durationSec); const parsed = Number(durationSec);
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown'; if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
@ -310,19 +263,8 @@ const resolveAssetPlaybackUrl = (value?: string) => {
if (normalized.startsWith('/file/download')) if (normalized.startsWith('/file/download'))
return `${baseURLApi}${normalized}`; return `${baseURLApi}${normalized}`;
if (normalized.startsWith('http://') || normalized.startsWith('https://')) { 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)}`;
}
return normalized; return normalized;
}
const normalizedPrivateUrl = normalized.replace(/^\/+/, ''); const normalizedPrivateUrl = normalized.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
@ -2364,7 +2306,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
if (!transitionPreview || !video) return; if (!transitionPreview || !video) return;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null; let startWatchdogTimer: ReturnType<typeof setTimeout> | null = null;
let finishTimer: ReturnType<typeof setTimeout> | null = null;
let hardTimeoutTimer: ReturnType<typeof setTimeout> | 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 = () => { const cleanupReverseFrame = () => {
if (reverseAnimationFrame.current !== null) { 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(); cleanupReverseFrame();
video.pause();
video.removeAttribute('src');
video.load();
cleanupPreviewBlobUrl();
setTransitionPreview(null); setTransitionPreview(null);
setPendingNavigationPageId((pendingPageId) => { setPendingNavigationPageId((pendingPageId) => {
const nextPageId = String(pendingPageId || '').trim(); const nextPageId = String(pendingPageId || '').trim();
@ -2386,25 +2405,59 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} }
return ''; return '';
}); });
console.info('Transition preview finished:', {
reason,
src: video.currentSrc || sourceUrl || '',
});
}; };
const configuredDurationMs = const configuredDurationSec = Number(transitionPreview.durationSec);
(transitionPreview.durationSec && transitionPreview.durationSec > 0 const getMediaErrorDetails = () => {
? transitionPreview.durationSec if (!video.error) return null;
: 0.7) * 1000; 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 = () => { const runReversePreview = () => {
cleanupReverseFrame(); cleanupReverseFrame();
const duration = const duration =
Number.isFinite(video.duration) && video.duration > 0 Number.isFinite(video.duration) && video.duration > 0
? video.duration ? video.duration
: Math.max(configuredDurationMs / 1000, 0.7); : Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: 0.7;
const reverseSeconds = const reverseSeconds =
transitionPreview.durationSec && transitionPreview.durationSec > 0 Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? transitionPreview.durationSec ? configuredDurationSec
: duration; : duration;
const reverseMs = Math.max(reverseSeconds * 1000, 400); const reverseMs = Math.max(reverseSeconds * 1000, 400);
const reverseRate = duration / reverseSeconds; const reverseRate = reverseSeconds > 0 ? duration / reverseSeconds : 1;
const startTime = performance.now(); const startTime = performance.now();
video.pause(); video.pause();
video.currentTime = duration; video.currentTime = duration;
@ -2415,7 +2468,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
video.currentTime = nextTime; video.currentTime = nextTime;
if (elapsed >= reverseMs || nextTime <= 0.001) { if (elapsed >= reverseMs || nextTime <= 0.001) {
finishPreview(); finishPreview('reverse-complete');
return; return;
} }
@ -2425,30 +2478,145 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
reverseAnimationFrame.current = requestAnimationFrame(step); 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 = () => { const onLoadedMetadata = () => {
if (transitionPreview.reverseMode === 'reverse') { if (didFinish) return;
if (transitionPreview.reverseMode === 'reverse' && !didStartPlayback) {
didStartPlayback = true;
if (startWatchdogTimer) {
clearTimeout(startWatchdogTimer);
startWatchdogTimer = null;
}
runReversePreview(); runReversePreview();
return; return;
} }
video.currentTime = 0; video.currentTime = 0;
video.play().catch((playError) => { attemptPlay();
console.error('Transition preview playback failed:', playError);
fallbackTimer = setTimeout(finishPreview, configuredDurationMs);
});
}; };
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('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded); 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 () => { return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded); video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
clearTimers();
cleanupReverseFrame(); cleanupReverseFrame();
if (fallbackTimer) clearTimeout(fallbackTimer); cleanupPreviewBlobUrl();
if (!didFinish) {
video.pause();
video.removeAttribute('src');
video.load();
}
}; };
}, [transitionPreview]); }, [transitionPreview]);
@ -3668,42 +3836,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
</div> </div>
{transitionPreview && ( {transitionPreview && (
<div className='fixed inset-0 z-50 bg-black/95 flex items-center justify-center'> <div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video <video
ref={transitionVideoRef} ref={transitionVideoRef}
src={resolveAssetPlaybackUrl( className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
transitionPreview.reverseMode === 'separate'
? transitionPreview.reverseVideoUrl || ''
: transitionPreview.videoUrl,
)}
className='h-full w-full object-cover'
muted muted
playsInline playsInline
autoPlay={transitionPreview.reverseMode !== 'reverse'}
preload='auto' preload='auto'
disablePictureInPicture
/> />
<div className='absolute bottom-4 left-4 text-xs text-white/80'>
{transitionPreview.title}
</div>
<button
type='button'
className='absolute top-4 right-4 rounded bg-white/20 px-3 py-1 text-xs text-white hover:bg-white/30'
onClick={() => {
setTransitionPreview(null);
setPendingNavigationPageId((pendingPageId) => {
const nextPageId = String(pendingPageId || '').trim();
if (nextPageId) {
setActivePageId(nextPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
}
return '';
});
}}
>
Close
</button>
</div> </div>
)} )}