Files
openscreen/src/lib/exporter/annotationRenderer.ts
T
2025-12-01 11:20:05 -07:00

316 lines
8.1 KiB
TypeScript

import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
'up': [
'M 50 20 L 50 80',
'M 50 20 L 35 35',
'M 50 20 L 65 35',
],
'down': [
'M 50 20 L 50 80',
'M 50 80 L 35 65',
'M 50 80 L 65 65',
],
'left': [
'M 80 50 L 20 50',
'M 20 50 L 35 35',
'M 20 50 L 35 65',
],
'right': [
'M 20 50 L 80 50',
'M 80 50 L 65 35',
'M 80 50 L 65 65',
],
'up-right': [
'M 25 75 L 75 25',
'M 75 25 L 60 30',
'M 75 25 L 70 40',
],
'up-left': [
'M 75 75 L 25 25',
'M 25 25 L 40 30',
'M 25 25 L 30 40',
],
'down-right': [
'M 25 25 L 75 75',
'M 75 75 L 70 60',
'M 75 75 L 60 70',
],
'down-left': [
'M 75 25 L 25 75',
'M 25 75 L 30 60',
'M 25 75 L 40 70',
],
};
function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array<{ cmd: string; args: number[] }> {
const commands: Array<{ cmd: string; args: number[] }> = [];
const parts = pathString.trim().split(/\s+/);
let i = 0;
while (i < parts.length) {
const cmd = parts[i];
if (cmd === 'M' || cmd === 'L') {
const x = parseFloat(parts[i + 1]) * scaleX;
const y = parseFloat(parts[i + 2]) * scaleY;
commands.push({ cmd, args: [x, y] });
i += 3;
} else {
i++;
}
}
return commands;
}
function renderArrow(
ctx: CanvasRenderingContext2D,
direction: ArrowDirection,
color: string,
strokeWidth: number,
x: number,
y: number,
width: number,
height: number,
_scaleFactor: number
) {
const paths = ARROW_PATHS[direction];
if (!paths) return;
ctx.save();
ctx.translate(x, y);
const padding = 8 * _scaleFactor;
const availableWidth = Math.max(0, width - padding * 2);
const availableHeight = Math.max(0, height - padding * 2);
const scale = Math.min(availableWidth / 100, availableHeight / 100);
const offsetX = padding + (availableWidth - 100 * scale) / 2;
const offsetY = padding + (availableHeight - 100 * scale) / 2;
// Apply centering offset
ctx.translate(offsetX, offsetY);
// Apply shadow filter
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 8 * scale;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4 * scale;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw all paths as a single shape to avoid overlapping shadows/strokes
ctx.beginPath();
for (const pathString of paths) {
const commands = parseSvgPath(pathString, scale, scale);
for (const { cmd, args } of commands) {
if (cmd === 'M') {
ctx.moveTo(args[0], args[1]);
} else if (cmd === 'L') {
ctx.lineTo(args[0], args[1]);
}
}
}
ctx.stroke();
ctx.restore();
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number
) {
const style = annotation.style;
ctx.save();
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
const scaledFontSize = style.fontSize * scaleFactor;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
ctx.textBaseline = 'middle';
const containerPadding = 8 * scaleFactor;
let textX = x;
let textY = y + height / 2;
if (style.textAlign === 'center') {
textX = x + width / 2;
ctx.textAlign = 'center';
} else if (style.textAlign === 'right') {
textX = x + width - containerPadding;
ctx.textAlign = 'right';
} else {
textX = x + containerPadding;
ctx.textAlign = 'left';
}
const lines = annotation.content.split('\n');
const lineHeight = scaledFontSize * 1.4;
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
const currentY = startY + index * lineHeight;
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
const metrics = ctx.measureText(line);
const verticalPadding = scaledFontSize * 0.1;
const horizontalPadding = scaledFontSize * 0.2;
const borderRadius = 4 * scaleFactor;
let bgX = textX - horizontalPadding;
const bgWidth = metrics.width + horizontalPadding * 2;
const contentHeight = scaledFontSize * 1.4;
const bgHeight = contentHeight + verticalPadding * 2;
const bgY = currentY - bgHeight / 2;
if (style.textAlign === 'center') {
bgX = textX - bgWidth / 2;
} else if (style.textAlign === 'right') {
bgX = textX - bgWidth;
}
ctx.fillStyle = style.backgroundColor;
ctx.beginPath();
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
ctx.fill();
}
ctx.fillStyle = style.color;
ctx.fillText(line, textX, currentY);
if (style.textDecoration === 'underline') {
const metrics = ctx.measureText(line);
let underlineX = textX;
const underlineY = currentY + scaledFontSize * 0.15;
if (style.textAlign === 'center') {
underlineX = textX - metrics.width / 2;
} else if (style.textAlign === 'right') {
underlineX = textX - metrics.width;
}
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
ctx.beginPath();
ctx.moveTo(underlineX, underlineY);
ctx.lineTo(underlineX + metrics.width, underlineY);
ctx.stroke();
}
});
ctx.restore();
}
async function renderImage(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number
): Promise<void> {
if (!annotation.content || !annotation.content.startsWith('data:image')) {
return;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// Preserve aspect ratio - contain the image within the bounds
const imgAspect = img.width / img.height;
const boxAspect = width / height;
let drawWidth = width;
let drawHeight = height;
let drawX = x;
let drawY = y;
if (imgAspect > boxAspect) {
drawHeight = width / imgAspect;
drawY = y + (height - drawHeight) / 2;
} else {
drawWidth = height * imgAspect;
drawX = x + (width - drawWidth) / 2;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
resolve();
};
img.onerror = () => {
console.error('[AnnotationRenderer] Failed to load image annotation');
resolve();
};
img.src = annotation.content;
});
}
export async function renderAnnotations(
ctx: CanvasRenderingContext2D,
annotations: AnnotationRegion[],
canvasWidth: number,
canvasHeight: number,
currentTimeMs: number,
scaleFactor: number = 1.0
): Promise<void> {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
);
// Sort by z-index (lower first, so higher z-index draws on top)
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
for (const annotation of sortedAnnotations) {
const x = (annotation.position.x / 100) * canvasWidth;
const y = (annotation.position.y / 100) * canvasHeight;
const width = (annotation.size.width / 100) * canvasWidth;
const height = (annotation.size.height / 100) * canvasHeight;
switch (annotation.type) {
case 'text':
renderText(ctx, annotation, x, y, width, height, scaleFactor);
break;
case 'image':
await renderImage(ctx, annotation, x, y, width, height);
break;
case 'figure':
if (annotation.figureData) {
renderArrow(
ctx,
annotation.figureData.arrowDirection,
annotation.figureData.color,
annotation.figureData.strokeWidth,
x,
y,
width,
height,
scaleFactor
);
}
break;
}
}
}