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); app.use('/api/file', fileRoutes);
// Body parser for all other routes // Body parser for all other routes
app.use(bodyParser.json({ limit: '1mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(runtimeContextMiddleware); app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = (req, res, next) => {

View File

@ -11,6 +11,7 @@ const jwtAuth = passport.authenticate('jwt', { session: false });
/** /**
* Middleware for public GET access. * Middleware for public GET access.
* Marks GET requests as public runtime requests for permission bypass. * Marks GET requests as public runtime requests for permission bypass.
* MUST run before checkCrudPermissions to set the flag first.
*/ */
const allowPublicRead = (req, _res, next) => { const allowPublicRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) { if (['GET', 'OPTIONS'].includes(req.method)) {
@ -19,7 +20,8 @@ const allowPublicRead = (req, _res, next) => {
return 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')); router.use(checkCrudPermissions('global_transition_defaults'));
/** /**
@ -35,7 +37,6 @@ router.use(checkCrudPermissions('global_transition_defaults'));
*/ */
router.get( router.get(
'/', '/',
allowPublicRead,
wrapAsync(async (_req, res) => { wrapAsync(async (_req, res) => {
const payload = await Global_transition_defaultsDBApi.findOne(); const payload = await Global_transition_defaultsDBApi.findOne();
res.status(200).send(payload); res.status(200).send(payload);
@ -62,7 +63,6 @@ router.get(
*/ */
router.get( router.get(
'/:id', '/:id',
allowPublicRead,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) { if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid global_transition_defaults 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 router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false }); 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. * Middleware: Production GET is public, everything else requires JWT.
* Determines public access from URL path, not headers. * Determines public access from URL path, not headers.
@ -18,8 +29,7 @@ const requireProductionOrAuth = (req, res, next) => {
const isReadOnly = ['GET', 'OPTIONS'].includes(req.method); const isReadOnly = ['GET', 'OPTIONS'].includes(req.method);
if (isProduction && isReadOnly) { if (isProduction && isReadOnly) {
// Public access for production GET - mark for permission bypass // Public access for production GET
req.isRuntimePublicRequest = true;
return next(); return next();
} }
@ -27,7 +37,8 @@ const requireProductionOrAuth = (req, res, next) => {
return jwtAuth(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')); 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 * 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) => { const getCachedFile = async (cachePath) => {
try { 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 stats = await fs.promises.stat(cachePath);
const age = (Date.now() - stats.mtimeMs) / 1000; const age = (Date.now() - stats.mtimeMs) / 1000;
if (age < config.s3CacheMaxAge) { if (age < config.s3CacheMaxAge) {
@ -410,16 +421,6 @@ const downloadFile = async (req, res) => {
res.setHeader('Content-Type', mimeTypes[ext]); res.setHeader('Content-Type', mimeTypes[ext]);
} }
log.debug(
{
provider,
privateUrl,
duration: Date.now() - startTime,
cached: true,
},
'File served from cache',
);
// Stream from cache // Stream from cache
return fs.createReadStream(cachePath).pipe(res); return fs.createReadStream(cachePath).pipe(res);
} }
@ -436,9 +437,14 @@ const downloadFile = async (req, res) => {
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`); res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
if (useCache && typeof result.body.pipe === 'function') { 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(); 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 // Use pipeline for proper error handling
const { PassThrough } = require('stream'); const { PassThrough } = require('stream');
@ -448,8 +454,43 @@ const downloadFile = async (req, res) => {
passThrough.pipe(res); passThrough.pipe(res);
passThrough.pipe(cacheStream); 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'); 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') { } else if (typeof result.body.pipe === 'function') {
result.body.pipe(res); result.body.pipe(res);
@ -457,12 +498,17 @@ const downloadFile = async (req, res) => {
const bytes = await result.body.transformToByteArray(); const bytes = await result.body.transformToByteArray();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// Cache the buffer // Cache the buffer atomically (write to temp, then rename)
if (useCache) { if (useCache) {
await ensureCacheDir(); await ensureCacheDir();
fs.promises.writeFile(cachePath, buffer).catch((err) => { const tempPath = cachePath + '.tmp';
log.warn({ err, cachePath }, 'Failed to write to cache'); 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); res.send(buffer);
@ -700,11 +746,6 @@ const initUploadSession = async (req, res) => {
contentType, contentType,
}); });
log.info(
{ sessionId, folder, filename, totalChunks, size },
'Upload session initialized',
);
return res.status(200).send({ return res.status(200).send({
sessionId, sessionId,
uploadedChunks: [], uploadedChunks: [],
@ -859,6 +900,7 @@ const finalizeUploadSession = async (req, res) => {
if (provider === 's3') { if (provider === 's3') {
const s3 = getS3Provider(); const s3 = getS3Provider();
const data = fs.readFileSync(assembledPath); const data = fs.readFileSync(assembledPath);
const result = await s3.upload(privateUrl, data, { const result = await s3.upload(privateUrl, data, {
contentType: session.contentType, contentType: session.contentType,
}); });

View File

@ -6,11 +6,18 @@
* Supports custom video playback settings (autoplay, loop, muted, start/end time). * 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 NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { scheduleAfterPaint } from '../../lib/browserUtils'; import { scheduleAfterPaint } from '../../lib/browserUtils';
import { baseURLApi } from '../../config';
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) // Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) // 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) // Block autoplay if video already played this session (when loop=false)
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; 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 = () => { const handleLoad = () => {
// Wait for paint to ensure background is actually rendered before reporting ready. // 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. // This prevents the transition overlay from being removed before the background is visible.
@ -106,15 +139,27 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onBackgroundReady?.(); 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+) // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
const videoWithRVFC = video as HTMLVideoElementWithRVFC; const videoWithRVFC = video as HTMLVideoElementWithRVFC;
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
videoWithRVFC.requestVideoFrameCallback(() => { videoWithRVFC.requestVideoFrameCallback(() => {
clearTimeout(timeout);
reportVideoReady(); reportVideoReady();
}); });
} else { } else {
// Fallback: use playing event + scheduleAfterPaint // Fallback: use playing event + scheduleAfterPaint
const onPlaying = () => { const onPlaying = () => {
clearTimeout(timeout);
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
reportVideoReady(); reportVideoReady();
}); });
@ -122,9 +167,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
video.addEventListener('playing', onPlaying, { once: true }); video.addEventListener('playing', onPlaying, { once: true });
return () => { return () => {
clearTimeout(timeout);
video.removeEventListener('playing', onPlaying); video.removeEventListener('playing', onPlaying);
}; };
} }
return () => clearTimeout(timeout);
}, [backgroundVideoUrl, onBackgroundReady]); }, [backgroundVideoUrl, onBackgroundReady]);
// When endTime is set, we disable native loop and handle it via the hook // 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. Note: muted attribute is always true for iOS autoplay compatibility.
Actual muted state is controlled via useBackgroundVideoPlayback hook Actual muted state is controlled via useBackgroundVideoPlayback hook
which sets video.muted property via JavaScript (useEffect). 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 && ( {backgroundVideoUrl && (
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain' className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl} src={videoSrc}
preload='metadata'
autoPlay={effectiveAutoplay} autoPlay={effectiveAutoplay}
loop={useNativeLoop} loop={useNativeLoop}
muted={videoMuted} muted={videoMuted}
playsInline playsInline
webkit-playsinline='' webkit-playsinline=''
onError={handleVideoError}
/> />
)} )}

View File

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

View File

@ -300,6 +300,7 @@ export default function RuntimePresentation({
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(), preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl, getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl, getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator?.getReadyBlob,
}, },
}); });
@ -818,6 +819,7 @@ export default function RuntimePresentation({
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */} {/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
{transitionPreview && ( {transitionPreview && (
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoKey={transitionPreview.videoUrl}
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={true} isActive={true}
isBuffering={ 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 ? 50
: 75, : 75,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl: true, // Create blob URL for instant display
persist: true, // Persist for resume after page refresh persist: true, // Persist for resume after page refresh
}) })
.catch((err) => { .catch((err) => {

View File

@ -60,6 +60,8 @@ interface UsePreloadOrchestratorResult {
isUrlPreloaded: (url: string) => Promise<boolean>; isUrlPreloaded: (url: string) => Promise<boolean>;
/** Instant lookup - returns decoded blob URL or null */ /** Instant lookup - returns decoded blob URL or null */
getReadyBlobUrl: (url: string) => string | 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 */ /** Whether all neighbor page backgrounds are ready for instant navigation */
areNeighborBackgroundsReady: boolean; areNeighborBackgroundsReady: boolean;
} }
@ -216,7 +218,6 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(item.assetType), assetType: mapAssetType(item.assetType),
priority: item.priority, priority: item.priority,
storageKey, storageKey,
createBlobUrl: true, // Create blob URL for instant display
persist: false, // Don't persist for online preload (in-memory only) persist: false, // Don't persist for online preload (in-memory only)
}) })
.then(() => { .then(() => {
@ -334,6 +335,12 @@ export function usePreloadOrchestrator(
return downloadManager.getReadyBlobUrl(url); 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 // 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 // This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
useEffect(() => { useEffect(() => {
@ -398,7 +405,7 @@ export function usePreloadOrchestrator(
const fullUrl = resolveAssetPlaybackUrl(storagePath); const fullUrl = resolveAssetPlaybackUrl(storagePath);
const hasAsset = await StorageManager.hasAsset(storageKey); const hasAsset = await StorageManager.hasAsset(storageKey);
if (hasAsset) { if (hasAsset) {
// Use DownloadManager.addJob with createBlobUrl to create the blob URL // DownloadManager will create blob URL from cached asset
await downloadManager.addJob({ await downloadManager.addJob({
assetId: `init-${storageKey}`, assetId: `init-${storageKey}`,
projectId: '', projectId: '',
@ -407,7 +414,6 @@ export function usePreloadOrchestrator(
variantType: 'original', variantType: 'original',
assetType: 'other', assetType: 'other',
storageKey, storageKey,
createBlobUrl: true,
persist: false, persist: false,
}); });
} }
@ -523,44 +529,17 @@ export function usePreloadOrchestrator(
}; };
// Two-phase preloading: current page first, then neighbors // 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 ( const addAssetsToQueue = async (
presignedUrls: Record<string, string> = {}, 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 // Helper to create download job
const createDownloadJob = ( const createDownloadJob = (
id: string, id: string,
storageKey: string, storageKey: string,
priority: number, priority: number,
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
pageId: string,
): Promise<void> | null => { ): Promise<void> | null => {
const resolvedUrl = resolveUrl(storageKey, presignedUrls); const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (!resolvedUrl) return null; if (!resolvedUrl) return null;
@ -574,14 +553,7 @@ export function usePreloadOrchestrator(
preloadedUrls.add(normalizedKey); preloadedUrls.add(normalizedKey);
// Determine if partial preload applies (neighbor pages only, media files only) // DownloadManager always creates blob URLs for reliable playback
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
return downloadManager return downloadManager
.addJob({ .addJob({
assetId: id, assetId: id,
@ -592,9 +564,7 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(assetType), assetType: mapAssetType(assetType),
priority, priority,
storageKey: normalizedKey, storageKey: normalizedKey,
createBlobUrl,
persist: false, persist: false,
maxBytes,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(resolvedUrl)) { if (isPresignedUrl(resolvedUrl)) {
@ -624,7 +594,6 @@ export function usePreloadOrchestrator(
currentPage.background_image_url, currentPage.background_image_url,
PRELOAD_CONFIG.priority.currentPage + 200, PRELOAD_CONFIG.priority.currentPage + 200,
'image', 'image',
currentPageId,
); );
if (job) currentPageImageJobs.push(job); if (job) currentPageImageJobs.push(job);
} }
@ -637,7 +606,6 @@ export function usePreloadOrchestrator(
currentPage.background_video_url, currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150, PRELOAD_CONFIG.priority.currentPage + 150,
'video', 'video',
currentPageId,
); );
// Not pushed to awaited jobs - video streams on its own // Not pushed to awaited jobs - video streams on its own
} }
@ -647,7 +615,6 @@ export function usePreloadOrchestrator(
currentPage.background_audio_url, currentPage.background_audio_url,
PRELOAD_CONFIG.priority.currentPage + 100, PRELOAD_CONFIG.priority.currentPage + 100,
'audio', 'audio',
currentPageId,
); );
// Not pushed to awaited jobs - audio streams on its own // 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) // PHASE 2: Preload everything else (don't wait)
// - Current page element assets (full downloads) // - Current page element assets
// - Neighbor page backgrounds (partial preload for video/audio) // - Neighbor page backgrounds
// - Neighbor page element assets (partial preload for video/audio) // - Neighbor page element assets
// - Transition videos from page links (partial preload - 3MB) // - 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'); logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions');
@ -686,7 +654,6 @@ export function usePreloadOrchestrator(
asset.url, asset.url,
asset.priority, asset.priority,
asset.assetType, asset.assetType,
asset.pageId,
); );
}); });
@ -700,7 +667,6 @@ export function usePreloadOrchestrator(
asset.url, asset.url,
asset.priority, asset.priority,
asset.assetType, asset.assetType,
asset.pageId,
); );
}); });
@ -714,7 +680,6 @@ export function usePreloadOrchestrator(
page.background_image_url, page.background_image_url,
PRELOAD_CONFIG.priority.neighborBase + 100, PRELOAD_CONFIG.priority.neighborBase + 100,
'image', 'image',
pageId,
); );
} }
if (page?.background_video_url) { if (page?.background_video_url) {
@ -723,7 +688,6 @@ export function usePreloadOrchestrator(
page.background_video_url, page.background_video_url,
PRELOAD_CONFIG.priority.neighborBase + 50, PRELOAD_CONFIG.priority.neighborBase + 50,
'video', 'video',
pageId,
); );
} }
if (page?.background_audio_url) { if (page?.background_audio_url) {
@ -732,7 +696,6 @@ export function usePreloadOrchestrator(
page.background_audio_url, page.background_audio_url,
PRELOAD_CONFIG.priority.neighborBase + 30, PRELOAD_CONFIG.priority.neighborBase + 30,
'audio', 'audio',
pageId,
); );
} }
}); });
@ -787,6 +750,7 @@ export function usePreloadOrchestrator(
getCachedBlobUrl, getCachedBlobUrl,
isUrlPreloaded, isUrlPreloaded,
getReadyBlobUrl, getReadyBlobUrl,
getReadyBlob,
areNeighborBackgroundsReady, areNeighborBackgroundsReady,
}; };
} }

View File

@ -25,6 +25,7 @@ import {
} from '../lib/assetUrl'; } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager'; import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils'; import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
import { TRANSITION_CONFIG } from '../config/transition.config';
export type ReverseMode = 'none' | 'separate'; export type ReverseMode = 'none' | 'separate';
@ -61,6 +62,8 @@ export interface UseTransitionPlaybackOptions {
preloadedUrls?: Set<string>; preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>; getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => 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 * @returns Finish offset in milliseconds before video end
*/ */
const getFinishBeforeEndMs = (): number => { const getFinishBeforeEndMs = (): number => {
const { finishBeforeEndMs } = TRANSITION_CONFIG;
if (isSafari()) { if (isSafari()) {
return 350; return finishBeforeEndMs.safari;
} }
if (isFirefox()) { if (isFirefox()) {
return 300; return finishBeforeEndMs.firefox;
} }
return 300; return finishBeforeEndMs.default;
}; };
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean { function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
@ -393,21 +397,35 @@ export function useTransitionPlayback(
}; };
const resolvePlayableSource = async (): Promise<string> => { const resolvePlayableSource = async (): Promise<string> => {
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl; const getReadyBlob = preloadRef.current?.getReadyBlob;
const currentStorageKey = storageKey; const currentStorageKey = storageKey;
// 1. Try storage key lookup first (most reliable for cache hits) // DEBUG: Disabled - network URL test showed issue is not with blob
if (getReadyBlobUrl && currentStorageKey) { // const USE_NETWORK_URL_DEBUG = true;
const readyUrl = getReadyBlobUrl(currentStorageKey);
if (readyUrl) { // 1. Try to get raw Blob and create FRESH blob URL (avoids decoder state issues)
logger.info('Using ready blob URL from storage key', { // 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), 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; const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && currentStorageKey) { if (getCachedBlobUrl && currentStorageKey) {
try { try {
@ -450,14 +468,20 @@ export function useTransitionPlayback(
return sourceUrl; return sourceUrl;
} }
// 4. Try ready blob URL by resolved URL // 4. Try raw Blob by resolved URL and create fresh blob URL
if (getReadyBlobUrl) { if (getReadyBlob) {
const readyUrl = getReadyBlobUrl(sourceUrl); const blob = getReadyBlob(sourceUrl);
if (readyUrl) { if (blob) {
logger.info('Using ready blob URL from resolved URL', { 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), url: sourceUrl.slice(-50),
}); });
return readyUrl; return freshBlobUrl;
} }
} }
@ -566,6 +590,16 @@ export function useTransitionPlayback(
lastLoadedSourceUrlRef.current = playableSourceUrl; lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.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(); attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => { startWatchdogTimerRef.current = setTimeout(() => {
@ -581,6 +615,12 @@ export function useTransitionPlayback(
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (didFinishRef.current) return; 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; video.currentTime = 0;
attemptPlay(); attemptPlay();
}; };
@ -627,12 +667,18 @@ export function useTransitionPlayback(
if (didFinishRef.current) return; if (didFinishRef.current) return;
const duration = video.duration; const duration = video.duration;
// Finish 300ms before end - gives margin for black/fade frames const { rvfcThreshold } = TRANSITION_CONFIG;
// that some videos have in the last 100-200ms // Finish shortly before end - minimal margin for black frames
if ( if (
Number.isFinite(duration) && 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'); finishPlayback('rvfc-end');
return; return;
} }
@ -676,9 +722,24 @@ export function useTransitionPlayback(
const duration = video.duration; const duration = video.duration;
if (!Number.isFinite(duration)) return; if (!Number.isFinite(duration)) return;
// Large buffer since timeupdate is infrequent // Small buffer to let video play closer to the end
// Safari: 600ms, Others: 400ms const { timeUpdateSafetyBuffer } = TRANSITION_CONFIG;
const safetyBuffer = isSafari() ? 0.6 : 0.4; 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) { if (video.currentTime >= duration - safetyBuffer) {
finishPlayback('timeupdate-end'); finishPlayback('timeupdate-end');
@ -712,7 +773,23 @@ export function useTransitionPlayback(
logIssue('video-stalled'); 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('loadedmetadata', onLoadedMetadata);
video.addEventListener('seeking', onSeeking);
video.addEventListener('seeked', onSeeked);
video.addEventListener('canplay', onCanPlay); video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying); video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded); video.addEventListener('ended', onEnded);
@ -738,6 +815,8 @@ export function useTransitionPlayback(
video.removeEventListener('error', onVideoError); video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort); video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled); video.removeEventListener('stalled', onStalled);
video.removeEventListener('seeking', onSeeking);
video.removeEventListener('seeked', onSeeked);
clearTimers(); clearTimers();
}; };
}, [ }, [

View File

@ -229,22 +229,8 @@ export class AssetCacheService {
if (!downloadUrl) continue; if (!downloadUrl) continue;
// Determine download parameters based on mode // Always full downloads with blob URLs for reliable playback
const isOnlineMode = mode === 'online'; // (presigned URL streaming fails on mobile Safari/Chrome)
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;
try { try {
await downloadManager await downloadManager
@ -257,9 +243,7 @@ export class AssetCacheService {
assetType: asset.assetType, assetType: asset.assetType,
priority: asset.priority, priority: asset.priority,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline', // Only persist for offline mode persist: mode === 'offline', // Only persist for offline mode
maxBytes,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(downloadUrl)) { if (isPresignedUrl(downloadUrl)) {
@ -287,9 +271,7 @@ export class AssetCacheService {
assetType: asset.assetType, assetType: asset.assetType,
priority: asset.priority, priority: asset.priority,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline', persist: mode === 'offline',
maxBytes,
}); });
} catch { } catch {
// Ignore retry failures // 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 * Clear project cache
*/ */

View File

@ -40,10 +40,7 @@ interface DownloadJob {
retryCount: number; retryCount: number;
addedAt: number; addedAt: number;
storageKey: string; // Canonical storage key for consistent caching 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) 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 usedProxyFallback?: boolean; // Whether we've already tried proxy URL fallback
abortController?: AbortController; abortController?: AbortController;
resolve?: () => void; resolve?: () => void;
@ -59,9 +56,9 @@ class DownloadManagerClass {
// Blob URL cache for instant lookup (storageKey → blobUrl) // Blob URL cache for instant lookup (storageKey → blobUrl)
private readyBlobUrls: Map<string, string> = new Map(); private readyBlobUrls: Map<string, string> = new Map();
// Track partial downloads completed in this session (not persisted) // Raw Blob cache for creating fresh blob URLs (storageKey → Blob)
// Prevents re-downloading same partial content on repeated page visits // Used by transitions to avoid decoder state issues with pre-created blob URLs
private partialDownloadsReady: Set<string> = new Set(); private readyBlobs: Map<string, Blob> = new Map();
private config = { private config = {
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
@ -73,6 +70,7 @@ class DownloadManagerClass {
/** /**
* Add a download job to the queue * Add a download job to the queue
* Always creates blob URLs for reliable playback on all devices
*/ */
async addJob(params: { async addJob(params: {
assetId: string; assetId: string;
@ -83,50 +81,21 @@ class DownloadManagerClass {
assetType: AssetType; assetType: AssetType;
priority?: number; priority?: number;
storageKey?: string; // Optional, will extract if not provided storageKey?: string; // Optional, will extract if not provided
createBlobUrl?: boolean; // Create blob URL after download
persist?: boolean; // Persist to IndexedDB for resume (default: true) persist?: boolean; // Persist to IndexedDB for resume (default: true)
maxBytes?: number; // Download limit in bytes (for partial preload)
}): Promise<void> { }): Promise<void> {
const storageKey = params.storageKey || extractStoragePath(params.url); const storageKey = params.storageKey || extractStoragePath(params.url);
const isPartialDownload = params.maxBytes !== undefined;
// For partial downloads, check session cache first (fast path) // Check cache status - if fully cached, create blob URL and return
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
const assetInfo = await StorageManager.getAssetInfo(storageKey); const assetInfo = await StorageManager.getAssetInfo(storageKey);
if (assetInfo?.exists) { if (assetInfo?.exists && !assetInfo.isPartial) {
if (isPartialDownload) { // Fully cached - create blob URL if not already ready
// For partial downloads, any cached version is sufficient if (!this.readyBlobUrls.has(storageKey)) {
this.partialDownloadsReady.add(storageKey); await this.createBlobUrlFromCache(storageKey);
return;
} }
return;
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
} }
// 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) // Check if already in queue (use storageKey for deduplication)
if ( if (
this.queue.some((j) => j.storageKey === storageKey) || this.queue.some((j) => j.storageKey === storageKey) ||
@ -134,27 +103,10 @@ class DownloadManagerClass {
(j) => j.storageKey === storageKey, (j) => j.storageKey === storageKey,
) )
) { ) {
return; // Already queued - no log needed return; // Already queued
}
// 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 new Promise((resolve, reject) => { 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 = { const job: DownloadJob = {
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
assetId: params.assetId, assetId: params.assetId,
@ -173,10 +125,7 @@ class DownloadManagerClass {
retryCount: 0, retryCount: 0,
addedAt: Date.now(), addedAt: Date.now(),
storageKey, storageKey,
createBlobUrl: shouldCreateBlobUrl, persist: params.persist ?? true,
persist: shouldPersist,
maxBytes: params.maxBytes,
isPartial: isPartialDownload,
resolve, resolve,
reject, reject,
}; };
@ -274,39 +223,23 @@ class DownloadManagerClass {
}); });
try { 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, { const response = await fetch(job.url, {
signal: job.abortController.signal, signal: job.abortController.signal,
headers,
}); });
// Accept both 200 OK and 206 Partial Content if (!response.ok) {
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const contentLength = response.headers.get('content-length'); const contentLength = response.headers.get('content-length');
job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0; 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; let blob: Blob;
if (response.body) { if (response.body) {
// Stream with progress tracking // Stream with progress tracking
const reader = response.body.getReader(); const reader = response.body.getReader();
const chunks: BlobPart[] = []; const chunks: BlobPart[] = [];
let reachedLimit = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -315,22 +248,6 @@ class DownloadManagerClass {
chunks.push(value); chunks.push(value);
job.bytesLoaded += value.length; 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.progress =
job.totalBytes > 0 job.totalBytes > 0
? Math.round((job.bytesLoaded / job.totalBytes) * 100) ? Math.round((job.bytesLoaded / job.totalBytes) * 100)
@ -357,11 +274,6 @@ class DownloadManagerClass {
type: type:
response.headers.get('content-type') || 'application/octet-stream', 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 { } else {
// No streaming, get blob directly // No streaming, get blob directly
blob = await response.blob(); blob = await response.blob();
@ -371,34 +283,25 @@ class DownloadManagerClass {
} }
// Store the asset using canonical storage key // 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, { await StorageManager.storeAsset(job.storageKey, blob, {
id: job.assetId, id: job.assetId,
projectId: job.projectId, projectId: job.projectId,
filename: job.filename, filename: job.filename,
variantType: job.variantType, variantType: job.variantType,
assetType: job.assetType, assetType: job.assetType,
isPartial: job.isPartial || false, isPartial: false,
}); });
if (job.isPartial) { // Store ORIGINAL blob directly (bypass cache retrieval to avoid potential corruption)
// Mark partial download as ready in session cache this.readyBlobs.set(job.storageKey, blob);
this.partialDownloadsReady.add(job.storageKey); 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 // Always create blob URL for reliable playback on all devices
// When the browser fetches the full media, SW will cache it using the storage key await this.createBlobUrlFromCache(job.storageKey);
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);
}
}
// Mark as completed // Mark as completed
job.status = 'completed'; job.status = 'completed';
@ -625,7 +528,6 @@ class DownloadManagerClass {
retryCount: item.retryCount, retryCount: item.retryCount,
addedAt: item.addedAt, addedAt: item.addedAt,
storageKey, storageKey,
createBlobUrl: true, // Create blob URL for resumed downloads
persist: true, persist: true,
}; };
@ -649,6 +551,16 @@ class DownloadManagerClass {
return this.readyBlobUrls.get(storageKey) || null; 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. * 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. * Use this when fetching via XHR (e.g., transition playback) to enable caching.
@ -670,6 +582,9 @@ class DownloadManagerClass {
assetType: metadata.assetType, 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 // Create blob URL and register for instant O(1) lookup
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
this.readyBlobUrls.set(storageKey, blobUrl); this.readyBlobUrls.set(storageKey, blobUrl);
@ -701,6 +616,13 @@ class DownloadManagerClass {
return; 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); const blobUrl = URL.createObjectURL(blob);
// Decode images to prevent white flash // Decode images to prevent white flash
@ -727,41 +649,13 @@ class DownloadManagerClass {
}); });
} }
} }
/** /**
* Register a presigned URL storage key mapping with the Service Worker. * Clear blob URLs cache (call on unmount to prevent memory leaks)
* 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)
*/ */
clearBlobUrls(): void { clearBlobUrls(): void {
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl)); this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
this.readyBlobUrls.clear(); this.readyBlobUrls.clear();
this.partialDownloadsReady.clear(); this.readyBlobs.clear();
// Clear SW URL mappings (optional, SW has its own cleanup interval)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_URL_MAPPINGS',
});
}
} }
/** /**
@ -774,7 +668,7 @@ class DownloadManagerClass {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
this.readyBlobUrls.delete(key); 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, preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator.getReadyBlob,
}, },
}); });
@ -1831,6 +1832,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
</div> </div>
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoKey={transitionPreview?.videoUrl}
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)} isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering} isBuffering={isReverseBuffering}

View File

@ -72,6 +72,11 @@ export const fetchByProjectAndEnv = createAsyncThunk<
return { key, data: result.data }; return { key, data: result.data };
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) { 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); return rejectWithValue(error.response.data as ApiError);
} }
throw error; throw error;