mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
220 lines
5.5 KiB
TypeScript
220 lines
5.5 KiB
TypeScript
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;
|
||
|
|
}
|