diff options
Diffstat (limited to 'apps/web/src/lib/preview/element-bounds.ts')
| -rw-r--r-- | apps/web/src/lib/preview/element-bounds.ts | 219 |
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; +} |
