diff --git a/src/main/screen-capture.ts b/src/main/screen-capture.ts index 97001dc..fb56c65 100644 --- a/src/main/screen-capture.ts +++ b/src/main/screen-capture.ts @@ -125,6 +125,7 @@ export class ScreenCapture { '-preset', 'medium', '-crf', this.getCrfValue(config.quality || 'medium'), '-pix_fmt', 'yuv420p', + '-g', frameRate, // Keyframe every 1 second for reliable seeking in MKV ]; // Add crop filter for region capture @@ -158,6 +159,7 @@ export class ScreenCapture { '-preset', 'medium', '-crf', this.getCrfValue(config.quality || 'medium'), '-pix_fmt', 'yuv420p', + '-g', frameRate, // Keyframe every 1 second for reliable seeking in MKV ]; // Add crop filter for region capture diff --git a/src/processing/sharp-renderer.ts b/src/processing/sharp-renderer.ts index 494ff52..ac0c436 100644 --- a/src/processing/sharp-renderer.ts +++ b/src/processing/sharp-renderer.ts @@ -1,6 +1,6 @@ import sharp from 'sharp'; import { existsSync, readFileSync } from 'fs'; -import type { ZoomConfig, CursorConfig } from '../types'; +import type { ZoomConfig, CursorConfig, MouseEffectsConfig } from '../types'; import type { CursorKeyframe, EasingType } from '../types/metadata'; import { generateSmoothedZoom } from './zoom-tracker'; import { createLogger } from '../utils/logger'; @@ -23,6 +23,9 @@ import { CURSOR_STATIC_THRESHOLD, CURSOR_HIDE_AFTER_MS, CURSOR_LOOP_DURATION_SECONDS, + CLICK_CIRCLE_DEFAULT_SIZE, + CLICK_CIRCLE_DEFAULT_COLOR, + CLICK_CIRCLE_DEFAULT_DURATION, } from '../utils/constants'; const logger = createLogger('SharpRenderer'); @@ -48,9 +51,16 @@ export interface FrameRenderOptions { cursorSize: number; cursorConfig?: CursorConfig; zoomConfig?: ZoomConfig; + effects?: MouseEffectsConfig; frameRate: number; } +export interface ActiveClickCircle { + x: number; // Click position X (video coords) + y: number; // Click position Y (video coords) + progress: number; // Animation progress 0→1 +} + export interface FrameData { frameIndex: number; timestamp: number; @@ -61,6 +71,7 @@ export interface FrameData { cursorVelocityY: number; cursorShape?: string; // Cursor shape for this frame (e.g., 'arrow', 'pointer', 'ibeam') clickAnimationScale?: number; // Scale factor for click animation (0-1) + activeClickCircles?: ActiveClickCircle[]; // Active click circles for this frame zoomCenterX?: number; zoomCenterY?: number; zoomLevel?: number; @@ -132,9 +143,15 @@ export async function renderFrame( currentFrameWidth = cropWidth; currentFrameHeight = cropHeight; - // Adjust cursor position relative to the crop + // Adjust cursor and click circle positions relative to the crop frameData.cursorX = frameData.cursorX - cropX; frameData.cursorY = frameData.cursorY - cropY; + if (frameData.activeClickCircles) { + for (const circle of frameData.activeClickCircles) { + circle.x -= cropX; + circle.y -= cropY; + } + } } if (frameData.frameIndex === 0) { @@ -248,20 +265,71 @@ export async function renderFrame( const cursorLeft = Math.round(frameData.cursorX - hotspotOffsetX); const cursorTop = Math.round(frameData.cursorY - hotspotOffsetY); - // Composite cursor overlay + // Collect all overlays into a single composite call (Sharp replaces previous composites) + const composites: sharp.OverlayOptions[] = []; + + // Cursor overlay if (cursorBuffer) { - // Ensure cursor is within bounds (allow hotspot to reach edges) const clampedLeft = Math.max(-hotspotOffsetX, Math.min(outputWidth - scaledCursorSize + hotspotOffsetX, cursorLeft)); const clampedTop = Math.max(-hotspotOffsetY, Math.min(outputHeight - scaledCursorSize + hotspotOffsetY, cursorTop)); - pipeline = pipeline.composite([ - { - input: cursorBuffer, - left: clampedLeft, - top: clampedTop, - blend: 'over', - }, - ]); + composites.push({ + input: cursorBuffer, + left: clampedLeft, + top: clampedTop, + blend: 'over', + }); + } + + // Click circle overlays + const activeCircles = frameData.activeClickCircles; + if (options.effects?.clickCircles?.enabled && activeCircles && activeCircles.length > 0) { + const { + size = CLICK_CIRCLE_DEFAULT_SIZE, + color = CLICK_CIRCLE_DEFAULT_COLOR, + } = options.effects.clickCircles; + + for (const circle of activeCircles) { + const easedProgress = 1 - Math.pow(1 - circle.progress, 3); + const radius = Math.round(size * scale * easedProgress); + const opacity = 1.0 * (1 - circle.progress); + const strokeWidth = Math.max(1, Math.round(3 * scale)); + + if (radius <= 0) continue; + + const svgSize = (radius + strokeWidth) * 2; + const svgCenter = svgSize / 2; + const fillOpacity = opacity * 0.15; + const svg = Buffer.from( + `` + + `` + + `` + ); + + const circleX = Math.round(circle.x * scale + offsetX); + const circleY = Math.round(circle.y * scale + offsetY); + const left = Math.round(circleX - svgSize / 2); + const top = Math.round(circleY - svgSize / 2); + + if (left + svgSize > 0 && left < outputWidth && top + svgSize > 0 && top < outputHeight) { + try { + const circleBuffer = await sharp(svg).png().toBuffer(); + composites.push({ + input: circleBuffer, + left: Math.max(0, left), + top: Math.max(0, top), + blend: 'over', + }); + } catch { + // Skip this circle if SVG conversion fails + } + } + } + } + + if (composites.length > 0) { + pipeline = pipeline.composite(composites); } // Write output as PNG (lossless) @@ -282,7 +350,8 @@ export function createFrameDataFromKeyframes( videoDimensions: { width: number; height: number }, cursorConfig?: CursorConfig, zoomConfig?: ZoomConfig, - clicks?: Array<{ timestamp: number; action: string }> + clicks?: Array<{ timestamp: number; action: string; x?: number; y?: number }>, + effects?: MouseEffectsConfig ): FrameData[] { const frameInterval = 1000 / frameRate; const totalFrames = Math.ceil(videoDuration / frameInterval); @@ -434,6 +503,27 @@ export function createFrameDataFromKeyframes( // Calculate click animation scale const clickAnimationScale = clicks ? calculateClickAnimationScale(timestamp, clicks) : 1.0; + // Calculate active click circles for this frame + let activeClickCircles: ActiveClickCircle[] | undefined; + if (effects?.clickCircles?.enabled && clicks) { + const duration = effects.clickCircles.duration || CLICK_CIRCLE_DEFAULT_DURATION; + const circles: ActiveClickCircle[] = []; + for (const click of clicks) { + if (click.action !== 'down') continue; + const elapsed = timestamp - click.timestamp; + if (elapsed >= 0 && elapsed <= duration && click.x != null && click.y != null) { + circles.push({ + x: click.x, + y: click.y, + progress: elapsed / duration, + }); + } + } + if (circles.length > 0) { + activeClickCircles = circles; + } + } + // Get cursor shape with stabilization to prevent flickering const rawCursorShape = cursorPos.shape || cursorConfig?.shape || 'arrow'; const cursorShape = cursorTypeStabilizer.update(rawCursorShape, timestamp); @@ -441,6 +531,19 @@ export function createFrameDataFromKeyframes( // Get zoom data const zoomData = interpolateZoom(timestamp); + const baseFrameData = { + frameIndex, + timestamp, + cursorX, + cursorY, + cursorVisible, + cursorVelocityX: velocityX, + cursorVelocityY: velocityY, + cursorShape, + clickAnimationScale, + activeClickCircles, + }; + if (zoomConfig?.enabled && zoomData) { const zoomVelocityX = (zoomData.centerX - prevZoomCenterX) / deltaTime; const zoomVelocityY = (zoomData.centerY - prevZoomCenterY) / deltaTime; @@ -448,15 +551,7 @@ export function createFrameDataFromKeyframes( prevZoomCenterY = zoomData.centerY; frameDataList.push({ - frameIndex, - timestamp, - cursorX, - cursorY, - cursorVisible, - cursorVelocityX: velocityX, - cursorVelocityY: velocityY, - cursorShape, - clickAnimationScale, + ...baseFrameData, zoomCenterX: zoomData.centerX, zoomCenterY: zoomData.centerY, zoomLevel: zoomData.level, @@ -464,17 +559,7 @@ export function createFrameDataFromKeyframes( zoomVelocityY, }); } else { - frameDataList.push({ - frameIndex, - timestamp, - cursorX, - cursorY, - cursorVisible, - cursorVelocityX: velocityX, - cursorVelocityY: velocityY, - cursorShape, - clickAnimationScale, - }); + frameDataList.push(baseFrameData); } } diff --git a/src/processing/video-processor.ts b/src/processing/video-processor.ts index 7c6547a..70dc21e 100644 --- a/src/processing/video-processor.ts +++ b/src/processing/video-processor.ts @@ -219,7 +219,8 @@ export class VideoProcessor { videoDimensions, cursorConfig, metadata.zoom.config.enabled ? metadata.zoom.config : undefined, - trimmedClicks + trimmedClicks, + metadata.effects ); logger.info(`Created frame data for ${frameDataList.length} frames from keyframes (matching ${extractionResult.frameCount} extracted frames)`); @@ -243,6 +244,7 @@ export class VideoProcessor { cursorSize: cursorConfig.size, cursorConfig, zoomConfig: metadata.zoom.config.enabled ? metadata.zoom.config : undefined, + effects: metadata.effects, frameRate, }; diff --git a/src/renderer/studio/components/Sidebar/PropertiesPanel.tsx b/src/renderer/studio/components/Sidebar/PropertiesPanel.tsx index 3bcd3cf..2ea02d2 100644 --- a/src/renderer/studio/components/Sidebar/PropertiesPanel.tsx +++ b/src/renderer/studio/components/Sidebar/PropertiesPanel.tsx @@ -1,5 +1,10 @@ import React from 'react'; import { useStudio } from '../../context/StudioContext'; +import { + CLICK_CIRCLE_DEFAULT_SIZE, + CLICK_CIRCLE_DEFAULT_COLOR, + DEFAULT_EFFECTS, +} from '../../../../utils/constants'; export function PropertiesPanel() { const { metadata, updateMetadata } = useStudio(); @@ -11,6 +16,9 @@ export function PropertiesPanel() { const motionBlurStrength = Math.round((metadata.cursor.config.motionBlur?.strength || 0.5) * 100); const zoomEnabled = metadata.zoom.config.enabled || false; const zoomLevel = metadata.zoom.config.level || 2.0; + const clickCirclesEnabled = metadata.effects?.clickCircles?.enabled ?? false; + const clickCirclesColor = metadata.effects?.clickCircles?.color ?? CLICK_CIRCLE_DEFAULT_COLOR; + const clickCirclesSize = metadata.effects?.clickCircles?.size ?? CLICK_CIRCLE_DEFAULT_SIZE; const handleCursorSizeChange = (value: number) => { updateMetadata(prev => ({ @@ -74,6 +82,45 @@ export function PropertiesPanel() { })); }; + const handleClickCirclesEnabledChange = (enabled: boolean) => { + updateMetadata(prev => { + const effects = { ...DEFAULT_EFFECTS, ...prev.effects }; + return { + ...prev, + effects: { + ...effects, + clickCircles: { ...effects.clickCircles, enabled }, + }, + }; + }); + }; + + const handleClickCirclesColorChange = (color: string) => { + updateMetadata(prev => { + const effects = { ...DEFAULT_EFFECTS, ...prev.effects }; + return { + ...prev, + effects: { + ...effects, + clickCircles: { ...effects.clickCircles, color }, + }, + }; + }); + }; + + const handleClickCirclesSizeChange = (size: number) => { + updateMetadata(prev => { + const effects = { ...DEFAULT_EFFECTS, ...prev.effects }; + return { + ...prev, + effects: { + ...effects, + clickCircles: { ...effects.clickCircles, size }, + }, + }; + }); + }; + return (

@@ -152,6 +199,47 @@ export function PropertiesPanel() {

)} + + {/* Click Effects */} +
+

