You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

324 lines
9.8 KiB

/**
* Utility functions for calculating node dimensions based on output aspect ratio.
*/
/**
* Extract dimensions from a base64 data URL image.
* @param base64DataUrl - The image as a base64 data URL (e.g., "data:image/png;base64,...")
* @returns Promise resolving to {width, height} or null if extraction fails
*/
export function getImageDimensions(
base64DataUrl: string
): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
if (!base64DataUrl || !base64DataUrl.startsWith("data:image")) {
resolve(null);
return;
}
let resolved = false;
const img = new Image();
const cleanup = () => {
img.onload = null;
img.onerror = null;
img.src = "";
};
const safeResolve = (value: { width: number; height: number } | null) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(value);
};
const timeout = setTimeout(() => safeResolve(null), 10_000);
img.onload = () => {
clearTimeout(timeout);
safeResolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
clearTimeout(timeout);
safeResolve(null);
};
img.src = base64DataUrl;
});
}
/**
* Extract dimensions from a video data URL or blob URL.
* @param videoUrl - The video as a data URL or blob URL
* @returns Promise resolving to {width, height} or null if extraction fails
*/
export function getVideoDimensions(
videoUrl: string
): Promise<{ width: number; height: number } | null> {
return new Promise((resolve) => {
if (!videoUrl) {
resolve(null);
return;
}
let resolved = false;
const video = document.createElement("video");
video.preload = "metadata";
const cleanup = () => {
video.onloadedmetadata = null;
video.onerror = null;
video.src = "";
video.load();
};
const safeResolve = (value: { width: number; height: number } | null) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(value);
};
const timeout = setTimeout(() => safeResolve(null), 10_000);
video.onloadedmetadata = () => {
clearTimeout(timeout);
safeResolve({ width: video.videoWidth, height: video.videoHeight });
};
video.onerror = () => {
clearTimeout(timeout);
safeResolve(null);
};
video.src = videoUrl;
});
}
/**
* Detect media type from URL and return dimensions using the appropriate loader.
* Handles data:image/*, data:video/*, blob:*, and http(s) URLs.
*/
export function getMediaDimensions(
url: string | null | undefined
): Promise<{ width: number; height: number } | null> {
if (!url) return Promise.resolve(null);
if (url.startsWith("data:image")) {
return getImageDimensions(url);
}
// data:video/*, blob:*, or http(s) URLs → treat as video
if (
url.startsWith("data:video") ||
url.startsWith("blob:") ||
url.startsWith("http")
) {
return getVideoDimensions(url);
}
return Promise.resolve(null);
}
/**
* Calculate a node size that matches the given aspect ratio, preferring to grow.
* No min/max clamping — the node sizes freely to fit the content.
*
* @param aspectRatio - content width / content height
* @param currentWidth - the node's current width
* @param currentHeight - the node's current height
* @param fullBleed - if true, skip chrome height offset
* @returns {width, height} that preserves the aspect ratio at the larger-area candidate
*/
export function calculateAspectFitSize(
aspectRatio: number,
currentWidth: number,
currentHeight: number,
fullBleed: boolean = false
): { width: number; height: number } {
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: currentWidth, height: currentHeight };
}
const chromeHeight = fullBleed ? 0 : NODE_CHROME_HEIGHT;
// Candidate A: keep current width, adjust height
const heightA = currentWidth / aspectRatio + chromeHeight;
const areaA = currentWidth * heightA;
// Candidate B: keep current height, adjust width
const widthB = (currentHeight - chromeHeight) * aspectRatio;
const areaB = widthB * currentHeight;
if (areaA >= areaB) {
return { width: Math.round(currentWidth), height: Math.round(heightA) };
}
return { width: Math.round(widthB), height: Math.round(currentHeight) };
}
// Node sizing constraints
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
const MIN_HEIGHT = 200;
const MAX_HEIGHT = 600;
// Node chrome: header (~40px), controls/padding (~60px)
const NODE_CHROME_HEIGHT = 100;
/**
* Calculate node dimensions that maintain aspect ratio within constraints.
* @param aspectRatio - Width divided by height (e.g., 16/9 for landscape, 9/16 for portrait)
* @param baseWidth - Starting width to calculate from (default 300px)
* @returns {width, height} dimensions that fit within constraints
*/
export function calculateNodeSize(
aspectRatio: number,
baseWidth: number = 300
): { width: number; height: number } {
// Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: 300, height: 300 }; // Return default square
}
// Start with base width and calculate content height
let width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, baseWidth));
// Calculate content area height based on aspect ratio
// Content height = width / aspectRatio
let contentHeight = width / aspectRatio;
let totalHeight = contentHeight + NODE_CHROME_HEIGHT;
// Check if height exceeds max - if so, scale down width to fit
if (totalHeight > MAX_HEIGHT) {
contentHeight = MAX_HEIGHT - NODE_CHROME_HEIGHT;
width = contentHeight * aspectRatio;
totalHeight = MAX_HEIGHT;
}
// Check if height is below min - if so, scale up width to fit
if (totalHeight < MIN_HEIGHT) {
contentHeight = MIN_HEIGHT - NODE_CHROME_HEIGHT;
width = contentHeight * aspectRatio;
totalHeight = MIN_HEIGHT;
}
// Clamp width to constraints
if (width > MAX_WIDTH) {
width = MAX_WIDTH;
contentHeight = width / aspectRatio;
totalHeight = contentHeight + NODE_CHROME_HEIGHT;
// Re-clamp height
totalHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, totalHeight));
}
if (width < MIN_WIDTH) {
width = MIN_WIDTH;
contentHeight = width / aspectRatio;
totalHeight = contentHeight + NODE_CHROME_HEIGHT;
// Re-clamp height
totalHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, totalHeight));
}
return {
width: Math.round(width),
height: Math.round(totalHeight),
};
}
/**
* Calculate node dimensions while preserving the user's manually set height.
* When a user manually resizes a node, we should maintain their height preference
* and only adjust the width to match the new content's aspect ratio.
*
* @param aspectRatio - Width divided by height of the content
* @param currentHeight - The node's current height (if manually set)
* @param skipChromeOffset - If true, skip subtracting NODE_CHROME_HEIGHT (for full-bleed nodes with floating headers)
* @returns {width, height} dimensions that preserve height when possible
*/
export function calculateNodeSizePreservingHeight(
aspectRatio: number,
currentHeight?: number,
skipChromeOffset: boolean = false
): { width: number; height: number } {
// Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: 300, height: 300 };
}
// No current height or below minimum = use default behavior
if (!currentHeight || currentHeight < MIN_HEIGHT) {
return skipChromeOffset ? calculateNodeSizeForFullBleed(aspectRatio) : calculateNodeSize(aspectRatio);
}
// Preserve height, calculate width to maintain aspect ratio
const chromeHeight = skipChromeOffset ? 0 : NODE_CHROME_HEIGHT;
const contentHeight = currentHeight - chromeHeight;
let newWidth = contentHeight * aspectRatio;
// Clamp width to constraints
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
return {
width: Math.round(newWidth),
height: Math.round(currentHeight),
};
}
/**
* Calculate node dimensions for full-bleed content (no header chrome).
* Used by nodes with floating headers where content fills the entire node area.
*
* @param aspectRatio - Width divided by height (e.g., 16/9 for landscape, 9/16 for portrait)
* @param currentHeight - Optional current height to preserve (if manually resized)
* @returns {width, height} dimensions that fit within constraints
*/
export function calculateNodeSizeForFullBleed(
aspectRatio: number,
currentHeight?: number
): { width: number; height: number } {
// Handle invalid aspect ratios
if (!aspectRatio || aspectRatio <= 0 || !isFinite(aspectRatio)) {
return { width: 300, height: 300 }; // Return default square
}
// If preserving height, calculate width
if (currentHeight && currentHeight >= MIN_HEIGHT) {
let width = currentHeight * aspectRatio;
width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
return {
width: Math.round(width),
height: Math.round(currentHeight),
};
}
// Start with base width and calculate height
let width = 300; // Default starting width
let height = width / aspectRatio;
// Check if height exceeds max - if so, scale down width to fit
if (height > MAX_HEIGHT) {
height = MAX_HEIGHT;
width = height * aspectRatio;
}
// Check if height is below min - if so, scale up width to fit
if (height < MIN_HEIGHT) {
height = MIN_HEIGHT;
width = height * aspectRatio;
}
// Clamp width to constraints
if (width > MAX_WIDTH) {
width = MAX_WIDTH;
height = width / aspectRatio;
// Re-clamp height
height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
if (width < MIN_WIDTH) {
width = MIN_WIDTH;
height = width / aspectRatio;
// Re-clamp height
height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, height));
}
return {
width: Math.round(width),
height: Math.round(height),
};
}