summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview/element-bounds.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/preview/element-bounds.ts')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts219
1 files changed, 219 insertions, 0 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
new file mode 100644
index 00000000..ec26c14d
--- /dev/null
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -0,0 +1,219 @@
+import type { TimelineTrack, TimelineElement } from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { isMainTrack } from "@/lib/timeline";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+
+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));
+
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let i = 0; i < lineMetrics.length; i++) {
+ const metrics = lineMetrics[i];
+ const y = i * lineHeightPx;
+ top = Math.min(
+ top,
+ y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
+ );
+ bottom = Math.max(
+ bottom,
+ y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ measuredWidth = maxWidth;
+ measuredHeight = bottom - top;
+ }
+
+ 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;
+}