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 { 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<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
const preloadOrchestrator = usePreloadOrchestrator({
pages,

View File

@ -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<string, number>,
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

View File

@ -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<string>();
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(() => {

View File

@ -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.

View File

@ -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<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
const createDownloadJob = (
id: string,
@ -549,6 +557,11 @@ export function usePreloadOrchestrator(
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
pageId: string,
): Promise<void> | 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<void>[] = [];
const currentPageImageJobs: Promise<void>[] = [];
// 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,

View File

@ -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<void> {
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<string> => {
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;

View File

@ -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.

View File

@ -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<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
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<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 = {
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<void> {
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<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
*/
@ -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',
});
}
}
/**

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
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;