diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx
index f416c323b..177e78c82 100644
--- a/src/components/video-editor/AnnotationOverlay.tsx
+++ b/src/components/video-editor/AnnotationOverlay.tsx
@@ -13,6 +13,7 @@ import {
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
+ type FigureData,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
@@ -184,13 +185,63 @@ export function AnnotationOverlay({
y,
]);
- const renderArrow = () => {
- const direction = annotation.figureData?.arrowDirection || "right";
- const color = annotation.figureData?.color || "#34B27B";
- const strokeWidth = annotation.figureData?.strokeWidth || 4;
-
- const ArrowComponent = getArrowComponent(direction);
- return ;
+ const renderFigure = (figureData: FigureData) => {
+ const kind = figureData.kind ?? "arrow";
+ switch (kind) {
+ case "arrow": {
+ const ArrowComponent = getArrowComponent(figureData.arrowDirection);
+ return (
+
+ );
+ }
+ case "rectangle":
+ return (
+
+ );
+ case "ellipse":
+ return (
+
+ );
+ default: {
+ const _exhaustiveCheck: never = kind;
+ console.warn(`AnnotationOverlay: unsupported figure kind "${String(_exhaustiveCheck)}"`);
+ return null;
+ }
+ }
};
const normalizePoint = (event: PointerEvent) => {
@@ -348,9 +399,7 @@ export function AnnotationOverlay({
);
}
- return (
- {renderArrow()}
- );
+ return renderFigure(annotation.figureData);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index 4c26c8858..bdd6b11bf 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -26,6 +26,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
+import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
@@ -33,6 +34,7 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
+import { alphaToPercent, getAlpha, percentToAlpha, withAlpha } from "./figureFill";
import {
type AnnotationRegion,
type AnnotationType,
@@ -589,9 +591,16 @@ export function AnnotationSettingsPanel({
color={annotation.figureData?.color || "#34B27B"}
colors={colorPalette}
onChange={(color) => {
+ const previous = annotation.figureData;
+ if (!previous) return;
+ const nextFill =
+ typeof previous.fill === "string"
+ ? withAlpha(color.hex, getAlpha(previous.fill))
+ : previous.fill;
const newFigureData: FigureData = {
- ...annotation.figureData!,
+ ...previous,
color: color.hex,
+ fill: nextFill,
};
onFigureDataChange?.(newFigureData);
}}
@@ -602,6 +611,111 @@ export function AnnotationSettingsPanel({
+
+ {(() => {
+ const figureData = annotation.figureData;
+ if (!figureData) return null;
+ const isClosedShape = (figureData.kind ?? "arrow") !== "arrow";
+ if (!isClosedShape) return null;
+ const fillEnabled = typeof figureData.fill === "string";
+ const defaultFillFromColor = withAlpha(figureData.color, percentToAlpha(20));
+ const fillValue = figureData.fill ?? defaultFillFromColor;
+ const currentAlpha = getAlpha(fillValue);
+ const opacityPercent = alphaToPercent(currentAlpha);
+ return (
+
+
+
+
+
+ {t("annotation.fill.toggle")}
+
+ {
+ const next: FigureData = {
+ ...figureData,
+ fill: checked ? defaultFillFromColor : undefined,
+ };
+ onFigureDataChange?.(next);
+ }}
+ aria-labelledby="annotation-fill-label"
+ className="data-[state=checked]:bg-[#34B27B] scale-90"
+ />
+
+
+ {fillEnabled && (
+ <>
+
+
+
+
+
+ {
+ const next: FigureData = {
+ ...figureData,
+ fill: withAlpha(color.hex, currentAlpha),
+ };
+ onFigureDataChange?.(next);
+ }}
+ style={{
+ borderRadius: "8px",
+ }}
+ />
+
+
+
+
+
+
+ {`${opacityPercent}%`}
+
+
+
{
+ const next: FigureData = {
+ ...figureData,
+ fill: withAlpha(fillValue, percentToAlpha(value)),
+ };
+ onFigureDataChange?.(next);
+ }}
+ min={0}
+ max={100}
+ step={1}
+ className="w-full"
+ />
+
+ >
+ )}
+
+ );
+ })()}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 0a03bf1bd..d3f8080c4 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -904,20 +904,33 @@ export default function VideoEditor() {
);
const handleAnnotationAdded = useCallback(
- (span: Span) => {
+ (span: Span, figureData?: FigureData) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++;
- const newRegion: AnnotationRegion = {
+ const baseRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
- type: "text",
- content: "Enter text...",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
+ // When figureData is provided by the caller (toolbar shape buttons), it is
+ // the source of truth — the new region is a figure with that exact data.
+ // No fallback to "arrow" or DEFAULT_FIGURE_DATA on this branch.
+ const newRegion: AnnotationRegion = figureData
+ ? {
+ ...baseRegion,
+ type: "figure",
+ content: "",
+ figureData: { ...figureData },
+ }
+ : {
+ ...baseRegion,
+ type: "text",
+ content: "Enter text...",
+ };
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
diff --git a/src/components/video-editor/figureFill.ts b/src/components/video-editor/figureFill.ts
new file mode 100644
index 000000000..2d9b9f20f
--- /dev/null
+++ b/src/components/video-editor/figureFill.ts
@@ -0,0 +1,109 @@
+/**
+ * Hex-color utilities for the annotation Fill section.
+ *
+ * Fill is stored on `FigureData.fill` as a single canonical string in
+ * `#RRGGBBAA` form (8-digit, alpha required when fill is set). The opacity
+ * slider in the inspector is a *view* of the alpha byte; toggling fill off
+ * sets `figureData.fill = undefined`. There is no separate alpha field.
+ *
+ * All functions throw on malformed input — this module is internal code, not
+ * a user-input parser. Callers must not rely on silent fallbacks.
+ */
+
+/**
+ * Structured representation of a parsed hex color.
+ *
+ * `rgb` is always `#RRGGBB` (lowercase, with leading `#`).
+ * `alpha` is an integer in the closed range `[0, 255]`. When the input was a
+ * 6-digit hex (no alpha component), `alpha` defaults to `255` (fully opaque).
+ */
+export interface ParsedHexColor {
+ readonly rgb: string;
+ readonly alpha: number;
+}
+
+const HEX_COLOR_REGEX = /^#([0-9a-f]{6})([0-9a-f]{2})?$/i;
+
+/**
+ * Parse a `#RRGGBB` or `#RRGGBBAA` hex color (case-insensitive) into its RGB
+ * and alpha components. Throws on malformed input.
+ */
+export function parseHexColor(hex: string): ParsedHexColor {
+ const match = HEX_COLOR_REGEX.exec(hex);
+ if (match === null) {
+ throw new Error(`Invalid hex color: ${hex}`);
+ }
+ const rgbHex = match[1].toLowerCase();
+ const alphaHex = match[2];
+ const alpha = alphaHex === undefined ? 255 : Number.parseInt(alphaHex, 16);
+ return { rgb: `#${rgbHex}`, alpha };
+}
+
+/**
+ * Format an RGB hex (`#RRGGBB`) plus an integer alpha byte (0-255) into a
+ * canonical `#RRGGBBAA` string. Lowercase, alpha is zero-padded to 2 digits.
+ *
+ * Throws if `rgb` is not `#RRGGBB` or `alpha` is not an integer in [0, 255].
+ */
+export function formatHexColor(rgb: string, alpha: number): string {
+ const match = HEX_COLOR_REGEX.exec(rgb);
+ if (match === null || match[2] !== undefined) {
+ throw new Error(`Invalid RGB hex (expected #RRGGBB): ${rgb}`);
+ }
+ if (!Number.isInteger(alpha) || alpha < 0 || alpha > 255) {
+ throw new Error(`Invalid alpha byte (expected integer 0-255): ${alpha}`);
+ }
+ const rgbLower = match[1].toLowerCase();
+ const alphaHex = alpha.toString(16).padStart(2, "0");
+ return `#${rgbLower}${alphaHex}`;
+}
+
+/**
+ * Convenience: take any `#RRGGBB` or `#RRGGBBAA` color, replace its alpha
+ * byte with the supplied one, and return the canonical `#RRGGBBAA` form.
+ *
+ * Throws if `color` is malformed or `alpha` is out of range.
+ */
+export function withAlpha(color: string, alpha: number): string {
+ const parsed = parseHexColor(color);
+ return formatHexColor(parsed.rgb, alpha);
+}
+
+/**
+ * Extract the alpha byte (0-255) from a `#RRGGBB` or `#RRGGBBAA` hex string.
+ * Throws on malformed input. A 6-digit hex resolves to `255`.
+ */
+export function getAlpha(fill: string): number {
+ return parseHexColor(fill).alpha;
+}
+
+/**
+ * Convert an alpha byte (0-255) to an integer percent (0-100). The result is
+ * rounded to the nearest int. Throws on non-finite or out-of-range input.
+ */
+export function alphaToPercent(alpha: number): number {
+ if (!Number.isFinite(alpha) || alpha < 0 || alpha > 255) {
+ throw new Error(`alphaToPercent: invalid alpha (expected finite 0-255): ${alpha}`);
+ }
+ return Math.round((alpha / 255) * 100);
+}
+
+/**
+ * Convert an integer percent (0-100) to an alpha byte (0-255). The result is
+ * rounded to the nearest int. Throws on non-finite or out-of-range input.
+ */
+export function percentToAlpha(percent: number): number {
+ if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
+ throw new Error(`percentToAlpha: invalid percent (expected finite 0-100): ${percent}`);
+ }
+ return Math.round((percent / 100) * 255);
+}
+
+/**
+ * Cheap structural check for a `#RRGGBB` or `#RRGGBBAA` hex color string.
+ * Use this to gate untrusted input before passing to `parseHexColor` /
+ * `withAlpha` (which throw on malformed input).
+ */
+export function isValidHexColor(value: unknown): value is string {
+ return typeof value === "string" && HEX_COLOR_REGEX.test(value);
+}
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 7259c1ee4..0de4db0b9 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -4,6 +4,7 @@ import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
+import { isValidHexColor } from "./figureFill";
import {
type AnnotationRegion,
type CropRegion,
@@ -23,6 +24,8 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
+ FIGURE_KINDS,
+ type FigureKind,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
@@ -40,6 +43,15 @@ import {
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
+const VALID_FIGURE_KINDS: ReadonlySet = new Set(FIGURE_KINDS);
+
+function normalizeFigureKind(value: unknown): FigureKind {
+ for (const kind of VALID_FIGURE_KINDS) {
+ if (value === kind) return kind;
+ }
+ return "arrow";
+}
+
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
// bundled wallpapers. Rewrite only paths that match a known install layout
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
@@ -375,6 +387,10 @@ export function normalizeProjectEditor(editor: Partial): Pro
? {
...DEFAULT_FIGURE_DATA,
...region.figureData,
+ kind: normalizeFigureKind(region.figureData.kind),
+ fill: isValidHexColor(region.figureData.fill)
+ ? region.figureData.fill
+ : undefined,
}
: undefined,
blurData:
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 81e621823..ffa3528b3 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -3,10 +3,12 @@ import { useTimelineContext } from "dnd-timeline";
import {
Check,
ChevronDown,
+ Circle,
Gauge,
MessageSquare,
Plus,
Scissors,
+ Square,
WandSparkles,
ZoomIn,
} from "lucide-react";
@@ -26,10 +28,12 @@ import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
+import { percentToAlpha, withAlpha } from "../figureFill";
import { TutorialHelp } from "../TutorialHelp";
import type {
AnnotationRegion,
CursorTelemetryPoint,
+ FigureData,
SpeedRegion,
TrimRegion,
ZoomFocus,
@@ -70,7 +74,7 @@ interface TimelineEditorProps {
selectedTrimId?: string | null;
onSelectTrim?: (id: string | null) => void;
annotationRegions?: AnnotationRegion[];
- onAnnotationAdded?: (span: Span) => void;
+ onAnnotationAdded?: (span: Span, figureData?: FigureData) => void;
onAnnotationSpanChange?: (id: string, span: Span) => void;
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
@@ -1217,6 +1221,54 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
+ const handleAddRectangleAnnotation = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const endPos = Math.min(startPos + defaultDuration, totalMs);
+
+ const figureData: FigureData = {
+ kind: "rectangle",
+ arrowDirection: "right",
+ color: "#34B27B",
+ strokeWidth: 4,
+ fill: withAlpha("#34B27B", percentToAlpha(20)),
+ };
+
+ onAnnotationAdded({ start: startPos, end: endPos }, figureData);
+ }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
+
+ const handleAddEllipseAnnotation = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const endPos = Math.min(startPos + defaultDuration, totalMs);
+
+ const figureData: FigureData = {
+ kind: "ellipse",
+ arrowDirection: "right",
+ color: "#34B27B",
+ strokeWidth: 4,
+ fill: withAlpha("#34B27B", percentToAlpha(20)),
+ };
+
+ onAnnotationAdded({ start: startPos, end: endPos }, figureData);
+ }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
+
const handleAddBlur = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
return;
@@ -1492,6 +1544,26 @@ export default function TimelineEditor({
>
+
+