summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview/element-bounds.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
commit5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch)
treeb33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/lib/preview/element-bounds.ts
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/lib/preview/element-bounds.ts')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts93
1 files changed, 64 insertions, 29 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index ec26c14d..5b1f301d 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -2,9 +2,12 @@ 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";
+import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
export interface ElementBounds {
cx: number;
@@ -60,17 +63,24 @@ export function getElementBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
}: {
element: TimelineElement;
canvasSize: { width: number; height: number };
mediaAsset?: MediaAsset | null;
+ localTime: number;
}): ElementBounds | null {
- if (element.type === "audio") return null;
+ if (element.type === "audio" || element.type === "effect") return null;
if ("hidden" in element && element.hidden) return null;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const sourceWidth = mediaAsset?.width ?? canvasWidth;
const sourceHeight = mediaAsset?.height ?? canvasHeight;
return getVisualElementBounds({
@@ -78,21 +88,31 @@ export function getElementBounds({
canvasHeight,
sourceWidth,
sourceHeight,
- transform: element.transform,
+ transform,
});
}
if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
return getVisualElementBounds({
canvasWidth,
canvasHeight,
sourceWidth: 200,
sourceHeight: 200,
- transform: element.transform,
+ transform,
});
}
if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
const scaledFontSize =
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const letterSpacing = element.letterSpacing ?? 0;
@@ -121,37 +141,46 @@ export function getElementBounds({
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 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 * 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 width = measuredWidth * element.transform.scale;
- const height = measuredHeight * element.transform.scale;
+ const width = measuredWidth * transform.scale;
+ const height = measuredHeight * transform.scale;
return {
- cx: canvasWidth / 2 + element.transform.position.x,
- cy: canvasHeight / 2 + element.transform.position.y,
+ cx: canvasWidth / 2 + transform.position.x,
+ cy: canvasHeight / 2 + transform.position.y,
width,
height,
- rotation: element.transform.rotate,
+ rotation: transform.rotate,
};
}
@@ -195,6 +224,11 @@ export function getVisibleElementsWithBounds({
});
for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
const mediaAsset =
element.type === "video" || element.type === "image"
? mediaMap.get(element.mediaId)
@@ -203,6 +237,7 @@ export function getVisibleElementsWithBounds({
element,
canvasSize,
mediaAsset,
+ localTime,
});
if (bounds) {
result.push({