pransitions smoothing

This commit is contained in:
Dmitri 2026-03-24 08:30:20 +04:00
parent 4c41205225
commit 0728923dd1

View File

@ -290,6 +290,94 @@ const resolveAssetPlaybackUrl = (value?: string) => {
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
};
/**
* Wait for all images on a page to be decoded before switching.
* This eliminates white flash by ensuring images are ready to paint.
*/
const waitForPageImages = async (
page: TourPage | null,
timeoutMs = 2000,
): Promise<void> => {
if (!page) return;
const imageUrls: string[] = [];
// Background image
if (page.background_image_url) {
const url = resolveAssetPlaybackUrl(page.background_image_url);
if (url) imageUrls.push(url);
}
// Parse ui_schema_json for element images
try {
const uiSchema =
typeof page.ui_schema_json === 'string'
? JSON.parse(page.ui_schema_json)
: page.ui_schema_json;
const pageElements = Array.isArray(uiSchema?.elements)
? uiSchema.elements
: [];
pageElements.forEach((el: Record<string, unknown>) => {
const imageFields = ['iconUrl', 'imageUrl', 'mediaUrl', 'src'];
imageFields.forEach((field) => {
const value = el[field];
if (typeof value === 'string' && value) {
const url = resolveAssetPlaybackUrl(value);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
// Carousel slides
const slides = el.carouselSlides;
if (Array.isArray(slides)) {
slides.forEach((slide: Record<string, unknown>) => {
if (typeof slide.imageUrl === 'string' && slide.imageUrl) {
const url = resolveAssetPlaybackUrl(slide.imageUrl);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
}
// Cards
const cards = el.cards;
if (Array.isArray(cards)) {
cards.forEach((card: Record<string, unknown>) => {
if (typeof card.imageUrl === 'string' && card.imageUrl) {
const url = resolveAssetPlaybackUrl(card.imageUrl);
if (url && !imageUrls.includes(url)) imageUrls.push(url);
}
});
}
});
} catch {
// Ignore parse errors
}
if (imageUrls.length === 0) return;
// Decode all images in parallel with timeout
const decodePromises = imageUrls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img.decode().then(() => resolve()).catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
};
const readMediaDuration = (
playbackUrl: string,
mediaType: 'video' | 'audio',
@ -2137,10 +2225,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (!hasPlayableTransition) {
setPendingNavigationPageId('');
setTransitionPreview(null);
setActivePageId(targetPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
// Wait for target page images to decode before switching (eliminates white flash)
const targetPage = pages.find((p) => p.id === targetPageId) || null;
waitForPageImages(targetPage).then(() => {
setActivePageId(targetPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
});
return;
}
@ -2638,25 +2730,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
clearTimers();
cleanupReverseFrame();
setIsBufferingForReverse(false);
// Pause video but keep last frame visible
video.pause();
video.removeAttribute('src');
video.load();
// Don't cleanup blob URL here - might be reused for reverse
setTransitionPreview(null);
setPendingNavigationPageId((pendingPageId) => {
const nextPageId = String(pendingPageId || '').trim();
if (nextPageId) {
setActivePageId(nextPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
}
return '';
});
logger.info('Transition preview finished', {
reason,
src: video.currentSrc || sourceUrl || '',
});
setPendingNavigationPageId((pendingPageId) => {
const nextPageId = String(pendingPageId || '').trim();
if (nextPageId) {
// Wait for target page images to decode before hiding transition
const targetPage = pages.find((p) => p.id === nextPageId) || null;
waitForPageImages(targetPage).then(() => {
// Switch page first (while transition still covers it)
setActivePageId(nextPageId);
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
// Wait for React to render, then hide transition
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video.removeAttribute('src');
video.load();
setTransitionPreview(null);
});
});
});
} else {
// No pending navigation - just cleanup
video.removeAttribute('src');
video.load();
setTransitionPreview(null);
}
return '';
});
};
const configuredDurationSec = Number(transitionPreview.durationSec);