From ff8909824bc686418af8a73cb0fef8f84d0ae3ad Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 08:29:53 -0500 Subject: [PATCH 01/13] feat: add kind discriminator to FigureData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a discriminated-union field on FigureData to distinguish the upcoming rectangle and ellipse annotation kinds from the existing arrow. Adds an optional fill color used by closed shapes. Legacy projects load with kind defaulted to "arrow" at the single normalization site in projectPersistence.normalizeProjectEditor; no schema bump required. Pure data-model groundwork — no rendering, toolbar, or inspector changes in this commit. --- src/components/video-editor/projectPersistence.ts | 1 + src/components/video-editor/types.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7259c1ee4..bab627685 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -375,6 +375,7 @@ export function normalizeProjectEditor(editor: Partial): Pro ? { ...DEFAULT_FIGURE_DATA, ...region.figureData, + kind: region.figureData.kind ?? "arrow", } : undefined, blurData: diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 87e433124..2590e23c6 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -62,9 +62,11 @@ export type ArrowDirection = | "down-left"; export interface FigureData { + kind: "arrow" | "rectangle" | "ellipse"; arrowDirection: ArrowDirection; color: string; strokeWidth: number; + fill?: string; } export type BlurShape = "rectangle" | "oval" | "freehand"; @@ -147,6 +149,7 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = { }; export const DEFAULT_FIGURE_DATA: FigureData = { + kind: "arrow", arrowDirection: "right", color: "#34B27B", strokeWidth: 4, From 2716e6561d107aa321e7310269f0158106727f1d Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 08:36:56 -0500 Subject: [PATCH 02/13] feat: render rectangle in overlay and export Render the rectangle annotation kind in the in-editor SVG overlay and the export canvas pipeline. Both renderers now switch on FigureData.kind with an exhaustive default arm; the existing arrow path is preserved unchanged. The ellipse arm is a no-op stub here; it will be filled in by the next commit. Rectangles use FigureData.color for stroke and FigureData.fill (when set) for fill, matching the export pipeline pixel-for-pixel. --- .../video-editor/AnnotationOverlay.tsx | 50 ++++++++--- src/lib/exporter/annotationRenderer.ts | 87 ++++++++++++++++--- 2 files changed, 115 insertions(+), 22 deletions(-) diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index f416c323b..79b6f8148 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,44 @@ 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) => { + switch (figureData.kind) { + case "arrow": { + const ArrowComponent = getArrowComponent(figureData.arrowDirection); + return ( +
+ +
+ ); + } + case "rectangle": + return ( + + + + ); + case "ellipse": + // rendered in Commit 3 + return null; + default: { + const _exhaustiveCheck: never = figureData.kind; + return _exhaustiveCheck; + } + } }; const normalizePoint = (event: PointerEvent) => { @@ -348,9 +380,7 @@ export function AnnotationOverlay({ ); } - return ( -
{renderArrow()}
- ); + return renderFigure(annotation.figureData); case "blur": { const shape = annotation.blurData?.shape ?? "rectangle"; diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index c0d5657f5..9591b77ff 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -1,4 +1,8 @@ -import { type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types"; +import { + type AnnotationRegion, + type ArrowDirection, + type FigureData, +} from "@/components/video-editor/types"; import { applyMosaicToImageData, getBlurOverlayColor, @@ -139,6 +143,75 @@ function renderArrow( ctx.restore(); } +function renderRectangle( + ctx: CanvasRenderingContext2D, + color: string, + strokeWidth: number, + fill: string | undefined, + x: number, + y: number, + width: number, + height: number, + scaleFactor: number, +) { + ctx.save(); + if (fill) { + ctx.fillStyle = fill; + ctx.fillRect(x, y, width, height); + } + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth * scaleFactor; + ctx.lineJoin = "miter"; + ctx.strokeRect(x, y, width, height); + ctx.restore(); +} + +function renderFigure( + ctx: CanvasRenderingContext2D, + figureData: FigureData, + x: number, + y: number, + width: number, + height: number, + scaleFactor: number, +) { + switch (figureData.kind) { + case "arrow": + renderArrow( + ctx, + figureData.arrowDirection, + figureData.color, + figureData.strokeWidth, + x, + y, + width, + height, + scaleFactor, + ); + return; + case "rectangle": + renderRectangle( + ctx, + figureData.color, + figureData.strokeWidth, + figureData.fill, + x, + y, + width, + height, + scaleFactor, + ); + return; + case "ellipse": + // rendered in Commit 3 + return; + default: { + const _exhaustiveCheck: never = figureData.kind; + return _exhaustiveCheck; + } + } +} + function drawBlurPath( ctx: CanvasRenderingContext2D, annotation: AnnotationRegion, @@ -429,17 +502,7 @@ export async function renderAnnotations( case "figure": if (annotation.figureData) { - renderArrow( - ctx, - annotation.figureData.arrowDirection, - annotation.figureData.color, - annotation.figureData.strokeWidth, - x, - y, - width, - height, - scaleFactor, - ); + renderFigure(ctx, annotation.figureData, x, y, width, height, scaleFactor); } break; From f609c4b0a631831b64b4a681b06f1aabf4effc44 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 08:41:52 -0500 Subject: [PATCH 03/13] feat: render ellipse in overlay and export Fill in the ellipse arm of the renderFigure switch in both the SVG overlay and the export canvas pipeline. Mirrors the structure used for rectangle: an inscribed ellipse with non-scaling stroke, FigureData.color for stroke and FigureData.fill (when set) for fill. The default _exhaustiveCheck arm now passes for all three kinds; no placeholder branches remain. --- .../video-editor/AnnotationOverlay.tsx | 21 +++++++++- src/lib/exporter/annotationRenderer.ts | 40 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 79b6f8148..e72274f11 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -216,8 +216,25 @@ export function AnnotationOverlay({ ); case "ellipse": - // rendered in Commit 3 - return null; + return ( + + + + ); default: { const _exhaustiveCheck: never = figureData.kind; return _exhaustiveCheck; diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index 9591b77ff..74305f9bd 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -166,6 +166,34 @@ function renderRectangle( ctx.restore(); } +function renderEllipse( + ctx: CanvasRenderingContext2D, + color: string, + strokeWidth: number, + fill: string | undefined, + x: number, + y: number, + width: number, + height: number, + scaleFactor: number, +) { + const cx = x + width / 2; + const cy = y + height / 2; + const rx = width / 2; + const ry = height / 2; + ctx.save(); + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth * scaleFactor; + ctx.stroke(); + ctx.restore(); +} + function renderFigure( ctx: CanvasRenderingContext2D, figureData: FigureData, @@ -203,7 +231,17 @@ function renderFigure( ); return; case "ellipse": - // rendered in Commit 3 + renderEllipse( + ctx, + figureData.color, + figureData.strokeWidth, + figureData.fill, + x, + y, + width, + height, + scaleFactor, + ); return; default: { const _exhaustiveCheck: never = figureData.kind; From c8c3fc2c893c10416ee12108d1a7cf39030e6b5b Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 08:52:05 -0500 Subject: [PATCH 04/13] feat: toolbar buttons for rectangle and ellipse Add two new buttons to the timeline annotation toolbar that create shape annotations: rectangle (Square icon) and ellipse (Circle icon). Each new annotation is born with kind set to its shape, the existing default color, and a translucent fill matching the border color (alpha 0x33). The annotation-added handler is widened to accept an optional FigureData. When provided, the region is constructed directly as type "figure" with that figureData; the legacy text-annotation path is preserved unchanged for the existing arrow button. Tooltip and aria-label use placeholder i18n keys that the next commit (i18n) will translate across all six locales. --- src/components/video-editor/VideoEditor.tsx | 21 +++++- .../video-editor/timeline/TimelineEditor.tsx | 73 ++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) 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/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 81e621823..ea6e4a43f 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"; @@ -30,6 +32,7 @@ import { TutorialHelp } from "../TutorialHelp"; import type { AnnotationRegion, CursorTelemetryPoint, + FigureData, SpeedRegion, TrimRegion, ZoomFocus, @@ -70,7 +73,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 +1220,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: "#34B27B33", + }; + + 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: "#34B27B33", + }; + + onAnnotationAdded({ start: startPos, end: endPos }, figureData); + }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); + const handleAddBlur = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) { return; @@ -1492,6 +1543,26 @@ export default function TimelineEditor({ > + + + + + { + const next: FigureData = { + ...figureData, + fill: `${color.hex}33`, + }; + onFigureDataChange?.(next); + }} + style={{ + borderRadius: "8px", + }} + /> + + + )} + + ); + })()} From b0b5c8f6e39f2e19cf2b4e805636eb8c82013768 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 09:04:03 -0500 Subject: [PATCH 06/13] i18n: shape annotation strings across 6 locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 4 new translation keys used by Commits 4 (toolbar) and 5 (inspector fill section) to every locale already shipped in the repo, keeping locale parity intact: - timeline.buttons.addRectangle - timeline.buttons.addEllipse - settings.annotation.fill.label - settings.annotation.fill.toggle Locales: en, zh-CN, es, fr, tr, ko-KR (the 6 listed in the spec) plus zh-TW (which exists in src/i18n/locales/ and is enforced by i18n:check). Spec §5.5 originally listed 9 keys; only 4 ended up referenced by the implementation, so only those are added — no dead strings. --- src/i18n/locales/en/settings.json | 6 +++++- src/i18n/locales/en/timeline.json | 2 ++ src/i18n/locales/es/settings.json | 6 +++++- src/i18n/locales/es/timeline.json | 2 ++ src/i18n/locales/fr/settings.json | 6 +++++- src/i18n/locales/fr/timeline.json | 2 ++ src/i18n/locales/ko-KR/settings.json | 6 +++++- src/i18n/locales/ko-KR/timeline.json | 2 ++ src/i18n/locales/tr/settings.json | 6 +++++- src/i18n/locales/tr/timeline.json | 2 ++ src/i18n/locales/zh-CN/settings.json | 6 +++++- src/i18n/locales/zh-CN/timeline.json | 2 ++ src/i18n/locales/zh-TW/settings.json | 6 +++++- src/i18n/locales/zh-TW/timeline.json | 2 ++ 14 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 00e7c088d..36b611843 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -146,7 +146,11 @@ "invalidImageType": "Invalid file type", "imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.", "imageUploadSuccess": "Image uploaded successfully!", - "failedImageUpload": "Failed to upload image" + "failedImageUpload": "Failed to upload image", + "fill": { + "label": "Fill", + "toggle": "Fill enabled" + } }, "fontStyles": { "classic": "Classic", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index b4d5bd8fb..ee85b0160 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "Suggest Zooms from Cursor", "addTrim": "Add Trim (T)", "addAnnotation": "Add Annotation (A)", + "addRectangle": "Add Rectangle", + "addEllipse": "Add Ellipse", "addBlur": "Add Blur (B)", "addSpeed": "Add Speed (S)" }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 92160bddf..170066cdc 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -146,7 +146,11 @@ "invalidImageType": "Tipo de archivo no válido", "imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.", "imageUploadSuccess": "¡Imagen subida exitosamente!", - "failedImageUpload": "Error al subir la imagen" + "failedImageUpload": "Error al subir la imagen", + "fill": { + "label": "Relleno", + "toggle": "Relleno activado" + } }, "fontStyles": { "classic": "Clásico", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 12a83b047..c436a28d1 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "Sugerir zooms desde el cursor", "addTrim": "Agregar recorte (T)", "addAnnotation": "Agregar anotación (A)", + "addRectangle": "Añadir rectángulo", + "addEllipse": "Añadir elipse", "addSpeed": "Agregar velocidad (S)", "addBlur": "Agregar desenfoque (B)" }, diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 0dff11fab..5c6547942 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -146,7 +146,11 @@ "invalidImageType": "Type de fichier invalide", "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.", "imageUploadSuccess": "Image téléversée avec succès !", - "failedImageUpload": "Échec du téléversement de l'image" + "failedImageUpload": "Échec du téléversement de l'image", + "fill": { + "label": "Remplissage", + "toggle": "Remplissage activé" + } }, "fontStyles": { "classic": "Classique", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 5985ea673..ccc1815b1 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "Suggérer des zooms depuis le curseur", "addTrim": "Ajouter une coupe (T)", "addAnnotation": "Ajouter une annotation (A)", + "addRectangle": "Ajouter un rectangle", + "addEllipse": "Ajouter une ellipse", "addSpeed": "Ajouter une vitesse (S)", "addBlur": "Ajouter un flou (B)" }, diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index cd9f73480..595c1eecd 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -125,7 +125,11 @@ "invalidImageType": "지원하지 않는 파일 형식입니다", "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.", "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!", - "failedImageUpload": "이미지 업로드에 실패했습니다" + "failedImageUpload": "이미지 업로드에 실패했습니다", + "fill": { + "label": "채우기", + "toggle": "채우기 사용" + } }, "fontStyles": { "classic": "클래식", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index 167c26ffe..5b3a55afa 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "커서 기반 줌 제안", "addTrim": "트림 추가 (T)", "addAnnotation": "주석 추가 (A)", + "addRectangle": "사각형 추가", + "addEllipse": "타원 추가", "addSpeed": "속도 추가 (S)" }, "hints": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 936f75c79..56d6450d8 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -128,7 +128,11 @@ "invalidImageType": "Geçersiz dosya türü", "imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.", "imageUploadSuccess": "Görüntü başarıyla yüklendi!", - "failedImageUpload": "Görüntü yüklenemedi" + "failedImageUpload": "Görüntü yüklenemedi", + "fill": { + "label": "Dolgu", + "toggle": "Dolgu açık" + } }, "fontStyles": { "classic": "Klasik", diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index 294640bff..38cd46662 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "İmleçten Yakınlaştırma Öner", "addTrim": "Kırpma Ekle (T)", "addAnnotation": "Açıklama Ekle (A)", + "addRectangle": "Dikdörtgen ekle", + "addEllipse": "Elips ekle", "addSpeed": "Hız Ekle (S)", "addBlur": "Bulanık ekle (B)" }, diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 10a8ecdf9..b297e864e 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -139,7 +139,11 @@ "invalidImageType": "无效的文件类型", "imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。", "imageUploadSuccess": "图片上传成功!", - "failedImageUpload": "上传图片失败" + "failedImageUpload": "上传图片失败", + "fill": { + "label": "填充", + "toggle": "启用填充" + } }, "fontStyles": { "classic": "经典", diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 7841dcb56..056c399df 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "根据光标建议缩放", "addTrim": "添加剪辑 (T)", "addAnnotation": "添加标注 (A)", + "addRectangle": "添加矩形", + "addEllipse": "添加椭圆", "addSpeed": "添加速度 (S)", "addBlur": "添加模糊 (B)" }, diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 6344a9967..d0567ce5b 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -139,7 +139,11 @@ "invalidImageType": "無效的檔案類型", "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。", "imageUploadSuccess": "圖片上傳成功!", - "failedImageUpload": "上傳圖片失敗" + "failedImageUpload": "上傳圖片失敗", + "fill": { + "label": "填充", + "toggle": "啟用填充" + } }, "fontStyles": { "classic": "經典", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 52457d61f..6fc79b8ce 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -4,6 +4,8 @@ "suggestZooms": "根據游標建議縮放", "addTrim": "新增剪輯 (T)", "addAnnotation": "新增標註 (A)", + "addRectangle": "新增矩形", + "addEllipse": "新增橢圓", "addSpeed": "新增速度 (S)", "addBlur": "新增模糊 (B)" }, From 9e114b1f005237bf474d8efc64b8ff1e40b8e0db Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 09:25:51 -0500 Subject: [PATCH 07/13] refactor: make FigureData.kind optional per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §3 declares kind as optional. The previous commits implemented it as required, which was a type-contract regression. All runtime consumers now read figureData.kind ?? "arrow" so a fresh FigureData without an explicit kind is treated as the legacy arrow shape. Load-path normalization in projectPersistence.ts continues to fill in the default for stored projects, so persisted state is unchanged. Exhaustive switches in AnnotationOverlay and annotationRenderer are preserved by switching on the narrowed local rather than figureData.kind directly; the never-typed default arm still proves exhaustiveness. --- src/components/video-editor/AnnotationOverlay.tsx | 5 +++-- src/components/video-editor/AnnotationSettingsPanel.tsx | 3 +-- src/components/video-editor/types.ts | 2 +- src/lib/exporter/annotationRenderer.ts | 5 +++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index e72274f11..e281428da 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -186,7 +186,8 @@ export function AnnotationOverlay({ ]); const renderFigure = (figureData: FigureData) => { - switch (figureData.kind) { + const kind = figureData.kind ?? "arrow"; + switch (kind) { case "arrow": { const ArrowComponent = getArrowComponent(figureData.arrowDirection); return ( @@ -236,7 +237,7 @@ export function AnnotationOverlay({ ); default: { - const _exhaustiveCheck: never = figureData.kind; + const _exhaustiveCheck: never = kind; return _exhaustiveCheck; } } diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 407868508..5f3c5c819 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -607,8 +607,7 @@ export function AnnotationSettingsPanel({ {(() => { const figureData = annotation.figureData; if (!figureData) return null; - const isClosedShape = - figureData.kind === "rectangle" || figureData.kind === "ellipse"; + const isClosedShape = (figureData.kind ?? "arrow") !== "arrow"; if (!isClosedShape) return null; const fillEnabled = typeof figureData.fill === "string"; const fillValue = figureData.fill ?? `${figureData.color}33`; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 2590e23c6..93ecabd67 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -62,7 +62,7 @@ export type ArrowDirection = | "down-left"; export interface FigureData { - kind: "arrow" | "rectangle" | "ellipse"; + kind?: "arrow" | "rectangle" | "ellipse"; arrowDirection: ArrowDirection; color: string; strokeWidth: number; diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index 74305f9bd..68cd43850 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -203,7 +203,8 @@ function renderFigure( height: number, scaleFactor: number, ) { - switch (figureData.kind) { + const kind = figureData.kind ?? "arrow"; + switch (kind) { case "arrow": renderArrow( ctx, @@ -244,7 +245,7 @@ function renderFigure( ); return; default: { - const _exhaustiveCheck: never = figureData.kind; + const _exhaustiveCheck: never = kind; return _exhaustiveCheck; } } From cb9878a02467d21d59853dcb6aeb124d1e4100b9 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 09:33:18 -0500 Subject: [PATCH 08/13] feat: fill opacity slider with single-source hex utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce figureFill.ts as the single source of truth for hex-color handling on figure annotations: typed parser/formatter for #RRGGBB and #RRGGBBAA, alpha extraction, and percent<->alpha conversions. Malformed input throws; no silent fallbacks. The inspector's Fill section now exposes an integer opacity slider (0-100) per spec §5.3. The slider drives the alpha byte of figureData.fill while figureData.color remains the RGB source. When the user changes the border color, the fill adopts the new RGB while preserving its current alpha; when they pick a new fill color, the same invariant holds. The previously hardcoded 0x33 alpha string is gone from both the inspector and the timeline toolbar buttons; both call sites now compose fills via withAlpha(color, percentToAlpha(20)). --- .../video-editor/AnnotationSettingsPanel.tsx | 100 ++++++++++++------ src/components/video-editor/figureFill.ts | 96 +++++++++++++++++ .../video-editor/timeline/TimelineEditor.tsx | 5 +- 3 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 src/components/video-editor/figureFill.ts diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 5f3c5c819..2de7ed2e7 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -34,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, @@ -590,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); }} @@ -610,8 +618,10 @@ export function AnnotationSettingsPanel({ const isClosedShape = (figureData.kind ?? "arrow") !== "arrow"; if (!isClosedShape) return null; const fillEnabled = typeof figureData.fill === "string"; - const fillValue = figureData.fill ?? `${figureData.color}33`; - const defaultFillFromColor = `${figureData.color}33`; + const defaultFillFromColor = withAlpha(figureData.color, percentToAlpha(20)); + const fillValue = figureData.fill ?? defaultFillFromColor; + const currentAlpha = getAlpha(fillValue); + const opacityPercent = alphaToPercent(currentAlpha); return (
@@ -636,42 +646,68 @@ export function AnnotationSettingsPanel({
{fillEnabled && ( - - - + + + { + const next: FigureData = { + ...figureData, + fill: withAlpha(color.hex, currentAlpha), + }; + onFigureDataChange?.(next); + }} + style={{ + borderRadius: "8px", + }} + /> + + +
+
+ + + {`${opacityPercent}%`} - - - - - { +
+ { const next: FigureData = { ...figureData, - fill: `${color.hex}33`, + fill: withAlpha(figureData.color, percentToAlpha(value)), }; onFigureDataChange?.(next); }} - style={{ - borderRadius: "8px", - }} + min={0} + max={100} + step={1} + className="w-full" /> - - +
+ )} ); diff --git a/src/components/video-editor/figureFill.ts b/src/components/video-editor/figureFill.ts new file mode 100644 index 000000000..db2e45fb4 --- /dev/null +++ b/src/components/video-editor/figureFill.ts @@ -0,0 +1,96 @@ +/** + * 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). + * Inputs are clamped into range; the result is rounded to the nearest int. + */ +export function alphaToPercent(alpha: number): number { + const clamped = Math.min(255, Math.max(0, alpha)); + return Math.round((clamped / 255) * 100); +} + +/** + * Convert an integer percent (0-100) to an alpha byte (0-255). + * Inputs are clamped into range; the result is rounded to the nearest int. + */ +export function percentToAlpha(percent: number): number { + const clamped = Math.min(100, Math.max(0, percent)); + return Math.round((clamped / 100) * 255); +} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index ea6e4a43f..ffa3528b3 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -28,6 +28,7 @@ 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, @@ -1238,7 +1239,7 @@ export default function TimelineEditor({ arrowDirection: "right", color: "#34B27B", strokeWidth: 4, - fill: "#34B27B33", + fill: withAlpha("#34B27B", percentToAlpha(20)), }; onAnnotationAdded({ start: startPos, end: endPos }, figureData); @@ -1262,7 +1263,7 @@ export default function TimelineEditor({ arrowDirection: "right", color: "#34B27B", strokeWidth: 4, - fill: "#34B27B33", + fill: withAlpha("#34B27B", percentToAlpha(20)), }; onAnnotationAdded({ start: startPos, end: endPos }, figureData); From 0c66445aa563047b1559d76af6b0848394a47894 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 09:37:30 -0500 Subject: [PATCH 09/13] i18n: opacity slider label across 6 locales Adds annotation.fill.opacity to every settings namespace so the new inspector slider label localizes correctly. Translations chosen for visual parity with surrounding annotation.fill keys. --- src/i18n/locales/en/settings.json | 3 ++- src/i18n/locales/es/settings.json | 3 ++- src/i18n/locales/fr/settings.json | 3 ++- src/i18n/locales/ko-KR/settings.json | 3 ++- src/i18n/locales/tr/settings.json | 3 ++- src/i18n/locales/zh-CN/settings.json | 3 ++- src/i18n/locales/zh-TW/settings.json | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 36b611843..3b2971efc 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -149,7 +149,8 @@ "failedImageUpload": "Failed to upload image", "fill": { "label": "Fill", - "toggle": "Fill enabled" + "toggle": "Fill enabled", + "opacity": "Opacity" } }, "fontStyles": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 170066cdc..f3af1900b 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -149,7 +149,8 @@ "failedImageUpload": "Error al subir la imagen", "fill": { "label": "Relleno", - "toggle": "Relleno activado" + "toggle": "Relleno activado", + "opacity": "Opacidad" } }, "fontStyles": { diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 5c6547942..df0250358 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -149,7 +149,8 @@ "failedImageUpload": "Échec du téléversement de l'image", "fill": { "label": "Remplissage", - "toggle": "Remplissage activé" + "toggle": "Remplissage activé", + "opacity": "Opacité" } }, "fontStyles": { diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 595c1eecd..a4df32129 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -128,7 +128,8 @@ "failedImageUpload": "이미지 업로드에 실패했습니다", "fill": { "label": "채우기", - "toggle": "채우기 사용" + "toggle": "채우기 사용", + "opacity": "불투명도" } }, "fontStyles": { diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 56d6450d8..a9e04595a 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -131,7 +131,8 @@ "failedImageUpload": "Görüntü yüklenemedi", "fill": { "label": "Dolgu", - "toggle": "Dolgu açık" + "toggle": "Dolgu açık", + "opacity": "Opaklık" } }, "fontStyles": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index b297e864e..e10c6a509 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -142,7 +142,8 @@ "failedImageUpload": "上传图片失败", "fill": { "label": "填充", - "toggle": "启用填充" + "toggle": "启用填充", + "opacity": "不透明度" } }, "fontStyles": { diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d0567ce5b..457d253ba 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -142,7 +142,8 @@ "failedImageUpload": "上傳圖片失敗", "fill": { "label": "填充", - "toggle": "啟用填充" + "toggle": "啟用填充", + "opacity": "不透明度" } }, "fontStyles": { From 9bd4261cd6479cf0b5b6b3816fa061a542836dc9 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 13:50:15 -0500 Subject: [PATCH 10/13] fix: opacity slider preserves fill RGB instead of border color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slider's onChange wrote withAlpha(figureData.color, …) which silently overwrote the fill's RGB with the border color whenever a user had diverged the two via the fill color picker. Read RGB from the current fillValue instead so the slider only changes the alpha byte. --- src/components/video-editor/AnnotationSettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 2de7ed2e7..b4f5dbf04 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -697,7 +697,7 @@ export function AnnotationSettingsPanel({ onValueChange={([value]) => { const next: FigureData = { ...figureData, - fill: withAlpha(figureData.color, percentToAlpha(value)), + fill: withAlpha(fillValue, percentToAlpha(value)), }; onFigureDataChange?.(next); }} From 2bc3fc17c1a91146687619c3ab7d6b311c947312 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 14:41:24 -0500 Subject: [PATCH 11/13] i18n: tighten shape annotation locale wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - es: addRectangle/addEllipse "Añadir" → "Agregar" (sibling consistency) - tr: addRectangle/addEllipse sentence → Title Case - zh-TW: fill.label/toggle 填充 → 填滿 (regional preference) --- src/i18n/locales/es/timeline.json | 4 ++-- src/i18n/locales/tr/timeline.json | 4 ++-- src/i18n/locales/zh-TW/settings.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index c436a28d1..d840f4bb9 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -4,8 +4,8 @@ "suggestZooms": "Sugerir zooms desde el cursor", "addTrim": "Agregar recorte (T)", "addAnnotation": "Agregar anotación (A)", - "addRectangle": "Añadir rectángulo", - "addEllipse": "Añadir elipse", + "addRectangle": "Agregar rectángulo", + "addEllipse": "Agregar elipse", "addSpeed": "Agregar velocidad (S)", "addBlur": "Agregar desenfoque (B)" }, diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index 38cd46662..44213fe35 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -4,8 +4,8 @@ "suggestZooms": "İmleçten Yakınlaştırma Öner", "addTrim": "Kırpma Ekle (T)", "addAnnotation": "Açıklama Ekle (A)", - "addRectangle": "Dikdörtgen ekle", - "addEllipse": "Elips ekle", + "addRectangle": "Dikdörtgen Ekle", + "addEllipse": "Elips Ekle", "addSpeed": "Hız Ekle (S)", "addBlur": "Bulanık ekle (B)" }, diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 457d253ba..47daeb62e 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -141,8 +141,8 @@ "imageUploadSuccess": "圖片上傳成功!", "failedImageUpload": "上傳圖片失敗", "fill": { - "label": "填充", - "toggle": "啟用填充", + "label": "填滿", + "toggle": "啟用填滿", "opacity": "不透明度" } }, From 0347006f98ce4efe90a660f17522d7eb6ba10ff2 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 14:41:31 -0500 Subject: [PATCH 12/13] fix: validate persisted figure data and harden exhaustive fallbacks - figureFill: alphaToPercent/percentToAlpha throw on non-finite or out-of-range input, matching the module's documented contract; add isValidHexColor guard for untrusted input. - types: derive FigureKind from a single FIGURE_KINDS const so the union and runtime validator share one source of truth. - projectPersistence: normalize unknown kind values from loaded JSON back to "arrow" and drop malformed fill strings before they reach the renderer or hex parsers. - AnnotationOverlay/annotationRenderer: exhaustive-default branches warn and return null/void instead of returning the never-typed kind string (which would have been injected into the DOM if a bad payload ever slipped past validation). --- .../video-editor/AnnotationOverlay.tsx | 3 +- src/components/video-editor/figureFill.ts | 29 ++++++++++++++----- .../video-editor/projectPersistence.ts | 17 ++++++++++- src/components/video-editor/types.ts | 5 +++- src/lib/exporter/annotationRenderer.ts | 3 +- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index e281428da..177e78c82 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -238,7 +238,8 @@ export function AnnotationOverlay({ ); default: { const _exhaustiveCheck: never = kind; - return _exhaustiveCheck; + console.warn(`AnnotationOverlay: unsupported figure kind "${String(_exhaustiveCheck)}"`); + return null; } } }; diff --git a/src/components/video-editor/figureFill.ts b/src/components/video-editor/figureFill.ts index db2e45fb4..2d9b9f20f 100644 --- a/src/components/video-editor/figureFill.ts +++ b/src/components/video-editor/figureFill.ts @@ -78,19 +78,32 @@ export function getAlpha(fill: string): number { } /** - * Convert an alpha byte (0-255) to an integer percent (0-100). - * Inputs are clamped into range; the result is rounded to the nearest int. + * 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 { - const clamped = Math.min(255, Math.max(0, alpha)); - return Math.round((clamped / 255) * 100); + 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). - * Inputs are clamped into range; the result is rounded to the nearest int. + * 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 { - const clamped = Math.min(100, Math.max(0, percent)); - return Math.round((clamped / 100) * 255); + 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 bab627685..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,7 +387,10 @@ export function normalizeProjectEditor(editor: Partial): Pro ? { ...DEFAULT_FIGURE_DATA, ...region.figureData, - kind: region.figureData.kind ?? "arrow", + kind: normalizeFigureKind(region.figureData.kind), + fill: isValidHexColor(region.figureData.fill) + ? region.figureData.fill + : undefined, } : undefined, blurData: diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 93ecabd67..d48fdaafc 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -61,8 +61,11 @@ export type ArrowDirection = | "down-right" | "down-left"; +export const FIGURE_KINDS = ["arrow", "rectangle", "ellipse"] as const; +export type FigureKind = (typeof FIGURE_KINDS)[number]; + export interface FigureData { - kind?: "arrow" | "rectangle" | "ellipse"; + kind?: FigureKind; arrowDirection: ArrowDirection; color: string; strokeWidth: number; diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index 68cd43850..b6e0d4282 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -246,7 +246,8 @@ function renderFigure( return; default: { const _exhaustiveCheck: never = kind; - return _exhaustiveCheck; + console.warn(`annotationRenderer: unsupported figure kind "${String(_exhaustiveCheck)}"`); + return; } } } From e02807e068888a3d4afa8d1a7d4a5711b05c7d91 Mon Sep 17 00:00:00 2001 From: Enriquefft Date: Mon, 27 Apr 2026 14:41:35 -0500 Subject: [PATCH 13/13] a11y: label fill toggle switch via aria-labelledby The Switch in the figure tab had no accessible name; pair its label with an id and reference it via aria-labelledby so screen readers announce the control's purpose. --- src/components/video-editor/AnnotationSettingsPanel.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index b4f5dbf04..bdd6b11bf 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -625,7 +625,10 @@ export function AnnotationSettingsPanel({ return (
-