/** Rendered height of a timeline-clip thumbnail strip, in CSS px. */ export const THUMBNAIL_CLIP_HEIGHT = 66; export interface ThumbnailStripLayout { /** Width of a single tile, in CSS px. */ frameW: number; /** Number of tiles needed to fill the container. */ frameCount: number; } /** * Measure an image without mounting it in React's DOM. The scheduler owns the * abort signal, so an unmounted clip cannot leave Blink retaining a pending * image request and its former React tree. */ export function probeImageAspect( imageSrc: string, signal: AbortSignal, tolerateSvgError = false, ): Promise { return new Promise((resolve, reject) => { const image = new Image(); const cleanup = () => { image.onload = null; image.onerror = null; signal.removeEventListener("abort", onAbort); }; const onAbort = () => { cleanup(); image.src = ""; reject(new DOMException("Aborted", "AbortError")); }; if (signal.aborted) { onAbort(); return; } image.onload = () => { cleanup(); resolve( image.naturalWidth > 0 && image.naturalHeight > 0 ? image.naturalWidth / image.naturalHeight : 16 / 9, ); }; image.onerror = () => { cleanup(); if (tolerateSvgError && /\.svg($|\?)/i.test(imageSrc)) resolve(16 / 9); else reject(new Error("Image thumbnail failed to load")); }; signal.addEventListener("abort", onAbort, { once: true }); image.src = imageSrc; }); } /** * Compute the film-strip tile layout for a clip thumbnail: fixed-height tiles * sized by the media's aspect ratio, repeated to fill the clip width. * Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9. */ export function computeThumbnailStrip( containerWidth: number, aspect: number, clipHeight: number = THUMBNAIL_CLIP_HEIGHT, minFrameWidth = 1, ): ThumbnailStripLayout { const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9; const frameW = Math.max(minFrameWidth, Math.round(clipHeight * safeAspect)); const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1; return { frameW, frameCount }; } /** * Percent-encode each segment of a composition-relative media path so filenames * containing spaces, parentheses, a U+202F narrow no-break space (the macOS * screenshot artifact), or any other non-ASCII / URL-unsafe character yield a * valid URL instead of a 404. Slashes are preserved as separators. * * Shared by every timeline media-URL builder (filmstrip thumbnails, audio * waveform, sub-composition preview) so they encode identically to the assets * panel — a raw segment 404s on the exact filenames the assets panel loads fine. */ export function encodePreviewPath(relativePath: string): string { return relativePath.split("/").map(encodeURIComponent).join("/"); } /** * Resolve a timeline element's media src to a URL loadable from the studio * (parent) document. Composition-relative paths (e.g. "assets/image.png") are * routed through the project preview endpoint with each segment encoded. * * External http(s), `data:`, and `blob:` URLs pass through untouched. A * same-origin absolute URL outside the project preview endpoint is the browser's * resolved form of a root-relative authored path, so route it back through the * active project instead of accidentally fetching the Studio shell. */ export function resolveMediaPreviewUrl( src: string, projectId: string, studioOrigin?: string, ): string { if (!src) return src; if (/^(?:data:|blob:)/i.test(src)) return src; let relativePath = src; let suffix = ""; if (/^https?:/i.test(src)) { let parsed: URL; try { parsed = new URL(src); } catch { return src; } if (!studioOrigin || parsed.origin !== studioOrigin) return src; const previewPath = new URL(`/api/projects/${projectId}/preview/`, studioOrigin).pathname; if (parsed.pathname.startsWith(previewPath)) return src; if (parsed.pathname.startsWith("/api/")) return src; try { relativePath = parsed.pathname .replace(/^\/+/, "") .split("/") .map(decodeURIComponent) .join("/"); } catch { return src; } suffix = `${parsed.search}${parsed.hash}`; } return `/api/projects/${projectId}/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`; }