Click Effects

+ +
+ +
+ + {clickCirclesEnabled && ( + <> +
+ + handleClickCirclesColorChange(e.target.value)} + /> +
+ +
+ + handleClickCirclesSizeChange(parseInt(e.target.value))} + /> + {clickCirclesSize}px +
+ + )} +
); } diff --git a/src/renderer/studio/hooks/useMetadata.ts b/src/renderer/studio/hooks/useMetadata.ts index 65d059d..c57a97e 100644 --- a/src/renderer/studio/hooks/useMetadata.ts +++ b/src/renderer/studio/hooks/useMetadata.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import type { RecordingMetadata } from '../../../types/metadata'; import { useStudioAPI, getInitPaths } from './useStudioAPI'; +import { DEFAULT_EFFECTS } from '../../../utils/constants'; export function useMetadata() { const api = useStudioAPI(); @@ -24,6 +25,13 @@ export function useMetadata() { try { setIsLoading(true); const data = await api.loadMetadata(paths.metadataPath); + data.effects = { + ...DEFAULT_EFFECTS, + ...data.effects, + clickCircles: { ...DEFAULT_EFFECTS.clickCircles, ...data.effects?.clickCircles }, + trail: { ...DEFAULT_EFFECTS.trail, ...data.effects?.trail }, + highlightRing: { ...DEFAULT_EFFECTS.highlightRing, ...data.effects?.highlightRing }, + }; setMetadata(data); setError(null); } catch (err) { @@ -51,6 +59,13 @@ export function useMetadata() { try { const result = await api.reloadMetadata(metadataPath); if (result.success && result.data) { + result.data.effects = { + ...DEFAULT_EFFECTS, + ...result.data.effects, + clickCircles: { ...DEFAULT_EFFECTS.clickCircles, ...result.data.effects?.clickCircles }, + trail: { ...DEFAULT_EFFECTS.trail, ...result.data.effects?.trail }, + highlightRing: { ...DEFAULT_EFFECTS.highlightRing, ...result.data.effects?.highlightRing }, + }; setMetadata(result.data); return true; } diff --git a/src/renderer/utils/cursor-renderer.ts b/src/renderer/utils/cursor-renderer.ts index 7a86f0f..e21a130 100644 --- a/src/renderer/utils/cursor-renderer.ts +++ b/src/renderer/utils/cursor-renderer.ts @@ -8,7 +8,12 @@ import { calculateClickAnimationScale, CursorTypeStabilizer, } from '../../processing/cursor-utils'; -import { DEFAULT_CURSOR_SIZE } from '../../utils/constants'; +import { + DEFAULT_CURSOR_SIZE, + CLICK_CIRCLE_DEFAULT_SIZE, + CLICK_CIRCLE_DEFAULT_COLOR, + CLICK_CIRCLE_DEFAULT_DURATION, +} from '../../utils/constants'; /** * Cursor position smoother for glide effect @@ -155,6 +160,77 @@ function preloadCursorImages(): void { preloadCursorImages(); +/** + * Easing function for click circle animation (ease-out cubic) + */ +function easeOutCubic(t: number): number { + return 1 - Math.pow(1 - t, 3); +} + +/** + * Render click circles on canvas at click positions + */ +function renderClickCircles( + ctx: CanvasRenderingContext2D, + metadata: RecordingMetadata, + timestamp: number, + videoWidth: number, + videoHeight: number, + displayWidth: number, + displayHeight: number +): void { + const effects = metadata.effects; + if (!effects?.clickCircles?.enabled) return; + + const clicks = metadata.clicks || []; + if (clicks.length === 0) return; + + const { + size = CLICK_CIRCLE_DEFAULT_SIZE, + color = CLICK_CIRCLE_DEFAULT_COLOR, + duration = CLICK_CIRCLE_DEFAULT_DURATION, + } = effects.clickCircles; + + // Calculate scale factors (same as renderCursor) + const scaleX = displayWidth / videoWidth; + const scaleY = displayHeight / videoHeight; + const scale = Math.min(scaleX, scaleY); + const actualDisplayWidth = videoWidth * scale; + const actualDisplayHeight = videoHeight * scale; + const offsetX = (displayWidth - actualDisplayWidth) / 2; + const offsetY = (displayHeight - actualDisplayHeight) / 2; + + for (const click of clicks) { + if (click.action !== 'down') continue; + if (click.x == null || click.y == null) continue; + + const elapsed = timestamp - click.timestamp; + if (elapsed < 0 || elapsed > duration) continue; + + const progress = elapsed / duration; + const radius = size * scale * easeOutCubic(progress); + const opacity = 1.0 * (1 - progress); + + const x = click.x * scale + offsetX; + const y = click.y * scale + offsetY; + + // Skip circles entirely outside the display area + if (x + radius < 0 || x - radius > displayWidth || y + radius < 0 || y - radius > displayHeight) continue; + + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.globalAlpha = opacity * 0.15; + ctx.fillStyle = color; + ctx.fill(); + ctx.globalAlpha = opacity; + ctx.strokeStyle = color; + ctx.lineWidth = 3 * scale; + ctx.stroke(); + ctx.restore(); + } +} + /** * Render cursor on canvas */ @@ -232,6 +308,9 @@ export function renderCursor( // Draw cursor using actual SVG assets drawCursorShape(ctx, x, y, cursorSize, shape); + + // Draw click circles on top of cursor + renderClickCircles(ctx, metadata, timestamp, videoWidth, videoHeight, displayWidth, displayHeight); } /** diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 060f3b9..2425266 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -230,6 +230,39 @@ const CLICK_DETECTION_TIMEOUT_MS = 50; */ const CLICK_DETECTION_THRESHOLD_MS = 200; +// ======================================== +// Click Circle Constants +// ======================================== + +/** + * Default click circle radius in pixels + */ +export const CLICK_CIRCLE_DEFAULT_SIZE = 40; + +/** + * Default click circle color + */ +export const CLICK_CIRCLE_DEFAULT_COLOR = '#ffffff'; + +/** + * Default click circle animation duration in milliseconds + */ +export const CLICK_CIRCLE_DEFAULT_DURATION = 400; + +/** + * Default mouse effects configuration + */ +export const DEFAULT_EFFECTS = { + clickCircles: { + enabled: false, + size: CLICK_CIRCLE_DEFAULT_SIZE, + color: CLICK_CIRCLE_DEFAULT_COLOR, + duration: CLICK_CIRCLE_DEFAULT_DURATION, + }, + trail: { enabled: false, length: 5, fadeSpeed: 0.5, color: '#ffffff' }, + highlightRing: { enabled: false, size: 30, color: '#ffffff', pulseSpeed: 0.5 }, +} as const satisfies import('../types').MouseEffectsConfig; + // ======================================== // Mouse Effects Constants // ========================================