From 94490a71af68a371a00c43bdfb90de33331903f7 Mon Sep 17 00:00:00 2001 From: huanld Date: Fri, 5 Jun 2026 05:58:14 +0700 Subject: [PATCH] Add guide video magnifier annotations --- .../video-editor/AnnotationOverlay.tsx | 99 ++++++++ src/components/video-editor/VideoEditor.tsx | 31 +++ src/components/video-editor/VideoPlayback.tsx | 26 ++- .../video-editor/guide/GuidePanel.tsx | 47 +++- .../video-editor/projectPersistence.ts | 45 +++- src/components/video-editor/types.ts | 16 +- src/guide/videoAnnotations.test.ts | 115 ++++++++++ src/guide/videoAnnotations.ts | 215 ++++++++++++++++++ src/lib/exporter/annotationRenderer.ts | 140 ++++++++++++ 9 files changed, 717 insertions(+), 17 deletions(-) create mode 100644 src/guide/videoAnnotations.test.ts create mode 100644 src/guide/videoAnnotations.ts diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 345423f..30172c2 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -82,6 +82,7 @@ export function AnnotationOverlay({ ); const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null); const mosaicCanvasRef = useRef(null); + const magnifierCanvasRef = useRef(null); const blurType = "mosaic"; const blurOverlayColor = annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : ""; @@ -183,6 +184,79 @@ export function AnnotationOverlay({ y, ]); + useEffect(() => { + if (annotation.type !== "magnifier") { + return; + } + void previewFrameVersion; + + const canvas = magnifierCanvasRef.current; + const sourceCanvas = previewSourceCanvas; + if (!canvas || !sourceCanvas) { + return; + } + + const sourceWidth = sourceCanvas.width; + const sourceHeight = sourceCanvas.height; + const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth; + const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight; + if ( + sourceWidth <= 0 || + sourceHeight <= 0 || + sourceClientWidth <= 0 || + sourceClientHeight <= 0 + ) { + return; + } + + const drawWidth = Math.max(1, Math.round(width)); + const drawHeight = Math.max(1, Math.round(height)); + canvas.width = drawWidth; + canvas.height = drawHeight; + + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + const zoom = Math.max(1, annotation.magnifierData?.zoom ?? 2.2); + const target = annotation.magnifierData?.target ?? { + x: annotation.position.x + annotation.size.width / 2, + y: annotation.position.y + annotation.size.height / 2, + }; + const scaleX = sourceWidth / sourceClientWidth; + const scaleY = sourceHeight / sourceClientHeight; + const targetX = (target.x / 100) * sourceClientWidth * scaleX; + const targetY = (target.y / 100) * sourceClientHeight * scaleY; + const sampleWidth = Math.max(1, drawWidth / zoom); + const sampleHeight = Math.max(1, drawHeight / zoom); + const sx = Math.max(0, Math.min(sourceWidth - sampleWidth, targetX - sampleWidth / 2)); + const sy = Math.max(0, Math.min(sourceHeight - sampleHeight, targetY - sampleHeight / 2)); + + context.clearRect(0, 0, drawWidth, drawHeight); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage( + sourceCanvas as CanvasImageSource, + sx, + sy, + Math.min(sampleWidth, sourceWidth - sx), + Math.min(sampleHeight, sourceHeight - sy), + 0, + 0, + drawWidth, + drawHeight, + ); + }, [ + annotation, + containerHeight, + containerWidth, + height, + previewFrameVersion, + previewSourceCanvas, + width, + ]); + const renderArrow = () => { const direction = annotation.figureData?.arrowDirection || "right"; const color = annotation.figureData?.color || "#34B27B"; @@ -351,6 +425,30 @@ export function AnnotationOverlay({
{renderArrow()}
); + case "magnifier": { + const shape = annotation.magnifierData?.shape ?? "circle"; + const caption = annotation.magnifierData?.caption; + return ( +
+ + {caption && ( +
+ {caption} +
+ )} +
+ ); + } + case "blur": { const shape = annotation.blurData?.shape ?? "rectangle"; const blurIntensity = Math.max( @@ -623,6 +721,7 @@ export function AnnotationOverlay({ annotation.type === "text" && "bg-transparent", annotation.type === "image" && "bg-transparent", annotation.type === "figure" && "bg-transparent", + annotation.type === "magnifier" && "bg-transparent", annotation.type === "blur" && "bg-transparent", isSelected && annotation.type !== "blur" && "shadow-lg", )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 45c8384..41f6af1 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -13,6 +13,8 @@ import { } from "@/components/ui/dialog"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; +import type { GuideSession } from "@/guide/contracts"; +import { buildGuideVideoAnnotations, buildGuideVideoSpeedRegions } from "@/guide/videoAnnotations"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; import { type Locale } from "@/i18n/config"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; @@ -1374,6 +1376,34 @@ export default function VideoEditor() { [pushState], ); + const handleGuideAttachToVideo = useCallback( + (session: GuideSession) => { + const guideAnnotations = buildGuideVideoAnnotations(session, { + nextId: () => `annotation-${nextAnnotationIdRef.current++}`, + nextZIndex: () => nextAnnotationZIndexRef.current++, + }); + const guideSpeedRegions = buildGuideVideoSpeedRegions(session, { + nextId: () => `speed-${nextSpeedIdRef.current++}`, + }); + if (guideAnnotations.length === 0 && guideSpeedRegions.length === 0) { + toast.error("Generate a guide draft before attaching steps to the video."); + return; + } + + pushState((prev) => ({ + annotationRegions: [...prev.annotationRegions, ...guideAnnotations], + speedRegions: [...prev.speedRegions, ...guideSpeedRegions], + })); + const firstTextAnnotation = guideAnnotations.find((annotation) => annotation.type === "text"); + setSelectedAnnotationId(firstTextAnnotation?.id ?? guideAnnotations[0]?.id ?? null); + setSelectedBlurId(null); + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + }, + [pushState], + ); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; @@ -2162,6 +2192,7 @@ export default function VideoEditor() { videoPath={videoPath} videoSourcePath={videoSourcePath} currentTimeMs={currentTime * 1000} + onAttachToVideo={handleGuideAttachToVideo} /> )}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 28c7b50..70c9b01 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1963,18 +1963,20 @@ const VideoPlayback = forwardRef( region: blurRegion, })), ].sort((a, b) => a.region.zIndex - b.region.zIndex); - const previewSnapshotCanvas = - filteredBlurRegions.length > 0 - ? (() => { - const app = appRef.current; - if (!app?.renderer?.extract) return null; - try { - return app.renderer.extract.canvas(app.stage); - } catch { - return null; - } - })() - : null; + const needsPreviewSnapshot = + filteredBlurRegions.length > 0 || + filteredAnnotations.some((annotation) => annotation.type === "magnifier"); + const previewSnapshotCanvas = needsPreviewSnapshot + ? (() => { + const app = appRef.current; + if (!app?.renderer?.extract) return null; + try { + return app.renderer.extract.canvas(app.stage); + } catch { + return null; + } + })() + : null; // Handle click-through cycling: when clicking same annotation, cycle to next const handleAnnotationClick = (clickedId: string) => { diff --git a/src/components/video-editor/guide/GuidePanel.tsx b/src/components/video-editor/guide/GuidePanel.tsx index e6a61e9..595f773 100644 --- a/src/components/video-editor/guide/GuidePanel.tsx +++ b/src/components/video-editor/guide/GuidePanel.tsx @@ -1,4 +1,4 @@ -import { KeyRound, ListChecks, RefreshCw, Save, Trash2, Wand2 } from "lucide-react"; +import { KeyRound, ListChecks, RefreshCw, Save, Trash2, Video, Wand2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -18,9 +18,10 @@ interface GuidePanelProps { videoPath: string | null; videoSourcePath: string | null; currentTimeMs: number; + onAttachToVideo?: (session: GuideSession) => void; } -type BusyAction = "load" | "generate"; +type BusyAction = "load" | "generate" | "attach"; interface GuideProgressState { label: string; @@ -71,6 +72,9 @@ const COPY = { noEvents: "No click events were captured for this guide.", ocrUnavailable: "Local OCR service is unavailable. You can still create a local draft.", exported: "Guide exported", + attachToVideo: "Attach to video", + attachedToVideo: "Guide steps attached to the video timeline.", + noDraft: "Generate a guide draft before attaching steps to the video.", progressPreparing: "Preparing events", progressSnapshots: "Capturing snapshots", progressOcr: "Running OCR", @@ -118,6 +122,9 @@ const COPY = { noEvents: "Chưa ghi nhận click event nào cho guide này.", ocrUnavailable: "OCR local chưa chạy. Vẫn có thể tạo draft local.", exported: "Đã export hướng dẫn", + attachToVideo: "Gắn vào video", + attachedToVideo: "Đã gắn các bước guide vào timeline video.", + noDraft: "Hãy tạo draft guide trước khi gắn vào video.", progressPreparing: "Đang chuẩn bị events", progressSnapshots: "Đang chụp ảnh", progressOcr: "Đang OCR", @@ -144,7 +151,12 @@ function getProgressPercent(progress: GuideProgressState | null): number { return Math.min(100, Math.max(progress.current > 0 ? 8 : 4, percent)); } -export function GuidePanel({ recordingId, videoPath, videoSourcePath }: GuidePanelProps) { +export function GuidePanel({ + recordingId, + videoPath, + videoSourcePath, + onAttachToVideo, +}: GuidePanelProps) { const { locale } = useI18n(); const copy = useMemo(() => (locale.startsWith("vi") ? COPY.vi : COPY.en), [locale]); const guideLanguage: GuideLanguage = locale.startsWith("vi") ? "vi" : "en"; @@ -551,6 +563,25 @@ export function GuidePanel({ recordingId, videoPath, videoSourcePath }: GuidePan videoPath, ]); + const handleAttachToVideo = useCallback(() => { + if (!session?.generatedGuide || session.generatedGuide.steps.length === 0) { + setMessage(copy.noDraft); + toast.error(copy.noDraft); + return; + } + if (!onAttachToVideo) { + return; + } + setBusyAction("attach"); + try { + onAttachToVideo(session); + setMessage(null); + toast.success(copy.attachedToVideo); + } finally { + setBusyAction(null); + } + }, [copy.attachedToVideo, copy.noDraft, onAttachToVideo, session]); + return (
@@ -629,6 +660,16 @@ export function GuidePanel({ recordingId, videoPath, videoSourcePath }: GuidePan + + {settingsOpen && (
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index f16d29e..8ac6f6e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -23,6 +23,7 @@ import { DEFAULT_BLUR_FREEHAND_POINTS, DEFAULT_BLUR_INTENSITY, DEFAULT_FIGURE_DATA, + DEFAULT_MAGNIFIER_DATA, DEFAULT_PLAYBACK_SPEED, DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_MOTION_BLUR, @@ -325,7 +326,10 @@ export function normalizeProjectEditor(editor: Partial): Pro startMs, endMs, type: - region.type === "image" || region.type === "figure" || region.type === "blur" + region.type === "image" || + region.type === "figure" || + region.type === "blur" || + region.type === "magnifier" ? region.type : "text", content: typeof region.content === "string" ? region.content : "", @@ -410,6 +414,45 @@ export function normalizeProjectEditor(editor: Partial): Pro : DEFAULT_BLUR_FREEHAND_POINTS, } : undefined, + magnifierData: + region.magnifierData && typeof region.magnifierData === "object" + ? { + ...DEFAULT_MAGNIFIER_DATA, + ...region.magnifierData, + target: { + x: clamp( + isFiniteNumber(region.magnifierData.target?.x) + ? region.magnifierData.target.x + : DEFAULT_MAGNIFIER_DATA.target.x, + 0, + 100, + ), + y: clamp( + isFiniteNumber(region.magnifierData.target?.y) + ? region.magnifierData.target.y + : DEFAULT_MAGNIFIER_DATA.target.y, + 0, + 100, + ), + }, + zoom: clamp( + isFiniteNumber(region.magnifierData.zoom) + ? region.magnifierData.zoom + : DEFAULT_MAGNIFIER_DATA.zoom, + 1, + 6, + ), + shape: + region.magnifierData.shape === "rounded" || + region.magnifierData.shape === "circle" + ? region.magnifierData.shape + : DEFAULT_MAGNIFIER_DATA.shape, + caption: + typeof region.magnifierData.caption === "string" + ? region.magnifierData.caption + : undefined, + } + : undefined, }; }) : []; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index fce4a19..9c00b47 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -206,7 +206,7 @@ export interface TrimRegion { endMs: number; } -export type AnnotationType = "text" | "image" | "figure" | "blur"; +export type AnnotationType = "text" | "image" | "figure" | "blur" | "magnifier"; export type ArrowDirection = | "up" @@ -245,6 +245,13 @@ export interface BlurData { freehandPoints?: Array<{ x: number; y: number }>; } +export interface MagnifierData { + target: AnnotationPosition; + zoom: number; + shape: "circle" | "rounded"; + caption?: string; +} + export interface AnnotationPosition { x: number; y: number; @@ -280,6 +287,7 @@ export interface AnnotationRegion { zIndex: number; figureData?: FigureData; blurData?: BlurData; + magnifierData?: MagnifierData; } export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = { @@ -330,6 +338,12 @@ export const DEFAULT_BLUR_DATA: BlurData = { freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS, }; +export const DEFAULT_MAGNIFIER_DATA: MagnifierData = { + target: { x: 50, y: 50 }, + zoom: 2.2, + shape: "circle", +}; + export interface CropRegion { x: number; y: number; diff --git a/src/guide/videoAnnotations.test.ts b/src/guide/videoAnnotations.test.ts new file mode 100644 index 0000000..034a197 --- /dev/null +++ b/src/guide/videoAnnotations.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { GUIDE_SCHEMA_VERSION, type GuideSession } from "./contracts"; +import { buildGuideVideoAnnotations, buildGuideVideoSpeedRegions } from "./videoAnnotations"; + +function createSession(): GuideSession { + return { + schemaVersion: GUIDE_SCHEMA_VERSION, + recordingId: "recording-1", + videoPath: "recording.mp4", + guidePath: "recording.guide.json", + outputDir: "recording-guide", + status: "draft-ready", + events: [], + snapshots: [], + ocrBlocks: [], + candidates: [ + { + id: "candidate-1", + eventId: "event-1", + timeMs: 1200, + action: "click", + targetText: "Settings", + targetRole: "button", + position: { + normalizedX: 0.2, + normalizedY: 0.25, + xPercent: 20, + yPercent: 25, + description: "top left", + }, + nearbyText: ["Settings"], + confidence: 0.91, + }, + ], + generatedGuide: { + title: "Guide", + steps: [ + { + id: "step-1", + order: 1, + title: "Open settings", + instruction: "Click Settings.", + sourceCandidateId: "candidate-1", + }, + ], + }, + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; +} + +describe("buildGuideVideoAnnotations", () => { + it("creates caption and pointer annotations from generated guide candidates", () => { + let id = 1; + let zIndex = 1; + const annotations = buildGuideVideoAnnotations(createSession(), { + nextId: () => `guide-video-${id++}`, + nextZIndex: () => zIndex++, + }); + + expect(annotations).toHaveLength(3); + expect(annotations[0]).toMatchObject({ + id: "guide-video-1", + type: "text", + startMs: 1200, + content: "1. Click Settings.", + }); + expect(annotations[0]?.position.x).toBeGreaterThan(20); + expect(annotations[1]).toMatchObject({ + id: "guide-video-2", + type: "magnifier", + magnifierData: { + target: { x: 20, y: 25 }, + zoom: 2.2, + shape: "circle", + caption: "Settings", + }, + }); + expect(annotations[2]).toMatchObject({ + id: "guide-video-3", + type: "figure", + figureData: { + color: "#34B27B", + }, + }); + }); + + it("returns an empty list when no draft exists", () => { + const session = createSession(); + session.generatedGuide = undefined; + + const annotations = buildGuideVideoAnnotations(session, { + nextId: () => "unused", + nextZIndex: () => 1, + }); + + expect(annotations).toEqual([]); + }); + + it("creates 0.3x speed regions for one second at each guide point", () => { + let id = 1; + const speedRegions = buildGuideVideoSpeedRegions(createSession(), { + nextId: () => `guide-speed-${id++}`, + }); + + expect(speedRegions).toEqual([ + { + id: "guide-speed-1", + startMs: 1200, + endMs: 2200, + speed: 0.3, + }, + ]); + }); +}); diff --git a/src/guide/videoAnnotations.ts b/src/guide/videoAnnotations.ts new file mode 100644 index 0000000..a16218d --- /dev/null +++ b/src/guide/videoAnnotations.ts @@ -0,0 +1,215 @@ +import { + type AnnotationRegion, + type ArrowDirection, + DEFAULT_ANNOTATION_STYLE, + DEFAULT_FIGURE_DATA, + DEFAULT_MAGNIFIER_DATA, + type SpeedRegion, +} from "@/components/video-editor/types"; +import type { GeneratedGuideStep, GuideSession, GuideStepCandidate } from "./contracts"; + +export interface BuildGuideVideoAnnotationsOptions { + nextId: () => string; + nextZIndex: () => number; + defaultDurationMs?: number; +} + +const DEFAULT_STEP_DURATION_MS = 3200; +const DEFAULT_STEP_SLOW_MOTION_DURATION_MS = 1000; +const DEFAULT_STEP_SLOW_MOTION_SPEED = 0.3; +const CAPTION_WIDTH = 34; +const CAPTION_HEIGHT = 13; +const MAGNIFIER_SIZE = 18; +const ARROW_SIZE = 10; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function findCandidate( + step: GeneratedGuideStep, + stepIndex: number, + candidates: GuideStepCandidate[], +): GuideStepCandidate | undefined { + if (step.sourceCandidateId) { + const matched = candidates.find((candidate) => candidate.id === step.sourceCandidateId); + if (matched) return matched; + } + const sorted = [...candidates].sort((left, right) => left.timeMs - right.timeMs); + return sorted[stepIndex]; +} + +function getCaptionPosition(candidate: GuideStepCandidate | undefined) { + const target = candidate?.position; + if (!target) { + return { x: 8, y: 8 }; + } + + const targetX = target.normalizedX * 100; + const targetY = target.normalizedY * 100; + const x = target.normalizedX < 0.5 ? targetX + 8 : targetX - CAPTION_WIDTH - 8; + const y = target.normalizedY < 0.5 ? targetY + 8 : targetY - CAPTION_HEIGHT - 8; + + return { + x: clamp(x, 2, 100 - CAPTION_WIDTH - 2), + y: clamp(y, 2, 100 - CAPTION_HEIGHT - 2), + }; +} + +function getArrowDirection( + candidate: GuideStepCandidate | undefined, + captionPosition: { x: number; y: number }, +): ArrowDirection { + const target = candidate?.position; + if (!target) return "right"; + + const captionCenterX = captionPosition.x + CAPTION_WIDTH / 2; + const captionCenterY = captionPosition.y + CAPTION_HEIGHT / 2; + const dx = target.normalizedX * 100 - captionCenterX; + const dy = target.normalizedY * 100 - captionCenterY; + const horizontal = dx > 8 ? "right" : dx < -8 ? "left" : ""; + const vertical = dy > 8 ? "down" : dy < -8 ? "up" : ""; + + if (vertical && horizontal) return `${vertical}-${horizontal}` as ArrowDirection; + return (horizontal || vertical || "right") as ArrowDirection; +} + +function buildCaption(step: GeneratedGuideStep) { + const instruction = step.instruction.trim(); + const title = step.title.trim(); + if (instruction) { + return `${step.order}. ${instruction}`; + } + return title ? `${step.order}. ${title}` : `Step ${step.order}`; +} + +export function buildGuideVideoAnnotations( + session: GuideSession, + options: BuildGuideVideoAnnotationsOptions, +): AnnotationRegion[] { + const guide = session.generatedGuide; + if (!guide || guide.steps.length === 0) { + return []; + } + + const durationMs = Math.max(1000, options.defaultDurationMs ?? DEFAULT_STEP_DURATION_MS); + const sortedSteps = [...guide.steps].sort((left, right) => left.order - right.order); + const annotations: AnnotationRegion[] = []; + + for (const [index, step] of sortedSteps.entries()) { + const candidate = findCandidate(step, index, session.candidates); + const startMs = Math.max(0, Math.round(candidate?.timeMs ?? index * durationMs)); + const endMs = Math.max(startMs + 750, startMs + durationMs); + const captionPosition = getCaptionPosition(candidate); + const arrowDirection = getArrowDirection(candidate, captionPosition); + + annotations.push({ + id: options.nextId(), + startMs, + endMs, + type: "text", + content: buildCaption(step), + textContent: buildCaption(step), + position: captionPosition, + size: { width: CAPTION_WIDTH, height: CAPTION_HEIGHT }, + style: { + ...DEFAULT_ANNOTATION_STYLE, + color: "#f8fafc", + backgroundColor: "rgba(15, 23, 42, 0.88)", + fontSize: 18, + fontWeight: "bold", + textAlign: "left", + }, + zIndex: options.nextZIndex(), + }); + + if (candidate?.position) { + annotations.push({ + id: options.nextId(), + startMs, + endMs, + type: "magnifier", + content: buildCaption(step), + position: { + x: clamp( + candidate.position.normalizedX * 100 - MAGNIFIER_SIZE / 2, + 0, + 100 - MAGNIFIER_SIZE, + ), + y: clamp( + candidate.position.normalizedY * 100 - MAGNIFIER_SIZE / 2, + 0, + 100 - MAGNIFIER_SIZE, + ), + }, + size: { width: MAGNIFIER_SIZE, height: MAGNIFIER_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: options.nextZIndex(), + magnifierData: { + ...DEFAULT_MAGNIFIER_DATA, + target: { + x: candidate.position.normalizedX * 100, + y: candidate.position.normalizedY * 100, + }, + caption: candidate.targetText, + }, + }); + annotations.push({ + id: options.nextId(), + startMs, + endMs, + type: "figure", + content: "", + position: { + x: clamp(candidate.position.normalizedX * 100 - ARROW_SIZE / 2, 0, 100 - ARROW_SIZE), + y: clamp(candidate.position.normalizedY * 100 - ARROW_SIZE / 2, 0, 100 - ARROW_SIZE), + }, + size: { width: ARROW_SIZE, height: ARROW_SIZE }, + style: { ...DEFAULT_ANNOTATION_STYLE }, + zIndex: options.nextZIndex(), + figureData: { + ...DEFAULT_FIGURE_DATA, + arrowDirection, + color: "#34B27B", + strokeWidth: 5, + }, + }); + } + } + + return annotations; +} + +export interface BuildGuideVideoSpeedRegionsOptions { + nextId: () => string; + durationMs?: number; + speed?: number; +} + +export function buildGuideVideoSpeedRegions( + session: GuideSession, + options: BuildGuideVideoSpeedRegionsOptions, +): SpeedRegion[] { + const guide = session.generatedGuide; + if (!guide || guide.steps.length === 0) { + return []; + } + + const durationMs = Math.max( + 100, + Math.round(options.durationMs ?? DEFAULT_STEP_SLOW_MOTION_DURATION_MS), + ); + const speed = options.speed ?? DEFAULT_STEP_SLOW_MOTION_SPEED; + const sortedSteps = [...guide.steps].sort((left, right) => left.order - right.order); + + return sortedSteps.map((step, index) => { + const candidate = findCandidate(step, index, session.candidates); + const startMs = Math.max(0, Math.round(candidate?.timeMs ?? index * durationMs)); + return { + id: options.nextId(), + startMs, + endMs: startMs + durationMs, + speed, + }; + }); +} diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index c0d5657..4724131 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -9,6 +9,8 @@ import { let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; +let magnifierScratchCanvas: HTMLCanvasElement | null = null; +let magnifierScratchCtx: CanvasRenderingContext2D | null = null; // Matches a single code point whose script is Han (including non-BMP // Extension A-F), Hiragana, Katakana (including halfwidth forms), or @@ -396,6 +398,130 @@ async function renderImage( }); } +function renderMagnifier( + ctx: CanvasRenderingContext2D, + annotation: AnnotationRegion, + x: number, + y: number, + width: number, + height: number, + canvasWidth: number, + canvasHeight: number, + scaleFactor: number, +) { + if (!magnifierScratchCanvas || !magnifierScratchCtx) { + magnifierScratchCanvas = document.createElement("canvas"); + magnifierScratchCtx = magnifierScratchCanvas.getContext("2d"); + } + if (!magnifierScratchCanvas || !magnifierScratchCtx) return; + + const data = annotation.magnifierData; + const zoom = Math.max(1, data?.zoom ?? 2.2); + const target = data?.target ?? { + x: annotation.position.x + annotation.size.width / 2, + y: annotation.position.y + annotation.size.height / 2, + }; + const targetX = (target.x / 100) * canvasWidth; + const targetY = (target.y / 100) * canvasHeight; + const sampleWidth = Math.max(1, width / zoom); + const sampleHeight = Math.max(1, height / zoom); + const sx = Math.max(0, Math.min(canvasWidth - sampleWidth, targetX - sampleWidth / 2)); + const sy = Math.max(0, Math.min(canvasHeight - sampleHeight, targetY - sampleHeight / 2)); + const sw = Math.max(1, Math.min(sampleWidth, canvasWidth - sx)); + const sh = Math.max(1, Math.min(sampleHeight, canvasHeight - sy)); + + magnifierScratchCanvas.width = Math.max(1, Math.round(width)); + magnifierScratchCanvas.height = Math.max(1, Math.round(height)); + magnifierScratchCtx.clearRect(0, 0, magnifierScratchCanvas.width, magnifierScratchCanvas.height); + magnifierScratchCtx.imageSmoothingEnabled = true; + magnifierScratchCtx.imageSmoothingQuality = "high"; + magnifierScratchCtx.drawImage( + ctx.canvas, + sx, + sy, + sw, + sh, + 0, + 0, + magnifierScratchCanvas.width, + magnifierScratchCanvas.height, + ); + + const centerX = x + width / 2; + const centerY = y + height / 2; + const shape = data?.shape ?? "circle"; + const radius = Math.min(width, height) / 2; + const cornerRadius = shape === "circle" ? radius : Math.min(18 * scaleFactor, radius); + + ctx.save(); + ctx.strokeStyle = "rgba(52,178,123,0.85)"; + ctx.lineWidth = Math.max(2, 2 * scaleFactor); + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + ctx.lineTo(targetX, targetY); + ctx.stroke(); + ctx.fillStyle = "#34B27B"; + ctx.beginPath(); + ctx.arc(targetX, targetY, Math.max(4, 4 * scaleFactor), 0, Math.PI * 2); + ctx.fill(); + + ctx.shadowColor = "rgba(0,0,0,0.38)"; + ctx.shadowBlur = 24 * scaleFactor; + ctx.shadowOffsetY = 12 * scaleFactor; + ctx.fillStyle = "rgba(15,23,42,0.92)"; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, cornerRadius); + ctx.fill(); + ctx.restore(); + + ctx.save(); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, cornerRadius); + ctx.clip(); + ctx.drawImage(magnifierScratchCanvas, x, y, width, height); + ctx.restore(); + + ctx.save(); + ctx.strokeStyle = "rgba(248,250,252,0.96)"; + ctx.lineWidth = Math.max(3, 3 * scaleFactor); + ctx.beginPath(); + ctx.roundRect(x, y, width, height, cornerRadius); + ctx.stroke(); + ctx.strokeStyle = "rgba(52,178,123,0.58)"; + ctx.lineWidth = Math.max(1, 1.5 * scaleFactor); + ctx.beginPath(); + ctx.roundRect( + x + 2 * scaleFactor, + y + 2 * scaleFactor, + width - 4 * scaleFactor, + height - 4 * scaleFactor, + cornerRadius, + ); + ctx.stroke(); + + const caption = data?.caption || ""; + if (caption) { + const fontSize = Math.max(12, 13 * scaleFactor); + ctx.font = `bold ${fontSize}px Inter, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const paddingX = 8 * scaleFactor; + const paddingY = 5 * scaleFactor; + const metrics = ctx.measureText(caption); + const captionWidth = Math.min(width * 1.6, metrics.width + paddingX * 2); + const captionHeight = fontSize + paddingY * 2; + const captionX = centerX - captionWidth / 2; + const captionY = y + height + 8 * scaleFactor; + ctx.fillStyle = "rgba(15,23,42,0.92)"; + ctx.beginPath(); + ctx.roundRect(captionX, captionY, captionWidth, captionHeight, 6 * scaleFactor); + ctx.fill(); + ctx.fillStyle = "#f8fafc"; + ctx.fillText(caption, centerX, captionY + captionHeight / 2, captionWidth - paddingX * 2); + } + ctx.restore(); +} + export async function renderAnnotations( ctx: CanvasRenderingContext2D, annotations: AnnotationRegion[], @@ -443,6 +569,20 @@ export async function renderAnnotations( } break; + case "magnifier": + renderMagnifier( + ctx, + annotation, + x, + y, + width, + height, + canvasWidth, + canvasHeight, + scaleFactor, + ); + break; + case "blur": renderBlur(ctx, annotation, x, y, width, height, scaleFactor); break;