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 (