diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4cbdd356..ae98cb17 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -17,6 +17,7 @@ const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json') const AUTO_RECORDING_PREFIX = 'recording-' const AUTO_RECORDING_RETENTION_COUNT = 20 const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000 +const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env['VITE_DEV_SERVER_URL']) function getScreen() { return nodeRequire('electron').screen as typeof import('electron').screen @@ -1201,6 +1202,7 @@ export function registerIpcHandlers( .map((name) => normalizeDesktopSourceName(name)) .filter(Boolean) ) + const ownAppName = normalizeDesktopSourceName(app.getName()) const screenSources = electronSources .filter((source) => source.id.startsWith('screen:')) @@ -1222,6 +1224,10 @@ export function registerIpcHandlers( return true } + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes('recordly')) { + return true + } + for (const ownName of ownWindowNames) { if (!ownName) continue if (normalizedName === ownName) { @@ -1255,10 +1261,14 @@ export function registerIpcHandlers( const normalizedWindowName = normalizeDesktopSourceName(source.windowTitle ?? source.name) const normalizedAppName = normalizeDesktopSourceName(source.appName ?? '') - if (normalizedAppName && normalizedAppName === normalizeDesktopSourceName(app.getName())) { + if (!ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedAppName && normalizedAppName === ownAppName) { return false } + if (ALLOW_RECORDLY_WINDOW_CAPTURE && (normalizedAppName === 'recordly' || normalizedWindowName?.includes('recordly'))) { + return true + } + if (!normalizedWindowName) { return true } @@ -1299,6 +1309,10 @@ export function registerIpcHandlers( return true } + if (ALLOW_RECORDLY_WINDOW_CAPTURE && normalizedName.includes('recordly')) { + return true + } + for (const ownName of ownWindowNames) { if (!ownName) continue if (normalizedName === ownName || normalizedName.includes(ownName) || ownName.includes(normalizedName)) { @@ -1375,6 +1389,8 @@ export function registerIpcHandlers( const appName = normalizeDesktopSourceName(String(source?.appName ?? '')) const ownAppName = normalizeDesktopSourceName(app.getName()) if ( + !ALLOW_RECORDLY_WINDOW_CAPTURE + && source?.id?.startsWith('window:') && appName && (appName === ownAppName || appName === 'recordly') diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 718162a7..b0e619bc 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -2,6 +2,7 @@ import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'p import { MotionBlurFilter } from 'pixi-filters/motion-blur'; import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types'; import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; +import { getRenderableAssetUrl } from '@/lib/assetPath'; import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; import { applyZoomTransform, computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState } from '@/components/video-editor/videoPlayback/zoomTransform'; import { DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX } from '@/components/video-editor/videoPlayback/constants'; @@ -184,7 +185,7 @@ export class FrameRenderer { } private async setupBackground(): Promise { - const wallpaper = this.config.wallpaper; + const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper); // Create background canvas for separate rendering (not affected by zoom) const bgCanvas = document.createElement('canvas'); @@ -300,6 +301,27 @@ export class FrameRenderer { this.backgroundSprite = bgCanvas as any; } + private async resolveWallpaperForExport(wallpaper: string): Promise { + if (!wallpaper) { + return wallpaper; + } + + if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) { + return wallpaper; + } + + const looksLikeAbsoluteFilePath = wallpaper.startsWith('/') + && !wallpaper.startsWith('//') + && !wallpaper.startsWith('/wallpapers/') + && !wallpaper.startsWith('/app-icons/'); + + const wallpaperAsset = looksLikeAbsoluteFilePath + ? `file://${encodeURI(wallpaper)}` + : wallpaper; + + return getRenderableAssetUrl(wallpaperAsset); + } + async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise { if (!this.app || !this.videoContainer || !this.cameraContainer) { throw new Error('Renderer not initialized'); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index 39118175..56b57817 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -291,9 +291,9 @@ export class StreamingVideoDecoder { /** * Resample buffered frames to fill the target frame count for this segment. - * Handles VFR sources by duplicating/decimating as needed. - * Uses interpolated source timestamps so animations advance smoothly - * even when multiple export frames map to the same source frame. + * Handles VFR sources by duplicating/decimating as needed. + * Choose the source frame by target source time so the picture and overlays + * are both driven from the same display timeline. */ private async deliverSegment( frames: VideoFrame[], @@ -312,23 +312,47 @@ export class StreamingVideoDecoder { let exportFrameIndex = startExportFrameIndex; for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) { - const sourceIdx = Math.min( - Math.floor(i * frames.length / segmentFrameCount), - frames.length - 1 - ); + const progress = segmentFrameCount > 1 ? i / segmentFrameCount : 0; + const targetSourceTimeMs = (segment.startSec + progress * segmentDuration) * 1000; + const sourceIdx = this.findSourceFrameIndexForTime(frames, targetSourceTimeMs * 1000); const sourceFrame = frames[sourceIdx]; - // Compute a uniformly-spaced source timestamp so zoom/cursor animations - // progress smoothly instead of stalling on duplicate VFR timestamps - const t = segmentFrameCount > 1 ? i / segmentFrameCount : 0; - const interpolatedSourceTimeMs = (segment.startSec + t * segmentDuration) * 1000; const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp }); - await onFrame(clone, exportFrameIndex * frameDurationUs, interpolatedSourceTimeMs); + await onFrame(clone, exportFrameIndex * frameDurationUs, targetSourceTimeMs); exportFrameIndex++; } return exportFrameIndex; } + private findSourceFrameIndexForTime(frames: VideoFrame[], targetTimestampUs: number): number { + if (frames.length <= 1) { + return 0; + } + + if (targetTimestampUs <= frames[0].timestamp) { + return 0; + } + + const lastIndex = frames.length - 1; + if (targetTimestampUs >= frames[lastIndex].timestamp) { + return lastIndex; + } + + let lo = 0; + let hi = lastIndex; + + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (frames[mid].timestamp <= targetTimestampUs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return lo; + } + private computeSegments( totalDuration: number, trimRegions?: TrimRegion[]