fixed preloading issue

This commit is contained in:
Dmitri 2026-04-07 16:42:56 +04:00
parent 8f1d3699a1
commit 7d251319f2
10 changed files with 792 additions and 180 deletions

View File

@ -29,7 +29,10 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader'; import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { useProjectAssets } from '../hooks/useProjectAssets'; import { useProjectAssets } from '../hooks/useProjectAssets';
import { usePageNavigation } from '../hooks/usePageNavigation'; import { usePageNavigation } from '../hooks/usePageNavigation';
import { extractPageLinksAndElements } from '../lib/extractPageLinks'; import {
extractPageLinksOnly,
extractElementsForPages,
} from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
@ -99,24 +102,47 @@ export default function RuntimePresentation({
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId // Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
// Extract page links and preload elements from ui_schema_json // Phase 1: Extract pageLinks from ALL pages (needed for navigation graph)
// This enables the neighbor graph to find connected pages for preloading // This is lightweight - only extracts navigation structure, not asset URLs
const { pageLinks, preloadElements } = useMemo(() => { const pageLinks = useMemo(() => {
const result = extractPageLinksAndElements(pages); const links = extractPageLinksOnly(pages);
if (result.pageLinks.length > 0 || result.preloadElements.length > 0) { if (links.length > 0) {
logger.info('[PRELOAD] Extracted page links and elements', { logger.info('[PRELOAD] Extracted page links', {
pageLinksCount: result.pageLinks.length, count: links.length,
preloadElementsCount: result.preloadElements.length, links: links.map((link) => ({
pageLinks: result.pageLinks.map((link) => ({
from: link.from_pageId?.slice(-8), from: link.from_pageId?.slice(-8),
to: link.to_pageId?.slice(-8), to: link.to_pageId?.slice(-8),
hasTransition: !!link.transition?.video_url, hasTransition: !!link.transition?.video_url,
})), })),
}); });
} }
return result; return links;
}, [pages]); }, [pages]);
// Phase 2: Extract elements only for current + neighbor pages (progressive)
// This avoids parsing ui_schema_json for all pages upfront
const preloadElements = useMemo(() => {
if (!selectedPageId || pages.length === 0) return [];
// Build simple neighbor set from pageLinks
const neighborIds = new Set<string>();
neighborIds.add(selectedPageId); // Current page
pageLinks.forEach((link) => {
if (link.from_pageId === selectedPageId && link.to_pageId) {
neighborIds.add(link.to_pageId); // Direct neighbors
}
});
// Extract elements only for current + neighbors
const elements = extractElementsForPages(pages, Array.from(neighborIds));
logger.info('[PRELOAD] Extracted elements for pages', {
currentPage: selectedPageId.slice(-8),
pageCount: neighborIds.size,
elementCount: elements.length,
});
return elements;
}, [pages, pageLinks, selectedPageId]);
// Initialize preload orchestrator with transformed data // Initialize preload orchestrator with transformed data
const preloadOrchestrator = usePreloadOrchestrator({ const preloadOrchestrator = usePreloadOrchestrator({
pages, pages,

View File

@ -20,10 +20,10 @@ export const PRELOAD_CONFIG = {
currentPage: 1000, currentPage: 1000,
neighborBase: 500, neighborBase: 500,
assetType: { assetType: {
transition: 150, // Highest - needed immediately on navigation click image: 100, // Backgrounds load first
image: 100, // Backgrounds load during transition playback
audio: 50, audio: 50,
video: 30, video: 30,
// Note: transitions are cached on first playback, not preloaded
} as Record<string, number>, } as Record<string, number>,
variant: { variant: {
thumbnail: 50, thumbnail: 50,
@ -65,6 +65,16 @@ export const PRELOAD_CONFIG = {
slowFrameThreshold: 1.3, // Multiplier of target frame time slowFrameThreshold: 1.3, // Multiplier of target frame time
}, },
// Partial preload settings (online mode only)
// Download only first N bytes of videos/audio for faster Phase 1 completion
// Playback uses presigned URL directly (browser handles remaining buffering)
// Note: Transitions are cached on first playback, not preloaded
partialPreload: {
enabled: true,
videoMaxBytes: 5 * 1024 * 1024, // 5MB (~5 seconds of video)
audioMaxBytes: 512 * 1024, // 512KB (~5 seconds of audio)
},
// Asset URL field names in element content_json (camelCase) // Asset URL field names in element content_json (camelCase)
assetFields: { assetFields: {
// All asset URL fields for preloading extraction // All asset URL fields for preloading extraction

View File

@ -73,12 +73,15 @@ function extractAssetsFromContent(
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value && urlFields.includes(key)) { if (typeof value === 'string' && value && urlFields.includes(key)) {
// Classify asset type - transition videos get highest priority // Classify asset type based on field name
// Skip transition fields - transitions are cached on first playback
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
let assetType: 'transition' | 'video' | 'audio' | 'image';
if (lowerKey.includes('transition')) { if (lowerKey.includes('transition')) {
assetType = 'transition'; continue; // Skip transitions
} else if (lowerKey.includes('video')) { }
let assetType: 'video' | 'audio' | 'image';
if (lowerKey.includes('video')) {
assetType = 'video'; assetType = 'video';
} else if (lowerKey.includes('audio')) { } else if (lowerKey.includes('audio')) {
assetType = 'audio'; assetType = 'audio';
@ -183,6 +186,41 @@ export function useNeighborGraph(
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
pageIds.forEach((pageId) => { pageIds.forEach((pageId) => {
// Find the page to get its background assets
const page = pages.find((p) => p.id === pageId);
if (page) {
// Add page background image (highest priority for page display)
if (page.background_image_url && !seenUrls.has(page.background_image_url)) {
seenUrls.add(page.background_image_url);
assets.push({
url: page.background_image_url,
pageId,
assetType: 'image',
priority: 0, // Will be calculated later
});
}
// Add page background video
if (page.background_video_url && !seenUrls.has(page.background_video_url)) {
seenUrls.add(page.background_video_url);
assets.push({
url: page.background_video_url,
pageId,
assetType: 'video',
priority: 0,
});
}
// Add page background audio
if (page.background_audio_url && !seenUrls.has(page.background_audio_url)) {
seenUrls.add(page.background_audio_url);
assets.push({
url: page.background_audio_url,
pageId,
assetType: 'audio',
priority: 0,
});
}
}
// Get elements for this page // Get elements for this page
const pageElements = elements.filter((el) => el.pageId === pageId); const pageElements = elements.filter((el) => el.pageId === pageId);
@ -207,22 +245,12 @@ export function useNeighborGraph(
link.is_active !== false && pageIds.includes(link.from_pageId || ''), link.is_active !== false && pageIds.includes(link.from_pageId || ''),
); );
matchingLinks.forEach((link) => { // Note: Transition videos are NOT extracted for preloading.
const videoUrl = link.transition?.video_url; // They are cached on first playback via useTransitionPlayback.cacheBlob()
if (videoUrl && !seenUrls.has(videoUrl)) {
seenUrls.add(videoUrl);
assets.push({
url: videoUrl,
pageId: link.from_pageId || '',
assetType: 'transition',
priority: 0,
});
}
});
return assets; return assets;
}; };
}, [elements, pageLinks]); }, [pages, elements, pageLinks]);
// Get prioritized assets for preloading // Get prioritized assets for preloading
const getPrioritizedAssets = useMemo(() => { const getPrioritizedAssets = useMemo(() => {

View File

@ -17,8 +17,9 @@ import {
resolveAssetPlaybackUrl, resolveAssetPlaybackUrl,
markPresignedUrlFailed, markPresignedUrlFailed,
isRelativeStoragePath, isRelativeStoragePath,
isPresignedUrl,
buildProxyUrl,
} from '../lib/assetUrl'; } from '../lib/assetUrl';
import { baseURLApi } from '../config';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
/** /**
@ -95,21 +96,6 @@ export interface UsePageSwitchResult {
clearPreviousBackground: () => void; clearPreviousBackground: () => void;
} }
/**
* Check if URL is a presigned S3 URL
*/
const isPresignedUrl = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
/**
* Build proxy URL from storage key for fallback
*/
const buildProxyUrl = (storageKey: string): string => {
const normalizedPath = storageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
};
/** /**
* Load and decode an image with presigned URL fallback. * Load and decode an image with presigned URL fallback.
* Returns the URL that successfully loaded. * Returns the URL that successfully loaded.

View File

@ -20,8 +20,9 @@ import {
isRelativeStoragePath, isRelativeStoragePath,
markPresignedUrlFailed, markPresignedUrlFailed,
markPresignedUrlsVerified, markPresignedUrlsVerified,
isPresignedUrl,
buildProxyUrl,
} from '../lib/assetUrl'; } from '../lib/assetUrl';
import { baseURLApi } from '../config';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import type { BlobUrlReadyEvent } from '../types/offline'; import type { BlobUrlReadyEvent } from '../types/offline';
import type { import type {
@ -30,21 +31,6 @@ import type {
PreloadElement, PreloadElement,
} from '../types/preload'; } from '../types/preload';
/**
* Check if URL is a presigned S3 URL
*/
const isPresignedUrl = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
/**
* Build proxy URL from storage key
*/
const buildProxyUrl = (storageKey: string): string => {
const normalizedPath = storageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
};
interface UsePreloadOrchestratorOptions { interface UsePreloadOrchestratorOptions {
pages: PreloadPage[]; pages: PreloadPage[];
pageLinks: PreloadPageLink[]; pageLinks: PreloadPageLink[];
@ -541,6 +527,28 @@ export function usePreloadOrchestrator(
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
const getMaxBytesForAsset = (
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
isNeighborPage: boolean,
): number | undefined => {
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
// 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,
@ -549,6 +557,11 @@ export function usePreloadOrchestrator(
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
pageId: string, pageId: string,
): Promise<void> | null => { ): Promise<void> | null => {
// Skip transitions - they're cached on first playback via useTransitionPlayback
if (assetType === 'transition') {
return null;
}
const resolvedUrl = resolveUrl(storageKey, presignedUrls); const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (!resolvedUrl) return null; if (!resolvedUrl) return null;
@ -561,6 +574,12 @@ export function usePreloadOrchestrator(
preloadedUrls.add(normalizedKey); preloadedUrls.add(normalizedKey);
// Determine if partial preload applies (neighbor pages only, media files only)
const isNeighborPage = pageId !== currentPageId;
const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage);
// For partial downloads, don't create blob URL - playback uses presigned URL
const createBlobUrl = maxBytes === undefined;
return downloadManager return downloadManager
.addJob({ .addJob({
assetId: id, assetId: id,
@ -571,8 +590,9 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(assetType), assetType: mapAssetType(assetType),
priority, priority,
storageKey: normalizedKey, storageKey: normalizedKey,
createBlobUrl: true, createBlobUrl,
persist: false, persist: false,
maxBytes,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(resolvedUrl)) { if (isPresignedUrl(resolvedUrl)) {
@ -598,8 +618,9 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(assetType), assetType: mapAssetType(assetType),
priority, priority,
storageKey: normalizedKey, storageKey: normalizedKey,
createBlobUrl: true, createBlobUrl,
persist: false, persist: false,
maxBytes, // Preserve partial preload behavior for retry
}); });
} catch { } catch {
// Ignore retry failures // Ignore retry failures
@ -609,13 +630,14 @@ export function usePreloadOrchestrator(
}; };
// ============================================ // ============================================
// PHASE 1: Load current page assets and WAIT // PHASE 1: Load current page IMAGE backgrounds only and WAIT
// Video/audio backgrounds stream on their own - don't block on them
// ============================================ // ============================================
logger.info('[PRELOAD] Phase 1: Loading current page assets'); logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
const currentPageJobs: Promise<void>[] = []; const currentPageImageJobs: Promise<void>[] = [];
// Current page background assets // Current page IMAGE background - WAIT for this (essential for visual)
if (currentPage?.background_image_url) { if (currentPage?.background_image_url) {
const job = createDownloadJob( const job = createDownloadJob(
`bg-img-${currentPageId}`, `bg-img-${currentPageId}`,
@ -624,58 +646,70 @@ export function usePreloadOrchestrator(
'image', 'image',
currentPageId, currentPageId,
); );
if (job) currentPageJobs.push(job); if (job) currentPageImageJobs.push(job);
} }
// Current page VIDEO/AUDIO backgrounds - DON'T wait (they can stream)
// These are started but not awaited - video player buffers on its own
if (currentPage?.background_video_url) { if (currentPage?.background_video_url) {
const job = createDownloadJob( createDownloadJob(
`bg-vid-${currentPageId}`, `bg-vid-${currentPageId}`,
currentPage.background_video_url, currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150, PRELOAD_CONFIG.priority.currentPage + 150,
'video', 'video',
currentPageId, currentPageId,
); );
if (job) currentPageJobs.push(job); // Not pushed to awaited jobs - video streams on its own
} }
if (currentPage?.background_audio_url) { if (currentPage?.background_audio_url) {
const job = createDownloadJob( createDownloadJob(
`bg-aud-${currentPageId}`, `bg-aud-${currentPageId}`,
currentPage.background_audio_url, currentPage.background_audio_url,
PRELOAD_CONFIG.priority.currentPage + 100, PRELOAD_CONFIG.priority.currentPage + 100,
'audio', 'audio',
currentPageId, currentPageId,
); );
if (job) currentPageJobs.push(job); // Not pushed to awaited jobs - audio streams on its own
} }
// Current page element assets (from neighbor graph with pageId === currentPageId) // Wait ONLY for IMAGE backgrounds (they're small and essential)
// Video/audio can stream - don't block the page
const phase1Start = Date.now();
if (currentPageImageJobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page image backgrounds', {
count: currentPageImageJobs.length,
});
await Promise.all(currentPageImageJobs);
logger.info('[PRELOAD] Phase 1 complete', {
elapsed: `${Date.now() - phase1Start}ms`,
});
} else {
logger.info('[PRELOAD] Phase 1 complete (no image backgrounds)');
}
// ============================================
// PHASE 2: Preload everything else (don't wait)
// - Current page element assets (full downloads)
// - Transition videos (partial preload - 3MB)
// - Neighbor page backgrounds (partial preload for video/audio)
// - Neighbor page element assets (partial preload for video/audio)
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading transitions and neighbors');
// Current page element assets (moved from Phase 1 for faster startup)
const currentPageAssets = assets.filter( const currentPageAssets = assets.filter(
(asset) => asset.pageId === currentPageId, (asset) => asset.pageId === currentPageId,
); );
currentPageAssets.forEach((asset) => { currentPageAssets.forEach((asset) => {
const job = createDownloadJob( createDownloadJob(
generateJobId(), generateJobId(),
asset.url, asset.url,
asset.priority, asset.priority,
asset.assetType, asset.assetType,
asset.pageId, asset.pageId,
); );
if (job) currentPageJobs.push(job);
}); });
// Wait for all current page assets to complete
if (currentPageJobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page assets', {
count: currentPageJobs.length,
});
await Promise.all(currentPageJobs);
logger.info('[PRELOAD] Current page assets ready');
}
// ============================================
// PHASE 2: Preload neighbor assets (don't wait)
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading neighbor assets');
// Neighbor page element assets // Neighbor page element assets
const neighborAssets = assets.filter( const neighborAssets = assets.filter(
(asset) => asset.pageId !== currentPageId, (asset) => asset.pageId !== currentPageId,

View File

@ -8,8 +8,15 @@ import {
} from 'react'; } from 'react';
import axios from 'axios'; import axios from 'axios';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { markPresignedUrlFailed, isRelativeStoragePath } from '../lib/assetUrl'; import {
import { baseURLApi } from '../config'; markPresignedUrlFailed,
isRelativeStoragePath,
resolveAssetPlaybackUrl,
isPresignedUrl,
buildProxyUrl,
extractStoragePath,
} from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { useReversePlayback } from './useReversePlayback'; import { useReversePlayback } from './useReversePlayback';
export type ReverseMode = 'none' | 'reverse' | 'separate'; export type ReverseMode = 'none' | 'reverse' | 'separate';
@ -107,43 +114,6 @@ function buildBlobRequestUrl(url: string): string {
return url; return url;
} }
/**
* Check if a URL is a presigned S3 URL (contains X-Amz-Signature)
*/
function isPresignedUrl(url: string): boolean {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
}
/**
* Convert a presigned URL back to proxy URL
* Extracts the storage key from the S3 path and builds a proxy URL
*/
function getProxyUrlFallback(
presignedUrl: string,
originalStorageKey?: string,
): string | null {
// If we have the original storage key, use it directly
if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) {
const normalizedPath = originalStorageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
}
// Try to extract path from presigned URL
try {
const url = new URL(presignedUrl);
// S3 path format: /bucket-prefix/assets/project-id/filename.ext
const pathParts = url.pathname.split('/').filter(Boolean);
// Skip the bucket prefix, take the rest as the storage path
if (pathParts.length >= 2) {
const storagePath = pathParts.slice(1).join('/');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(storagePath)}`;
}
} catch {
// URL parsing failed
}
return null;
}
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> { async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return; if (urls.length === 0) return;
@ -524,27 +494,74 @@ export function useTransitionPlayback(
} }
} }
// 6. Fetch video as blob (network fallback) // 6. Fetch video as blob with presigned URL support
// Follows usePageSwitch.loadImageWithFallback pattern:
// Try presigned URL first (SW can intercept for caching), fallback to proxy if it fails
logger.info('Fetching video as blob for seeking support', { logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode, reverseMode: currentTransition.reverseMode,
}); });
// Re-resolve URL to get presigned URL if now available
// (may have been cached since transition started)
const freshUrl = storageKey
? resolveAssetPlaybackUrl(storageKey)
: sourceUrl;
const token = const token =
typeof window !== 'undefined' typeof window !== 'undefined'
? localStorage.getItem('token') || '' ? localStorage.getItem('token') || ''
: ''; : '';
const requestUrl = buildBlobRequestUrl(sourceUrl);
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blobUrl = URL.createObjectURL(response.data); // Helper: Fetch video and return blob URL, caching for next time
lastLoadedBlobUrlRef.current = blobUrl; const fetchVideoAsBlob = async (url: string): Promise<string> => {
lastLoadedSourceUrlRef.current = sourceUrl; logger.info('Fetching video from URL', {
logger.info('Created blob URL for video', { url: url.slice(0, 80),
blobUrl: blobUrl.substring(0, 50), isPresigned: isPresignedUrl(url),
}); });
return blobUrl;
const response = await axios.get(url, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blob = response.data as Blob;
// Cache for next time using existing DownloadManager pattern
if (storageKey) {
const normalizedKey = extractStoragePath(storageKey);
const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, {
assetType: 'transition',
});
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return blobUrl;
}
// Fallback: create blob URL without caching
const blobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created blob URL for video (no caching)', {
blobUrl: blobUrl.substring(0, 50),
});
return blobUrl;
};
try {
// Try fetching with potentially presigned URL (SW can intercept if S3)
return await fetchVideoAsBlob(freshUrl);
} catch (error) {
// If presigned URL failed and we have storage key, retry with proxy
if (storageKey && isPresignedUrl(freshUrl)) {
logger.info('Presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-40),
});
markPresignedUrlFailed(storageKey);
const proxyUrl = buildProxyUrl(storageKey);
return await fetchVideoAsBlob(proxyUrl);
}
throw error;
}
}; };
const loadAndPlay = async () => { const loadAndPlay = async () => {
@ -720,12 +737,10 @@ export function useTransitionPlayback(
markPresignedUrlFailed(originalVideoUrl); markPresignedUrlFailed(originalVideoUrl);
} }
// Get proxy fallback URL // Get proxy fallback URL using storage key
const fallbackUrl = getProxyUrlFallback( const videoStorageKey = currentTransition.videoUrl;
currentUrl, if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) {
currentTransition.videoUrl, const fallbackUrl = buildProxyUrl(videoStorageKey);
);
if (fallbackUrl) {
didTryFallbackRef.current = true; didTryFallbackRef.current = true;
video.pause(); video.pause();
video.src = fallbackUrl; video.src = fallbackUrl;

View File

@ -16,6 +16,21 @@ const isPresignedS3Url = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature='); return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
}; };
/**
* Check if URL is a presigned S3 URL (exported version for reuse across hooks)
*/
export const isPresignedUrl = (url: string): boolean => {
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
};
/**
* Build proxy URL from storage key for fallback when presigned URL fails
*/
export const buildProxyUrl = (storageKey: string): string => {
const normalizedPath = storageKey.replace(/^\/+/, '');
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
};
/** /**
* Setup Axios interceptor to detect presigned URL failures. * Setup Axios interceptor to detect presigned URL failures.
* Called once during app initialization. * Called once during app initialization.

View File

@ -180,3 +180,130 @@ export function extractPageLinksAndElements(
return { pageLinks, preloadElements }; return { pageLinks, preloadElements };
} }
/**
* Extract only page links from pages (lightweight - no asset extraction).
* Used for building navigation graph without loading all asset URLs.
*
* This is more efficient than extractPageLinksAndElements when you only need
* the navigation structure (e.g., for determining neighbors).
*
* @param pages - Array of pages with ui_schema_json
* @param allPages - Optional: all pages for slug-to-id resolution. If not provided, uses `pages`.
* @returns Array of page links for navigation graph
*/
export function extractPageLinksOnly(
pages: PageWithSchema[],
allPages?: PageWithSchema[],
): PreloadPageLink[] {
const pagesForLookup = allPages || pages;
const pageLinks: PreloadPageLink[] = [];
// Build slug-to-id map for resolving targetPageSlug
const slugToIdMap = new Map<string, string>();
pagesForLookup.forEach((page) => {
if (page.slug) {
slugToIdMap.set(page.slug, page.id);
}
});
pages.forEach((page) => {
const uiSchema = parseUiSchema(page.ui_schema_json);
if (!uiSchema) return;
const pageElements = Array.isArray(uiSchema.elements)
? (uiSchema.elements as Record<string, unknown>[])
: [];
pageElements.forEach((el) => {
// Build synthetic page link for navigation elements
const targetSlug =
el.targetPageSlug && typeof el.targetPageSlug === 'string'
? el.targetPageSlug
: '';
const legacyTargetId =
el.targetPageId && typeof el.targetPageId === 'string'
? el.targetPageId
: '';
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
let resolvedTargetPageId = '';
if (targetSlug) {
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
} else if (legacyTargetId) {
// Legacy: targetPageId might be a slug or an ID
resolvedTargetPageId =
slugToIdMap.get(legacyTargetId) || legacyTargetId;
}
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
pageLinks.push({
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
from_pageId: page.id,
to_pageId: resolvedTargetPageId,
is_active: true,
transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'
? {
id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
video_url: el.transitionVideoUrl,
}
: undefined,
});
}
});
});
return pageLinks;
}
/**
* Extract preload elements only for specified pages (on-demand).
* Used for progressive element loading - only parses ui_schema_json
* for pages that are actually needed (current + neighbors).
*
* @param pages - Array of pages with ui_schema_json
* @param pageIds - Array of page IDs to extract elements for
* @returns Array of preload elements for the specified pages
*
* @example
* // Extract elements only for current page and its neighbors
* const neighborIds = [currentPageId, ...getNeighborIds(currentPageId)];
* const elements = extractElementsForPages(pages, neighborIds);
*/
export function extractElementsForPages(
pages: PageWithSchema[],
pageIds: string[],
): PreloadElement[] {
const preloadElements: PreloadElement[] = [];
const pageIdSet = new Set(pageIds);
pages.forEach((page) => {
// Skip pages not in requested set
if (!pageIdSet.has(page.id)) return;
const uiSchema = parseUiSchema(page.ui_schema_json);
if (!uiSchema) return;
const pageElements = Array.isArray(uiSchema.elements)
? (uiSchema.elements as Record<string, unknown>[])
: [];
pageElements.forEach((el) => {
// Build preload element with asset URLs
const contentObj = extractAssetFields(el);
if (Object.keys(contentObj).length > 0) {
preloadElements.push({
id:
String(el.id || '') ||
`element-${page.id}-${Math.random().toString(36).slice(2)}`,
pageId: page.id,
element_type: String(el.type || ''),
content_json: JSON.stringify(contentObj),
});
}
});
});
return preloadElements;
}

View File

@ -37,6 +37,8 @@ interface DownloadJob {
storageKey: string; // Canonical storage key for consistent caching storageKey: string; // Canonical storage key for consistent caching
createBlobUrl?: boolean; // Create decoded blob URL after download 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)
abortController?: AbortController; abortController?: AbortController;
resolve?: () => void; resolve?: () => void;
reject?: (error: Error) => void; reject?: (error: Error) => void;
@ -51,6 +53,10 @@ 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)
// Prevents re-downloading same partial content on repeated page visits
private partialDownloadsReady: Set<string> = new Set();
private config = { private config = {
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
chunkSize: PRELOAD_CONFIG.videoChunkSize, chunkSize: PRELOAD_CONFIG.videoChunkSize,
@ -73,19 +79,31 @@ class DownloadManagerClass {
storageKey?: string; // Optional, will extract if not provided storageKey?: string; // Optional, will extract if not provided
createBlobUrl?: boolean; // Create blob URL after download 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;
// Check if already downloaded using canonical key // For partial downloads, check session cache (not persisted to storage)
const hasAsset = await StorageManager.hasAsset(storageKey); if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
if (hasAsset) { logger.info('[DownloadManager] Partial download already ready (session)', {
// Already cached - create blob URL if requested storageKey: storageKey.slice(-50),
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) { });
await this.createBlobUrlFromCache(storageKey);
}
return; return;
} }
// Check if already downloaded using canonical key (full downloads only)
if (!isPartialDownload) {
const hasAsset = await StorageManager.hasAsset(storageKey);
if (hasAsset) {
// Already cached - create blob URL if requested
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
await this.createBlobUrlFromCache(storageKey);
}
return;
}
}
// 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) ||
@ -97,6 +115,16 @@ class DownloadManagerClass {
} }
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,
@ -115,8 +143,10 @@ class DownloadManagerClass {
retryCount: 0, retryCount: 0,
addedAt: Date.now(), addedAt: Date.now(),
storageKey, storageKey,
createBlobUrl: params.createBlobUrl ?? false, createBlobUrl: shouldCreateBlobUrl,
persist: params.persist ?? true, persist: shouldPersist,
maxBytes: params.maxBytes,
isPartial: isPartialDownload,
resolve, resolve,
reject, reject,
}; };
@ -213,23 +243,39 @@ 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,
}); });
if (!response.ok) { // Accept both 200 OK and 206 Partial Content
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();
@ -237,6 +283,23 @@ 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)
@ -249,17 +312,25 @@ class DownloadManagerClass {
totalBytes: job.totalBytes, totalBytes: job.totalBytes,
}); });
await OfflineDbManager.updateQueueProgress( // Only update queue progress if persisting
job.id, if (job.persist !== false) {
job.bytesLoaded, await OfflineDbManager.updateQueueProgress(
job.totalBytes, job.id,
); job.bytesLoaded,
job.totalBytes,
);
}
} }
blob = new Blob(chunks, { blob = new Blob(chunks, {
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();
@ -268,18 +339,34 @@ class DownloadManagerClass {
job.progress = 100; job.progress = 100;
} }
// Store the asset using canonical storage key // For partial downloads, don't store to cache (not useful for offline)
await StorageManager.storeAsset(job.storageKey, blob, { // Full downloads are stored for offline access
id: job.assetId, if (!job.isPartial) {
projectId: job.projectId, // Store the asset using canonical storage key
filename: job.filename, await StorageManager.storeAsset(job.storageKey, blob, {
variantType: job.variantType, id: job.assetId,
assetType: job.assetType, projectId: job.projectId,
}); filename: job.filename,
variantType: job.variantType,
assetType: job.assetType,
});
// Create blob URL if requested // Create blob URL if requested
if (job.createBlobUrl) { if (job.createBlobUrl) {
await this.createBlobUrlFromCache(job.storageKey); await this.createBlobUrlFromCache(job.storageKey);
}
} else {
// Mark partial download as ready in session cache
this.partialDownloadsReady.add(job.storageKey);
// 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', {
storageKey: job.storageKey.slice(-50),
bytesLoaded: job.bytesLoaded,
});
} }
// Mark as completed // Mark as completed
@ -502,6 +589,45 @@ class DownloadManagerClass {
return this.readyBlobUrls.get(storageKey) || null; return this.readyBlobUrls.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.
*/
async cacheBlob(
storageKey: string,
blob: Blob,
metadata: {
assetType: AssetType;
projectId?: string;
},
): Promise<string> {
// Store in Cache API / IndexedDB via existing StorageManager
await StorageManager.storeAsset(storageKey, blob, {
id: `cached-${storageKey}`,
projectId: metadata.projectId || '',
filename: storageKey.split('/').pop() || 'asset',
variantType: 'original',
assetType: metadata.assetType,
});
// Create blob URL and register for instant O(1) lookup
const blobUrl = URL.createObjectURL(blob);
this.readyBlobUrls.set(storageKey, blobUrl);
// Emit event for consumers (existing pattern)
downloadEventBus.emitBlobUrlReady({
storageKey,
blobUrl,
});
logger.info('[DownloadManager] Cached external blob', {
storageKey: storageKey.slice(-50),
size: blob.size,
});
return blobUrl;
}
/** /**
* Create blob URL from cached asset and store in readyBlobUrls map * Create blob URL from cached asset and store in readyBlobUrls map
*/ */
@ -543,11 +669,36 @@ class DownloadManagerClass {
} }
/** /**
* Clear blob URLs (call on unmount to prevent memory leaks) * 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)
*/ */
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();
// Clear SW URL mappings (optional, SW has its own cleanup interval)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_URL_MAPPINGS',
});
}
} }
/** /**

View File

@ -93,6 +93,77 @@ const isVideoRequest = (request: Request): boolean => {
); );
}; };
// Check if request is audio
const isAudioRequest = (request: Request): boolean => {
const url = new URL(request.url);
return ['.mp3', '.wav', '.ogg', '.m4a', '.aac'].some((ext) =>
url.pathname.toLowerCase().endsWith(ext),
);
};
/**
* Extract storage path from various URL formats.
* Handles:
* - Presigned S3 URLs: https://s3.../bucket/assets/project/file.mp4?X-Amz-Signature=...
* - Backend proxy URLs: http://localhost:8080/api/file/download?privateUrl=assets%2F...
* - Relative paths: assets/project/file.mp4
*/
const extractStoragePathFromUrl = (url: string): string | null => {
try {
// Backend proxy URL format
if (url.includes('/file/download?privateUrl=')) {
const match = url.match(/privateUrl=([^&]+)/);
if (match) {
return decodeURIComponent(match[1]).replace(/^\/+/, '');
}
}
// Presigned S3 URL
if (url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=')) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
const assetsIndex = pathParts.findIndex((part) => part === 'assets');
if (assetsIndex !== -1) {
return pathParts.slice(assetsIndex).join('/');
}
if (pathParts.length > 1) {
return pathParts.slice(1).join('/');
}
}
// Already a relative storage path (starts with 'assets/')
if (url.startsWith('assets/')) {
return url;
}
// Full S3 URL (non-presigned)
const s3Match = url.match(
/^https?:\/\/[^/]+\.s3\.[^/]+\.amazonaws\.com\/[^/]+\/(assets\/.+)$/,
);
if (s3Match) {
return s3Match[1].split('?')[0]; // Remove query params
}
return null;
} catch {
return null;
}
};
// Storage path → storage key mapping (for caching browser video/audio requests)
// When main thread does partial preload, it registers {storagePath → storageKey}
// SW extracts storage path from any URL format and uses it for cache lookups
const storagePathToKeyMap = new Map<string, string>();
// Clean up old mappings every hour (session cleanup)
setInterval(
() => {
storagePathToKeyMap.clear();
console.log('[SW] Cleared storage path mappings');
},
60 * 60 * 1000,
);
// Initialize Serwist // Initialize Serwist
const serwist = new Serwist({ const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST, precacheEntries: self.__SW_MANIFEST,
@ -117,6 +188,19 @@ const serwist = new Serwist({
cacheName: OFFLINE_CONFIG.cacheNames.assets, cacheName: OFFLINE_CONFIG.cacheNames.assets,
plugins: [ plugins: [
{ {
// Transform URL to storage key for consistent caching
// Matches preload behavior (DownloadManager uses storage keys)
cacheKeyWillBeUsed: async ({ request, mode }) => {
const storagePath = extractStoragePathFromUrl(request.url);
if (storagePath) {
console.log(
`[SW] Using storagePath for static asset ${mode}:`,
storagePath.slice(-40),
);
return new Request(storagePath);
}
return request;
},
cacheWillUpdate: async ({ response }) => { cacheWillUpdate: async ({ response }) => {
if (response && response.status === 200) { if (response && response.status === 200) {
return response; return response;
@ -127,13 +211,39 @@ const serwist = new Serwist({
], ],
}), }),
}, },
// Videos - Cache First with Range Request support // Videos - Cache First with Range Request support and storage key mapping
{ {
matcher: ({ request }) => isVideoRequest(request), matcher: ({ request }) => isVideoRequest(request),
handler: new CacheFirst({ handler: new CacheFirst({
cacheName: OFFLINE_CONFIG.cacheNames.assets, cacheName: OFFLINE_CONFIG.cacheNames.assets,
plugins: [ plugins: [
{ {
// Transform URL to storage key for BOTH cache reads and writes
// Per Serwist: mode='read' for lookups, mode='write' for storing
cacheKeyWillBeUsed: async ({ request, mode }) => {
// Extract storage path from any URL format
const storagePath = extractStoragePathFromUrl(request.url);
if (storagePath) {
// Check if we have a mapping for this storage path
const storageKey = storagePathToKeyMap.get(storagePath);
if (storageKey) {
console.log(
`[SW] Using storageKey for video ${mode}:`,
storageKey.slice(-40),
);
return new Request(storageKey);
}
// No explicit mapping, use extracted storage path as cache key
console.log(
`[SW] Using storagePath for video ${mode}:`,
storagePath.slice(-40),
);
return new Request(storagePath);
}
// No storage path extracted - use original URL (fallback)
return request;
},
// Handle range requests for video seeking // Handle range requests for video seeking
cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { cachedResponseWillBeUsed: async ({ cachedResponse, request }) => {
if (!cachedResponse) return null; if (!cachedResponse) return null;
@ -178,6 +288,77 @@ const serwist = new Serwist({
], ],
}), }),
}, },
// Audio - Cache First with Range Request support and storage key mapping
{
matcher: ({ request }) => isAudioRequest(request),
handler: new CacheFirst({
cacheName: OFFLINE_CONFIG.cacheNames.assets,
plugins: [
{
// Transform URL to storage key for BOTH cache reads and writes
cacheKeyWillBeUsed: async ({ request, mode }) => {
// Extract storage path from any URL format
const storagePath = extractStoragePathFromUrl(request.url);
if (storagePath) {
const storageKey = storagePathToKeyMap.get(storagePath);
if (storageKey) {
console.log(
`[SW] Using storageKey for audio ${mode}:`,
storageKey.slice(-40),
);
return new Request(storageKey);
}
// No explicit mapping, use extracted storage path as cache key
console.log(
`[SW] Using storagePath for audio ${mode}:`,
storagePath.slice(-40),
);
return new Request(storagePath);
}
return request;
},
// Handle range requests for audio seeking
cachedResponseWillBeUsed: async ({ cachedResponse, request }) => {
if (!cachedResponse) return null;
const rangeHeader = request.headers.get('range');
if (!rangeHeader) return cachedResponse;
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) return cachedResponse;
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : undefined;
const blob = await cachedResponse.blob();
const slicedBlob =
end !== undefined
? blob.slice(start, end + 1)
: blob.slice(start);
return new Response(slicedBlob, {
status: 206,
statusText: 'Partial Content',
headers: {
'Content-Type':
cachedResponse.headers.get('Content-Type') || 'audio/mpeg',
'Content-Length': String(slicedBlob.size),
'Content-Range': `bytes ${start}-${end !== undefined ? end : blob.size - 1}/${blob.size}`,
'Accept-Ranges': 'bytes',
},
});
},
cacheWillUpdate: async ({ response }) => {
if (response && response.status === 200) {
return response;
}
return null;
},
},
],
}),
},
// API requests - Network First // API requests - Network First
{ {
matcher: ({ url }) => url.pathname.startsWith('/api/'), matcher: ({ url }) => url.pathname.startsWith('/api/'),
@ -186,15 +367,30 @@ const serwist = new Serwist({
networkTimeoutSeconds: 10, networkTimeoutSeconds: 10,
}), }),
}, },
// Dynamic assets (audio, other cacheable) - Cache First // Dynamic assets (other cacheable, excluding video and audio) - Cache First
// Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here) // Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here)
{ {
matcher: ({ request }) => matcher: ({ request }) =>
isCacheableRequest(request) && !isVideoRequest(request), isCacheableRequest(request) &&
!isVideoRequest(request) &&
!isAudioRequest(request),
handler: new CacheFirst({ handler: new CacheFirst({
cacheName: OFFLINE_CONFIG.cacheNames.assets, cacheName: OFFLINE_CONFIG.cacheNames.assets,
plugins: [ plugins: [
{ {
// Transform URL to storage key for consistent caching
// Matches preload behavior (DownloadManager uses storage keys)
cacheKeyWillBeUsed: async ({ request, mode }) => {
const storagePath = extractStoragePathFromUrl(request.url);
if (storagePath) {
console.log(
`[SW] Using storagePath for dynamic asset ${mode}:`,
storagePath.slice(-40),
);
return new Request(storagePath);
}
return request;
},
cacheWillUpdate: async ({ response }) => { cacheWillUpdate: async ({ response }) => {
if (response && response.status === 200) { if (response && response.status === 200) {
return response; return response;
@ -215,6 +411,29 @@ self.addEventListener('message', (event) => {
const { type, payload } = event.data || {}; const { type, payload } = event.data || {};
switch (type) { switch (type) {
case 'REGISTER_CACHE_URL':
// Register storage path → storage key mapping for media caching
// Main thread sends this after partial preload; when browser fetches
// the full media during playback, we cache using the storage key
if (payload?.storageKey) {
// Extract storage path from presigned URL (or use storageKey directly)
const storagePath = payload.presignedUrl
? extractStoragePathFromUrl(payload.presignedUrl) || payload.storageKey
: payload.storageKey;
storagePathToKeyMap.set(storagePath, payload.storageKey);
console.log('[SW] Registered storage path for caching', {
storagePath: storagePath.slice(-50),
storageKey: payload.storageKey.slice(-50),
});
}
break;
case 'CLEAR_URL_MAPPINGS':
// Clear storage path mappings (called on cleanup/unmount)
storagePathToKeyMap.clear();
console.log('[SW] Storage path mappings cleared');
break;
case 'CACHE_ASSETS': case 'CACHE_ASSETS':
// Cache specific assets for a project/page // Cache specific assets for a project/page
if (Array.isArray(payload?.urls)) { if (Array.isArray(payload?.urls)) {
@ -256,13 +475,14 @@ self.addEventListener('message', (event) => {
break; break;
case 'CLEAR_CACHE': case 'CLEAR_CACHE':
// Clear all dynamic caches // Clear all dynamic caches and storage path mappings
storagePathToKeyMap.clear();
event.waitUntil( event.waitUntil(
Promise.all([ Promise.all([
caches.delete(OFFLINE_CONFIG.cacheNames.dynamic), caches.delete(OFFLINE_CONFIG.cacheNames.dynamic),
caches.delete(OFFLINE_CONFIG.cacheNames.assets), caches.delete(OFFLINE_CONFIG.cacheNames.assets),
]).then(() => { ]).then(() => {
console.log('[SW] Caches cleared'); console.log('[SW] Caches and storage path mappings cleared');
}), }),
); );
break; break;