Transition basic
This commit is contained in:
parent
07dccfbc37
commit
991ac75f32
@ -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<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 = () => {
|
||||
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) => {
|
||||
</div>
|
||||
|
||||
{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
|
||||
ref={transitionVideoRef}
|
||||
src={resolveAssetPlaybackUrl(
|
||||
transitionPreview.reverseMode === 'separate'
|
||||
? transitionPreview.reverseVideoUrl || ''
|
||||
: transitionPreview.videoUrl,
|
||||
)}
|
||||
className='h-full w-full object-cover'
|
||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={transitionPreview.reverseMode !== 'reverse'}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user