OpenCut/apps/web/src/lib/preview/element-bounds.ts

231 lines
6.3 KiB
TypeScript

import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { MediaAsset } from "@/types/assets";
import { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
export interface ElementBounds {
cx: number;
cy: number;
width: number;
height: number;
rotation: number;
}
export interface ElementWithBounds {
trackId: string;
elementId: string;
element: TimelineElement;
bounds: ElementBounds;
}
function getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform,
}: {
canvasWidth: number;
canvasHeight: number;
sourceWidth: number;
sourceHeight: number;
transform: {
scale: number;
position: { x: number; y: number };
rotate: number;
};
}): ElementBounds {
const containScale = Math.min(
canvasWidth / sourceWidth,
canvasHeight / sourceHeight,
);
const scaledWidth = sourceWidth * containScale * transform.scale;
const scaledHeight = sourceHeight * containScale * transform.scale;
const cx = canvasWidth / 2 + transform.position.x;
const cy = canvasHeight / 2 + transform.position.y;
return {
cx,
cy,
width: scaledWidth,
height: scaledHeight,
rotation: transform.rotate,
};
}
export function getElementBounds({
element,
canvasSize,
mediaAsset,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
}): ElementBounds | null {
if (element.type === "audio") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth,
sourceHeight,
transform: element.transform,
});
}
if (element.type === "sticker") {
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: 200,
sourceHeight: 200,
transform: element.transform,
});
}
if (element.type === "text") {
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const letterSpacing = element.letterSpacing ?? 0;
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
const lineHeightPx = scaledFontSize * lineHeight;
let measuredWidth = 100;
let measuredHeight = scaledFontSize;
const canvas = document.createElement("canvas");
canvas.width = 4096;
canvas.height = 4096;
const ctx = canvas.getContext("2d");
if (ctx) {
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
ctx.textAlign = element.textAlign as CanvasTextAlign;
if ("letterSpacing" in ctx) {
(
ctx as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing = `${letterSpacing}px`;
}
const lines = element.content.split("\n");
const lineMetrics = lines.map((line) => ctx.measureText(line));
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
background: element.background,
fontSizeRatio,
});
measuredWidth = visualRect.width;
measuredHeight = visualRect.height;
const localCenterX = visualRect.left + visualRect.width / 2;
const localCenterY = visualRect.top + visualRect.height / 2;
const scaledCenterX = localCenterX * element.transform.scale;
const scaledCenterY = localCenterY * element.transform.scale;
const rotationRad = (element.transform.rotate * Math.PI) / 180;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
return {
cx: canvasWidth / 2 + element.transform.position.x + rotatedCenterX,
cy: canvasHeight / 2 + element.transform.position.y + rotatedCenterY,
width: measuredWidth * element.transform.scale,
height: measuredHeight * element.transform.scale,
rotation: element.transform.rotate,
};
}
const width = measuredWidth * element.transform.scale;
const height = measuredHeight * element.transform.scale;
return {
cx: canvasWidth / 2 + element.transform.position.x,
cy: canvasHeight / 2 + element.transform.position.y,
width,
height,
rotation: element.transform.rotate,
};
}
return null;
}
export function getVisibleElementsWithBounds({
tracks,
currentTime,
canvasSize,
mediaAssets,
}: {
tracks: TimelineTrack[];
currentTime: number;
canvasSize: { width: number; height: number };
mediaAssets: MediaAsset[];
}): ElementWithBounds[] {
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const visibleTracks = tracks.filter(
(track) => !("hidden" in track && track.hidden),
);
const orderedTracks = [
...visibleTracks.filter((track) => !isMainTrack(track)),
...visibleTracks.filter((track) => isMainTrack(track)),
].reverse();
const result: ElementWithBounds[] = [];
for (const track of orderedTracks) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.filter(
(element) =>
currentTime >= element.startTime &&
currentTime < element.startTime + element.duration,
)
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
: undefined;
const bounds = getElementBounds({
element,
canvasSize,
mediaAsset,
});
if (bounds) {
result.push({
trackId: track.id,
elementId: element.id,
element,
bounds,
});
}
}
}
return result;
}