From 262745a97f3cf15d3ca8ca63f8fa0701f9cf0bd6 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Mon, 1 Dec 2025 11:20:05 -0700 Subject: [PATCH] final annotation preview and export --- .../video-editor/AnnotationOverlay.tsx | 129 +------ .../video-editor/AnnotationSettingsPanel.tsx | 332 +++++------------- src/components/video-editor/ArrowSvgs.tsx | 194 ++++++++++ src/components/video-editor/VideoEditor.tsx | 31 +- src/components/video-editor/VideoPlayback.tsx | 13 +- src/components/video-editor/types.ts | 12 +- src/lib/exporter/annotationRenderer.ts | 315 +++++++++++++++++ src/lib/exporter/frameRenderer.ts | 29 +- src/lib/exporter/videoExporter.ts | 8 +- 9 files changed, 663 insertions(+), 400 deletions(-) create mode 100644 src/components/video-editor/ArrowSvgs.tsx create mode 100644 src/lib/exporter/annotationRenderer.ts diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index c1f743a..0331ca9 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -2,14 +2,7 @@ import { useRef } from "react"; import { Rnd } from "react-rnd"; import type { AnnotationRegion } from "./types"; import { cn } from "@/lib/utils"; -import { - FaArrowUp, FaArrowDown, FaArrowLeft, FaArrowRight, - FaCircle, FaSquare, FaStar, FaHeart, FaPlay -} from "react-icons/fa"; -import { - BsArrowUpRight, BsArrowDownRight, BsArrowDownLeft, BsArrowUpLeft -} from "react-icons/bs"; -import { BiRectangle } from "react-icons/bi"; +import { getArrowComponent } from "./ArrowSvgs"; interface AnnotationOverlayProps { annotation: AnnotationRegion; @@ -39,96 +32,15 @@ export function AnnotationOverlay({ const width = (annotation.size.width / 100) * containerWidth; const height = (annotation.size.height / 100) * containerHeight; - console.log('[AnnotationOverlay] Rendering:', { - id: annotation.id, - type: annotation.type, - content: annotation.content.substring(0, 30), - position: annotation.position, - size: annotation.size, - containerWidth, - containerHeight, - calculatedPixels: { x, y, width, height }, - isSelected - }); - const isDraggingRef = useRef(false); const renderArrow = () => { const direction = annotation.figureData?.arrowDirection || 'right'; const color = annotation.figureData?.color || '#34B27B'; - const iconProps = { - style: { - width: '100%', - height: '100%', - color, - filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.3))' - } - }; - - switch (direction) { - case 'up': return ; - case 'down': return ; - case 'left': return ; - case 'right': return ; - case 'up-right': return ; - case 'up-left': return ; - case 'down-right': return ; - case 'down-left': return ; - default: return ; - } - }; - - const renderShape = () => { - const shapeType = annotation.figureData?.shapeType || 'circle'; - const color = annotation.figureData?.color || '#34B27B'; - const filled = annotation.figureData?.filled ?? true; const strokeWidth = annotation.figureData?.strokeWidth || 4; - const shapeStyle: React.CSSProperties = { - width: '100%', - height: '100%', - color: filled ? color : 'transparent', - stroke: color, - strokeWidth: filled ? 0 : strokeWidth, - filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.3))' - }; - - const IconComponent = (() => { - switch (shapeType) { - case 'circle': return FaCircle; - case 'square': return FaSquare; - case 'triangle': return FaPlay; - case 'rectangle': return BiRectangle; - case 'star': return FaStar; - case 'heart': return FaHeart; - default: return FaCircle; - } - })(); - - return filled ? ( - - ) : ( - - {shapeType === 'circle' && ( - - )} - {shapeType === 'square' && ( - - )} - {shapeType === 'rectangle' && ( - - )} - {shapeType === 'triangle' && ( - - )} - {shapeType === 'star' && ( - - )} - {shapeType === 'heart' && ( - - )} - - ); + const ArrowComponent = getArrowComponent(direction); + return ; }; const renderContent = () => { @@ -188,39 +100,16 @@ export function AnnotationOverlay({ if (!annotation.figureData) { return (
- No figure data + No arrow data
); } - const figureType = annotation.figureData.figureType; - - if (figureType === 'arrow') { - return ( -
- {renderArrow()} -
- ); - } - - if (figureType === 'shape') { - return ( -
- {renderShape()} -
- ); - } - - if (figureType === 'emoji') { - const emojiSize = annotation.figureData.emojiSize || 64; - return ( -
- {annotation.figureData.emoji || '😊'} -
- ); - } - - return null; + return ( +
+ {renderArrow()} +
+ ); default: return null; diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index c8fdb6b..9d11bcb 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -1,27 +1,17 @@ import { useState, useRef } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info, Shapes, Smile } from "lucide-react"; +import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react"; import { toast } from "sonner"; import Colorful from '@uiw/react-color-colorful'; import { hsvaToHex, hexToHsva } from '@uiw/color-convert'; -import type { AnnotationRegion, AnnotationType, FigureType, ArrowDirection, ShapeType, FigureData } from "./types"; +import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { Switch } from "@/components/ui/switch"; import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; -import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'; -import { - FaArrowUp, FaArrowDown, FaArrowLeft, FaArrowRight, - FaCircle, FaSquare, FaStar, FaHeart -} from "react-icons/fa"; -import { - BsArrowUpRight, BsArrowDownRight, BsArrowDownLeft, BsArrowUpLeft -} from "react-icons/bs"; -import { FaPlay } from "react-icons/fa"; -import { BiRectangle } from "react-icons/bi"; +import { getArrowComponent } from "./ArrowSvgs"; interface AnnotationSettingsPanelProps { annotation: AnnotationRegion; @@ -59,7 +49,6 @@ export function AnnotationSettingsPanel({ const [figureColorHsva, setFigureColorHsva] = useState( hexToHsva(annotation.figureData?.color || '#34B27B') ); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); @@ -121,8 +110,10 @@ export function AnnotationSettingsPanel({ Image - - Figure + + + + Arrow @@ -365,240 +356,97 @@ export function AnnotationSettingsPanel({
- - { + +
+ {([ + 'up', 'down', 'left', 'right', + 'up-right', 'up-left', 'down-right', 'down-left', + ] as ArrowDirection[]).map((direction) => { + const ArrowComponent = getArrowComponent(direction); + return ( + + ); + })} +
+
+ +
+ + { const newFigureData: FigureData = { ...annotation.figureData!, - figureType: value as FigureType, + strokeWidth: value, }; onFigureDataChange?.(newFigureData); }} + min={1} + max={6} + step={1} className="w-full" - > - - - - Arrow - - - - Shape - - - - Emoji - - - - -
- -
- {[ - { value: 'up' as ArrowDirection, icon: FaArrowUp }, - { value: 'down' as ArrowDirection, icon: FaArrowDown }, - { value: 'left' as ArrowDirection, icon: FaArrowLeft }, - { value: 'right' as ArrowDirection, icon: FaArrowRight }, - { value: 'up-right' as ArrowDirection, icon: BsArrowUpRight }, - { value: 'up-left' as ArrowDirection, icon: BsArrowUpLeft }, - { value: 'down-right' as ArrowDirection, icon: BsArrowDownRight }, - { value: 'down-left' as ArrowDirection, icon: BsArrowDownLeft }, - ].map(({ value, icon: Icon }) => ( - - ))} -
-
-
- - -
- -
- {[ - { value: 'circle' as ShapeType, icon: FaCircle, label: 'Circle' }, - { value: 'square' as ShapeType, icon: FaSquare, label: 'Square' }, - { value: 'rectangle' as ShapeType, icon: BiRectangle, label: 'Rectangle' }, - { value: 'triangle' as ShapeType, icon: FaPlay, label: 'Triangle' }, - { value: 'star' as ShapeType, icon: FaStar, label: 'Star' }, - { value: 'heart' as ShapeType, icon: FaHeart, label: 'Heart' }, - ].map(({ value, icon: Icon, label }) => ( - - ))} -
-
- -
- - { - const newFigureData: FigureData = { - ...annotation.figureData!, - filled: checked, - }; - onFigureDataChange?.(newFigureData); - }} - /> -
- - {!annotation.figureData?.filled && ( -
- - { - const newFigureData: FigureData = { - ...annotation.figureData!, - strokeWidth: value, - }; - onFigureDataChange?.(newFigureData); - }} - min={1} - max={20} - step={1} - className="w-full" - /> -
- )} -
- - -
- -
-
- {annotation.figureData?.emoji || '😊'} -
- -
-
- -
- - { - const newFigureData: FigureData = { - ...annotation.figureData!, - emojiSize: value, - }; - onFigureDataChange?.(newFigureData); - }} - min={16} - max={200} - step={4} - className="w-full" - /> -
- - {showEmojiPicker && ( -
- { - const newFigureData: FigureData = { - ...annotation.figureData!, - emoji: emojiData.emoji, - }; - onFigureDataChange?.(newFigureData); - setShowEmojiPicker(false); - }} - width="100%" - height={300} - searchPlaceHolder="Search emoji..." - previewConfig={{ showPreview: false }} - /> -
- )} -
- + />
- {annotation.figureData?.figureType !== 'emoji' && ( -
- - - - - - -
- { - setFigureColorHsva(color.hsva); - const newFigureData: FigureData = { - ...annotation.figureData!, - color: hsvaToHex(color.hsva), - }; - onFigureDataChange?.(newFigureData); - }} - style={{ width: '100%', borderRadius: '8px' }} - /> -
-
-
-
- )} +
+ + + + + + +
+ { + setFigureColorHsva(color.hsva); + const newFigureData: FigureData = { + ...annotation.figureData!, + color: hsvaToHex(color.hsva), + }; + onFigureDataChange?.(newFigureData); + }} + style={{ width: '100%', borderRadius: '8px' }} + /> +
+
+
+
diff --git a/src/components/video-editor/ArrowSvgs.tsx b/src/components/video-editor/ArrowSvgs.tsx new file mode 100644 index 0000000..4c11ea3 --- /dev/null +++ b/src/components/video-editor/ArrowSvgs.tsx @@ -0,0 +1,194 @@ +import type { ArrowDirection } from './types'; + +interface ArrowSvgProps { + color: string; + strokeWidth: number; + className?: string; +} + +/** + * Inline SVG arrow components for 8 directions. + * These match the visual style of the previous icon-based arrows but use + * pure SVG paths for easy replication in export. + */ + +export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) { + return ( + + + + + + + + + ); +} + +export function getArrowComponent(direction: ArrowDirection) { + switch (direction) { + case 'up': return ArrowUp; + case 'down': return ArrowDown; + case 'left': return ArrowLeft; + case 'right': return ArrowRight; + case 'up-right': return ArrowUpRight; + case 'up-left': return ArrowUpLeft; + case 'down-right': return ArrowDownRight; + case 'down-left': return ArrowDownLeft; + } +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 39ad6fc..8ad76c6 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -151,7 +151,6 @@ export default function VideoEditor() { depth: DEFAULT_ZOOM_DEPTH, focus: { cx: 0.5, cy: 0.5 }, }; - console.log('Zoom region added:', newRegion); setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); setSelectedTrimId(null); @@ -165,7 +164,6 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), }; - console.log('Trim region added:', newRegion); setTrimRegions((prev) => [...prev, newRegion]); setSelectedTrimId(id); setSelectedZoomId(null); @@ -173,7 +171,6 @@ export default function VideoEditor() { }, []); const handleZoomSpanChange = useCallback((id: string, span: Span) => { - console.log('Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); setZoomRegions((prev) => prev.map((region) => region.id === id @@ -188,7 +185,6 @@ export default function VideoEditor() { }, []); const handleTrimSpanChange = useCallback((id: string, span: Span) => { - console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); setTrimRegions((prev) => prev.map((region) => region.id === id @@ -231,7 +227,6 @@ export default function VideoEditor() { }, [selectedZoomId]); const handleZoomDelete = useCallback((id: string) => { - console.log('Zoom region deleted:', id); setZoomRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedZoomId === id) { setSelectedZoomId(null); @@ -239,7 +234,6 @@ export default function VideoEditor() { }, [selectedZoomId]); const handleTrimDelete = useCallback((id: string) => { - console.log('Trim region deleted:', id); setTrimRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedTrimId === id) { setSelectedTrimId(null); @@ -260,7 +254,6 @@ export default function VideoEditor() { style: { ...DEFAULT_ANNOTATION_STYLE }, zIndex, }; - console.log('Annotation region added:', newRegion); setAnnotationRegions((prev) => [...prev, newRegion]); setSelectedAnnotationId(id); setSelectedZoomId(null); @@ -268,7 +261,6 @@ export default function VideoEditor() { }, []); const handleAnnotationSpanChange = useCallback((id: string, span: Span) => { - console.log('Annotation span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); setAnnotationRegions((prev) => prev.map((region) => region.id === id @@ -283,7 +275,6 @@ export default function VideoEditor() { }, []); const handleAnnotationDelete = useCallback((id: string) => { - console.log('Annotation region deleted:', id); setAnnotationRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAnnotationId === id) { setSelectedAnnotationId(null); @@ -291,7 +282,6 @@ export default function VideoEditor() { }, [selectedAnnotationId]); const handleAnnotationContentChange = useCallback((id: string, content: string) => { - console.log('[VideoEditor] Annotation content changed:', { id, content }); setAnnotationRegions((prev) => { const updated = prev.map((region) => { if (region.id !== id) return region; @@ -305,13 +295,11 @@ export default function VideoEditor() { return { ...region, content }; } }); - console.log('[VideoEditor] Updated annotation regions:', updated); return updated; }); - }, []); + }, []);; const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => { - console.log('[VideoEditor] Annotation type changed:', { id, type }); setAnnotationRegions((prev) => { const updated = prev.map((region) => { if (region.id !== id) return region; @@ -332,13 +320,11 @@ export default function VideoEditor() { return updatedRegion; }); - console.log('[VideoEditor] Updated annotation regions after type change:', updated); return updated; }); }, []); const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => { - console.log('Annotation style changed:', { id, style }); setAnnotationRegions((prev) => prev.map((region) => region.id === id @@ -349,7 +335,6 @@ export default function VideoEditor() { }, []); const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => { - console.log('Annotation figure data changed:', { id, figureData }); setAnnotationRegions((prev) => prev.map((region) => region.id === id @@ -500,6 +485,15 @@ export default function VideoEditor() { bitrate = 80_000_000; } + // Get preview CONTAINER dimensions for scaling + // Annotations render in HTML overlay matching container, not PixiJS canvas + const playbackRef = videoPlaybackRef.current; + const containerElement = playbackRef?.containerRef?.current; + const previewWidth = containerElement?.clientWidth || 1920; + const previewHeight = containerElement?.clientHeight || 1080; + + + const exporter = new VideoExporter({ videoUrl: videoPath, width: exportWidth, @@ -517,6 +511,9 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + annotationRegions, + previewWidth, + previewHeight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -557,7 +554,7 @@ export default function VideoEditor() { setIsExporting(false); exporterRef.current = null; } - }, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, isPlaying, aspectRatio]); + }, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio]); const handleCancelExport = useCallback(() => { if (exporterRef.current) { diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 7c0231a..8978641 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -48,6 +48,7 @@ export interface VideoPlaybackRef { app: Application | null; videoSprite: Sprite | null; videoContainer: Container | null; + containerRef: React.RefObject; play: () => Promise; pause: () => void; } @@ -209,15 +210,13 @@ const VideoPlayback = forwardRef(({ app: appRef.current, videoSprite: videoSpriteRef.current, videoContainer: videoContainerRef.current, + containerRef, play: async () => { - const video = videoRef.current; - if (!video) { - allowPlaybackRef.current = false; - return; - } - allowPlaybackRef.current = true; + const vid = videoRef.current; + if (!vid) return; try { - await video.play(); + allowPlaybackRef.current = true; + await vid.play(); } catch (error) { allowPlaybackRef.current = false; throw error; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 4b289de..e138d75 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -21,19 +21,12 @@ export interface TrimRegion { export type AnnotationType = 'text' | 'image' | 'figure'; -export type FigureType = 'arrow' | 'shape' | 'emoji'; export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left'; -export type ShapeType = 'circle' | 'square' | 'rectangle' | 'triangle' | 'star' | 'heart'; export interface FigureData { - figureType: FigureType; - arrowDirection?: ArrowDirection; - shapeType?: ShapeType; - emoji?: string; - emojiSize?: number; + arrowDirection: ArrowDirection; color: string; strokeWidth: number; - filled: boolean; } export interface AnnotationPosition { @@ -94,12 +87,9 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = { }; export const DEFAULT_FIGURE_DATA: FigureData = { - figureType: 'arrow', arrowDirection: 'right', color: '#34B27B', strokeWidth: 4, - filled: true, - emojiSize: 64, }; diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts new file mode 100644 index 0000000..af04e19 --- /dev/null +++ b/src/lib/exporter/annotationRenderer.ts @@ -0,0 +1,315 @@ +import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types'; + +// 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', + ], +}; + +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 { + 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 { + // 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; + } + } +} diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 099a587..92bbf67 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,10 +1,11 @@ import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js'; -import type { ZoomRegion, CropRegion } from '@/components/video-editor/types'; +import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types'; import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform'; -import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA, VIEWPORT_SCALE } from '@/components/video-editor/videoPlayback/constants'; +import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants'; import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils'; +import { renderAnnotations } from './annotationRenderer'; interface FrameRenderConfig { width: number; @@ -20,6 +21,9 @@ interface FrameRenderConfig { cropRegion: CropRegion; videoWidth: number; videoHeight: number; + annotationRegions?: AnnotationRegion[]; + previewWidth?: number; + previewHeight?: number; } interface AnimationState { @@ -297,6 +301,27 @@ export class FrameRenderer { // Composite with shadows to final output canvas this.compositeWithShadows(); + + // Render annotations on top if present + if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) { + // Calculate scale factor based on export vs preview dimensions + const previewWidth = this.config.previewWidth || 1920; + const previewHeight = this.config.previewHeight || 1080; + const scaleX = this.config.width / previewWidth; + const scaleY = this.config.height / previewHeight; + const scaleFactor = (scaleX + scaleY) / 2; + + + + await renderAnnotations( + this.compositeCtx, + this.config.annotationRegions, + this.config.width, + this.config.height, + timeMs, + scaleFactor + ); + } } private updateLayout(): void { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 03362c6..bb4b5fd 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -2,7 +2,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types'; import { VideoFileDecoder } from './videoDecoder'; import { FrameRenderer } from './frameRenderer'; import { VideoMuxer } from './muxer'; -import type { ZoomRegion, CropRegion, TrimRegion } from '@/components/video-editor/types'; +import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types'; interface VideoExporterConfig extends ExportConfig { videoUrl: string; @@ -17,6 +17,9 @@ interface VideoExporterConfig extends ExportConfig { padding?: number; videoPadding?: number; cropRegion: CropRegion; + annotationRegions?: AnnotationRegion[]; + previewWidth?: number; + previewHeight?: number; onProgress?: (progress: ExportProgress) => void; } @@ -94,6 +97,9 @@ export class VideoExporter { cropRegion: this.config.cropRegion, videoWidth: videoInfo.width, videoHeight: videoInfo.height, + annotationRegions: this.config.annotationRegions, + previewWidth: this.config.previewWidth, + previewHeight: this.config.previewHeight, }); await this.renderer.initialize();