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 ? (
-
- ) : (
-
- );
+ 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();