diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-01 01:32:09 +0100 |
| commit | 5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch) | |
| tree | b33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/lib/preview | |
| parent | 744419469da403be57d30b5b19f6b43246caa57d (diff) | |
| parent | 354f4dec67ef7eb39d34fca941ca34025a68136b (diff) | |
| download | opencut-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.ts | 93 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-coords.ts | 14 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-snap.ts | 28 |
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({ |
