diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index 0df4541c..b275ce23 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -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; +} + +const annotationImagePromiseCache = new Map>(); + +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 { + const cachedPromise = annotationImagePromiseCache.get(source); + if (cachedPromise) { + return cachedPromise; + } + + const loadPromise = new Promise((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 { + 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 = { - '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 { - 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 { - // 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 { + 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; +} +