summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview
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
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/lib/preview')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts93
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts14
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts28
3 files changed, 98 insertions, 37 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({
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
index 08c16735..dcc87282 100644
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -74,3 +74,17 @@ export function getDisplayScale({
y: canvasRect.height / canvasSize.height,
};
}
+
+export function screenPixelsToLogicalThreshold({
+ canvas,
+ screenPixels,
+}: {
+ canvas: HTMLCanvasElement;
+ screenPixels: number;
+}): { x: number; y: number } {
+ const canvasRect = canvas.getBoundingClientRect();
+ return {
+ x: screenPixels * (canvas.width / canvasRect.width),
+ y: screenPixels * (canvas.height / canvasRect.height),
+ };
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index bd53f0ca..36efc3ce 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -3,10 +3,10 @@ export interface SnapLine {
position: number;
}
-const SNAP_THRESHOLD = 10;
const ROTATION_SNAP_STEP_DEGREES = 90;
const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
export interface SnapResult {
snappedPosition: { x: number; y: number };
@@ -17,10 +17,12 @@ export function snapPosition({
proposedPosition,
canvasSize,
elementSize,
+ snapThreshold,
}: {
proposedPosition: { x: number; y: number };
canvasSize: { width: number; height: number };
elementSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): SnapResult {
const centerX = 0;
const centerY = 0;
@@ -41,11 +43,13 @@ export function snapPosition({
function getClosestAxisSnap({
candidates,
+ threshold,
}: {
candidates: AxisSnapCandidate[];
+ threshold: number;
}): AxisSnapCandidate | null {
const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= SNAP_THRESHOLD,
+ (candidate) => candidate.distance <= threshold,
);
if (snapCandidatesWithinThreshold.length === 0) {
return null;
@@ -95,8 +99,14 @@ export function snapPosition({
});
}
- const closestX = getClosestAxisSnap({ candidates: xCandidates });
- const closestY = getClosestAxisSnap({ candidates: yCandidates });
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
const x = closestX?.snappedPosition ?? proposedPosition.x;
const y = closestY?.snappedPosition ?? proposedPosition.y;
@@ -124,12 +134,14 @@ export function snapScale({
baseWidth,
baseHeight,
canvasSize,
+ snapThreshold,
}: {
proposedScale: number;
position: { x: number; y: number };
baseWidth: number;
baseHeight: number;
canvasSize: { width: number; height: number };
+ snapThreshold: { x: number; y: number };
}): ScaleSnapResult {
const centerX = 0;
const centerY = 0;
@@ -162,7 +174,7 @@ export function snapScale({
for (const target of verticalTargets) {
const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= SNAP_THRESHOLD) {
+ if (distanceLeft <= snapThreshold.x) {
const scale = (2 * (position.x - target.position)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -173,7 +185,7 @@ export function snapScale({
}
}
const distanceRight = Math.abs(rightEdge - target.position);
- if (distanceRight <= SNAP_THRESHOLD) {
+ if (distanceRight <= snapThreshold.x) {
const scale = (2 * (target.position - position.x)) / baseWidth;
if (scale > MIN_SCALE) {
candidates.push({
@@ -199,7 +211,7 @@ export function snapScale({
for (const target of horizontalTargets) {
const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= SNAP_THRESHOLD) {
+ if (distanceTop <= snapThreshold.y) {
const scale = (2 * (position.y - target.position)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({
@@ -210,7 +222,7 @@ export function snapScale({
}
}
const distanceBottom = Math.abs(bottomEdge - target.position);
- if (distanceBottom <= SNAP_THRESHOLD) {
+ if (distanceBottom <= snapThreshold.y) {
const scale = (2 * (target.position - position.y)) / baseHeight;
if (scale > MIN_SCALE) {
candidates.push({