summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview/element-bounds.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/preview/element-bounds.ts
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/preview/element-bounds.ts')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts259
1 files changed, 148 insertions, 111 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 08ffbfa8..5c416afc 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -1,17 +1,12 @@
-import type { TimelineTrack, TimelineElement } from "@/types/timeline";
-import type { MediaAsset } from "@/types/assets";
+import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/timeline";
-import {
- DEFAULT_TEXT_ELEMENT,
- DEFAULT_LINE_HEIGHT,
- DEFAULT_TEXT_BACKGROUND,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
+import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
+import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
+import { measureTextElement } from "@/lib/text/measure-element";
import {
getElementLocalTime,
resolveTransformAtTime,
- resolveNumberAtTime,
} from "@/lib/animation";
export interface ElementBounds {
@@ -41,7 +36,8 @@ function getVisualElementBounds({
sourceWidth: number;
sourceHeight: number;
transform: {
- scale: number;
+ scaleX: number;
+ scaleY: number;
position: { x: number; y: number };
rotate: number;
};
@@ -50,8 +46,8 @@ function getVisualElementBounds({
canvasWidth / sourceWidth,
canvasHeight / sourceHeight,
);
- const scaledWidth = sourceWidth * containScale * transform.scale;
- const scaledHeight = sourceHeight * containScale * transform.scale;
+ const scaledWidth = sourceWidth * containScale * transform.scaleX;
+ const scaledHeight = sourceHeight * containScale * transform.scaleY;
const cx = canvasWidth / 2 + transform.position.x;
const cy = canvasHeight / 2 + transform.position.y;
@@ -64,7 +60,53 @@ function getVisualElementBounds({
};
}
-export function getElementBounds({
+function getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ rect: { left: number; top: number; width: number; height: number };
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const localCenterX = rect.left + rect.width / 2;
+ const localCenterY = rect.top + rect.height / 2;
+ const scaledCenterX = localCenterX * transform.scaleX;
+ const scaledCenterY = localCenterY * transform.scaleY;
+ const rotationRad = (transform.rotate * Math.PI) / 180;
+ const cos = Math.cos(rotationRad);
+ const sin = Math.sin(rotationRad);
+ return {
+ cx:
+ canvasWidth / 2 +
+ transform.position.x +
+ scaledCenterX * cos -
+ scaledCenterY * sin,
+ cy:
+ canvasHeight / 2 +
+ transform.position.y +
+ scaledCenterX * sin +
+ scaledCenterY * cos,
+ width: rect.width * transform.scaleX,
+ height: rect.height * transform.scaleY,
+ rotation: transform.rotate,
+ };
+}
+
+/**
+ * Bounds policy: bounds reflect base content geometry (text glyphs + background,
+ * sticker/image/video content area) and base transform. Post-effect spill (blur,
+ * glow) and mask-clipped regions are intentionally excluded — handles manipulate
+ * the canonical element geometry, not visual effect output.
+ */
+function getElementBounds({
element,
canvasSize,
mediaAsset,
@@ -106,123 +148,118 @@ export function getElementBounds({
return getVisualElementBounds({
canvasWidth,
canvasHeight,
- sourceWidth: 200,
- sourceHeight: 200,
+ sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
+ sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
transform,
});
}
- if (element.type === "text") {
+ if (element.type === "graphic") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
- 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;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
- let measuredWidth = 100;
- let measuredHeight = scaledFontSize;
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const canvas = document.createElement("canvas");
- canvas.width = 4096;
- canvas.height = 4096;
const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
- 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 resolvedBackground = {
- ...element.background,
- paddingX: resolveNumberAtTime({
- baseValue:
- element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue:
- element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue:
- element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue:
- element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- };
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
- measuredWidth = visualRect.width;
- measuredHeight = visualRect.height;
- const localCenterX = visualRect.left + visualRect.width / 2;
- const localCenterY = visualRect.top + visualRect.height / 2;
- const scaledCenterX = localCenterX * transform.scale;
- const scaledCenterY = localCenterY * transform.scale;
- const rotationRad = (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 + transform.position.x + rotatedCenterX,
- cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
- width: measuredWidth * transform.scale,
- height: measuredHeight * transform.scale,
- rotation: transform.rotate,
- };
- }
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
- const width = measuredWidth * transform.scale;
- const height = measuredHeight * transform.scale;
- return {
- cx: canvasWidth / 2 + transform.position.x,
- cy: canvasHeight / 2 + transform.position.y,
- width,
- height,
- rotation: transform.rotate,
- };
+ return getTransformedRectBounds({
+ canvasWidth,
+ canvasHeight,
+ rect: measured.visualRect,
+ transform,
+ });
}
return null;
}
+export const ROTATION_HANDLE_OFFSET = 24;
+
+export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+export type Edge = "right" | "left" | "bottom";
+
+export function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getEdgeHandlePosition({
+ bounds,
+ edge,
+}: {
+ bounds: ElementBounds;
+ edge: Edge;
+}): { x: number; y: number } {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
+ const localY = edge === "bottom" ? halfHeight : 0;
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+export function getRotationHandlePosition({
+ bounds,
+}: {
+ bounds: ElementBounds;
+}): { x: number; y: number } {
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
+ return {
+ x: bounds.cx - localY * sin,
+ y: bounds.cy + localY * cos,
+ };
+}
+
export function getVisibleElementsWithBounds({
tracks,
currentTime,