simplified video preloading

This commit is contained in:
Dmitri 2026-05-04 23:09:37 +02:00
parent 2073bee244
commit 4634ad9207
15 changed files with 374 additions and 316 deletions

View File

@ -143,8 +143,8 @@ app.use('/api/file/upload-sessions', uploadLimiter);
app.use('/api/file', fileRoutes);
// Body parser for all other routes
app.use(bodyParser.json({ limit: '1mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' }));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth = (req, res, next) => {

View File

@ -11,6 +11,7 @@ const jwtAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware for public GET access.
* Marks GET requests as public runtime requests for permission bypass.
* MUST run before checkCrudPermissions to set the flag first.
*/
const allowPublicRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
@ -19,7 +20,8 @@ const allowPublicRead = (req, _res, next) => {
return next();
};
// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest)
// Apply public read first, then CRUD permission checks
router.use(allowPublicRead);
router.use(checkCrudPermissions('global_transition_defaults'));
/**
@ -35,7 +37,6 @@ router.use(checkCrudPermissions('global_transition_defaults'));
*/
router.get(
'/',
allowPublicRead,
wrapAsync(async (_req, res) => {
const payload = await Global_transition_defaultsDBApi.findOne();
res.status(200).send(payload);
@ -62,7 +63,6 @@ router.get(
*/
router.get(
'/:id',
allowPublicRead,
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid global_transition_defaults id');

View File

@ -8,6 +8,17 @@ const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware: Mark authenticated reads as public to bypass permission check.
* Constructor page users are authenticated but may not have explicit permission.
*/
const allowAuthenticatedRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
req.isRuntimePublicRequest = true;
}
return next();
};
/**
* Middleware: Production GET is public, everything else requires JWT.
* Determines public access from URL path, not headers.
@ -18,8 +29,7 @@ const requireProductionOrAuth = (req, res, next) => {
const isReadOnly = ['GET', 'OPTIONS'].includes(req.method);
if (isProduction && isReadOnly) {
// Public access for production GET - mark for permission bypass
req.isRuntimePublicRequest = true;
// Public access for production GET
return next();
}
@ -27,7 +37,8 @@ const requireProductionOrAuth = (req, res, next) => {
return jwtAuth(req, res, next);
};
// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest)
// Mark reads as public first, then apply CRUD permission checks
router.use(allowAuthenticatedRead);
router.use(checkCrudPermissions('project_transition_settings'));
/**

View File

@ -35,9 +35,20 @@ const getCachePath = (privateUrl) => {
/**
* Check if a cached file exists and is still valid
* Returns invalid if a download is in progress (.downloading file exists)
*/
const getCachedFile = async (cachePath) => {
try {
// Check if download is in progress - if so, don't use cache
const downloadingPath = cachePath + '.downloading';
try {
await fs.promises.access(downloadingPath);
// Download in progress, cache is not valid
return { stats: null, valid: false };
} catch {
// No download in progress, continue checking cache
}
const stats = await fs.promises.stat(cachePath);
const age = (Date.now() - stats.mtimeMs) / 1000;
if (age < config.s3CacheMaxAge) {
@ -410,16 +421,6 @@ const downloadFile = async (req, res) => {
res.setHeader('Content-Type', mimeTypes[ext]);
}
log.debug(
{
provider,
privateUrl,
duration: Date.now() - startTime,
cached: true,
},
'File served from cache',
);
// Stream from cache
return fs.createReadStream(cachePath).pipe(res);
}
@ -436,9 +437,14 @@ const downloadFile = async (req, res) => {
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
if (useCache && typeof result.body.pipe === 'function') {
// Stream to both response and cache file
// Stream to both response and cache file using atomic writes
await ensureCacheDir();
const cacheStream = fs.createWriteStream(cachePath);
const tempPath = cachePath + '.tmp';
const downloadingPath = cachePath + '.downloading';
// Create marker file to indicate download in progress
await fs.promises.writeFile(downloadingPath, '');
const cacheStream = fs.createWriteStream(tempPath);
// Use pipeline for proper error handling
const { PassThrough } = require('stream');
@ -448,8 +454,43 @@ const downloadFile = async (req, res) => {
passThrough.pipe(res);
passThrough.pipe(cacheStream);
cacheStream.on('error', (err) => {
// Track bytes written to verify complete download
let bytesWritten = 0;
passThrough.on('data', (chunk) => {
bytesWritten += chunk.length;
});
cacheStream.on('finish', async () => {
try {
// Verify we got the expected size
const expectedSize = result.contentLength;
if (expectedSize && bytesWritten !== expectedSize) {
log.warn(
{ cachePath, bytesWritten, expectedSize },
'Cache file size mismatch, discarding',
);
await fs.promises.unlink(tempPath).catch(() => {});
} else {
// Atomic rename: temp → final
await fs.promises.rename(tempPath, cachePath);
log.debug(
{ cachePath, bytesWritten },
'Cache file written successfully',
);
}
} catch (err) {
log.warn({ err, cachePath }, 'Failed to finalize cache file');
} finally {
// Remove download marker
await fs.promises.unlink(downloadingPath).catch(() => {});
}
});
cacheStream.on('error', async (err) => {
log.warn({ err, cachePath }, 'Failed to write to cache');
// Cleanup temp and marker files
await fs.promises.unlink(tempPath).catch(() => {});
await fs.promises.unlink(downloadingPath).catch(() => {});
});
} else if (typeof result.body.pipe === 'function') {
result.body.pipe(res);
@ -457,12 +498,17 @@ const downloadFile = async (req, res) => {
const bytes = await result.body.transformToByteArray();
const buffer = Buffer.from(bytes);
// Cache the buffer
// Cache the buffer atomically (write to temp, then rename)
if (useCache) {
await ensureCacheDir();
fs.promises.writeFile(cachePath, buffer).catch((err) => {
log.warn({ err, cachePath }, 'Failed to write to cache');
});
const tempPath = cachePath + '.tmp';
fs.promises
.writeFile(tempPath, buffer)
.then(() => fs.promises.rename(tempPath, cachePath))
.catch((err) => {
log.warn({ err, cachePath }, 'Failed to write to cache');
fs.promises.unlink(tempPath).catch(() => {});
});
}
res.send(buffer);
@ -700,11 +746,6 @@ const initUploadSession = async (req, res) => {
contentType,
});
log.info(
{ sessionId, folder, filename, totalChunks, size },
'Upload session initialized',
);
return res.status(200).send({
sessionId,
uploadedChunks: [],
@ -859,6 +900,7 @@ const finalizeUploadSession = async (req, res) => {
if (provider === 's3') {
const s3 = getS3Provider();
const data = fs.readFileSync(assembledPath);
const result = await s3.upload(privateUrl, data, {
contentType: session.contentType,
});

View File

@ -6,11 +6,18 @@
* Supports custom video playback settings (autoplay, loop, muted, start/end time).
*/
import React, { useRef, useEffect } from 'react';
import React, {
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from 'react';
import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { scheduleAfterPaint } from '../../lib/browserUtils';
import { baseURLApi } from '../../config';
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
@ -74,6 +81,32 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Block autoplay if video already played this session (when loop=false)
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay;
// Video error state for fallback to proxy URL
const [videoError, setVideoError] = useState(false);
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => {
if (!backgroundVideoUrl) return undefined;
if (videoError && videoStoragePath) {
// Fallback to backend proxy (bypasses CORS issues)
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
}
return backgroundVideoUrl;
}, [backgroundVideoUrl, videoStoragePath, videoError]);
// Reset error state when video URL changes
useEffect(() => {
setVideoError(false);
}, [backgroundVideoUrl]);
const handleVideoError = useCallback(() => {
if (!videoError && videoStoragePath) {
// eslint-disable-next-line no-console
console.warn('[CanvasBackground] Video error, falling back to proxy URL');
setVideoError(true);
}
}, [videoError, videoStoragePath]);
const handleLoad = () => {
// Wait for paint to ensure background is actually rendered before reporting ready.
// This prevents the transition overlay from being removed before the background is visible.
@ -106,15 +139,27 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onBackgroundReady?.();
};
// Timeout fallback - report ready after 5 seconds even if video hasn't started
// Prevents infinite loading on slow networks or video initialization failures
const timeout = setTimeout(() => {
// eslint-disable-next-line no-console
console.warn(
'[CanvasBackground] Video ready timeout, reporting ready anyway',
);
reportVideoReady();
}, 5000);
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
const videoWithRVFC = video as HTMLVideoElementWithRVFC;
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
videoWithRVFC.requestVideoFrameCallback(() => {
clearTimeout(timeout);
reportVideoReady();
});
} else {
// Fallback: use playing event + scheduleAfterPaint
const onPlaying = () => {
clearTimeout(timeout);
scheduleAfterPaint(() => {
reportVideoReady();
});
@ -122,9 +167,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
video.addEventListener('playing', onPlaying, { once: true });
return () => {
clearTimeout(timeout);
video.removeEventListener('playing', onPlaying);
};
}
return () => clearTimeout(timeout);
}, [backgroundVideoUrl, onBackgroundReady]);
// When endTime is set, we disable native loop and handle it via the hook
@ -176,18 +224,21 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
Note: muted attribute is always true for iOS autoplay compatibility.
Actual muted state is controlled via useBackgroundVideoPlayback hook
which sets video.muted property via JavaScript (useEffect).
webkit-playsinline is legacy attribute for older iOS versions. */}
webkit-playsinline is legacy attribute for older iOS versions.
preload="metadata" is required for iOS Safari video initialization. */}
{backgroundVideoUrl && (
<video
ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
src={videoSrc}
preload='metadata'
autoPlay={effectiveAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
webkit-playsinline=''
onError={handleVideoError}
/>
)}

View File

@ -24,6 +24,8 @@ interface TransitionPreviewOverlayProps {
videoFit?: 'contain' | 'cover';
/** Additional opacity value for fade-out effects (0-1) */
opacity?: number;
/** Forces video element remount when changed - prevents decoder state issues with pre-created blob URLs */
videoKey?: string;
}
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
@ -33,6 +35,7 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
letterboxStyles,
videoFit = 'contain',
opacity,
videoKey,
}) => {
if (!isActive) return null;
@ -54,7 +57,9 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Video element - container handles visibility, video is always opaque */}
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
<video
key={videoKey}
ref={videoRef}
className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'

View File

@ -300,6 +300,7 @@ export default function RuntimePresentation({
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator?.getReadyBlob,
},
});
@ -818,6 +819,7 @@ export default function RuntimePresentation({
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
{transitionPreview && (
<TransitionPreviewOverlay
videoKey={transitionPreview.videoUrl}
videoRef={transitionVideoRef}
isActive={true}
isBuffering={

View File

@ -0,0 +1,55 @@
/**
* Transition Playback Configuration
*
* Centralized configuration for transition video playback timing.
* These values control when the transition finishes relative to the video end
* to prevent black frames while maximizing video playback time.
*/
export const TRANSITION_CONFIG = {
/**
* Duration timer offset (milliseconds before video end)
* The video playback finishes this many ms before the actual video end.
* Used as a fallback when other detection methods fail.
*/
finishBeforeEndMs: {
safari: 100,
firefox: 100,
default: 100,
},
/**
* TimeUpdate safety buffer (seconds before video end)
* When timeupdate event fires with currentTime >= (duration - buffer),
* the transition is considered finished.
* Safari needs a slightly larger buffer due to less frequent timeupdate events.
*/
timeUpdateSafetyBuffer: {
safari: 0.15,
default: 0.1,
},
/**
* RequestVideoFrameCallback threshold (seconds before video end)
* When using high-precision frame callbacks, finish this many seconds
* before the video end to prevent showing black/fade frames.
*/
rvfcThreshold: 0.1,
/**
* Video loading timeouts
*/
timeouts: {
loadTimeoutMs: 10000, // Max time to wait for video to load
playTimeoutMs: 5000, // Max time to wait for play to start
},
/**
* Retry settings
*/
retry: {
maxDecodeRetries: 1, // Safari decode error retry attempts
},
} as const;
export type TransitionConfig = typeof TRANSITION_CONFIG;

View File

@ -420,7 +420,6 @@ export function useOfflineMode(
? 50
: 75,
storageKey: asset.storageKey,
createBlobUrl: true, // Create blob URL for instant display
persist: true, // Persist for resume after page refresh
})
.catch((err) => {

View File

@ -60,6 +60,8 @@ interface UsePreloadOrchestratorResult {
isUrlPreloaded: (url: string) => Promise<boolean>;
/** Instant lookup - returns decoded blob URL or null */
getReadyBlobUrl: (url: string) => string | null;
/** Instant lookup - returns raw Blob for creating fresh blob URLs (used by transitions) */
getReadyBlob: (url: string) => Blob | null;
/** Whether all neighbor page backgrounds are ready for instant navigation */
areNeighborBackgroundsReady: boolean;
}
@ -216,7 +218,6 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(item.assetType),
priority: item.priority,
storageKey,
createBlobUrl: true, // Create blob URL for instant display
persist: false, // Don't persist for online preload (in-memory only)
})
.then(() => {
@ -334,6 +335,12 @@ export function usePreloadOrchestrator(
return downloadManager.getReadyBlobUrl(url);
}, []);
// Instant lookup - returns raw Blob for creating fresh blob URLs (O(1) Map lookup)
// Used by transitions to avoid decoder state issues with pre-created blob URLs
const getReadyBlob = useCallback((url: string): Blob | null => {
return downloadManager.getReadyBlob(url);
}, []);
// Initialize ready blob URLs from cache for current page's assets
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
useEffect(() => {
@ -398,7 +405,7 @@ export function usePreloadOrchestrator(
const fullUrl = resolveAssetPlaybackUrl(storagePath);
const hasAsset = await StorageManager.hasAsset(storageKey);
if (hasAsset) {
// Use DownloadManager.addJob with createBlobUrl to create the blob URL
// DownloadManager will create blob URL from cached asset
await downloadManager.addJob({
assetId: `init-${storageKey}`,
projectId: '',
@ -407,7 +414,6 @@ export function usePreloadOrchestrator(
variantType: 'original',
assetType: 'other',
storageKey,
createBlobUrl: true,
persist: false,
});
}
@ -523,44 +529,17 @@ export function usePreloadOrchestrator(
};
// Two-phase preloading: current page first, then neighbors
// All assets use full download with blob URLs for reliable playback
// (presigned URL streaming fails on mobile Safari/Chrome)
const addAssetsToQueue = async (
presignedUrls: Record<string, string> = {},
) => {
// Helper to determine max bytes for partial preload (online mode only)
// IMPORTANT: Only applies to NEIGHBOR pages, not the current page
// Transitions always use partial preload (regardless of page)
const getMaxBytesForAsset = (
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
isNeighborPage: boolean,
): number | undefined => {
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
// Transitions always use partial preload - they need just enough to start quickly
if (assetType === 'transition') {
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
}
// Current page assets should be fully downloaded for best UX
if (!isNeighborPage) return undefined;
// Neighbor page media uses partial preload
switch (assetType) {
case 'video':
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
case 'audio':
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
default:
return undefined; // Images need full download for display
}
};
// Helper to create download job
const createDownloadJob = (
id: string,
storageKey: string,
priority: number,
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
pageId: string,
): Promise<void> | null => {
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (!resolvedUrl) return null;
@ -574,14 +553,7 @@ export function usePreloadOrchestrator(
preloadedUrls.add(normalizedKey);
// Determine if partial preload applies (neighbor pages only, media files only)
const isNeighborPage = pageId !== currentPageId;
const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage);
// Create blob URL for images (instant navigation) and full downloads
// Partial downloads (video/audio/transition) use presigned URL directly for playback
const createBlobUrl = assetType === 'image' || maxBytes === undefined;
// DownloadManager automatically handles presigned URL → proxy fallback
// DownloadManager always creates blob URLs for reliable playback
return downloadManager
.addJob({
assetId: id,
@ -592,9 +564,7 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(assetType),
priority,
storageKey: normalizedKey,
createBlobUrl,
persist: false,
maxBytes,
})
.then(() => {
if (isPresignedUrl(resolvedUrl)) {
@ -624,7 +594,6 @@ export function usePreloadOrchestrator(
currentPage.background_image_url,
PRELOAD_CONFIG.priority.currentPage + 200,
'image',
currentPageId,
);
if (job) currentPageImageJobs.push(job);
}
@ -637,7 +606,6 @@ export function usePreloadOrchestrator(
currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150,
'video',
currentPageId,
);
// Not pushed to awaited jobs - video streams on its own
}
@ -647,7 +615,6 @@ export function usePreloadOrchestrator(
currentPage.background_audio_url,
PRELOAD_CONFIG.priority.currentPage + 100,
'audio',
currentPageId,
);
// Not pushed to awaited jobs - audio streams on its own
}
@ -669,10 +636,11 @@ export function usePreloadOrchestrator(
// ============================================
// PHASE 2: Preload everything else (don't wait)
// - Current page element assets (full downloads)
// - Neighbor page backgrounds (partial preload for video/audio)
// - Neighbor page element assets (partial preload for video/audio)
// - Transition videos from page links (partial preload - 3MB)
// - Current page element assets
// - Neighbor page backgrounds
// - Neighbor page element assets
// - Transition videos from page links
// All assets use full download with blob URLs for mobile compatibility
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions');
@ -686,7 +654,6 @@ export function usePreloadOrchestrator(
asset.url,
asset.priority,
asset.assetType,
asset.pageId,
);
});
@ -700,7 +667,6 @@ export function usePreloadOrchestrator(
asset.url,
asset.priority,
asset.assetType,
asset.pageId,
);
});
@ -714,7 +680,6 @@ export function usePreloadOrchestrator(
page.background_image_url,
PRELOAD_CONFIG.priority.neighborBase + 100,
'image',
pageId,
);
}
if (page?.background_video_url) {
@ -723,7 +688,6 @@ export function usePreloadOrchestrator(
page.background_video_url,
PRELOAD_CONFIG.priority.neighborBase + 50,
'video',
pageId,
);
}
if (page?.background_audio_url) {
@ -732,7 +696,6 @@ export function usePreloadOrchestrator(
page.background_audio_url,
PRELOAD_CONFIG.priority.neighborBase + 30,
'audio',
pageId,
);
}
});
@ -787,6 +750,7 @@ export function usePreloadOrchestrator(
getCachedBlobUrl,
isUrlPreloaded,
getReadyBlobUrl,
getReadyBlob,
areNeighborBackgroundsReady,
};
}

View File

@ -25,6 +25,7 @@ import {
} from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
import { TRANSITION_CONFIG } from '../config/transition.config';
export type ReverseMode = 'none' | 'separate';
@ -61,6 +62,8 @@ export interface UseTransitionPlaybackOptions {
preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => string | null;
/** Get raw Blob for creating fresh blob URLs (avoids decoder state issues) */
getReadyBlob?: (url: string) => Blob | null;
};
}
@ -92,13 +95,14 @@ const DEFAULT_TIMEOUTS = {
* @returns Finish offset in milliseconds before video end
*/
const getFinishBeforeEndMs = (): number => {
const { finishBeforeEndMs } = TRANSITION_CONFIG;
if (isSafari()) {
return 350;
return finishBeforeEndMs.safari;
}
if (isFirefox()) {
return 300;
return finishBeforeEndMs.firefox;
}
return 300;
return finishBeforeEndMs.default;
};
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
@ -393,21 +397,35 @@ export function useTransitionPlayback(
};
const resolvePlayableSource = async (): Promise<string> => {
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl;
const getReadyBlob = preloadRef.current?.getReadyBlob;
const currentStorageKey = storageKey;
// 1. Try storage key lookup first (most reliable for cache hits)
if (getReadyBlobUrl && currentStorageKey) {
const readyUrl = getReadyBlobUrl(currentStorageKey);
if (readyUrl) {
logger.info('Using ready blob URL from storage key', {
// DEBUG: Disabled - network URL test showed issue is not with blob
// const USE_NETWORK_URL_DEBUG = true;
// 1. Try to get raw Blob and create FRESH blob URL (avoids decoder state issues)
// Pre-created blob URLs cause video jumping (0.2s → 3.2s) due to browser decoder caching
if (getReadyBlob && currentStorageKey) {
const blob = getReadyBlob(currentStorageKey);
if (blob) {
// Revoke previous blob URL if exists
if (lastLoadedBlobUrlRef.current) {
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
}
// Create fresh blob URL - this is synchronous and O(1)
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created fresh blob URL from cached blob', {
storageKey: currentStorageKey.slice(-50),
blobSize: blob.size,
blobType: blob.type,
});
return readyUrl;
return freshBlobUrl;
}
}
// 2. Try cached blob URL by storage key
// 2. Try cached blob URL by storage key (async fallback)
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && currentStorageKey) {
try {
@ -450,14 +468,20 @@ export function useTransitionPlayback(
return sourceUrl;
}
// 4. Try ready blob URL by resolved URL
if (getReadyBlobUrl) {
const readyUrl = getReadyBlobUrl(sourceUrl);
if (readyUrl) {
logger.info('Using ready blob URL from resolved URL', {
// 4. Try raw Blob by resolved URL and create fresh blob URL
if (getReadyBlob) {
const blob = getReadyBlob(sourceUrl);
if (blob) {
if (lastLoadedBlobUrlRef.current) {
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
}
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created fresh blob URL from resolved URL blob', {
url: sourceUrl.slice(-50),
});
return readyUrl;
return freshBlobUrl;
}
}
@ -566,6 +590,16 @@ export function useTransitionPlayback(
lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.current = playableSourceUrl;
// Debug: log video state
logger.info('Video state after load', {
src: playableSourceUrl.slice(-50),
currentTime: video.currentTime,
duration: video.duration,
playbackRate: video.playbackRate,
readyState: video.readyState,
paused: video.paused,
});
attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => {
@ -581,6 +615,12 @@ export function useTransitionPlayback(
const onLoadedMetadata = () => {
if (didFinishRef.current) return;
logger.info('onLoadedMetadata', {
duration: video.duration.toFixed(2),
currentTime: video.currentTime.toFixed(2),
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
});
video.currentTime = 0;
attemptPlay();
};
@ -627,12 +667,18 @@ export function useTransitionPlayback(
if (didFinishRef.current) return;
const duration = video.duration;
// Finish 300ms before end - gives margin for black/fade frames
// that some videos have in the last 100-200ms
const { rvfcThreshold } = TRANSITION_CONFIG;
// Finish shortly before end - minimal margin for black frames
if (
Number.isFinite(duration) &&
metadata.mediaTime >= duration - 0.3
metadata.mediaTime >= duration - rvfcThreshold
) {
// Debug: log before finishing
logger.info('rvfc-end triggered', {
mediaTime: metadata.mediaTime.toFixed(2),
duration: duration.toFixed(2),
threshold: (duration - rvfcThreshold).toFixed(2),
});
finishPlayback('rvfc-end');
return;
}
@ -676,9 +722,24 @@ export function useTransitionPlayback(
const duration = video.duration;
if (!Number.isFinite(duration)) return;
// Large buffer since timeupdate is infrequent
// Safari: 600ms, Others: 400ms
const safetyBuffer = isSafari() ? 0.6 : 0.4;
// Small buffer to let video play closer to the end
const { timeUpdateSafetyBuffer } = TRANSITION_CONFIG;
const safetyBuffer = isSafari()
? timeUpdateSafetyBuffer.safari
: timeUpdateSafetyBuffer.default;
// Debug: log full video state to diagnose jumping
logger.info('onTimeUpdate', {
currentTime: video.currentTime.toFixed(2),
duration: duration.toFixed(2),
threshold: (duration - safetyBuffer).toFixed(2),
willFinish: video.currentTime >= duration - safetyBuffer,
readyState: video.readyState,
paused: video.paused,
ended: video.ended,
seeking: video.seeking,
playbackRate: video.playbackRate,
});
if (video.currentTime >= duration - safetyBuffer) {
finishPlayback('timeupdate-end');
@ -712,7 +773,23 @@ export function useTransitionPlayback(
logIssue('video-stalled');
};
// Debug: track seeking
const onSeeking = () => {
logger.info('Video seeking', {
currentTime: video.currentTime.toFixed(2),
duration: video.duration.toFixed(2),
});
};
const onSeeked = () => {
logger.info('Video seeked', {
currentTime: video.currentTime.toFixed(2),
});
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('seeking', onSeeking);
video.addEventListener('seeked', onSeeked);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded);
@ -738,6 +815,8 @@ export function useTransitionPlayback(
video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
video.removeEventListener('seeking', onSeeking);
video.removeEventListener('seeked', onSeeked);
clearTimers();
};
}, [

View File

@ -229,22 +229,8 @@ export class AssetCacheService {
if (!downloadUrl) continue;
// Determine download parameters based on mode
const isOnlineMode = mode === 'online';
const isCurrentPageAsset =
currentPageId && asset.pageId === currentPageId;
// Online mode: use partial downloads for neighbor page media
// Offline mode: always full downloads
const maxBytes = this.getMaxBytesForAsset(
asset.assetType,
isOnlineMode,
!isCurrentPageAsset,
);
// Create blob URL for images (instant navigation) and full downloads
const createBlobUrl =
asset.assetType === 'image' || maxBytes === undefined;
// Always full downloads with blob URLs for reliable playback
// (presigned URL streaming fails on mobile Safari/Chrome)
try {
await downloadManager
@ -257,9 +243,7 @@ export class AssetCacheService {
assetType: asset.assetType,
priority: asset.priority,
storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline', // Only persist for offline mode
maxBytes,
})
.then(() => {
if (isPresignedUrl(downloadUrl)) {
@ -287,9 +271,7 @@ export class AssetCacheService {
assetType: asset.assetType,
priority: asset.priority,
storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline',
maxBytes,
});
} catch {
// Ignore retry failures
@ -311,39 +293,6 @@ export class AssetCacheService {
});
}
/**
* Determine max bytes for partial preload (online mode only)
*/
private static getMaxBytesForAsset(
assetType: AssetType,
isOnlineMode: boolean,
isNeighborPage: boolean,
): number | undefined {
// Offline mode: always full downloads
if (!isOnlineMode) return undefined;
// Partial preload disabled
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
// Transitions always use partial preload
if (assetType === 'transition') {
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
}
// Current page assets should be fully downloaded
if (!isNeighborPage) return undefined;
// Neighbor page media uses partial preload
switch (assetType) {
case 'video':
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
case 'audio':
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
default:
return undefined; // Images need full download for display
}
}
/**
* Clear project cache
*/

View File

@ -40,10 +40,7 @@ interface DownloadJob {
retryCount: number;
addedAt: number;
storageKey: string; // Canonical storage key for consistent caching
createBlobUrl?: boolean; // Create decoded blob URL after download
persist?: boolean; // Persist to IndexedDB for resume (default: true)
maxBytes?: number; // Partial download limit (undefined = full download)
isPartial?: boolean; // Whether this was a partial download (for tracking)
usedProxyFallback?: boolean; // Whether we've already tried proxy URL fallback
abortController?: AbortController;
resolve?: () => void;
@ -59,9 +56,9 @@ class DownloadManagerClass {
// Blob URL cache for instant lookup (storageKey → blobUrl)
private readyBlobUrls: Map<string, string> = new Map();
// Track partial downloads completed in this session (not persisted)
// Prevents re-downloading same partial content on repeated page visits
private partialDownloadsReady: Set<string> = new Set();
// Raw Blob cache for creating fresh blob URLs (storageKey → Blob)
// Used by transitions to avoid decoder state issues with pre-created blob URLs
private readyBlobs: Map<string, Blob> = new Map();
private config = {
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
@ -73,6 +70,7 @@ class DownloadManagerClass {
/**
* Add a download job to the queue
* Always creates blob URLs for reliable playback on all devices
*/
async addJob(params: {
assetId: string;
@ -83,50 +81,21 @@ class DownloadManagerClass {
assetType: AssetType;
priority?: number;
storageKey?: string; // Optional, will extract if not provided
createBlobUrl?: boolean; // Create blob URL after download
persist?: boolean; // Persist to IndexedDB for resume (default: true)
maxBytes?: number; // Download limit in bytes (for partial preload)
}): Promise<void> {
const storageKey = params.storageKey || extractStoragePath(params.url);
const isPartialDownload = params.maxBytes !== undefined;
// For partial downloads, check session cache first (fast path)
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
logger.info(
'[DownloadManager] Partial download already ready (session)',
{
storageKey: storageKey.slice(-50),
},
);
return;
}
// Check cache status using getAssetInfo for smart handling
// Check cache status - if fully cached, create blob URL and return
const assetInfo = await StorageManager.getAssetInfo(storageKey);
if (assetInfo?.exists) {
if (isPartialDownload) {
// For partial downloads, any cached version is sufficient
this.partialDownloadsReady.add(storageKey);
return;
if (assetInfo?.exists && !assetInfo.isPartial) {
// Fully cached - create blob URL if not already ready
if (!this.readyBlobUrls.has(storageKey)) {
await this.createBlobUrlFromCache(storageKey);
}
if (!assetInfo.isPartial) {
// Fully cached - create blob URL if requested
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
await this.createBlobUrlFromCache(storageKey);
}
return;
}
// Asset exists but is partial - full download requested
// NOTE: Don't log here - will log after deduplication check below
return;
}
// Track if this is an upgrade request (for logging after dedup check)
const isUpgradingPartial =
assetInfo?.exists && assetInfo.isPartial && !isPartialDownload;
// Check if already in queue (use storageKey for deduplication)
if (
this.queue.some((j) => j.storageKey === storageKey) ||
@ -134,27 +103,10 @@ class DownloadManagerClass {
(j) => j.storageKey === storageKey,
)
) {
return; // Already queued - no log needed
}
// Now we know this job will actually be added - safe to log
if (isUpgradingPartial) {
logger.info('[DownloadManager] Upgrading partial to full download', {
storageKey: storageKey.slice(-50),
});
return; // Already queued
}
return new Promise((resolve, reject) => {
// For partial downloads, don't persist and don't create blob URL
// (video will play from presigned URL, browser handles buffering)
const isPartialDownload = params.maxBytes !== undefined;
const shouldPersist = isPartialDownload
? false
: (params.persist ?? true);
const shouldCreateBlobUrl = isPartialDownload
? false
: (params.createBlobUrl ?? false);
const job: DownloadJob = {
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
assetId: params.assetId,
@ -173,10 +125,7 @@ class DownloadManagerClass {
retryCount: 0,
addedAt: Date.now(),
storageKey,
createBlobUrl: shouldCreateBlobUrl,
persist: shouldPersist,
maxBytes: params.maxBytes,
isPartial: isPartialDownload,
persist: params.persist ?? true,
resolve,
reject,
};
@ -274,39 +223,23 @@ class DownloadManagerClass {
});
try {
// Build request headers - use Range header for partial downloads
const headers: HeadersInit = {};
if (job.maxBytes) {
headers['Range'] = `bytes=0-${job.maxBytes - 1}`;
logger.info('[DownloadManager] Partial download requested', {
url: job.url.slice(-50),
maxBytes: job.maxBytes,
});
}
const response = await fetch(job.url, {
signal: job.abortController.signal,
headers,
});
// Accept both 200 OK and 206 Partial Content
if (!response.ok && response.status !== 206) {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentLength = response.headers.get('content-length');
job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
// For partial downloads, track if we reached the limit
const isPartialResponse = response.status === 206 || job.maxBytes;
let blob: Blob;
if (response.body) {
// Stream with progress tracking
const reader = response.body.getReader();
const chunks: BlobPart[] = [];
let reachedLimit = false;
while (true) {
const { done, value } = await reader.read();
@ -315,22 +248,6 @@ class DownloadManagerClass {
chunks.push(value);
job.bytesLoaded += value.length;
// Check if we've reached the maxBytes limit
if (job.maxBytes && job.bytesLoaded >= job.maxBytes) {
reachedLimit = true;
logger.info('[DownloadManager] Reached partial download limit', {
bytesLoaded: job.bytesLoaded,
maxBytes: job.maxBytes,
});
// Cancel the remaining download gracefully
try {
await reader.cancel();
} catch {
// Ignore cancel errors - stream may already be closed
}
break;
}
job.progress =
job.totalBytes > 0
? Math.round((job.bytesLoaded / job.totalBytes) * 100)
@ -357,11 +274,6 @@ class DownloadManagerClass {
type:
response.headers.get('content-type') || 'application/octet-stream',
});
// For partial downloads, mark as complete even if we didn't get everything
if (reachedLimit || isPartialResponse) {
job.progress = 100; // Consider partial download as "complete"
}
} else {
// No streaming, get blob directly
blob = await response.blob();
@ -371,34 +283,25 @@ class DownloadManagerClass {
}
// Store the asset using canonical storage key
// Partial downloads are now stored with isPartial: true for offline mode awareness
await StorageManager.storeAsset(job.storageKey, blob, {
id: job.assetId,
projectId: job.projectId,
filename: job.filename,
variantType: job.variantType,
assetType: job.assetType,
isPartial: job.isPartial || false,
isPartial: false,
});
if (job.isPartial) {
// Mark partial download as ready in session cache
this.partialDownloadsReady.add(job.storageKey);
// Store ORIGINAL blob directly (bypass cache retrieval to avoid potential corruption)
this.readyBlobs.set(job.storageKey, blob);
logger.info('[DownloadManager] Stored original blob', {
storageKey: job.storageKey.slice(-50),
blobSize: blob.size,
blobType: blob.type,
});
// Register with Service Worker for full-file caching during playback
// When the browser fetches the full media, SW will cache it using the storage key
this.registerUrlForCaching(job.url, job.storageKey);
logger.info('[DownloadManager] Partial download complete (stored)', {
storageKey: job.storageKey.slice(-50),
bytesLoaded: job.bytesLoaded,
});
} else {
// Create blob URL if requested (full downloads only)
if (job.createBlobUrl) {
await this.createBlobUrlFromCache(job.storageKey);
}
}
// Always create blob URL for reliable playback on all devices
await this.createBlobUrlFromCache(job.storageKey);
// Mark as completed
job.status = 'completed';
@ -625,7 +528,6 @@ class DownloadManagerClass {
retryCount: item.retryCount,
addedAt: item.addedAt,
storageKey,
createBlobUrl: true, // Create blob URL for resumed downloads
persist: true,
};
@ -649,6 +551,16 @@ class DownloadManagerClass {
return this.readyBlobUrls.get(storageKey) || null;
}
/**
* Get raw Blob for creating fresh blob URLs (O(1) lookup).
* Used by transitions to avoid decoder state issues with pre-created blob URLs.
* Returns null if blob not cached.
*/
getReadyBlob(url: string): Blob | null {
const storageKey = extractStoragePath(url);
return this.readyBlobs.get(storageKey) || null;
}
/**
* Cache an externally fetched blob and register blob URL for instant lookup.
* Use this when fetching via XHR (e.g., transition playback) to enable caching.
@ -670,6 +582,9 @@ class DownloadManagerClass {
assetType: metadata.assetType,
});
// Store raw Blob for transitions (they need fresh blob URLs each playback)
this.readyBlobs.set(storageKey, blob);
// Create blob URL and register for instant O(1) lookup
const blobUrl = URL.createObjectURL(blob);
this.readyBlobUrls.set(storageKey, blobUrl);
@ -701,6 +616,13 @@ class DownloadManagerClass {
return;
}
// Store raw Blob for transitions (they need fresh blob URLs each playback
// to avoid decoder state issues that cause video jumping)
// Only set if not already present (original download blob takes priority)
if (!this.readyBlobs.has(storageKey)) {
this.readyBlobs.set(storageKey, blob);
}
const blobUrl = URL.createObjectURL(blob);
// Decode images to prevent white flash
@ -727,41 +649,13 @@ class DownloadManagerClass {
});
}
}
/**
* Register a presigned URL storage key mapping with the Service Worker.
* This enables the SW to cache the full response when the browser fetches the media
* during playback, using the canonical storage key instead of the expiring presigned URL.
*/
private registerUrlForCaching(
presignedUrl: string,
storageKey: string,
): void {
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'REGISTER_CACHE_URL',
payload: { presignedUrl, storageKey },
});
logger.info('[DownloadManager] Registered URL for SW caching', {
storageKey: storageKey.slice(-40),
});
}
}
/**
* Clear blob URLs and partial downloads cache (call on unmount to prevent memory leaks)
* Clear blob URLs cache (call on unmount to prevent memory leaks)
*/
clearBlobUrls(): void {
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
this.readyBlobUrls.clear();
this.partialDownloadsReady.clear();
// Clear SW URL mappings (optional, SW has its own cleanup interval)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_URL_MAPPINGS',
});
}
this.readyBlobs.clear();
}
/**
@ -774,7 +668,7 @@ class DownloadManagerClass {
URL.revokeObjectURL(blobUrl);
this.readyBlobUrls.delete(key);
}
this.partialDownloadsReady.delete(key);
this.readyBlobs.delete(key);
}
}

View File

@ -601,6 +601,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator.getReadyBlob,
},
});
@ -1831,6 +1832,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
</div>
<TransitionPreviewOverlay
videoKey={transitionPreview?.videoUrl}
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering}

View File

@ -72,6 +72,11 @@ export const fetchByProjectAndEnv = createAsyncThunk<
return { key, data: result.data };
} catch (error) {
if (isAxiosError(error) && error.response) {
// 404 means no project-specific settings exist - this is expected,
// the app will fall back to global defaults
if (error.response.status === 404) {
return { key, data: null };
}
return rejectWithValue(error.response.data as ApiError);
}
throw error;