Files
hyperframes/packages/studio/src/player/components/thumbnailUtils.ts
T

133 lines
4.4 KiB
TypeScript

/** 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<number> {
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}`;
}