diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index 7f7ccf9..392eaef 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -29,7 +29,10 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePageDataLoader } from '../hooks/usePageDataLoader'; import { useProjectAssets } from '../hooks/useProjectAssets'; import { usePageNavigation } from '../hooks/usePageNavigation'; -import { extractPageLinksAndElements } from '../lib/extractPageLinks'; +import { + extractPageLinksOnly, + extractElementsForPages, +} from '../lib/extractPageLinks'; import { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; @@ -99,24 +102,47 @@ export default function RuntimePresentation({ // Note: Initial page selection is handled by usePageNavigation hook via defaultPageId - // Extract page links and preload elements from ui_schema_json - // This enables the neighbor graph to find connected pages for preloading - const { pageLinks, preloadElements } = useMemo(() => { - const result = extractPageLinksAndElements(pages); - if (result.pageLinks.length > 0 || result.preloadElements.length > 0) { - logger.info('[PRELOAD] Extracted page links and elements', { - pageLinksCount: result.pageLinks.length, - preloadElementsCount: result.preloadElements.length, - pageLinks: result.pageLinks.map((link) => ({ + // Phase 1: Extract pageLinks from ALL pages (needed for navigation graph) + // This is lightweight - only extracts navigation structure, not asset URLs + const pageLinks = useMemo(() => { + const links = extractPageLinksOnly(pages); + if (links.length > 0) { + logger.info('[PRELOAD] Extracted page links', { + count: links.length, + links: links.map((link) => ({ from: link.from_pageId?.slice(-8), to: link.to_pageId?.slice(-8), hasTransition: !!link.transition?.video_url, })), }); } - return result; + return links; }, [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(); + 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 const preloadOrchestrator = usePreloadOrchestrator({ pages, diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts index c611f68..6ac8f91 100644 --- a/frontend/src/config/preload.config.ts +++ b/frontend/src/config/preload.config.ts @@ -20,10 +20,10 @@ export const PRELOAD_CONFIG = { currentPage: 1000, neighborBase: 500, assetType: { - transition: 150, // Highest - needed immediately on navigation click - image: 100, // Backgrounds load during transition playback + image: 100, // Backgrounds load first audio: 50, video: 30, + // Note: transitions are cached on first playback, not preloaded } as Record, variant: { thumbnail: 50, @@ -65,6 +65,16 @@ export const PRELOAD_CONFIG = { 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) assetFields: { // All asset URL fields for preloading extraction diff --git a/frontend/src/hooks/useNeighborGraph.ts b/frontend/src/hooks/useNeighborGraph.ts index 73d6109..b27f548 100644 --- a/frontend/src/hooks/useNeighborGraph.ts +++ b/frontend/src/hooks/useNeighborGraph.ts @@ -73,12 +73,15 @@ function extractAssetsFromContent( for (const [key, value] of Object.entries(obj)) { 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(); - let assetType: 'transition' | 'video' | 'audio' | 'image'; if (lowerKey.includes('transition')) { - assetType = 'transition'; - } else if (lowerKey.includes('video')) { + continue; // Skip transitions + } + + let assetType: 'video' | 'audio' | 'image'; + if (lowerKey.includes('video')) { assetType = 'video'; } else if (lowerKey.includes('audio')) { assetType = 'audio'; @@ -183,6 +186,41 @@ export function useNeighborGraph( const seenUrls = new Set(); 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 const pageElements = elements.filter((el) => el.pageId === pageId); @@ -207,22 +245,12 @@ export function useNeighborGraph( link.is_active !== false && pageIds.includes(link.from_pageId || ''), ); - matchingLinks.forEach((link) => { - const videoUrl = link.transition?.video_url; - if (videoUrl && !seenUrls.has(videoUrl)) { - seenUrls.add(videoUrl); - assets.push({ - url: videoUrl, - pageId: link.from_pageId || '', - assetType: 'transition', - priority: 0, - }); - } - }); + // Note: Transition videos are NOT extracted for preloading. + // They are cached on first playback via useTransitionPlayback.cacheBlob() return assets; }; - }, [elements, pageLinks]); + }, [pages, elements, pageLinks]); // Get prioritized assets for preloading const getPrioritizedAssets = useMemo(() => { diff --git a/frontend/src/hooks/usePageSwitch.ts b/frontend/src/hooks/usePageSwitch.ts index 572ff24..762fb0c 100644 --- a/frontend/src/hooks/usePageSwitch.ts +++ b/frontend/src/hooks/usePageSwitch.ts @@ -17,8 +17,9 @@ import { resolveAssetPlaybackUrl, markPresignedUrlFailed, isRelativeStoragePath, + isPresignedUrl, + buildProxyUrl, } from '../lib/assetUrl'; -import { baseURLApi } from '../config'; import { logger } from '../lib/logger'; /** @@ -95,21 +96,6 @@ export interface UsePageSwitchResult { 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. * Returns the URL that successfully loaded. diff --git a/frontend/src/hooks/usePreloadOrchestrator.ts b/frontend/src/hooks/usePreloadOrchestrator.ts index 9650db0..7f8954f 100644 --- a/frontend/src/hooks/usePreloadOrchestrator.ts +++ b/frontend/src/hooks/usePreloadOrchestrator.ts @@ -20,8 +20,9 @@ import { isRelativeStoragePath, markPresignedUrlFailed, markPresignedUrlsVerified, + isPresignedUrl, + buildProxyUrl, } from '../lib/assetUrl'; -import { baseURLApi } from '../config'; import { logger } from '../lib/logger'; import type { BlobUrlReadyEvent } from '../types/offline'; import type { @@ -30,21 +31,6 @@ import type { PreloadElement, } 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 { pages: PreloadPage[]; pageLinks: PreloadPageLink[]; @@ -541,6 +527,28 @@ export function usePreloadOrchestrator( const addAssetsToQueue = async ( presignedUrls: Record = {}, ) => { + // 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 const createDownloadJob = ( id: string, @@ -549,6 +557,11 @@ export function usePreloadOrchestrator( assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', pageId: string, ): Promise | null => { + // Skip transitions - they're cached on first playback via useTransitionPlayback + if (assetType === 'transition') { + return null; + } + const resolvedUrl = resolveUrl(storageKey, presignedUrls); if (!resolvedUrl) return null; @@ -561,6 +574,12 @@ export function usePreloadOrchestrator( preloadedUrls.add(normalizedKey); + // Determine if partial preload applies (neighbor pages only, media files only) + const isNeighborPage = pageId !== currentPageId; + const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage); + // For partial downloads, don't create blob URL - playback uses presigned URL + const createBlobUrl = maxBytes === undefined; + return downloadManager .addJob({ assetId: id, @@ -571,8 +590,9 @@ export function usePreloadOrchestrator( assetType: mapAssetType(assetType), priority, storageKey: normalizedKey, - createBlobUrl: true, + createBlobUrl, persist: false, + maxBytes, }) .then(() => { if (isPresignedUrl(resolvedUrl)) { @@ -598,8 +618,9 @@ export function usePreloadOrchestrator( assetType: mapAssetType(assetType), priority, storageKey: normalizedKey, - createBlobUrl: true, + createBlobUrl, persist: false, + maxBytes, // Preserve partial preload behavior for retry }); } catch { // 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[] = []; + const currentPageImageJobs: Promise[] = []; - // Current page background assets + // Current page IMAGE background - WAIT for this (essential for visual) if (currentPage?.background_image_url) { const job = createDownloadJob( `bg-img-${currentPageId}`, @@ -624,58 +646,70 @@ export function usePreloadOrchestrator( 'image', 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) { - const job = createDownloadJob( + createDownloadJob( `bg-vid-${currentPageId}`, currentPage.background_video_url, PRELOAD_CONFIG.priority.currentPage + 150, 'video', currentPageId, ); - if (job) currentPageJobs.push(job); + // Not pushed to awaited jobs - video streams on its own } if (currentPage?.background_audio_url) { - const job = createDownloadJob( + createDownloadJob( `bg-aud-${currentPageId}`, currentPage.background_audio_url, PRELOAD_CONFIG.priority.currentPage + 100, 'audio', 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( (asset) => asset.pageId === currentPageId, ); currentPageAssets.forEach((asset) => { - const job = createDownloadJob( + createDownloadJob( generateJobId(), asset.url, asset.priority, asset.assetType, 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 const neighborAssets = assets.filter( (asset) => asset.pageId !== currentPageId, diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 89cc169..5579513 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -8,8 +8,15 @@ import { } from 'react'; import axios from 'axios'; import { logger } from '../lib/logger'; -import { markPresignedUrlFailed, isRelativeStoragePath } from '../lib/assetUrl'; -import { baseURLApi } from '../config'; +import { + markPresignedUrlFailed, + isRelativeStoragePath, + resolveAssetPlaybackUrl, + isPresignedUrl, + buildProxyUrl, + extractStoragePath, +} from '../lib/assetUrl'; +import { downloadManager } from '../lib/offline/DownloadManager'; import { useReversePlayback } from './useReversePlayback'; export type ReverseMode = 'none' | 'reverse' | 'separate'; @@ -107,43 +114,6 @@ function buildBlobRequestUrl(url: string): string { 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 { 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', { 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 = typeof window !== 'undefined' ? 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); - lastLoadedBlobUrlRef.current = blobUrl; - lastLoadedSourceUrlRef.current = sourceUrl; - logger.info('Created blob URL for video', { - blobUrl: blobUrl.substring(0, 50), - }); - return blobUrl; + // Helper: Fetch video and return blob URL, caching for next time + const fetchVideoAsBlob = async (url: string): Promise => { + logger.info('Fetching video from URL', { + url: url.slice(0, 80), + isPresigned: isPresignedUrl(url), + }); + + 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 () => { @@ -720,12 +737,10 @@ export function useTransitionPlayback( markPresignedUrlFailed(originalVideoUrl); } - // Get proxy fallback URL - const fallbackUrl = getProxyUrlFallback( - currentUrl, - currentTransition.videoUrl, - ); - if (fallbackUrl) { + // Get proxy fallback URL using storage key + const videoStorageKey = currentTransition.videoUrl; + if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) { + const fallbackUrl = buildProxyUrl(videoStorageKey); didTryFallbackRef.current = true; video.pause(); video.src = fallbackUrl; diff --git a/frontend/src/lib/assetUrl.ts b/frontend/src/lib/assetUrl.ts index a61dd37..6fe9490 100644 --- a/frontend/src/lib/assetUrl.ts +++ b/frontend/src/lib/assetUrl.ts @@ -16,6 +16,21 @@ const isPresignedS3Url = (url: string): boolean => { 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. * Called once during app initialization. diff --git a/frontend/src/lib/extractPageLinks.ts b/frontend/src/lib/extractPageLinks.ts index e244478..4218040 100644 --- a/frontend/src/lib/extractPageLinks.ts +++ b/frontend/src/lib/extractPageLinks.ts @@ -180,3 +180,130 @@ export function extractPageLinksAndElements( 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(); + 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[]) + : []; + + 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[]) + : []; + + 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; +} diff --git a/frontend/src/lib/offline/DownloadManager.ts b/frontend/src/lib/offline/DownloadManager.ts index f3a6871..9114eb4 100644 --- a/frontend/src/lib/offline/DownloadManager.ts +++ b/frontend/src/lib/offline/DownloadManager.ts @@ -37,6 +37,8 @@ interface DownloadJob { storageKey: string; // Canonical storage key for consistent caching createBlobUrl?: boolean; // Create decoded blob URL after download persist?: boolean; // Persist to IndexedDB for resume (default: true) + maxBytes?: number; // Partial download limit (undefined = full download) + isPartial?: boolean; // Whether this was a partial download (for tracking) abortController?: AbortController; resolve?: () => void; reject?: (error: Error) => void; @@ -51,6 +53,10 @@ class DownloadManagerClass { // Blob URL cache for instant lookup (storageKey → blobUrl) private readyBlobUrls: Map = new Map(); + // Track partial downloads completed in this session (not persisted) + // Prevents re-downloading same partial content on repeated page visits + private partialDownloadsReady: Set = new Set(); + private config = { maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, chunkSize: PRELOAD_CONFIG.videoChunkSize, @@ -73,19 +79,31 @@ class DownloadManagerClass { storageKey?: string; // Optional, will extract if not provided createBlobUrl?: boolean; // Create blob URL after download persist?: boolean; // Persist to IndexedDB for resume (default: true) + maxBytes?: number; // Download limit in bytes (for partial preload) }): Promise { const storageKey = params.storageKey || extractStoragePath(params.url); + const isPartialDownload = params.maxBytes !== undefined; - // Check if already downloaded using canonical key - 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); - } + // For partial downloads, check session cache (not persisted to storage) + if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) { + logger.info('[DownloadManager] Partial download already ready (session)', { + storageKey: storageKey.slice(-50), + }); 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) if ( this.queue.some((j) => j.storageKey === storageKey) || @@ -97,6 +115,16 @@ class DownloadManagerClass { } return new Promise((resolve, reject) => { + // For partial downloads, don't persist and don't create blob URL + // (video will play from presigned URL, browser handles buffering) + const isPartialDownload = params.maxBytes !== undefined; + const shouldPersist = isPartialDownload + ? false + : (params.persist ?? true); + const shouldCreateBlobUrl = isPartialDownload + ? false + : (params.createBlobUrl ?? false); + const job: DownloadJob = { id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, assetId: params.assetId, @@ -115,8 +143,10 @@ class DownloadManagerClass { retryCount: 0, addedAt: Date.now(), storageKey, - createBlobUrl: params.createBlobUrl ?? false, - persist: params.persist ?? true, + createBlobUrl: shouldCreateBlobUrl, + persist: shouldPersist, + maxBytes: params.maxBytes, + isPartial: isPartialDownload, resolve, reject, }; @@ -213,23 +243,39 @@ class DownloadManagerClass { }); try { + // Build request headers - use Range header for partial downloads + const headers: HeadersInit = {}; + if (job.maxBytes) { + headers['Range'] = `bytes=0-${job.maxBytes - 1}`; + logger.info('[DownloadManager] Partial download requested', { + url: job.url.slice(-50), + maxBytes: job.maxBytes, + }); + } + const response = await fetch(job.url, { signal: job.abortController.signal, + headers, }); - 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}`); } const contentLength = response.headers.get('content-length'); job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0; + // For partial downloads, track if we reached the limit + const isPartialResponse = response.status === 206 || job.maxBytes; + let blob: Blob; if (response.body) { // Stream with progress tracking const reader = response.body.getReader(); const chunks: BlobPart[] = []; + let reachedLimit = false; while (true) { const { done, value } = await reader.read(); @@ -237,6 +283,23 @@ class DownloadManagerClass { chunks.push(value); job.bytesLoaded += value.length; + + // Check if we've reached the maxBytes limit + if (job.maxBytes && job.bytesLoaded >= job.maxBytes) { + reachedLimit = true; + logger.info('[DownloadManager] Reached partial download limit', { + bytesLoaded: job.bytesLoaded, + maxBytes: job.maxBytes, + }); + // Cancel the remaining download gracefully + try { + await reader.cancel(); + } catch { + // Ignore cancel errors - stream may already be closed + } + break; + } + job.progress = job.totalBytes > 0 ? Math.round((job.bytesLoaded / job.totalBytes) * 100) @@ -249,17 +312,25 @@ class DownloadManagerClass { totalBytes: job.totalBytes, }); - await OfflineDbManager.updateQueueProgress( - job.id, - job.bytesLoaded, - job.totalBytes, - ); + // Only update queue progress if persisting + if (job.persist !== false) { + await OfflineDbManager.updateQueueProgress( + job.id, + job.bytesLoaded, + job.totalBytes, + ); + } } blob = new Blob(chunks, { type: response.headers.get('content-type') || 'application/octet-stream', }); + + // For partial downloads, mark as complete even if we didn't get everything + if (reachedLimit || isPartialResponse) { + job.progress = 100; // Consider partial download as "complete" + } } else { // No streaming, get blob directly blob = await response.blob(); @@ -268,18 +339,34 @@ class DownloadManagerClass { job.progress = 100; } - // Store the asset using canonical storage key - await StorageManager.storeAsset(job.storageKey, blob, { - id: job.assetId, - projectId: job.projectId, - filename: job.filename, - variantType: job.variantType, - assetType: job.assetType, - }); + // For partial downloads, don't store to cache (not useful for offline) + // Full downloads are stored for offline access + if (!job.isPartial) { + // Store the asset using canonical storage key + await StorageManager.storeAsset(job.storageKey, blob, { + id: job.assetId, + projectId: job.projectId, + filename: job.filename, + variantType: job.variantType, + assetType: job.assetType, + }); - // Create blob URL if requested - if (job.createBlobUrl) { - await this.createBlobUrlFromCache(job.storageKey); + // Create blob URL if requested + if (job.createBlobUrl) { + 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 @@ -502,6 +589,45 @@ class DownloadManagerClass { 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 { + // 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 */ @@ -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 { this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl)); this.readyBlobUrls.clear(); + this.partialDownloadsReady.clear(); + + // Clear SW URL mappings (optional, SW has its own cleanup interval) + if (navigator.serviceWorker?.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'CLEAR_URL_MAPPINGS', + }); + } } /** diff --git a/frontend/src/sw.ts b/frontend/src/sw.ts index 55f114b..e62b784 100644 --- a/frontend/src/sw.ts +++ b/frontend/src/sw.ts @@ -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(); + +// Clean up old mappings every hour (session cleanup) +setInterval( + () => { + storagePathToKeyMap.clear(); + console.log('[SW] Cleared storage path mappings'); + }, + 60 * 60 * 1000, +); + // Initialize Serwist const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, @@ -117,6 +188,19 @@ const serwist = new Serwist({ cacheName: OFFLINE_CONFIG.cacheNames.assets, 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 }) => { if (response && response.status === 200) { 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), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, 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 cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { 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 { matcher: ({ url }) => url.pathname.startsWith('/api/'), @@ -186,15 +367,30 @@ const serwist = new Serwist({ 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) { matcher: ({ request }) => - isCacheableRequest(request) && !isVideoRequest(request), + isCacheableRequest(request) && + !isVideoRequest(request) && + !isAudioRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, 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 }) => { if (response && response.status === 200) { return response; @@ -215,6 +411,29 @@ self.addEventListener('message', (event) => { const { type, payload } = event.data || {}; 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': // Cache specific assets for a project/page if (Array.isArray(payload?.urls)) { @@ -256,13 +475,14 @@ self.addEventListener('message', (event) => { break; case 'CLEAR_CACHE': - // Clear all dynamic caches + // Clear all dynamic caches and storage path mappings + storagePathToKeyMap.clear(); event.waitUntil( Promise.all([ caches.delete(OFFLINE_CONFIG.cacheNames.dynamic), caches.delete(OFFLINE_CONFIG.cacheNames.assets), ]).then(() => { - console.log('[SW] Caches cleared'); + console.log('[SW] Caches and storage path mappings cleared'); }), ); break;