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({ > + +