Transition basic
This commit is contained in:
parent
07dccfbc37
commit
991ac75f32
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user