preload annotation assets for export

This commit is contained in:
webadderall
2026-04-04 13:07:34 +11:00
parent cc63fa1b24
commit 4e83ec98ee
+225 -150
View File
@@ -1,57 +1,93 @@
import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
export interface AnnotationRenderAssets {
imageCache: Map<string, HTMLImageElement>;
}
const annotationImagePromiseCache = new Map<string, Promise<HTMLImageElement | null>>();
function getAnnotationImageContent(annotation: AnnotationRegion): string | null {
const source = annotation.imageContent || annotation.content;
if (!source || !source.startsWith("data:image")) {
return null;
}
return source;
}
function loadAnnotationImage(source: string): Promise<HTMLImageElement | null> {
const cachedPromise = annotationImagePromiseCache.get(source);
if (cachedPromise) {
return cachedPromise;
}
const loadPromise = new Promise<HTMLImageElement | null>((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => {
console.error("[AnnotationRenderer] Failed to load image annotation");
resolve(null);
};
img.src = source;
});
annotationImagePromiseCache.set(source, loadPromise);
return loadPromise;
}
export async function preloadAnnotationAssets(
annotations: AnnotationRegion[] = [],
): Promise<AnnotationRenderAssets> {
const uniqueSources = [
...new Set(
annotations
.filter((annotation) => annotation.type === "image")
.map((annotation) => getAnnotationImageContent(annotation))
.filter((source): source is string => !!source),
),
];
if (uniqueSources.length === 0) {
return { imageCache: new Map() };
}
const loadedSources = await Promise.all(
uniqueSources.map(async (source) => {
const image = await loadAnnotationImage(source);
return image ? ([source, image] as const) : null;
}),
);
return {
imageCache: new Map(
loadedSources.filter((entry): entry is readonly [string, HTMLImageElement] => !!entry),
),
};
}
// 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',
],
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[] }> {
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') {
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] });
@@ -60,11 +96,10 @@ function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array
i++;
}
}
return commands;
}
function renderArrow(
ctx: CanvasRenderingContext2D,
direction: ArrowDirection,
@@ -74,55 +109,51 @@ function renderArrow(
y: number,
width: number,
height: number,
_scaleFactor: 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.shadowColor = "rgba(0, 0, 0, 0.3)";
ctx.shadowBlur = 8 * scale;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4 * scale;
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.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
for (const pathString of paths) {
const commands = parseSvgPath(pathString, scale, scale);
for (const { cmd, args } of commands) {
if (cmd === 'M') {
if (cmd === "M") {
ctx.moveTo(args[0], args[1]);
} else if (cmd === 'L') {
} else if (cmd === "L") {
ctx.lineTo(args[0], args[1]);
}
}
}
ctx.stroke();
ctx.restore();
}
@@ -133,49 +164,48 @@ function renderText(
y: number,
width: number,
height: number,
scaleFactor: number
scaleFactor: number,
) {
const style = annotation.style;
ctx.save();
// Clip text to annotation box bounds (matches editor's overflow: hidden)
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.clip();
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
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';
ctx.textBaseline = "middle";
const containerPadding = 8 * scaleFactor;
let textX = x;
let textY = y + height / 2;
if (style.textAlign === 'center') {
const textY = y + height / 2;
if (style.textAlign === "center") {
textX = x + width / 2;
ctx.textAlign = 'center';
} else if (style.textAlign === 'right') {
ctx.textAlign = "center";
} else if (style.textAlign === "right") {
textX = x + width - containerPadding;
ctx.textAlign = 'right';
ctx.textAlign = "right";
} else {
textX = x + containerPadding;
ctx.textAlign = 'left';
ctx.textAlign = "left";
}
const availableWidth = width - containerPadding * 2;
const rawLines = annotation.content.split('\n');
const rawLines = annotation.content.split("\n");
const lines: string[] = [];
for (const rawLine of rawLines) {
if (!rawLine) {
lines.push('');
lines.push("");
continue;
}
const words = rawLine.split(/(\s+)/);
let current = '';
let current = "";
for (const word of words) {
const test = current + word;
if (current && ctx.measureText(test).width > availableWidth) {
@@ -190,49 +220,49 @@ function renderText(
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') {
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') {
if (style.textAlign === "center") {
bgX = textX - bgWidth / 2;
} else if (style.textAlign === 'right') {
} 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') {
if (style.textDecoration === "underline") {
const metrics = ctx.measureText(line);
let underlineX = textX;
const underlineY = currentY + scaledFontSize * 0.15;
if (style.textAlign === 'center') {
if (style.textAlign === "center") {
underlineX = textX - metrics.width / 2;
} else if (style.textAlign === 'right') {
} else if (style.textAlign === "right") {
underlineX = textX - metrics.width;
}
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
ctx.beginPath();
@@ -241,7 +271,7 @@ function renderText(
ctx.stroke();
}
});
ctx.restore();
}
@@ -251,42 +281,36 @@ async function renderImage(
x: number,
y: number,
width: number,
height: number
height: number,
assets?: AnnotationRenderAssets,
): Promise<void> {
if (!annotation.content || !annotation.content.startsWith('data:image')) {
const source = getAnnotationImageContent(annotation);
if (!source) {
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;
});
const img = assets?.imageCache.get(source) ?? (await loadAnnotationImage(source));
if (!img) {
return;
}
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);
}
export async function renderAnnotations(
@@ -295,32 +319,31 @@ export async function renderAnnotations(
canvasWidth: number,
canvasHeight: number,
currentTimeMs: number,
scaleFactor: number = 1.0
scaleFactor: number = 1.0,
assets?: AnnotationRenderAssets,
): Promise<void> {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
(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':
case "text":
renderText(ctx, annotation, x, y, width, height, scaleFactor);
break;
case 'image':
await renderImage(ctx, annotation, x, y, width, height);
case "image":
await renderImage(ctx, annotation, x, y, width, height, assets);
break;
case 'figure':
case "figure":
if (annotation.figureData) {
renderArrow(
ctx,
@@ -331,7 +354,7 @@ export async function renderAnnotations(
y,
width,
height,
scaleFactor
scaleFactor,
);
}
break;
@@ -339,3 +362,55 @@ export async function renderAnnotations(
}
}
export async function renderAnnotationToCanvas(
annotation: AnnotationRegion,
width: number,
height: number,
scaleFactor: number = 1.0,
assets?: AnnotationRenderAssets,
): Promise<HTMLCanvasElement | null> {
const canvasWidth = Math.max(1, Math.ceil(width));
const canvasHeight = Math.max(1, Math.ceil(height));
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
return null;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
switch (annotation.type) {
case "text":
renderText(ctx, annotation, 0, 0, canvasWidth, canvasHeight, scaleFactor);
break;
case "image":
await renderImage(ctx, annotation, 0, 0, canvasWidth, canvasHeight, assets);
break;
case "figure":
if (!annotation.figureData) {
return null;
}
renderArrow(
ctx,
annotation.figureData.arrowDirection,
annotation.figureData.color,
annotation.figureData.strokeWidth,
0,
0,
canvasWidth,
canvasHeight,
scaleFactor,
);
break;
}
return canvas;
}