Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/screen-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
151 changes: 118 additions & 33 deletions src/processing/sharp-renderer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
`<svg width="${svgSize}" height="${svgSize}" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="${svgCenter}" cy="${svgCenter}" r="${radius}" ` +
`fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="${strokeWidth}" opacity="${opacity}"/>` +
`</svg>`
);

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)
Expand All @@ -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);
Expand Down Expand Up @@ -434,47 +503,63 @@ 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);

// 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;
prevZoomCenterX = zoomData.centerX;
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,
zoomVelocityX,
zoomVelocityY,
});
} else {
frameDataList.push({
frameIndex,
timestamp,
cursorX,
cursorY,
cursorVisible,
cursorVelocityX: velocityX,
cursorVelocityY: velocityY,
cursorShape,
clickAnimationScale,
});
frameDataList.push(baseFrameData);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/processing/video-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);

Expand All @@ -243,6 +244,7 @@ export class VideoProcessor {
cursorSize: cursorConfig.size,
cursorConfig,
zoomConfig: metadata.zoom.config.enabled ? metadata.zoom.config : undefined,
effects: metadata.effects,
frameRate,
};

Expand Down
88 changes: 88 additions & 0 deletions src/renderer/studio/components/Sidebar/PropertiesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 => ({
Expand Down Expand Up @@ -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 (
<div className="p-4 border-b border-white/[0.04]">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#666666] mb-3.5">
Expand Down Expand Up @@ -152,6 +199,47 @@ export function PropertiesPanel() {
</div>
)}
</div>

{/* Click Effects */}
<div className="mt-5">
<h4 className="text-xs font-medium text-[#808080] mb-3">Click Effects</h4>

<div className="setting-item">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={clickCirclesEnabled}
onChange={(e) => handleClickCirclesEnabledChange(e.target.checked)}
/>
Click Circles
</label>
</div>

{clickCirclesEnabled && (
<>
<div className="setting-item">
<label>Color:</label>
<input
type="color"
value={clickCirclesColor}
onChange={(e) => handleClickCirclesColorChange(e.target.value)}
/>
</div>

<div className="setting-item">
<label>Size:</label>
<input
type="range"
min="20"
max="80"
value={clickCirclesSize}
onChange={(e) => handleClickCirclesSizeChange(parseInt(e.target.value))}
/>
<span>{clickCirclesSize}px</span>
</div>
</>
)}
</div>
</div>
);
}
Loading