diff --git a/branding/source-assets/recordlygeneric.svg b/branding/source-assets/recordlygeneric.svg new file mode 100644 index 00000000..57910c64 --- /dev/null +++ b/branding/source-assets/recordlygeneric.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/branding/source-assets/recordlymac.png b/branding/source-assets/recordlymac.png new file mode 100644 index 00000000..6e4baf24 Binary files /dev/null and b/branding/source-assets/recordlymac.png differ diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index cdd90881..a2f81889 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -71,6 +71,7 @@ type RendererMarketplaceReviewStatus = import("./extensions/extensionTypes").MarketplaceReviewStatus; type RendererMarketplaceSearchResult = import("./extensions/extensionTypes").MarketplaceSearchResult; +type RendererRecordingSessionData = import("./ipc/types").RecordingSessionData; interface RendererFfmpegAudioMuxMetrics { tempVideoWriteMs?: number; @@ -561,6 +562,9 @@ interface Window { onRecordingStateChanged: ( callback: (state: { recording: boolean; sourceName: string }) => void, ) => () => void; + onRecordingSessionChanged: ( + callback: (session: RendererRecordingSessionData | null) => void, + ) => () => void; onRecordingInterrupted: ( callback: (state: { reason: string; message: string }) => void, ) => () => void; diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index f097cea9..77b34c45 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -190,6 +190,7 @@ export function summarizeMicrophoneChunkTiming( /** Probe the duration of a media file (in seconds) using the container header. */ export async function probeMediaDurationSeconds(filePath: string): Promise { + const start = Date.now(); const ffmpegPath = getFfmpegBinaryPath(); try { await execFileAsync(ffmpegPath, ["-i", filePath, "-hide_banner"], { timeout: 5000 }); @@ -199,6 +200,10 @@ export async function probeMediaDurationSeconds(filePath: string): Promise { + const start = Date.now(); try { const result = await execFileAsync( getFfprobeBinaryPath(), @@ -273,7 +279,6 @@ export async function probeVideoStreamDuration( "error", "-select_streams", "v:0", - "-count_frames", "-show_entries", "stream=duration,nb_frames,nb_read_frames,avg_frame_rate,r_frame_rate", "-of", @@ -286,6 +291,10 @@ export async function probeVideoStreamDuration( return parseFfprobeVideoStreamDuration(stdout); } catch { return null; + } finally { + console.log( + `[PERF:MAIN] probeVideoStreamDuration: COMPLETED in ${Date.now() - start}ms`, + ); } } diff --git a/electron/ipc/recording/mac.ts b/electron/ipc/recording/mac.ts index 1227b0af..09dd5aa7 100644 --- a/electron/ipc/recording/mac.ts +++ b/electron/ipc/recording/mac.ts @@ -1,14 +1,10 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { execFile } from "node:child_process"; import fs from "node:fs/promises"; -import { promisify } from "node:util"; import { BrowserWindow } from "electron"; import { persistPendingCursorTelemetry, snapshotCursorTelemetryForPersistence, } from "../cursor/telemetry"; -import { getFfmpegBinaryPath } from "../ffmpeg/binary"; -import { appendSyncedAudioFilter, getAudioSyncAdjustment } from "../ffmpeg/filters"; import { lastNativeCaptureDiagnostics, nativeCaptureMicrophonePath, @@ -27,21 +23,15 @@ import { setNativeCaptureTargetPath, setNativeScreenRecordingActive, } from "../state"; -import type { AudioSyncAdjustment } from "../types"; import { isAutoRecordingPath, moveFileWithOverwrite } from "../utils"; import { getFileSizeIfPresent, - getRecordingAudioMuxTimeoutMs, - getUsableCompanionAudioCandidates, - probeMediaDurationSeconds, recordNativeCaptureDiagnostics, validateRecordedVideo, } from "./diagnostics"; import { emitRecordingInterrupted } from "./events"; import { pruneAutoRecordings } from "./prune"; -import { muxNativeWindowsVideoWithAudio } from "./windows"; -const execFileAsync = promisify(execFile); export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { @@ -128,155 +118,35 @@ export async function muxNativeMacRecordingWithAudio( systemAudioPath?: string | null, microphonePath?: string | null, ) { - const ffmpegPath = getFfmpegBinaryPath(); - const mixedOutputPath = `${videoPath}.mixed.mp4`; + console.log("[mac-mux] Optimization active: keeping tracks separate."); + + const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, ""); - const inputs = ["-i", videoPath]; - const availableAudioInputs: string[] = []; - const audioFilePaths: string[] = []; - - for (const [label, audioPath] of [ - ["system", systemAudioPath], - ["microphone", microphonePath], - ] as const) { - if (!audioPath) continue; + // Optimization: instead of heavy FFmpeg muxing, we ensure audio sidecars + // are available alongside the video for the editor. + if (systemAudioPath) { + const finalSystemPath = `${videoPathWithoutExt}.system.wav`; try { - const stat = await fs.stat(audioPath); - if (stat.size <= 0) { - console.warn(`[mux] Skipping ${label} audio: file is empty (${audioPath})`); - await fs.rm(audioPath, { force: true }).catch(() => undefined); - continue; + const stat = await fs.stat(systemAudioPath); + if (stat.size > 0 && systemAudioPath !== finalSystemPath) { + await moveFileWithOverwrite(systemAudioPath, finalSystemPath); } - inputs.push("-i", audioPath); - availableAudioInputs.push(label); - audioFilePaths.push(audioPath); - } catch { - console.warn(`[mux] Skipping ${label} audio: file not accessible (${audioPath})`); + } catch (err) { + console.error(`[mac-mux] Failed to handle system audio:`, err); } } - if (availableAudioInputs.length === 0) { - console.warn( - "[mux] No valid audio files to mux — video will have no audio. " + - `system=${systemAudioPath ?? "none"} mic=${microphonePath ?? "none"}`, - ); - return; - } - - const videoDuration = await probeMediaDurationSeconds(videoPath); - const muxTimeoutMs = getRecordingAudioMuxTimeoutMs(videoDuration); - const audioAdjustments: Map = new Map(); - - if (videoDuration > 0) { - for (let i = 0; i < audioFilePaths.length; i++) { - const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]); - const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration); - audioAdjustments.set(availableAudioInputs[i], adjustment); - if (adjustment.mode === "tempo") { - console.log( - `[mux] ${availableAudioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`, - ); - } else if (adjustment.mode === "delay" && adjustment.delayMs > 0) { - console.log( - `[mux] ${availableAudioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`, - ); + if (microphonePath) { + const finalMicPath = `${videoPathWithoutExt}.mic.wav`; + try { + const stat = await fs.stat(microphonePath); + if (stat.size > 0 && microphonePath !== finalMicPath) { + await moveFileWithOverwrite(microphonePath, finalMicPath); } + } catch (err) { + console.error(`[mac-mux] Failed to handle mic audio:`, err); } } - - const systemAdjustment = audioAdjustments.get("system") ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - const micAdjustment = audioAdjustments.get("microphone") ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - - // Always route through the filter graph so that aresample=async=1 is - // applied to every audio stream. This corrects progressive clock drift - // between the video and audio tracks that a simple duration comparison - // cannot detect (e.g. audio gradually falling behind under CPU load). - let args: string[]; - if (availableAudioInputs.length === 2) { - const filterParts: string[] = []; - appendSyncedAudioFilter(filterParts, "[1:a]", "s", systemAdjustment); - appendSyncedAudioFilter(filterParts, "[2:a]", "m", micAdjustment); - filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]"); - args = [ - "-y", - "-hide_banner", - "-nostdin", - "-nostats", - ...inputs, - "-filter_complex", - filterParts.join(";"), - "-map", - "0:v:0", - "-map", - "[aout]", - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "192k", - "-shortest", - mixedOutputPath, - ]; - } else { - const singleAdjustment = audioAdjustments.get(availableAudioInputs[0]) ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - const filterParts: string[] = []; - appendSyncedAudioFilter(filterParts, "[1:a]", "aout", singleAdjustment); - args = [ - "-y", - "-hide_banner", - "-nostdin", - "-nostats", - ...inputs, - "-filter_complex", - filterParts.join(";"), - "-map", - "0:v:0", - "-map", - "[aout]", - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "192k", - "-shortest", - mixedOutputPath, - ]; - } - - console.log("[mux] Running ffmpeg:", ffmpegPath, args.join(" ")); - - try { - await execFileAsync(ffmpegPath, args, { - timeout: muxTimeoutMs, - maxBuffer: 20 * 1024 * 1024, - }); - await validateRecordedVideo(mixedOutputPath); - } catch (error) { - const execError = error as NodeJS.ErrnoException & { stderr?: string }; - console.error("[mux] failed:", execError.stderr || execError.message || String(error)); - await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined); - throw error; - } - - await moveFileWithOverwrite(mixedOutputPath, videoPath); - console.log("[mux] Successfully muxed audio into video:", videoPath); } export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) { @@ -289,6 +159,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr } setNativeScreenRecordingActive(false); + console.log("[mac-finalize] Optimization active: skipping safety-net muxing."); setNativeCaptureTargetPath(null); setNativeCaptureStopRequested(false); setNativeCaptureSystemAudioPath(null); @@ -317,28 +188,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr } export async function finalizeStoredVideo(videoPath: string) { - // Safety net: if companion audio files still exist, the mux was skipped — attempt it now - if (videoPath.endsWith(".mp4")) { - const companionCandidates = await getUsableCompanionAudioCandidates(videoPath); - for (const { systemPath, micPath, platform } of companionCandidates) { - if (platform === "mac" || platform === "win") { - console.log( - `[finalize] Detected un-muxed ${platform} audio files alongside video — attempting safety-net mux`, - ); - try { - if (platform === "win") { - await muxNativeWindowsVideoWithAudio(videoPath, systemPath, micPath); - } else { - await muxNativeMacRecordingWithAudio(videoPath, systemPath, micPath); - } - console.log("[finalize] Safety-net mux completed successfully"); - } catch (error) { - console.warn("[finalize] Safety-net mux failed:", error); - } - break; - } - } - } + console.log("[finalize] Optimization active: skipping safety-net muxing."); let validation: { fileSizeBytes: number; durationSeconds: number | null }; try { @@ -374,7 +224,11 @@ export async function finalizeStoredVideo(videoPath: string) { snapshotCursorTelemetryForPersistence(); setCurrentVideoPath(videoPath); setCurrentProjectPath(null); - await persistPendingCursorTelemetry(videoPath); + try { + await persistPendingCursorTelemetry(videoPath); + } catch (error) { + console.warn("[mac-stop] Failed to persist cursor telemetry:", error); + } if (isAutoRecordingPath(videoPath)) { await pruneAutoRecordings([videoPath]); } diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 5b41fc4d..f3e43db5 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -1,15 +1,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { execFile } from "node:child_process"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; -import { promisify } from "node:util"; import { BrowserWindow } from "electron"; -import { getFfmpegBinaryPath } from "../ffmpeg/binary"; -import { - appendSyncedAudioFilter, - applyRecordedAudioStartDelay, - getAudioSyncAdjustment, -} from "../ffmpeg/filters"; import { getWindowsCaptureExePath } from "../paths/binaries"; import { selectedSource, @@ -21,23 +13,11 @@ import { windowsCaptureTargetPath, windowsNativeCaptureActive, } from "../state"; -import type { AudioSyncAdjustment } from "../types"; -import { moveFileWithOverwrite } from "../utils"; import { - shouldKeepRecordingAudioSidecars, - WINDOWS_NATIVE_MIC_PRE_FILTERS, -} from "./audioFilters"; -import { - getCompanionAudioStartDelayMs, - getRecordingAudioMuxTimeoutMs, - probeMediaDurationSeconds, - probeVideoStreamDurationSeconds, - validateRecordedVideo, -} from "./diagnostics"; + AudioSyncAdjustment, +} from "../types"; import { emitRecordingInterrupted } from "./events"; - -const execFileAsync = promisify(execFile); -const MIN_NATIVE_WINDOWS_VIDEO_PAD_MS = 500; +import { moveFileWithOverwrite } from "../utils"; export type NativeWindowsVideoPaddingResult = { padded: boolean; @@ -63,7 +43,7 @@ export type NativeWindowsAudioMuxResult = { } >; outputPath?: string; - keptAudioSidecars: boolean; + keptAudioSidecars?: boolean; }; export async function isNativeWindowsCaptureAvailable(): Promise { @@ -189,297 +169,74 @@ export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStrea }); } -export async function extendNativeWindowsVideoToDuration( - videoPath: string, - targetDurationMs: number | null | undefined, -): Promise { - if (!Number.isFinite(targetDurationMs) || (targetDurationMs ?? 0) <= 0) { - return { - padded: false, - durationSeconds: 0, - containerDurationSeconds: await probeMediaDurationSeconds(videoPath), - targetDurationSeconds: 0, - padDurationSeconds: 0, - }; - } - - const containerDurationSeconds = await probeMediaDurationSeconds(videoPath); - const currentDurationSeconds = await probeVideoStreamDurationSeconds(videoPath); - if (currentDurationSeconds <= 0) { - return { - padded: false, - durationSeconds: currentDurationSeconds, - containerDurationSeconds, - targetDurationSeconds: (targetDurationMs ?? 0) / 1000, - padDurationSeconds: 0, - }; - } - - const targetDurationSeconds = (targetDurationMs ?? 0) / 1000; - const padDurationSeconds = targetDurationSeconds - currentDurationSeconds; - if (padDurationSeconds * 1000 < MIN_NATIVE_WINDOWS_VIDEO_PAD_MS) { - return { - padded: false, - durationSeconds: currentDurationSeconds, - containerDurationSeconds, - targetDurationSeconds, - padDurationSeconds: Math.max(0, padDurationSeconds), - }; - } - - const ffmpegPath = getFfmpegBinaryPath(); - const paddedOutputPath = `${videoPath}.duration-padded.mp4`; - - try { - await execFileAsync( - ffmpegPath, - [ - "-y", - "-hide_banner", - "-nostdin", - "-nostats", - "-i", - videoPath, - "-vf", - `tpad=stop_mode=clone:stop_duration=${padDurationSeconds.toFixed(3)}`, - "-an", - "-c:v", - "libx264", - "-preset", - "veryfast", - "-crf", - "18", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - paddedOutputPath, - ], - { timeout: 300000, maxBuffer: 10 * 1024 * 1024 }, - ); - await validateRecordedVideo(paddedOutputPath); - await moveFileWithOverwrite(paddedOutputPath, videoPath); - return { - padded: true, - durationSeconds: targetDurationSeconds, - containerDurationSeconds, - targetDurationSeconds, - padDurationSeconds, - }; - } catch (error) { - await fs.rm(paddedOutputPath, { force: true }).catch(() => undefined); - throw error; - } -} - export async function muxNativeWindowsVideoWithAudio( videoPath: string, systemAudioPath: string | null, micAudioPath: string | null, ): Promise { - const ffmpegPath = getFfmpegBinaryPath(); - const keepAudioSidecars = shouldKeepRecordingAudioSidecars(); - const inputs: string[] = ["-i", videoPath]; - const audioInputs: string[] = []; - const audioFilePaths: string[] = []; + const start = Date.now(); + console.log("[PERF:MAIN] muxNativeWindowsVideoWithAudio: STARTED"); const audio: NativeWindowsAudioMuxResult["audio"] = {}; + const audioInputs: string[] = []; - for (const [label, audioPath] of [ - ["system", systemAudioPath], - ["mic", micAudioPath], - ] as const) { - if (!audioPath) continue; + const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, ""); + + // Optimization: instead of heavy FFmpeg muxing, we just move the audio sidecars + // to their final companion paths so the editor can find them as separate tracks. + if (systemAudioPath) { + const finalSystemPath = `${videoPathWithoutExt}.system.wav`; try { - const stat = await fs.stat(audioPath); - if (stat.size <= 0) { - console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`); - if (!keepAudioSidecars) { - await fs.rm(audioPath, { force: true }).catch(() => undefined); + const stat = await fs.stat(systemAudioPath); + if (stat.size > 0) { + if (systemAudioPath !== finalSystemPath) { + await moveFileWithOverwrite(systemAudioPath, finalSystemPath); } - continue; + audioInputs.push("system"); + audio.system = { + path: finalSystemPath, + sizeBytes: stat.size, + durationSeconds: 0, + startDelayMs: null, + adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 }, + }; } - inputs.push("-i", audioPath); - audioInputs.push(label); - audioFilePaths.push(audioPath); - audio[label] = { - path: audioPath, - sizeBytes: stat.size, - durationSeconds: 0, - startDelayMs: null, - adjustment: { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }, - }; - } catch { - console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`); + } catch (err) { + console.error(`[mux-win] Failed to handle system audio:`, err); } } - const videoDuration = await probeVideoStreamDurationSeconds(videoPath); - const muxTimeoutMs = getRecordingAudioMuxTimeoutMs(videoDuration); - const audioAdjustments: Map = new Map(); - if (audioInputs.length === 0) { - return { - muxed: false, - videoDurationSeconds: videoDuration, - muxTimeoutMs, - audioInputs, - audio, - keptAudioSidecars: keepAudioSidecars, - }; - } - - if (videoDuration > 0) { - for (let i = 0; i < audioFilePaths.length; i++) { - const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]); - const recordedStartDelayMs = await getCompanionAudioStartDelayMs(audioFilePaths[i]); - const adjustment = applyRecordedAudioStartDelay( - getAudioSyncAdjustment(videoDuration, audioDuration), - recordedStartDelayMs, - ); - audioAdjustments.set(audioInputs[i], adjustment); - audio[audioInputs[i]] = { - ...audio[audioInputs[i]], - durationSeconds: audioDuration, - startDelayMs: recordedStartDelayMs, - adjustment, - }; - if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "delay") { - console.log( - `[mux-win] ${audioInputs[i]} audio recorded a start delay of ${adjustment.delayMs}ms`, - ); - } else if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "pad") { - console.log( - `[mux-win] ${audioInputs[i]} audio started on time but ends ${adjustment.durationDeltaMs}ms early — padding trailing silence`, - ); - } else if (adjustment.mode === "tempo") { - console.log( - `[mux-win] ${audioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`, - ); - } else if (adjustment.mode === "delay" && adjustment.delayMs > 0) { - console.log( - `[mux-win] ${audioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`, - ); - } else if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) { - console.log( - `[mux-win] ${audioInputs[i]} audio is much shorter than video by ${adjustment.durationDeltaMs}ms — padding trailing silence`, - ); + if (micAudioPath) { + const finalMicPath = `${videoPathWithoutExt}.mic.wav`; + try { + const stat = await fs.stat(micAudioPath); + if (stat.size > 0) { + if (micAudioPath !== finalMicPath) { + await moveFileWithOverwrite(micAudioPath, finalMicPath); + } + audioInputs.push("mic"); + audio.mic = { + path: finalMicPath, + sizeBytes: stat.size, + durationSeconds: 0, + startDelayMs: null, + adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 }, + }; } + } catch (err) { + console.error(`[mux-win] Failed to handle mic audio:`, err); } } - const mixedOutputPath = `${videoPath}.muxed.mp4`; - const systemAdjustment = audioAdjustments.get("system") ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - const micAdjustment = audioAdjustments.get("mic") ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - - try { - if (audioInputs.length === 2) { - const filterParts: string[] = []; - appendSyncedAudioFilter(filterParts, "[1:a]", "s", systemAdjustment); - appendSyncedAudioFilter(filterParts, "[2:a]", "m", micAdjustment, { - preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS, - }); - filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]"); - - await execFileAsync( - ffmpegPath, - [ - "-y", - "-hide_banner", - "-nostdin", - "-nostats", - ...inputs, - "-filter_complex", - filterParts.join(";"), - "-map", - "0:v:0", - "-map", - "[aout]", - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "192k", - "-shortest", - mixedOutputPath, - ], - { timeout: muxTimeoutMs, maxBuffer: 20 * 1024 * 1024 }, - ); - } else { - const singleAdjustment = audioAdjustments.get(audioInputs[0]) ?? { - mode: "none", - delayMs: 0, - tempoRatio: 1, - durationDeltaMs: 0, - }; - - const filterParts: string[] = []; - // Always route through the filter graph so that aresample=async=1 is - // applied. This corrects progressive clock drift between video and - // audio tracks that a simple duration comparison cannot detect. - appendSyncedAudioFilter( - filterParts, - "[1:a]", - "aout", - singleAdjustment, - audioInputs[0] === "mic" ? { preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS } : 1, - ); - - await execFileAsync( - ffmpegPath, - [ - "-y", - "-hide_banner", - "-nostdin", - "-nostats", - ...inputs, - "-filter_complex", - filterParts.join(";"), - "-map", - "0:v:0", - "-map", - "[aout]", - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "192k", - "-shortest", - mixedOutputPath, - ], - { timeout: muxTimeoutMs, maxBuffer: 20 * 1024 * 1024 }, - ); - } - - await validateRecordedVideo(mixedOutputPath); - await moveFileWithOverwrite(mixedOutputPath, videoPath); - } catch (error) { - await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined); - throw error; - } + console.log( + `[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`, + ); return { - muxed: true, - videoDurationSeconds: videoDuration, - muxTimeoutMs, + muxed: false, + videoDurationSeconds: 0, // No longer needed here + muxTimeoutMs: 0, audioInputs, audio, - outputPath: videoPath, keptAudioSidecars: true, }; } diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 54cdc8aa..a65fd907 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { dialog, ipcMain, shell } from "electron"; +import { BrowserWindow, dialog, ipcMain, shell } from "electron"; import { RECORDINGS_DIR } from "../../appPaths"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; import { @@ -20,6 +20,7 @@ import { persistRecordingsDirectorySetting, rememberRecentProject, replaceApprovedSessionLocalReadPaths, + rememberApprovedLocalReadPath, resolveApprovedLocalMediaPath, saveProjectThumbnail, saveRecentProjectPaths, @@ -562,6 +563,13 @@ export function registerProjectHandlers() { if (!options?.preserveProjectPath) { setCurrentProjectPath(null) } + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('recording-session-changed', nextSession); + } + } + return { success: true, webcamPath: nextSession.webcamPath ?? null } }) @@ -574,14 +582,19 @@ export function registerProjectHandlers() { timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), }); - await replaceApprovedSessionLocalReadPaths([ - currentRecordingSession!.videoPath, - currentRecordingSession!.webcamPath, - ]) + await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath) + await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath) if (!options?.preserveProjectPath) { setCurrentProjectPath(null) } await persistRecordingSessionManifest(currentRecordingSession!) + + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('recording-session-changed', currentRecordingSession); + } + } + return { success: true } }) diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 9e9e6e96..feb6aefb 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -20,6 +20,7 @@ import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/mon import { normalizeCursorTelemetrySamples, pauseCursorCaptureAtBoundary, + persistPendingCursorTelemetry, resetCursorCaptureClock, resumeCursorCapture, sampleCursorPoint, @@ -68,7 +69,6 @@ import { } from "../recording/mac"; import { attachWindowsCaptureLifecycle, - extendNativeWindowsVideoToDuration, isNativeWindowsCaptureAvailable, muxNativeWindowsVideoWithAudio, waitForWindowsCaptureStart, @@ -821,6 +821,9 @@ export function registerRecordingHandlers( ); ipcMain.handle("stop-native-screen-recording", async () => { + const start = Date.now(); + console.log("[PERF:MAIN] Handler: stop-native-screen-recording: STARTED"); + try { // Windows native capture stop path if (process.platform === "win32" && windowsNativeCaptureActive) { try { @@ -872,6 +875,15 @@ export function registerRecordingHandlers( durationSeconds: validation.durationSeconds, }, }); + + // Persist cursor telemetry before returning so the editor can find it immediately + snapshotCursorTelemetryForPersistence(); + try { + await persistPendingCursorTelemetry(finalVideoPath); + } catch (error) { + console.warn("Failed to persist cursor telemetry during native stop:", error); + } + return { success: true, path: finalVideoPath }; } catch (error) { console.error("Failed to stop native Windows capture:", error); @@ -1093,11 +1105,16 @@ export function registerRecordingHandlers( return recovered; } - return { - success: false, - message: "Failed to stop native ScreenCaptureKit recording", - error: String(error), - }; + return { + success: false, + message: "Failed to stop native ScreenCaptureKit recording", + error: String(error), + }; + } + } finally { + console.log( + `[PERF:MAIN] Handler: stop-native-screen-recording: COMPLETED in ${Date.now() - start}ms`, + ); } }); @@ -1258,139 +1275,116 @@ export function registerRecordingHandlers( }); ipcMain.handle("mux-native-windows-recording", async (_event, expectedDurationMs?: number) => { - const videoPath = windowsPendingVideoPath; - const orphanedMicAudioPath = windowsOrphanedMicAudioPath; - const diagnosticsSystemAudioPath = windowsSystemAudioPath; - const diagnosticsMicAudioPath = windowsMicAudioPath; - setWindowsPendingVideoPath(null); - setWindowsOrphanedMicAudioPath(null); - - if (!videoPath) { - return { success: false, message: "No native Windows video pending for mux" }; - } - + const start = Date.now(); + console.log("[PERF:MAIN] Handler: mux-native-windows-recording: STARTED"); try { - await writeWindowsRecordingDiagnostics(videoPath, { - phase: "mux-start", - expectedDurationMs, - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - details: { - hasSystemAudio: Boolean(diagnosticsSystemAudioPath), - hasMicrophone: Boolean(diagnosticsMicAudioPath), - hasOrphanedMicrophone: Boolean(orphanedMicAudioPath), - }, - }); - try { - const padding = await extendNativeWindowsVideoToDuration( - videoPath, - expectedDurationMs, - ); - await writeWindowsRecordingDiagnostics(videoPath, { - phase: "pad", - expectedDurationMs, - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - details: { ...padding }, - }); - if (padding.padded) { - console.log( - `[mux-win] Extended native Windows video to ${padding.durationSeconds.toFixed(3)}s using the final frame`, - ); - } - } catch (paddingError) { - console.warn( - "[mux-win] Failed to extend native Windows video duration:", - paddingError, - ); - await writeWindowsRecordingDiagnostics(videoPath, { - phase: "pad", - expectedDurationMs, - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - error: String(paddingError), - }); + const videoPath = windowsPendingVideoPath; + const orphanedMicAudioPath = windowsOrphanedMicAudioPath; + const diagnosticsSystemAudioPath = windowsSystemAudioPath; + const diagnosticsMicAudioPath = windowsMicAudioPath; + setWindowsPendingVideoPath(null); + setWindowsOrphanedMicAudioPath(null); + + if (!videoPath) { + return { success: false, message: "No native Windows video pending for mux" }; } - let muxDetails: unknown = null; - if (diagnosticsSystemAudioPath || diagnosticsMicAudioPath) { - muxDetails = await muxNativeWindowsVideoWithAudio( - videoPath, - diagnosticsSystemAudioPath, - diagnosticsMicAudioPath, - ); + try { + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-start", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + details: { + hasSystemAudio: Boolean(diagnosticsSystemAudioPath), + hasMicrophone: Boolean(diagnosticsMicAudioPath), + hasOrphanedMicrophone: Boolean(orphanedMicAudioPath), + }, + }); + console.log("[mux-win] Optimization active: skipping video padding."); + + let muxDetails: unknown = null; + if (diagnosticsSystemAudioPath || diagnosticsMicAudioPath) { + muxDetails = await muxNativeWindowsVideoWithAudio( + videoPath, + diagnosticsSystemAudioPath, + diagnosticsMicAudioPath, + ); + setWindowsSystemAudioPath(null); + setWindowsMicAudioPath(null); + } + + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }); + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-complete", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + details: { + fileSizeBytes: await getFileSizeIfPresent(videoPath), + mux: muxDetails, + }, + }); + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); + return await finalizeStoredVideo(videoPath); + } catch (error) { + console.error("Failed to mux native Windows recording:", error); + recordNativeCaptureDiagnostics({ + backend: "windows-wgc", + phase: "mux", + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + fileSizeBytes: await getFileSizeIfPresent(videoPath), + error: String(error), + }); + await writeWindowsRecordingDiagnostics(videoPath, { + phase: "mux-error", + expectedDurationMs, + outputPath: videoPath, + systemAudioPath: diagnosticsSystemAudioPath, + microphonePath: diagnosticsMicAudioPath, + error: String(error), + details: { + fileSizeBytes: await getFileSizeIfPresent(videoPath), + }, + }); setWindowsSystemAudioPath(null); setWindowsMicAudioPath(null); - } - - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "mux", - outputPath: videoPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - }); - await writeWindowsRecordingDiagnostics(videoPath, { - phase: "mux-complete", - expectedDurationMs, - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - details: { - fileSizeBytes: await getFileSizeIfPresent(videoPath), - mux: muxDetails, - }, - }); - await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); - return await finalizeStoredVideo(videoPath); - } catch (error) { - console.error("Failed to mux native Windows recording:", error); - recordNativeCaptureDiagnostics({ - backend: "windows-wgc", - phase: "mux", - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - fileSizeBytes: await getFileSizeIfPresent(videoPath), - error: String(error), - }); - await writeWindowsRecordingDiagnostics(videoPath, { - phase: "mux-error", - expectedDurationMs, - outputPath: videoPath, - systemAudioPath: diagnosticsSystemAudioPath, - microphonePath: diagnosticsMicAudioPath, - error: String(error), - details: { - fileSizeBytes: await getFileSizeIfPresent(videoPath), - }, - }); - setWindowsSystemAudioPath(null); - setWindowsMicAudioPath(null); - await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); - try { - return await finalizeStoredVideo(videoPath); - } catch { + await cleanupWindowsOrphanedMicAudioPath(orphanedMicAudioPath); try { - await validateRecordedVideo(videoPath); + return await finalizeStoredVideo(videoPath); + } catch { + try { + await validateRecordedVideo(videoPath); + return { + success: false, + path: videoPath, + message: "Failed to mux native Windows recording", + error: String(error), + }; + } catch { + // The fallback path is not safely playable; surface the original mux error. + } + return { success: false, - path: videoPath, message: "Failed to mux native Windows recording", error: String(error), }; - } catch { - // The fallback path is not safely playable; surface the original mux error. } - - return { - success: false, - message: "Failed to mux native Windows recording", - error: String(error), - }; } + } finally { + console.log( + `[PERF:MAIN] Handler: mux-native-windows-recording: COMPLETED in ${Date.now() - start}ms`, + ); } }); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index 0ce77fcb..4ba6b35a 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -13,6 +13,7 @@ import { resolveLinuxWindowBounds, stopWindowBoundsCapture, } from "../cursor/bounds"; +import { reassertHudOverlayMousePassthrough } from "../../windows"; const execFileAsync = promisify(execFile); const SOURCE_LIST_CACHE_TTL_MS = 1200; @@ -487,11 +488,24 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} throw loadError } - setTimeout(() => { - if (!highlightWin.isDestroyed()) highlightWin.close() - }, 1700) + // The highlight window appearing (even with focusable:false) can corrupt + // the WS_EX_TRANSPARENT flag on the HUD on Windows 11+, breaking hover + // detection until the user moves their mouse over the bar again. + // Re-assert passthrough immediately so click-through is restored at once. + reassertHudOverlayMousePassthrough(); - return { success: true } + const highlightCloseTimer = setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close() + }, 1700) + + highlightWin.on("closed", () => { + clearTimeout(highlightCloseTimer); + // Re-assert once more when the window is actually destroyed so the + // native flag is clean regardless of timing. + reassertHudOverlayMousePassthrough(); + }); + + return { success: true } } catch (error) { console.error('Failed to show source highlight:', error) return { success: false } diff --git a/electron/main.ts b/electron/main.ts index a4836f74..0a50b6f9 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -197,9 +197,14 @@ function getExistingEditorWindow(): BrowserWindow | null { let defaultTrayIcon: ReturnType | null = null; let recordingTrayIcon: ReturnType | null = null; +function getPlatformAppIconFilename(size: 32 | 128 | 512) { + const baseName = process.platform === "darwin" ? "recordlymac" : "recordly"; + return `app-icons/${baseName}-${size}.png`; +} + function getDefaultTrayIcon() { if (!defaultTrayIcon) { - defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png"); + defaultTrayIcon = getTrayIcon(getPlatformAppIconFilename(32)); } return defaultTrayIcon; } @@ -283,9 +288,14 @@ function focusOrCreateMainWindow() { return; } - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - return; + if (!mainWindow || mainWindow.isDestroyed()) { + const existingHud = getHudOverlayWindow(); + if (existingHud && !existingHud.isDestroyed()) { + mainWindow = existingHud; + } else { + createWindow(); + return; + } } if (mainWindow && !mainWindow.isDestroyed()) { @@ -524,7 +534,7 @@ function syncDockIcon() { return; } - const dockIcon = getAppImage("app-icons/recordly-512.png"); + const dockIcon = getAppImage(getPlatformAppIconFilename(512)); if (!dockIcon.isEmpty()) { app.dock.setIcon(dockIcon); } @@ -595,7 +605,7 @@ function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknow const notification = new Notification({ title: getUpdateNotificationTitle(updatePayload), body: getUpdateNotificationBody(updatePayload), - icon: getAppImage("app-icons/recordly-128.png"), + icon: getAppImage(getPlatformAppIconFilename(128)), silent: false, }); @@ -793,7 +803,15 @@ function createEditorWindowWrapper() { const previousWindow = mainWindow; if (previousWindow && !previousWindow.isDestroyed()) { const closingEditorWindow = isEditorWindow(previousWindow); - closeEditorWindowBypassingUnsavedPrompt(previousWindow); + + if (closingEditorWindow) { + closeEditorWindowBypassingUnsavedPrompt(previousWindow); + } else { + // It's the HUD or another window. Hide it instead of closing so background + // tasks (like webcam finalizing) can finish in its renderer process. + previousWindow.hide(); + } + if (!closingEditorWindow) { isForceClosing = false; } @@ -923,7 +941,21 @@ app.whenReady().then(async () => { } ipcMain.on("hud-overlay-close", () => { - app.quit(); + const hud = getHudOverlayWindow(); + if (hud) { + console.log("[main] Closing HUD window via hud-overlay-close"); + hud.close(); + } + + // If this was the last window (or we are in a state where we should quit), do it. + // We use a small delay to allow window.close() to propagate. + setTimeout(() => { + const windows = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()); + if (windows.length === 0) { + console.log("[main] No windows left, quitting app"); + app.quit(); + } + }, 100); }); syncDockIcon(); createTray(); diff --git a/electron/preload.ts b/electron/preload.ts index cfb8015f..c823de71 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; +import type { RecordingSessionData } from "./ipc/types"; type NativeVideoExportWriteResult = { success: boolean; error?: string }; type NativeVideoAudioMuxMetrics = { @@ -681,6 +682,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ) => { return ipcRenderer.invoke("set-current-recording-session", session, options); }, + onRecordingSessionChanged: (callback: (session: RecordingSessionData | null) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: RecordingSessionData | null) => + callback(payload); + ipcRenderer.on("recording-session-changed", listener); + return () => ipcRenderer.removeListener("recording-session-changed", listener); + }, getCurrentRecordingSession: () => { return ipcRenderer.invoke("get-current-recording-session"); }, diff --git a/electron/windows.ts b/electron/windows.ts index f1b448b6..26b94892 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -13,10 +13,11 @@ const nodeRequire = createRequire(import.meta.url); const APP_ROOT = path.join(electronWindowsDir, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const RENDERER_DIST = path.join(APP_ROOT, "dist"); +const WINDOW_ICON_FILENAME = process.platform === "darwin" ? "recordlymac-512.png" : "recordly-512.png"; const WINDOW_ICON_PATH = path.join( process.env.VITE_PUBLIC || RENDERER_DIST, "app-icons", - "recordly-512.png", + WINDOW_ICON_FILENAME, ); let hudOverlayWindow: BrowserWindow | null = null; @@ -373,6 +374,7 @@ export function createHudOverlayWindow(): BrowserWindow { skipTaskbar: true, hasShadow: false, show: false, + focusable: false, webPreferences: { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, @@ -513,6 +515,36 @@ export function getHudOverlayWindow(): BrowserWindow | null { return hudOverlayWindow && !hudOverlayWindow.isDestroyed() ? hudOverlayWindow : null; } +/** + * Re-initialise the HUD overlay's mouse passthrough state. + * + * On Windows 11+, any new BrowserWindow appearing (even focusable:false ones + * like the source highlight overlay) can silently corrupt the + * WS_EX_TRANSPARENT flag that backs setIgnoreMouseEvents forwarding. Call + * this after any operation that creates or destroys a sibling window so that + * hover detection on the HUD is immediately restored without requiring the + * user to move their mouse over the bar. + */ +export function reassertHudOverlayMousePassthrough(): void { + if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { + return; + } + + const hud = getHudOverlayWindow(); + if (!hud) { + return; + } + + // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully + // re-initialised rather than merely re-asserted in a potentially broken state. + hud.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!hud.isDestroyed()) { + hud.setIgnoreMouseEvents(true, { forward: true }); + } + }, 50); +} + export function createUpdateToastWindow(): BrowserWindow { const initialBounds = getUpdateToastBounds(); const parentWindow = @@ -696,6 +728,8 @@ function loadPackagedEditorWindow(win: BrowserWindow) { } export function createEditorWindow(): BrowserWindow { + const perfStart = Date.now(); + console.log("[PERF:MAIN] createEditorWindow: STARTED"); const isMac = process.platform === "darwin"; const { workArea, workAreaSize } = getScreen().getPrimaryDisplay(); const initialWidth = isMac ? Math.round(workAreaSize.width * 0.85) : workArea.width; @@ -735,12 +769,12 @@ export function createEditorWindow(): BrowserWindow { }); win.once("ready-to-show", () => { - console.log("[editor-window] ready-to-show"); + console.log(`[PERF:MAIN] Editor Window: ready-to-show in ${Date.now() - perfStart}ms`); win.show(); }); win.webContents.on("did-finish-load", () => { - console.log("[editor-window] did-finish-load", win.webContents.getURL()); + console.log(`[PERF:MAIN] Editor Window: did-finish-load in ${Date.now() - perfStart}ms`); win?.webContents.send("main-process-message", new Date().toLocaleString()); // Fallback for Linux/Wayland where `ready-to-show` may not fire reliably. if (!win.isDestroyed() && !win.isVisible()) { diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns index 625c9d48..6500f620 100644 Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ diff --git a/icons/icons/png/1024x1024.png b/icons/icons/png/1024x1024.png index d872eecc..c194c3ff 100644 Binary files a/icons/icons/png/1024x1024.png and b/icons/icons/png/1024x1024.png differ diff --git a/icons/icons/png/128x128.png b/icons/icons/png/128x128.png index 4c1f0392..8dc2b50f 100644 Binary files a/icons/icons/png/128x128.png and b/icons/icons/png/128x128.png differ diff --git a/icons/icons/png/16x16.png b/icons/icons/png/16x16.png index b4f52806..66ea3f13 100644 Binary files a/icons/icons/png/16x16.png and b/icons/icons/png/16x16.png differ diff --git a/icons/icons/png/24x24.png b/icons/icons/png/24x24.png index f7d84fde..b56ffe89 100644 Binary files a/icons/icons/png/24x24.png and b/icons/icons/png/24x24.png differ diff --git a/icons/icons/png/256x256.png b/icons/icons/png/256x256.png index e3dd6f09..6d6eb25e 100644 Binary files a/icons/icons/png/256x256.png and b/icons/icons/png/256x256.png differ diff --git a/icons/icons/png/32x32.png b/icons/icons/png/32x32.png index 9e8bf557..dee1d807 100644 Binary files a/icons/icons/png/32x32.png and b/icons/icons/png/32x32.png differ diff --git a/icons/icons/png/48x48.png b/icons/icons/png/48x48.png index e08d759f..78725c5a 100644 Binary files a/icons/icons/png/48x48.png and b/icons/icons/png/48x48.png differ diff --git a/icons/icons/png/512x512.png b/icons/icons/png/512x512.png index e648f30d..1d1a8fa6 100644 Binary files a/icons/icons/png/512x512.png and b/icons/icons/png/512x512.png differ diff --git a/icons/icons/png/64x64.png b/icons/icons/png/64x64.png index 6931b1b9..fc3c7a67 100644 Binary files a/icons/icons/png/64x64.png and b/icons/icons/png/64x64.png differ diff --git a/icons/icons/win/icon.ico b/icons/icons/win/icon.ico index 57b6cff3..6a327f3d 100644 Binary files a/icons/icons/win/icon.ico and b/icons/icons/win/icon.ico differ diff --git a/public/app-icons/recordly-1024.png b/public/app-icons/recordly-1024.png index d872eecc..c194c3ff 100644 Binary files a/public/app-icons/recordly-1024.png and b/public/app-icons/recordly-1024.png differ diff --git a/public/app-icons/recordly-128.png b/public/app-icons/recordly-128.png index 4c1f0392..8dc2b50f 100644 Binary files a/public/app-icons/recordly-128.png and b/public/app-icons/recordly-128.png differ diff --git a/public/app-icons/recordly-16.png b/public/app-icons/recordly-16.png index b4f52806..66ea3f13 100644 Binary files a/public/app-icons/recordly-16.png and b/public/app-icons/recordly-16.png differ diff --git a/public/app-icons/recordly-256.png b/public/app-icons/recordly-256.png index e3dd6f09..6d6eb25e 100644 Binary files a/public/app-icons/recordly-256.png and b/public/app-icons/recordly-256.png differ diff --git a/public/app-icons/recordly-32.png b/public/app-icons/recordly-32.png index 9e8bf557..dee1d807 100644 Binary files a/public/app-icons/recordly-32.png and b/public/app-icons/recordly-32.png differ diff --git a/public/app-icons/recordly-512.png b/public/app-icons/recordly-512.png index e648f30d..1d1a8fa6 100644 Binary files a/public/app-icons/recordly-512.png and b/public/app-icons/recordly-512.png differ diff --git a/public/app-icons/recordly-64.png b/public/app-icons/recordly-64.png index 6931b1b9..fc3c7a67 100644 Binary files a/public/app-icons/recordly-64.png and b/public/app-icons/recordly-64.png differ diff --git a/public/app-icons/recordlymac-1024.png b/public/app-icons/recordlymac-1024.png new file mode 100644 index 00000000..f3066213 Binary files /dev/null and b/public/app-icons/recordlymac-1024.png differ diff --git a/public/app-icons/recordlymac-128.png b/public/app-icons/recordlymac-128.png new file mode 100644 index 00000000..b8c7878b Binary files /dev/null and b/public/app-icons/recordlymac-128.png differ diff --git a/public/app-icons/recordlymac-16.png b/public/app-icons/recordlymac-16.png new file mode 100644 index 00000000..9ff78688 Binary files /dev/null and b/public/app-icons/recordlymac-16.png differ diff --git a/public/app-icons/recordlymac-256.png b/public/app-icons/recordlymac-256.png new file mode 100644 index 00000000..9d092540 Binary files /dev/null and b/public/app-icons/recordlymac-256.png differ diff --git a/public/app-icons/recordlymac-32.png b/public/app-icons/recordlymac-32.png new file mode 100644 index 00000000..a62bfe7f Binary files /dev/null and b/public/app-icons/recordlymac-32.png differ diff --git a/public/app-icons/recordlymac-512.png b/public/app-icons/recordlymac-512.png new file mode 100644 index 00000000..2cc3f5f5 Binary files /dev/null and b/public/app-icons/recordlymac-512.png differ diff --git a/public/app-icons/recordlymac-64.png b/public/app-icons/recordlymac-64.png new file mode 100644 index 00000000..e8248806 Binary files /dev/null and b/public/app-icons/recordlymac-64.png differ diff --git a/public/openscreen.png b/public/openscreen.png deleted file mode 100644 index 96ab2ae8..00000000 Binary files a/public/openscreen.png and /dev/null differ diff --git a/public/preview.png b/public/preview.png deleted file mode 100644 index 46440f5c..00000000 Binary files a/public/preview.png and /dev/null differ diff --git a/public/preview2.png b/public/preview2.png deleted file mode 100644 index fdbea3e8..00000000 Binary files a/public/preview2.png and /dev/null differ diff --git a/public/preview3.png b/public/preview3.png deleted file mode 100644 index 9dfdd9df..00000000 Binary files a/public/preview3.png and /dev/null differ diff --git a/public/preview4.png b/public/preview4.png deleted file mode 100644 index 52694f5d..00000000 Binary files a/public/preview4.png and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index 6b779430..9b2b6b6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,11 +13,12 @@ import { loadAllCustomFonts } from "./lib/customFonts"; export default function App() { const [windowType, setWindowType] = useState(""); const { t } = useI18n(); + const isMacOS = /mac/i.test(navigator.platform); + const appIconSrc = isMacOS ? "/app-icons/recordlymac-128.png" : "/app-icons/recordly-128.png"; useEffect(() => { const params = new URLSearchParams(window.location.search); const type = params.get("windowType") || ""; - const isMacOS = /mac/i.test(navigator.platform); setWindowType(type); document.documentElement.dataset.windowType = type; @@ -81,7 +82,7 @@ export default function App() {
{t("app.name", diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 80d9d222..49547069 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -515,6 +515,7 @@ function LaunchWindowContent() {
; webcamPreviewDragStartRef: RefObject; }) { - const anyPopoverOpenRef = useRef(false); const isMouseOverHudRef = useRef(false); + const timeoutRef = useRef(null); useEffect(() => { - anyPopoverOpenRef.current = openId !== null; if (openId !== null) { window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); } else { // Proactively check if we should ignore mouse when popover closes setTimeout(() => { - if (!isMouseOverHudRef.current && !anyPopoverOpenRef.current) { + if (!isMouseOverHudRef.current) { window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); } }, 150); } }, [openId]); + useEffect(() => { + const handleMouseOver = (e: globalThis.MouseEvent) => { + const target = e.target as HTMLElement | null; + if (!target) return; + const isInteractive = !!target.closest( + ".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]" + ); + + if (isInteractive) { + isMouseOverHudRef.current = true; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + } else { + isMouseOverHudRef.current = false; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 300); + } + }; + + window.addEventListener("mouseover", handleMouseOver); + return () => window.removeEventListener("mouseover", handleMouseOver); + }, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + const beginInteractiveHudAction = useCallback(() => { isMouseOverHudRef.current = true; window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); @@ -39,8 +70,6 @@ export function useLaunchHudInteractionState({ window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); }, []); - const timeoutRef = useRef(null); - const handleHudMouseLeave = useCallback((event: MouseEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { @@ -56,11 +85,8 @@ export function useLaunchHudInteractionState({ !isHudDraggingRef.current && !isWebcamPreviewDraggingRef.current && !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current && - !anyPopoverOpenRef.current + !isMouseOverHudRef.current ) { - // If a popover is open, we can still ignore mouse if the mouse is truly gone, - // but we give a bit more breathing room (the 300ms timeout). window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); } }, 300); diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx index ead6cdc4..55aa5f47 100644 --- a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; interface LaunchPopoverCoordinatorValue { openId: string | null; @@ -22,6 +22,12 @@ export function LaunchPopoverCoordinatorProvider({ children }: { children: React const isOpen = useCallback((id: string) => openId === id, [openId]); + useEffect(() => { + const handleBlur = () => setOpenId(null); + window.addEventListener("blur", handleBlur); + return () => window.removeEventListener("blur", handleBlur); + }, []); + const value = useMemo( () => ({ openId, diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 94687308..9a5a5290 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -25,6 +25,7 @@ const LOCALE_LABELS: Record = { en: "English", es: "Español", fr: "Français", + it: "Italiano", nl: "Nederlands", ko: "한국어", "pt-BR": "Português", diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index 856de855..be349192 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -80,6 +80,7 @@ export function HudPopover({ {trigger} , + VariantProps { + label?: string; +} + +/** + * A magnificent Skeleton component designed with Apple-inspired aesthetics. + * Supports shimmer animations, pulse effects, and labels. + */ +const Skeleton = React.forwardRef( + ({ className, variant, animation, label, children, ...props }, ref) => { + return ( +
+ {(label || children) && ( +
+ {label && ( +
+ {label.split("").map((char, i) => ( + + {char} + + ))} +
+ )} + {children} +
+ )} +
+ ); + }, +); + +Skeleton.displayName = "Skeleton"; + +export { Skeleton, skeletonVariants }; diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 56e74d01..0e131cd7 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -642,6 +642,7 @@ const APP_LANGUAGE_LABELS: Record = { en: "English", es: "Español", fr: "Français", + it: "Italiano", nl: "Nederlands", ko: "한국어", "pt-BR": "Português", @@ -3239,7 +3240,7 @@ export function SettingsPanel({ value={settings.volume} defaultValue={1} min={0} - max={2} + max={1} step={0.01} onChange={(v) => onSourceAudioTrackVolumeChange?.(track.id, v)} formatValue={(v) => `${Math.round(v * 100)}%`} diff --git a/src/components/video-editor/SliderControl.tsx b/src/components/video-editor/SliderControl.tsx index d9db8998..dd0aaf0d 100644 --- a/src/components/video-editor/SliderControl.tsx +++ b/src/components/video-editor/SliderControl.tsx @@ -1,5 +1,5 @@ import type { PointerEvent as ReactPointerEvent } from "react"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, memo, useEffect } from "react"; import { cn } from "@/lib/utils"; interface SliderControlProps { @@ -27,7 +27,7 @@ function quantizeToStep(value: number, min: number, step: number) { return min + Math.round((value - min) / step) * step; } -export function SliderControl({ +export const SliderControl = memo(function SliderControl({ label, value, defaultValue: _defaultValue, @@ -40,30 +40,51 @@ export function SliderControl({ accentColor = "blue", }: SliderControlProps) { const rootRef = useRef(null); + const valueTextRef = useRef(null); + const boundsRef = useRef(null); + const requestRef = useRef(null); + const pct = Math.min(100, Math.max(0, ((value - min) / (max - min || 1)) * 100)); + const dividerClass = accentColor === "purple" ? "bg-foreground/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]" : "bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]"; - const setValueFromClientX = useCallback( + // Sync initial and prop-driven changes to CSS variable + useEffect(() => { + if (rootRef.current) { + rootRef.current.style.setProperty("--slider-pct", String(pct / 100)); + } + }, [pct]); + + const updateValue = useCallback( (clientX: number) => { - const root = rootRef.current; - if (!root) { + const bounds = boundsRef.current; + if (!bounds || bounds.width <= 6) { return; } - const bounds = root.getBoundingClientRect(); - if (!(bounds.width > 0)) { - return; - } - - const normalized = clamp((clientX - bounds.left) / bounds.width, 0, 1); + const normalized = clamp((clientX - (bounds.left + 3)) / (bounds.width - 6), 0, 1); const rawValue = min + normalized * (max - min); const nextValue = clamp(quantizeToStep(rawValue, min, step), min, max); - onChange(Number(nextValue.toFixed(6))); + const finalValue = Number(nextValue.toFixed(6)); + const finalPct = (((finalValue - min) / (max - min || 1)) * 100).toFixed(4); + + // Direct DOM update for instant feedback + if (rootRef.current) { + rootRef.current.style.setProperty("--slider-pct", String(Number(finalPct) / 100)); + rootRef.current.setAttribute("aria-valuenow", String(finalValue)); + rootRef.current.setAttribute("aria-valuetext", formatValue(finalValue)); + } + if (valueTextRef.current) { + valueTextRef.current.textContent = formatValue(finalValue); + } + + // Notify parent + onChange(finalValue); }, - [max, min, onChange, step], + [max, min, onChange, step, formatValue], ); const handlePointerDown = useCallback( @@ -72,15 +93,24 @@ export function SliderControl({ const pointerId = event.pointerId; const target = event.currentTarget; + // Cache bounds to avoid layout thrashing during move + boundsRef.current = target.getBoundingClientRect(); + target.setPointerCapture(pointerId); - setValueFromClientX(event.clientX); + updateValue(event.clientX); const handlePointerMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== pointerId) { return; } - setValueFromClientX(moveEvent.clientX); + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + } + + requestRef.current = requestAnimationFrame(() => { + updateValue(moveEvent.clientX); + }); }; const finishPointer = (finishEvent: PointerEvent) => { @@ -88,17 +118,27 @@ export function SliderControl({ return; } + if (requestRef.current) { + cancelAnimationFrame(requestRef.current); + requestRef.current = null; + } + + if (finishEvent.type === "pointerup") { + updateValue(finishEvent.clientX); + } + target.releasePointerCapture(pointerId); target.removeEventListener("pointermove", handlePointerMove); target.removeEventListener("pointerup", finishPointer); target.removeEventListener("pointercancel", finishPointer); + boundsRef.current = null; }; target.addEventListener("pointermove", handlePointerMove); target.addEventListener("pointerup", finishPointer); target.addEventListener("pointercancel", finishPointer); }, - [setValueFromClientX], + [updateValue], ); return ( @@ -124,11 +164,16 @@ export function SliderControl({ } }} className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-editor-bg/80 px-1.5 outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]/40" + style={ + { + "--slider-pct": String(pct / 100), + } as React.CSSProperties + } >
0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0, + width: "calc(var(--slider-pct) * (100% - 6px))", }} />
{label} - + {formatValue(value)}
); -} +}); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9a4642fa..fd3a3141 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2499,6 +2499,34 @@ export default function VideoEditor() { smokeExportConfig.webcamSize, ]); + useEffect(() => { + if (!window.electronAPI.onRecordingSessionChanged) { + return; + } + + return window.electronAPI.onRecordingSessionChanged((session) => { + console.log("[VideoEditor] onRecordingSessionChanged received!", { + sessionVideoPath: session?.videoPath, + videoSourcePath: videoSourcePath, + match: session?.videoPath === videoSourcePath, + webcamPath: session?.webcamPath + }); + + if (!session || session.videoPath !== videoSourcePath) { + return; + } + + setWebcam((prev) => ({ + ...prev, + enabled: Boolean(session.webcamPath), + sourcePath: session.webcamPath ?? null, + timeOffsetMs: session.webcamPath + ? (session.timeOffsetMs ?? prev.timeOffsetMs) + : DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + }); + }, [videoSourcePath]); + useEffect(() => { let cancelled = false; if (!webcam.sourcePath) { @@ -6356,15 +6384,17 @@ export default function VideoEditor() { > c.showSourceAudio)} sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings} getSourceAudioTrackSettingsForClip={ diff --git a/src/components/video-editor/audio/useAudioPreviewSync.ts b/src/components/video-editor/audio/useAudioPreviewSync.ts index 78482460..3ff9d116 100644 --- a/src/components/video-editor/audio/useAudioPreviewSync.ts +++ b/src/components/video-editor/audio/useAudioPreviewSync.ts @@ -189,20 +189,9 @@ export function useAudioPreviewSync({ audio.volume = 1; audio.dataset.sourceAudioPath = audioPath; - const context = ensureSourceAudioContext(); - const masterGain = sourceAudioMasterGainRef.current; - if (context && masterGain && !sourceAudioMediaNodesRef.current.has(audioPath)) { - try { - const mediaNode = context.createMediaElementSource(audio); - const trackGainNode = context.createGain(); - mediaNode.connect(trackGainNode); - trackGainNode.connect(masterGain); - sourceAudioMediaNodesRef.current.set(audioPath, mediaNode); - sourceAudioGainNodesRef.current.set(audioPath, trackGainNode); - } catch (error) { - onSourceFallbackLoadError(error); - } - } + // Web Audio API createMediaElementSource breaks preservesPitch on Chromium. + // We route directly through the HTMLAudioElement to ensure pitch preservation works + // during speed changes. Note: this limits maximum preview volume to 1.0 (100%). if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) { audio.pause(); @@ -245,10 +234,7 @@ export function useAudioPreviewSync({ })(); } - const trackGainNode = sourceAudioGainNodesRef.current.get(audioPath); - if (trackGainNode) { - trackGainNode.gain.value = Math.max(0, Math.min(2, getSourceTrackPreviewGain(audioPath))); - } + audio.volume = Math.max(0, Math.min(1, getSourceTrackPreviewGain(audioPath) * (isCurrentClipMuted ? 0 : previewVolume))); } if (sourceAudioMasterGainRef.current) { @@ -378,10 +364,7 @@ export function useAudioPreviewSync({ for (const audio of sourceAudioElementsRef.current.values()) { const sourceAudioPath = audio.dataset.sourceAudioPath ?? ""; - const trackGainNode = sourceAudioGainNodesRef.current.get(sourceAudioPath); - if (trackGainNode) { - trackGainNode.gain.value = Math.max(0, Math.min(2, getSourceTrackPreviewGain(sourceAudioPath))); - } + audio.volume = Math.max(0, Math.min(1, getSourceTrackPreviewGain(sourceAudioPath) * (isCurrentClipMuted ? 0 : previewVolume))); enablePitchPreservingPlayback(audio); const audioDuration = Number.isFinite(audio.duration) ? audio.duration : null; diff --git a/src/components/video-editor/audio/useClipAudioSettingsController.ts b/src/components/video-editor/audio/useClipAudioSettingsController.ts index 08ad0d5e..924d8652 100644 --- a/src/components/video-editor/audio/useClipAudioSettingsController.ts +++ b/src/components/video-editor/audio/useClipAudioSettingsController.ts @@ -63,7 +63,7 @@ export function useClipAudioSettingsController({ normalize: false, }; const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; - return Math.max(0, Math.min(2, settings.volume * normalizeGain)); + return Math.max(0, Math.min(1, settings.volume * normalizeGain)); }, [embeddedTrackId, previewSourceAudioTrackSettings]); const getSourceTrackPreviewGain = useCallback( @@ -74,7 +74,7 @@ export function useClipAudioSettingsController({ normalize: false, }; const normalizeGain = settings.normalize ? SOURCE_AUDIO_NORMALIZE_GAIN : 1; - return Math.max(0, Math.min(2, settings.volume * normalizeGain)); + return Math.max(0, Math.min(1, settings.volume * normalizeGain)); }, [previewSourceAudioTrackSettings], ); diff --git a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts index 22688534..9f126853 100644 --- a/src/components/video-editor/audio/useSourceAudioTrackSettings.ts +++ b/src/components/video-editor/audio/useSourceAudioTrackSettings.ts @@ -116,7 +116,7 @@ export function useSourceAudioTrackSettings({ setSourceAudioTrackSettingsByClip((prev) => { const prevClip = prev[selectedClipId] ?? defaultSourceAudioTrackSettings; const nextVolume = Number.isFinite(volume) - ? Math.max(0, Math.min(2, volume)) + ? Math.max(0, Math.min(1, volume)) : (prevClip[id]?.volume ?? 1); const prevNormalize = prevClip[id]?.normalize ?? false; if ( diff --git a/src/components/video-editor/audio/waveform/WaveformGenerator.ts b/src/components/video-editor/audio/waveform/WaveformGenerator.ts index b44c274f..9dd6c69f 100644 --- a/src/components/video-editor/audio/waveform/WaveformGenerator.ts +++ b/src/components/video-editor/audio/waveform/WaveformGenerator.ts @@ -2,6 +2,8 @@ import WorkerConstructor from "./waveform.worker?worker"; import type { AudioPeaksData } from "../../timeline/core/timelineTypes"; import { WAVEFORM_DEFAULT_PEAK_COUNT } from "../../timeline/core/constants"; +const MAX_WAVEFORM_PEAKS = 200_000; + export class WaveformGenerator { private audioContext: AudioContext; private worker: Worker; @@ -74,22 +76,37 @@ export class WaveformGenerator { const arrayBuffer = await response.arrayBuffer(); const decoded = await this.audioContext.decodeAudioData(arrayBuffer); - + const adaptivePeakCount = Math.max( + peakCount, + Math.floor(decoded.duration * 500) + ); + const boundedPeakCount = Math.min(adaptivePeakCount, MAX_WAVEFORM_PEAKS); const channels: Float32Array[] = []; for (let i = 0; i < decoded.numberOfChannels; i++) { // We slice to transfer the underlying buffer to the worker channels.push(decoded.getChannelData(i).slice()); } - const peaks = await this.computePeaksWithWorker(channels, peakCount); + const peaks = await this.computePeaksWithWorker(channels, boundedPeakCount); + // Robust Normalization: Use 99.5th percentile to avoid being squashed by a single loud spike/pop let max = 0; - for (let i = 0; i < peaks.length; i++) { - if (peaks[i] > max) max = peaks[i]; + const sortedPeaks = [...peaks].sort((a, b) => a - b); + const percentileIndex = Math.floor(sortedPeaks.length * 0.995); + const robustMax = sortedPeaks[percentileIndex] || 0; + + // Fallback to absolute max if the percentile is zero (very quiet file) + if (robustMax === 0) { + for (let i = 0; i < peaks.length; i++) { + if (peaks[i] > max) max = peaks[i]; + } + } else { + max = robustMax; } + if (max > 0) { for (let i = 0; i < peaks.length; i++) { - peaks[i] /= max; + peaks[i] = Math.min(1.0, peaks[i] / max); } } diff --git a/src/components/video-editor/audio/waveform/waveform.worker.ts b/src/components/video-editor/audio/waveform/waveform.worker.ts index 1e43df98..80403a09 100644 --- a/src/components/video-editor/audio/waveform/waveform.worker.ts +++ b/src/components/video-editor/audio/waveform/waveform.worker.ts @@ -24,12 +24,17 @@ workerScope.onmessage = (e: MessageEvent) => { const firstChannel = channels[0]; const result = new Float32Array(samples); const total = firstChannel.length; + const blockSize = total / samples; for (let i = 0; i < samples; i++) { - const start = Math.floor((i * total) / samples); - const end = Math.floor(((i + 1) * total) / samples); + const start = Math.floor(i * blockSize); + const end = Math.min(total, Math.floor((i + 1) * blockSize)); + let max = 0; - for (let j = start; j < end; j++) { + // Ensure we check at least one sample even if blockSize < 1 + const actualEnd = Math.max(start + 1, end); + + for (let j = start; j < actualEnd && j < total; j++) { for (let c = 0; c < channels.length; c++) { const val = Math.abs(channels[c][j]); if (val > max) max = val; diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 44405905..b38e42f7 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -12,6 +12,7 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; import { useMemo } from "react"; import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; import AudioWaveform from "./components/waveform/AudioWaveform"; import type { AudioPeaksData } from "./core/timelineTypes"; import glassStyles from "./ItemGlass.module.css"; @@ -34,6 +35,8 @@ interface ItemProps { waveformNormalize?: boolean; muted?: boolean; variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; + isLoading?: boolean; + loadingLabel?: string; } // Map zoom depth to multiplier labels @@ -73,15 +76,49 @@ export default function Item({ waveformNormalize = false, muted = false, variant = "zoom", + isLoading = false, + loadingLabel, children, }: ItemProps) { const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, - disabled, + disabled: disabled || isLoading, data: { rowId }, }); + const timeLabel = useMemo( + () => `${formatMs(span.start)} – ${formatMs(span.end)}`, + [span.start, span.end], + ); + + if (isLoading) { + return ( +
event.stopPropagation()} + onClickCapture={(event) => event.stopPropagation()} + > + +
+ ); + } + const isZoom = variant === "zoom"; const isTrim = variant === "trim"; const isClip = variant === "clip"; @@ -101,11 +138,6 @@ export default function Item({ ? glassStyles.glassDarkGreen : glassStyles.glassYellow; - const timeLabel = useMemo( - () => `${formatMs(span.start)} – ${formatMs(span.end)}`, - [span.start, span.end], - ); - const MIN_ITEM_PX = 6; const handleSelect = () => { onSelect?.(); diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index df2ed47b..02b8b9b7 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -2,21 +2,13 @@ import type { Span } from "dnd-timeline"; import { Plus } from "@phosphor-icons/react"; import { forwardRef, - type KeyboardEvent as ReactKeyboardEvent, - useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { - type AspectRatio, -} from "@/utils/aspectRatioUtils"; -import { formatShortcut } from "@/utils/platformUtils"; -import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences"; import { fromFileUrl } from "../projectPersistence"; import type { SourceAudioTrackMeta, @@ -40,7 +32,6 @@ import { calculateTimelineScale } from "./core/time"; import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime"; import { useTimelineRange } from "./hooks/useTimelineRange"; import TimelineCanvas from "./components/viewport/TimelineCanvas"; -import TimelineToolbar from "./components/toolbar/TimelineToolbar"; export interface TimelineEditorProps { videoDuration: number; @@ -80,12 +71,9 @@ export interface TimelineEditorProps { onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; - aspectRatio?: AspectRatio; - onAspectRatioChange?: (aspectRatio: AspectRatio) => void; - onOpenCropEditor?: () => void; - isCropped?: boolean; videoPath?: string | null; - hideToolbar?: boolean; + videoSourcePath?: string | null; + cursorTelemetrySourcePath?: string | null; showSourceAudioTrack?: boolean; onSourceAudioAvailabilityChange?: (available: boolean) => void; sourceAudioTrackSettings?: SourceAudioTrackSettings; @@ -170,12 +158,9 @@ const TimelineEditor = forwardRef( onAudioDelete, selectedAudioId, onSelectAudio, - aspectRatio = "native", - onAspectRatioChange, - onOpenCropEditor, - isCropped = false, videoPath, - hideToolbar = false, + videoSourcePath, + cursorTelemetrySourcePath, showSourceAudioTrack = false, onSourceAudioAvailabilityChange, sourceAudioTrackSettings = {}, @@ -185,9 +170,6 @@ const TimelineEditor = forwardRef( ref, ) { const t = useScopedT("settings"); - const tTimeline = useScopedT("timeline"); - const tEditor = useScopedT("editor"); - const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const totalMs = useMemo( () => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration], @@ -211,16 +193,7 @@ const TimelineEditor = forwardRef( totalMs, timelineContainerRef, }); - const [customAspectWidth, setCustomAspectWidth] = useState( - initialEditorPreferences.customAspectWidth, - ); - const [customAspectHeight, setCustomAspectHeight] = useState( - initialEditorPreferences.customAspectHeight, - ); - const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", - zoom: "Ctrl + Scroll", - }); + const [liveSpanPreviewById, setLiveSpanPreviewById] = useState>({}); const liveZoomPreview = useMemo(() => { const previewSpans: Record = { ...liveSpanPreviewById }; @@ -272,7 +245,7 @@ const TimelineEditor = forwardRef( return { previewSpans, hiddenZoomIds }; }, [clipRegions, liveSpanPreviewById, zoomRegions]); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); - const sourceAudioPeaks = useTimelineAudioPeaks(videoPath, { + const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks(videoPath, { enableSourceSidecarFallback: true, }); const localSourcePath = useMemo(() => { @@ -290,8 +263,8 @@ const TimelineEditor = forwardRef( () => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "system") : null), [localSourcePath], ); - const micSidecarPeaks = useTimelineAudioPeaks(micSidecarPath); - const systemSidecarPeaks = useTimelineAudioPeaks(systemSidecarPath); + const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks(micSidecarPath); + const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks(systemSidecarPath); const sourceAudioTracks = useMemo(() => { if (systemSidecarPeaks || micSidecarPeaks) { const tracks: SourceAudioTrackWithPeaks[] = []; @@ -319,6 +292,17 @@ const TimelineEditor = forwardRef( ] : []; }, [micSidecarPeaks, sourceAudioPeaks, systemSidecarPeaks, t]); + + const isLoading = useMemo(() => { + // If we are still actively trying to load audio peaks (main or sidecars) + if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) return true; + + // Robust telemetry loading detection: + // If a source path is set but telemetry hasn't arrived (or failed/retried) for it yet. + if (videoSourcePath && cursorTelemetrySourcePath !== videoSourcePath) return true; + + return false; + }, [videoPath, videoSourcePath, cursorTelemetrySourcePath, sourceAudioLoading, micSidecarLoading, systemSidecarLoading]); useEffect(() => { onSourceAudioTracksMetaChange?.(sourceAudioTracks.map((t) => ({ id: t.id, label: t.label }))); }, [onSourceAudioTracksMetaChange, sourceAudioTracks]); @@ -327,53 +311,6 @@ const TimelineEditor = forwardRef( onSourceAudioAvailabilityChange?.(sourceAudioTracks.length > 0); }, [onSourceAudioAvailabilityChange, sourceAudioTracks.length]); - useEffect(() => { - if (aspectRatio === "native") { - return; - } - const [width, height] = aspectRatio.split(":"); - if (width && height) { - setCustomAspectWidth(width); - setCustomAspectHeight(height); - } - }, [aspectRatio]); - - useEffect(() => { - saveEditorPreferences({ - customAspectWidth, - customAspectHeight, - }); - }, [customAspectHeight, customAspectWidth]); - - const applyCustomAspectRatio = useCallback(() => { - const width = Number.parseInt(customAspectWidth, 10); - const height = Number.parseInt(customAspectHeight, 10); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - toast.error("Custom aspect ratio must be positive numbers."); - return; - } - onAspectRatioChange?.(`${width}:${height}` as AspectRatio); - }, [customAspectHeight, customAspectWidth, onAspectRatioChange]); - - const handleCustomAspectRatioKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - // Prevent Radix DropdownMenu typeahead from selecting preset items while typing. - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - applyCustomAspectRatio(); - } - }, - [applyCustomAspectRatio], - ); - - useEffect(() => { - formatShortcut(["shift", "mod", "Scroll"]).then((pan) => { - formatShortcut(["mod", "Scroll"]).then((zoom) => { - setScrollLabels({ pan, zoom }); - }); - }); - }, []); const { keyframes, selectedKeyframeId, @@ -393,11 +330,6 @@ const TimelineEditor = forwardRef( handleItemSpanChange, canPlaceZoomAtMs, addZoomAtMs, - handleAddZoom, - handleSuggestZooms, - handleSplitClip, - handleAddAudio, - handleAddAnnotation, } = useTimelineEditorRuntime({ ref, videoDuration, @@ -441,12 +373,6 @@ const TimelineEditor = forwardRef( keyShortcuts, isTimelineFocusedRef, }); - const handleToolbarAddAnnotation = useCallback(() => { - handleAddAnnotation(); - }, [handleAddAnnotation]); - const handleToolbarAddAudio = useCallback(() => { - void handleAddAudio(); - }, [handleAddAudio]); if (!videoDuration || videoDuration === 0) { return ( @@ -466,32 +392,6 @@ const TimelineEditor = forwardRef( return (
- {hideToolbar ? null : ( - - )}
( showSourceAudioTrack={showSourceAudioTrack} liveSpanPreviewById={liveZoomPreview.previewSpans} liveHiddenItemIds={Array.from(liveZoomPreview.hiddenZoomIds)} + isLoading={isLoading} />
diff --git a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx index 7ff0c1a9..d780dcfb 100644 --- a/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx +++ b/src/components/video-editor/timeline/components/playhead/PlaybackCursor.tsx @@ -9,6 +9,7 @@ interface PlaybackCursorProps { onSeek?: (time: number) => void; timelineRef: RefObject; keyframes?: { id: string; time: number }[]; + isLoading?: boolean; } export default function PlaybackCursor({ @@ -17,6 +18,7 @@ export default function PlaybackCursor({ onSeek, timelineRef, keyframes = [], + isLoading = false, }: PlaybackCursorProps) { const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; @@ -100,11 +102,27 @@ export default function PlaybackCursor({
- {formatPlayheadTime(clampedTime)} +
+ {formatPlayheadTime(clampedTime).split("").map((char, i) => ( + + {char} + + ))} +
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index 7efffc05..589f5630 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -68,6 +68,7 @@ interface TimelineCanvasProps { showSourceAudioTrack?: boolean; liveSpanPreviewById?: Record; liveHiddenItemIds?: string[]; + isLoading?: boolean; } interface TimelineHoverParams { @@ -250,6 +251,7 @@ interface TimelineCanvasRowsProps { onZoomRowMouseLeave: MouseEventHandler; onZoomRowMouseDown: MouseEventHandler; onZoomRowClick: MouseEventHandler; + isLoading?: boolean; } interface AudioItemWithWaveformProps { @@ -267,7 +269,7 @@ function AudioItemWithWaveform({ isSelected, onSelectAudio, }: AudioItemWithWaveformProps) { - const peaks = useTimelineAudioPeaks(item.audioPath ?? null); + const { peaks } = useTimelineAudioPeaks(item.audioPath ?? null); const normalizedWaveformSpan = useMemo(() => { const duration = Math.max(0, waveformSpan.end - waveformSpan.start); return { start: 0, end: duration }; @@ -282,7 +284,7 @@ function AudioItemWithWaveform({ variant="audio" waveformPeaks={peaks} waveformSegmentSpan={normalizedWaveformSpan} - waveformGain={Math.max(0, Math.min(2, item.audioGain ?? 1))} + waveformGain={Math.max(0, Math.min(1, item.audioGain ?? 1))} waveformNormalize={Boolean(item.audioNormalize)} > {item.label} @@ -317,6 +319,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ onZoomRowMouseLeave, onZoomRowMouseDown, onZoomRowClick, + isLoading = false, }: TimelineCanvasRowsProps) { const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]); const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => { @@ -383,6 +386,8 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ isSelected={selectAllBlocksActive || item.id === selectedClipId} onSelectId={onSelectClip} variant="clip" + isLoading={isLoading} + loadingLabel="Analyzing..." > {item.label} @@ -407,7 +412,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ variant="audio" waveformPeaks={track.peaks} waveformSegmentSpan={item.sourceSpan ?? item.span} - waveformGain={Math.max(0, Math.min(2, settings.volume))} + waveformGain={Math.max(0, Math.min(1, settings.volume))} waveformNormalize={Boolean(settings.normalize)} muted={item.muted} > @@ -530,6 +535,7 @@ export default function TimelineCanvas({ showSourceAudioTrack = false, liveSpanPreviewById, liveHiddenItemIds, + isLoading = false, }: TimelineCanvasProps) { const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); @@ -735,6 +741,7 @@ export default function TimelineCanvas({ onSeek={onSeek} timelineRef={localTimelineRef} keyframes={keyframes} + isLoading={isLoading} /> {canShowGhostPlayhead && (
diff --git a/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx index 2380d598..1b192651 100644 --- a/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx +++ b/src/components/video-editor/timeline/components/waveform/AudioWaveform.tsx @@ -75,31 +75,32 @@ function AudioWaveformComponent({ const { peaks: peakData, durationMs } = peaks; if (durationMs <= 0 || peakData.length === 0) return; - const rawVisibleStartMs = segmentStartMs ?? range.start; - const rawVisibleEndMs = segmentEndMs ?? range.end; - const msPerBin = durationMs / peakData.length; - const visibleStartMs = - msPerBin > 0 ? Math.round(rawVisibleStartMs / msPerBin) * msPerBin : rawVisibleStartMs; - const visibleEndMs = - msPerBin > 0 ? Math.round(rawVisibleEndMs / msPerBin) * msPerBin : rawVisibleEndMs; + // Use raw values for smooth zooming/panning (no snapping) + const visibleStartMs = segmentStartMs ?? range.start; + const visibleEndMs = segmentEndMs ?? range.end; const visibleDurationMs = visibleEndMs - visibleStartMs; + if (visibleDurationMs <= 0) return; const midY = height / 2; - ctx.beginPath(); + for (let px = 0; px < width; px++) { const t = visibleStartMs + (px / width) * visibleDurationMs; - const exactIndex = Math.max( - 0, - Math.min(peakData.length - 1, (t / durationMs) * (peakData.length - 1)), - ); + + // If the timeline time is beyond the actual audio duration, we draw nothing (flat line) + if (t < 0 || t > durationMs) continue; + + const exactIndex = (t / durationMs) * (peakData.length - 1); const leftIndex = Math.floor(exactIndex); const rightIndex = Math.min(peakData.length - 1, leftIndex + 1); const mix = exactIndex - leftIndex; + let amplitude = peakData[leftIndex] * (1 - mix) + peakData[rightIndex] * mix; + if (normalize) amplitude = Math.sqrt(Math.max(0, amplitude)); amplitude = Math.max(0, Math.min(1, amplitude * gain)); + const barHeight = amplitude * midY * 0.85; ctx.moveTo(px, midY - barHeight); diff --git a/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts index fa750e29..87ebc357 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineAudioPeaks.ts @@ -40,20 +40,30 @@ interface TimelineAudioPeaksOptions { peakCount?: number; } +export interface TimelineAudioPeaksResult { + peaks: AudioPeaksData | null; + loading: boolean; +} + export function useTimelineAudioPeaks( mediaResource: string | null | undefined, options: TimelineAudioPeaksOptions = {}, -): AudioPeaksData | null { - const [data, setData] = useState(null); +): TimelineAudioPeaksResult { + const [peaks, setPeaks] = useState(null); + const [loading, setLoading] = useState(false); const sourceRef = useRef(mediaResource); const enableSourceSidecarFallback = options.enableSourceSidecarFallback ?? false; const peakCount = options.peakCount ?? WAVEFORM_DEFAULT_PEAK_COUNT; useEffect(() => { sourceRef.current = mediaResource; - setData(null); - if (!mediaResource) return; + setPeaks(null); + if (!mediaResource) { + setLoading(false); + return; + } + setLoading(true); let cancelled = false; const run = async () => { @@ -64,29 +74,50 @@ export function useTimelineAudioPeaks( try { const result = await tryGenerate(mediaResource); - if (!cancelled && sourceRef.current === mediaResource) setData(result); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } return; } catch { // fallthrough } - if (!enableSourceSidecarFallback) return; + if (!enableSourceSidecarFallback) { + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + return; + } const localPathFromServer = extractLocalPathFromMediaServerUrl(mediaResource); const localSourcePath = localPathFromServer || (/^file:\/\//i.test(mediaResource) ? fromFileUrl(mediaResource) : mediaResource); - if (!localSourcePath) return; + if (!localSourcePath) { + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } + return; + } - for (const candidate of buildSidecarAudioCandidates(localSourcePath)) { + const candidates = buildSidecarAudioCandidates(localSourcePath); + for (const candidate of candidates) { try { const result = await tryGenerate(candidate); - if (!cancelled && sourceRef.current === mediaResource) setData(result); + if (!cancelled && sourceRef.current === mediaResource) { + setPeaks(result); + setLoading(false); + } return; } catch { // try next } } + + if (!cancelled && sourceRef.current === mediaResource) { + setLoading(false); + } }; void run(); @@ -96,5 +127,5 @@ export function useTimelineAudioPeaks( }; }, [mediaResource, enableSourceSidecarFallback, peakCount]); - return data; + return { peaks, loading }; } diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 9da6ab0c..0a167800 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -38,6 +38,14 @@ import frLaunch from "@/i18n/locales/fr/launch.json"; import frSettings from "@/i18n/locales/fr/settings.json"; import frShortcuts from "@/i18n/locales/fr/shortcuts.json"; import frTimeline from "@/i18n/locales/fr/timeline.json"; +import itCommon from "@/i18n/locales/it/common.json"; +import itDialogs from "@/i18n/locales/it/dialogs.json"; +import itEditor from "@/i18n/locales/it/editor.json"; +import itExtensions from "@/i18n/locales/it/extensions.json"; +import itLaunch from "@/i18n/locales/it/launch.json"; +import itSettings from "@/i18n/locales/it/settings.json"; +import itShortcuts from "@/i18n/locales/it/shortcuts.json"; +import itTimeline from "@/i18n/locales/it/timeline.json"; import koCommon from "@/i18n/locales/ko/common.json"; import koDialogs from "@/i18n/locales/ko/dialogs.json"; import koEditor from "@/i18n/locales/ko/editor.json"; @@ -114,6 +122,16 @@ const messages: Record = { shortcuts: frShortcuts, extensions: frExtensions, }, + it: { + common: itCommon, + launch: itLaunch, + editor: itEditor, + timeline: itTimeline, + settings: itSettings, + dialogs: itDialogs, + shortcuts: itShortcuts, + extensions: itExtensions, + }, nl: { common: nlCommon, launch: nlLaunch, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 40628d47..6f021761 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -38,7 +38,6 @@ const WEBCAM_WIDTH = 1280; const WEBCAM_HEIGHT = 720; const WEBCAM_FRAME_RATE = 30; const WEBCAM_SUFFIX = "-webcam"; -const SOURCE_AUDIO_MUX_TOAST_ID = "recording-audio-mux-warning"; const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error"; const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error"; export type BrowserMicrophoneProfile = @@ -620,6 +619,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const finalizeRecordingSession = useCallback( async (videoPath: string, webcamPath: string | null) => { + const start = performance.now(); + console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED"); const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current; try { if (webcamPath) { @@ -648,6 +649,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setFinalizing(false); await window.electronAPI.switchToEditor(); + console.log( + `[PERF:RENDERER] Finalize Session & Switch to Editor: COMPLETED in ${(performance.now() - start).toFixed(2)}ms`, + ); }, [], ); @@ -863,6 +867,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamPath = await stopWebcamRecorder(); await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path, startDelayMs); await finalizeRecordingSession(result.path, webcamPath); + + if (typeof window.electronAPI?.hudOverlayClose === "function") { + window.electronAPI.hudOverlayClose(); + } + return result.path; }, [ @@ -1004,6 +1013,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setFinalizing(true); void (async () => { + const stopStart = performance.now(); + console.log("[PERF:RENDERER] Total Stop Sequence: STARTED"); + const fallbackStartDelayMs = micFallbackStartDelayMs.current; const fallbackTrackSettings = micFallbackTrackSettings.current; const stoppedAtMs = Date.now(); @@ -1014,9 +1026,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const isNativeWindows = nativeWindowsRecording.current; nativeWindowsRecording.current = false; + const ipcStopStart = performance.now(); + console.log("[PERF:RENDERER] IPC: stopNativeScreenRecording: STARTED"); const result = await window.electronAPI.stopNativeScreenRecording(); + console.log( + `[PERF:RENDERER] IPC: stopNativeScreenRecording: COMPLETED in ${(performance.now() - ipcStopStart).toFixed(2)}ms`, + ); + await window.electronAPI?.setRecordingState(false); - const webcamPath = await webcamPathPromise; if (!result.success || !result.path) { console.error( @@ -1030,6 +1047,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { fallbackStartDelayMs, ); if (recoveredPath) { + console.log( + `[PERF:RENDERER] Total Stop Sequence (RECOVERED) in ${(performance.now() - stopStart).toFixed(2)}ms`, + ); return; } } catch (recoveryError) { @@ -1046,36 +1066,58 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - let finalPath = result.path; + const finalPath = result.path; - if (isNativeWindows) { - const muxResult = - await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); - if (!muxResult?.success || !muxResult.path) { - void logNativeCaptureDiagnostics("mux-native-windows-recording"); - const fallbackPath = muxResult?.path ?? finalPath; - const warningMessage = - muxResult?.error || - muxResult?.message || - "Failed to finish the native Windows audio mux"; - toast.warning( - `${warningMessage}. Recording was saved, but audio playback or export may be incomplete.`, - { id: SOURCE_AUDIO_MUX_TOAST_ID, duration: 10000 }, - ); - finalPath = fallbackPath; - } else { - finalPath = muxResult.path; - } - } + // 1. Finalize the session and switch to editor immediately (Optimistic UI) + // We pass null for webcamPath initially to avoid blocking on webcam disk writes/muxing. + await finalizeRecordingSession(finalPath, null); - await storeMicrophoneSidecar( - micFallbackBlobPromise, - finalPath, - fallbackStartDelayMs, - fallbackTrackSettings, - ); + // 2. Perform background finalization (webcam, muxing, sidecars) + // We don't await this to keep the UI responsive + void (async () => { + try { + // Await the webcam path in the background + const webcamPath = await webcamPathPromise; + console.log("[useScreenRecorder] Background native processing: webcamPath is", webcamPath); - await finalizeRecordingSession(finalPath, webcamPath); + // Store sidecars + await storeMicrophoneSidecar( + micFallbackBlobPromise, + finalPath, + fallbackStartDelayMs, + fallbackTrackSettings, + ); + + // Perform muxing/renaming if on Windows + if (isNativeWindows) { + await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); + } + + console.log("[useScreenRecorder] Emitting setCurrentRecordingSession with:", { finalPath, webcamPath }); + + // Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready. + // This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets. + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + }); + + console.log( + `[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`, + ); + } catch (bgError) { + console.error("Error in background finalization:", bgError); + } finally { + // After all background tasks are done (webcam, mic sidecars, muxing), + // we can safely close the HUD window to release hardware and resources. + if (typeof window.electronAPI?.hudOverlayClose === "function") { + console.log("[useScreenRecorder] All background tasks finished, closing HUD"); + window.electronAPI.hudOverlayClose(); + } + } + })(); })(); return; } @@ -1712,10 +1754,34 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (videoResult.path) { - const webcamPath = pendingWebcamPathPromise.current - ? await pendingWebcamPathPromise.current - : resolvedWebcamPath.current; - await finalizeRecordingSession(videoResult.path, webcamPath); + const finalVideoPath = videoResult.path; + // 1. Launch editor immediately (Optimistic UI) + await finalizeRecordingSession(finalVideoPath, null); + + // 2. Background webcam processing + void (async () => { + const webcamPath = pendingWebcamPathPromise.current + ? await pendingWebcamPathPromise.current + : resolvedWebcamPath.current; + + try { + if (webcamPath) { + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalVideoPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + }); + } + } finally { + // After all background tasks are done (webcam), + // we can safely close the HUD window to release hardware and resources. + if (typeof window.electronAPI?.hudOverlayClose === "function") { + console.log("[useScreenRecorder:browser] All background tasks finished, closing HUD"); + window.electronAPI.hudOverlayClose(); + } + } + })(); } else { await notifyRecordingFinalizationFailure("Failed to save the recording."); } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 9613f368..2ecee76f 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,6 +1,6 @@ export const DEFAULT_LOCALE = "en" as const; -export const SUPPORTED_LOCALES = ["en", "es", "fr", "nl", "ko", "pt-BR", "zh-CN", "zh-TW"] as const; +export const SUPPORTED_LOCALES = ["en", "es", "fr", "it", "nl", "ko", "pt-BR", "zh-CN", "zh-TW"] as const; export const I18N_NAMESPACES = [ "common", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json new file mode 100644 index 00000000..9c270d7e --- /dev/null +++ b/src/i18n/locales/it/common.json @@ -0,0 +1,26 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Editor Recordly", + "subtitle": "Registrazione e modifica dello schermo", + "language": "Lingua", + "manageRecordings": "Apri cartella registrazioni" + }, + "actions": { + "cancel": "Annulla", + "close": "Chiudi", + "export": "Esporta", + "load": "Carica", + "redo": "Ripeti", + "reset": "Ripristina", + "save": "Salva", + "undo": "Annulla", + "delete": "Elimina", + "done": "Fatto" + }, + "errors": { + "invalidFileType": "Tipo di file non valido", + "failedToUploadImage": "Caricamento immagine non riuscito", + "fileReadError": "Si è verificato un errore durante la lettura del file." + } +} diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json new file mode 100644 index 00000000..4681c0bc --- /dev/null +++ b/src/i18n/locales/it/dialogs.json @@ -0,0 +1,62 @@ +{ + "export": { + "pleaseTryAgain": "Riprova", + "compilingGifProgress": "Compilazione GIF... {{progress}}%", + "compilingGifWait": "Compilazione GIF... Potrebbe richiedere del tempo", + "takeMoment": "Potrebbe richiedere un momento...", + "exportFailed": "Esportazione non riuscita", + "compilingGifTitle": "Compilazione GIF", + "exportingFormat": "Esportazione {{format}}", + "exportComplete": "Esportazione completata", + "formatReady": "Il tuo {{format}} è pronto", + "showInFolder": "Mostra nella cartella", + "compiling": "Compilazione", + "renderingFrames": "Rendering frame", + "processing": "Elaborazione...", + "status": "Stato", + "format": "Formato", + "compilingStatus": "Compilazione in corso...", + "frames": "Frame", + "cancelExport": "Annulla esportazione", + "reopenSaveDialog": "Riapri finestra di salvataggio", + "savedSuccess": "{{format}} salvato con successo!" + }, + "addFont": { + "title": "Aggiungi Google Font", + "heading": "Aggiungi Google Font", + "description": "Aggiungi un font personalizzato da Google Fonts da usare nelle annotazioni.", + "urlLabel": "URL di importazione Google Fonts", + "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap", + "urlHelp": "Recupera l'URL da Google Fonts: seleziona un font → clicca \"Get font\" → copia l'URL @import", + "nameLabel": "Nome visualizzato", + "namePlaceholder": "Il mio font personalizzato", + "nameHelp": "Questo è il nome con cui il font apparirà nel selettore", + "adding": "Aggiunta in corso...", + "addFont": "Aggiungi font", + "enterUrl": "Inserisci un URL di importazione Google Fonts", + "invalidUrl": "Inserisci un URL Google Fonts valido", + "enterName": "Inserisci un nome per il font", + "extractFailed": "Impossibile estrarre la famiglia di font dall'URL", + "addSuccess": "Font \"{{name}}\" aggiunto con successo", + "addFailed": "Aggiunta del font non riuscita", + "loadTimeout": "Il caricamento del font ha richiesto troppo tempo. Verifica l'URL e riprova.", + "loadFailed": "Impossibile caricare il font. Verifica che l'URL Google Fonts sia corretto." + }, + "shortcutsConfig": { + "title": "Scorciatoie da tastiera", + "configurable": "Configurabile", + "fixed": "Fisso", + "pressEscToCancel": "Premi Esc per annullare", + "clickToChange": "Clicca per modificare", + "pressAKey": "Premi un tasto…", + "alreadyUsedBy": "Già utilizzato da {{action}}", + "swap": "Scambia", + "reserved": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.", + "saved": "Scorciatoie da tastiera salvate", + "resetNotice": "Ripristinate le scorciatoie predefinite — clicca Salva per applicare", + "instructions": "Clicca su una scorciatoia, poi premi la nuova combinazione di tasti. Premi Esc per annullare.", + "resetToDefaults": "Ripristina predefinite", + "cancel": "Annulla", + "save": "Salva" + } +} diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json new file mode 100644 index 00000000..b23d247d --- /dev/null +++ b/src/i18n/locales/it/editor.json @@ -0,0 +1,142 @@ +{ + "playback": { + "play": "Riproduci", + "pause": "Pausa", + "skipBack": "Indietro", + "skipForward": "Avanti", + "muteUnmute": "Disattiva/Attiva audio" + }, + "annotations": { + "settings": "Impostazioni annotazione", + "active": "Attiva", + "text": "Testo", + "image": "Immagine", + "arrow": "Freccia", + "blur": "Sfocatura", + "textContent": "Contenuto del testo", + "textPlaceholder": "Inserisci il tuo testo...", + "fontStyle": "Stile font", + "selectStyle": "Seleziona stile", + "size": "Dimensione", + "toggleBold": "Attiva grassetto", + "toggleItalic": "Attiva corsivo", + "toggleUnderline": "Attiva sottolineato", + "alignLeft": "Allinea a sinistra", + "alignCenter": "Allinea al centro", + "alignRight": "Allinea a destra", + "textColor": "Colore testo", + "background": "Sfondo", + "none": "Nessuno", + "clearBackground": "Rimuovi sfondo", + "uploadImage": "Carica immagine", + "supportedFormats": "Formati supportati: JPG, PNG, GIF, WebP", + "arrowDirection": "Direzione freccia", + "strokeWidth": "Spessore tratto: {{width}}px", + "arrowColor": "Colore freccia", + "deleteAnnotation": "Elimina annotazione", + "shortcutsAndTips": "Scorciatoie e consigli", + "tipSelectAnnotation": "Sposta il cursore sulla sezione dell'annotazione sovrapposta e seleziona un elemento.", + "tipCycleForward": "Usa Tab per scorrere tra gli elementi sovrapposti.", + "tipCycleBackward": "Usa Shift+Tab per scorrere indietro.", + "imageUploadSuccess": "Immagine caricata con successo!", + "imageUploadError": "Carica un file immagine JPG, PNG, GIF o WebP.", + "blurStrength": "Intensità sfocatura: {{strength}}", + "solidColor": "Colore pieno (Censura)", + "borderRadius": "Raggio bordo" + }, + + "fontStyles": { + "classic": "Classico", + "editor": "Editor", + "strong": "Grassetto", + "typewriter": "Macchina da scrivere", + "deco": "Deco", + "simple": "Semplice", + "modern": "Moderno", + "clean": "Pulito" + }, + "format": { + "mp4Video": "Video MP4", + "mp4Description": "File video di alta qualità", + "gifAnimation": "Animazione GIF", + "gifDescription": "Immagine animata da condividere" + }, + "gifOptions": { + "frameRate": "Frequenza fotogrammi", + "outputSize": "Dimensione output", + "outputDimensions": "Output: {{width}} × {{height}}px", + "loopAnimation": "Riproduci in loop", + "loopDescription": "La GIF verrà riprodotta continuamente" + }, + "tutorial": { + "howTrimmingWorks": "Come funziona il taglio", + "title": "Come funziona il taglio", + "understanding": "Capire come tagliare le parti indesiderate del tuo video.", + "descriptionP1": "Lo strumento Taglia funziona definendo i segmenti che vuoi", + "descriptionRemove": "rimuovere", + "descriptionP2": "dal tuo video.", + "descriptionP3": "Qualsiasi parte della timeline coperta da un segmento di taglio rosso verrà rimossa durante l'esportazione.", + "visualExample": "Esempio visivo", + "removed": "RIMOSSO", + "kept": "Mantenuto", + "finalVideo": "Video finale", + "part": "Parte {{number}}", + "addTrimStep": "1. Aggiungi taglio", + "addTrimDesc": "Premi T o clicca sull'icona delle forbici per contrassegnare una sezione da rimuovere.", + "adjustStep": "2. Regola", + "adjustDesc": "Trascina i bordi della regione rossa per coprire esattamente ciò che vuoi tagliare." + }, + "feedback": { + "trigger": "Feedback", + "title": "Feedback e contatti", + "description": "Contattaci direttamente o apri una segnalazione se qualcosa non funziona o manca.", + "emailLabel": "Email", + "xLabel": "X", + "reportIssue": "Segnala problema / invia feedback", + "openFailed": "Apertura del link non riuscita." + }, + "keyboardShortcuts": { + "trigger": "Scorciatoie", + "title": "Scorciatoie da tastiera", + "description": "Riferimento rapido per i controlli della timeline e dell'editor.", + "customizeTooltip": "Personalizza scorciatoie", + "customize": "Personalizza", + "panTimeline": "Sposta timeline", + "zoomTimeline": "Zoom timeline", + "cycleAnnotations": "Scorri annotazioni", + "tab": "Tab" + }, + "actions": { + "saveAgain": "Salva di nuovo", + "showInFolder": "Mostra nella cartella" + }, + "project": { + "untitled": "Senza titolo" + }, + "nativeCaptureUnavailable": { + "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", + "description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. Recordly funziona comunque, ma non è possibile applicare lo smoothing del cursore.", + "confirm": "Ok" + }, + "exportStatus": { + "exporting": "Esportazione in corso", + "renderingFile": "Rendering del file in corso.", + "preparing": "Preparazione esportazione...", + "completePercent": "{{percent}}% completato", + "issue": "Problema di esportazione", + "complete": "Esportazione completata", + "savedSuccessfully": "Il tuo file è stato salvato con successo." + }, + "export": { + "processingAudioEdits": "Elaborazione audio con modifiche di velocità/overlay" + }, + "toolbar": { + "addLayer": "Aggiungi livello", + "splitClip": "Dividi clip (C)" + }, + "timeline": { + "expand": "Espandi timeline", + "collapse": "Comprimi timeline" + }, + "openRecordingsFolder": "Apri cartella registrazioni" +} diff --git a/src/i18n/locales/it/extensions.json b/src/i18n/locales/it/extensions.json new file mode 100644 index 00000000..4fcfbadd --- /dev/null +++ b/src/i18n/locales/it/extensions.json @@ -0,0 +1,60 @@ +{ + "title": "Estensioni", + "tabs": { + "browse": "Esplora", + "installed": "Installate" + }, + "actions": { + "submit": "Invia un'estensione", + "docs": "Documentazione estensioni", + "refresh": "Aggiorna", + "openFolder": "Apri cartella estensioni", + "uninstall": "Disinstalla", + "install": "Installa", + "installing": "Installazione in corso", + "add": "Aggiungi", + "retry": "Riprova", + "close": "Chiudi", + "folder": "Cartella" + }, + "status": { + "enabled": "Abilitata", + "disabled": "Disabilitata", + "installed": "Installata" + }, + "detail": { + "by": "Di {{author}}", + "unknownAuthor": "Autore sconosciuto", + "noDescription": "Nessuna descrizione", + "downloads": "{{count}} download", + "preview": "Anteprima", + "screenshotAlt": "Schermata {{number}}", + "description": "Descrizione", + "tags": "Tag", + "permissions": "Autorizzazioni", + "location": "Posizione", + "error": "Errore: {{message}}" + }, + "empty": { + "title": "Nessuna estensione", + "description": "Installa estensioni per aggiungere cornici, effetti del cursore e strumenti per l'editor." + }, + "search": { + "placeholder": "Cerca estensioni...", + "noResults": "Nessuna estensione trovata", + "noMarketplace": "Nessuna estensione del marketplace ancora disponibile", + "count": "{{count}} estensione", + "countPlural": "{{count}} estensioni" + }, + "toast": { + "installedAndEnabled": "Estensione installata e abilitata", + "uninstalled": "{{name}} disinstallata", + "uninstallFailed": "Disinstallazione di {{name}} non riuscita", + "searchFailed": "Ricerca nel marketplace non riuscita", + "refreshed": "Estensioni aggiornate", + "refreshFailed": "Aggiornamento delle estensioni non riuscito", + "marketplaceInstalled": "{{name}} installata e abilitata", + "marketplaceInstallFailed": "Installazione di {{name}} non riuscita", + "enableFailed": "Abilitazione dell'estensione non riuscita" + } +} diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json new file mode 100644 index 00000000..c27abc3a --- /dev/null +++ b/src/i18n/locales/it/launch.json @@ -0,0 +1,80 @@ +{ + "recording": { + "disableSystemAudio": "Disabilita audio di sistema", + "enableSystemAudio": "Abilita audio di sistema", + "disableMicrophone": "Disabilita microfono", + "enableMicrophone": "Abilita microfono", + "micToggleDisabledTip": "Il microfono non può essere attivato/disattivato durante la registrazione", + "disableWebcam": "Disabilita overlay webcam", + "enableWebcam": "Abilita overlay webcam", + "countdownDelay": "Ritardo conto alla rovescia", + "noDelay": "Nessun ritardo", + "record": "Registra", + "recordingFolder": "Percorso registrazioni: {{path}}", + "chooseRecordingsFolder": "Scegli percorso registrazioni", + "folderPath": "Percorso: /{{name}}/", + "openVideoFile": "Apri file video", + "openProject": "Apri progetto", + "hideHudFromVideo": "Nascondi HUD dalla registrazione", + "showHudInVideo": "Mostra HUD nella registrazione", + "hideHud": "Nascondi HUD", + "closeApp": "Chiudi app", + "screens": "Schermi", + "windows": "Finestre", + "screen": "Schermo", + "window": "Finestra", + "noSourcesFound": "Nessuna sorgente trovata", + "microphone": "Microfono", + "turnOffMicrophone": "Spegni microfono", + "selectMicToEnable": "Seleziona un microfono da abilitare", + "noMicrophonesFound": "Nessun microfono trovato", + "webcam": "Webcam", + "turnOffWebcam": "Spegni webcam", + "hideFloatingWebcamPreview": "Nascondi anteprima fluttuante", + "showFloatingWebcamPreview": "Mostra anteprima fluttuante", + "selectWebcamToEnable": "Seleziona una webcam da abilitare", + "noWebcamsFound": "Nessuna webcam trovata", + "recordingsFolder": "Percorso registrazioni", + "language": "Lingua", + "paused": "IN PAUSA", + "rec": "REC", + "resume": "Riprendi", + "pause": "Pausa", + "stop": "Stop", + "cancel": "Annulla", + "more": "Altro", + "update": { + "update": "Aggiorna", + "updated": "Aggiornato", + "idleTitle": "Verifica aggiornamenti.", + "checkingTitle": "Verifica aggiornamenti in corso...", + "downloadingTitle": "Download dell'aggiornamento in corso...", + "errorTitle": "Verifica aggiornamenti non riuscita. Clicca per riprovare.", + "upToDateTitle": "Recordly {{version}} è aggiornato.", + "availableTitle": "Recordly {{version}} è disponibile.", + "availableGenericTitle": "È disponibile un aggiornamento." + } + }, + "sourceSelector": { + "loadingSources": "Caricamento sorgenti...", + "screens": "Schermi", + "windows": "Finestre", + "noScreensAvailable": "Nessuno schermo disponibile", + "noWindowsAvailable": "Nessuna finestra disponibile", + "windowsNote": "Solo le finestre visibili (non ridotte a icona) possono essere registrate.", + "windowPlaceholder": "Finestra", + "cancel": "Annulla", + "share": "Condividi" + }, + "permissions": { + "screenRecordingNeeded": "Recordly necessita del permesso di Registrazione schermo prima di iniziare. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", + "screenRecordingMissing": "Manca ancora il permesso di Registrazione schermo. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", + "accessibilityNeeded": "Recordly necessita anche del permesso di Accessibilità per il tracciamento del cursore. Sono state aperte le Impostazioni di sistema. Dopo averlo abilitato, esci e riapri Recordly.", + "accessibilityMissing": "Manca ancora il permesso di Accessibilità. Le Impostazioni di sistema sono state riaperte. Abilitalo, poi esci e riapri Recordly prima di registrare.", + "selectSource": "Seleziona una sorgente da registrare", + "systemAudioUnavailable": "L'audio di sistema non è disponibile per questa sorgente. La registrazione continuerà senza audio di sistema.", + "microphoneDenied": "L'accesso al microfono è stato negato. La registrazione continuerà senza audio del microfono.", + "failedToStart": "Avvio della registrazione non riuscito: {{error}}", + "failedToStartGeneric": "Avvio della registrazione non riuscito" + } +} diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json new file mode 100644 index 00000000..d9b9a1a1 --- /dev/null +++ b/src/i18n/locales/it/settings.json @@ -0,0 +1,219 @@ +{ + "zoom": { + "level": "Livello di zoom", + "selectRegion": "Seleziona una regione di zoom da modificare", + "deleteZoom": "Elimina zoom", + "modeAuto": "Auto", + "modeManual": "Manuale", + "modeManualDescription": "Imposta un punto focale fisso per questo zoom", + "modeAutoDescription": "La camera si ricentra quando il cursore si avvicina al bordo della vista zoomata" + }, + "trim": { + "deleteRegion": "Elimina regione di taglio" + }, + "speed": { + "playbackSpeed": "Velocità di riproduzione", + "selectRegion": "Seleziona una regione di velocità da modificare", + "deleteRegion": "Elimina regione di velocità", + "label": "Velocità" + }, + "clip": { + "title": "Clip", + "mute": "Disattiva audio", + "mutedState": "Audio disattivato", + "unmutedState": "Audio in riproduzione", + "separateClipFromAudio": "Separa clip dall'audio", + "delete": "Elimina clip" + }, + "effects": { + "title": "Effetti video", + "show": "Mostra", + "showCursor": "Mostra cursore", + "loopCursor": "Cursore in loop", + "cursorStyle": "Stile cursore", + "cursorStyleOptions": { + "macos": "macOS", + "tahoe": "Tahoe", + "tahoe-inverted": "Tahoe invertito", + "dot": "Punto", + "figma": "Minimal", + "lavender": "Lavanda", + "parched": "Parched", + "chooper": "Chooper", + "amongus": "Among Us", + "turtle": "Tartaruga" + }, + "backgroundBlur": "Sfocatura sfondo", + "zoomMotionBlur": "Motion blur dello zoom", + "temporalZoomMotionBlur": "Sfocatura zoom temporale", + "temporalZoomMotionBlurDescription": "Controlla la finestra dell'otturatore e i campioni di frame usati dal nuovo passaggio di sfocatura zoom.", + "zoomMotionBlurSamples": "Campioni di sfocatura", + "zoomMotionBlurShutter": "Otturatore", + "auto": "Auto", + "connectZooms": "Connetti zoom", + "connectZoomsDescription": "Unisce regioni di zoom consecutive in un movimento di camera continuo.", + "autoApplyFreshRecordingZooms": "Applica automaticamente zoom alle nuove registrazioni", + "autoApplyFreshRecordingZoomsDescription": "Suggerisci automaticamente zoom con ricentratura ai bordi quando apri una nuova registrazione.", + "zoomGeneralTitle": "Generale", + "zoomGeneralDescription": "Impostazioni di movimento globali per ogni transizione di zoom.", + "zoomInTitle": "Zoom in", + "zoomInDescription": "Controlla come la camera entra in una regione di zoom.", + "zoomOutTitle": "Zoom out", + "zoomOutDescription": "Controlla come la camera esce da una regione di zoom.", + "connectedZoomTitle": "Tra zoom", + "connectedZoomDescription": "Regola lo scorrimento tra regioni di zoom consecutive quando la connessione è abilitata.", + "motionPresetsTitle": "Preset di movimento", + "motionPresetsZoomHint": "I preset di movimento dello zoom sono disponibili nelle Impostazioni.", + "animationPresets": "Preset di animazione", + "cursorMotionPresets": "Preset di movimento del cursore", + "motionPresets": { + "focused": { + "label": "Focalizzato", + "description": "Movimento più rapido per demo, walkthrough e registrazioni quotidiane." + }, + "smooth": { + "label": "Fluido", + "description": "Movimento più delicato per presentazioni, video in stile keynote e reveal raffinati." + } + }, + "zoomInDuration": "Durata zoom in", + "zoomInOverlap": "Sovrapposizione zoom in", + "zoomOutDuration": "Durata zoom out", + "zoomInEasing": "Curva zoom in", + "zoomOutEasing": "Curva zoom out", + "connectedZoomGap": "Distanza zoom connessi", + "connectedZoomDuration": "Durata zoom connessi", + "connectedZoomEasing": "Curva pan connesso", + "zoomEasingOptions": { + "recordly": "Recordly", + "glide": "Glide", + "smooth": "Fluido", + "snappy": "Rapido", + "linear": "Lineare" + }, + "cursorSize": "Dimensione cursore", + "cursorSmoothing": "Smoothing cursore", + "cursorSpringStiffness": "Rigidezza molla cursore", + "cursorSpringDamping": "Smorzamento molla cursore", + "cursorSpringMass": "Massa molla cursore", + "off": "Off", + "cursorMotionBlur": "Motion blur del cursore", + "cursorClickBounce": "Rimbalzo al clic del cursore", + "cursorClickBounceDuration": "Velocità rimbalzo", + "cursorSway": "Oscillazione cursore", + "webcam": "Overlay webcam", + "webcamFootage": "Filmato webcam", + "webcamFootageDescription": "Nessun filmato webcam collegato a questo video", + "uploadWebcamFootage": "Carica filmato", + "replaceWebcamFootage": "Sostituisci filmato", + "removeWebcamFootage": "Rimuovi filmato", + "webcamFootageAdded": "Filmato webcam collegato", + "webcamFootageRemoved": "Filmato webcam rimosso", + "webcamSize": "Dimensione webcam", + "webcamCrop": "Ritaglio webcam", + "webcamReactToZoom": "La webcam reagisce allo zoom", + "webcamMirror": "Specchia webcam", + "webcamRoundness": "Arrotondamento webcam", + "webcamShadow": "Ombra webcam", + "shadow": "Ombra", + "radius": "Raggio", + "roundness": "Arrotondamento", + "padding": "Padding", + "paddingLinked": "Collegato (uniforme)", + "paddingUnlinked": "Scollegato (asimmetrico)", + "paddingTop": "Alto", + "paddingBottom": "Basso", + "paddingLeft": "Sinistra", + "paddingRight": "Destra", + "removeBackground": "Rimuovi sfondo" + }, + "sections": { + "scene": "Scena", + "captions": "Sottotitoli", + "zoom": "Zoom", + "cursor": "Cursore", + "webcam": "Webcam", + "frame": "Cornice", + "crop": "Ritaglio" + }, + "captions": { + "enabled": "Mostra", + "language": "Lingua", + "downloading": "Download in corso...", + "deleteModel": "Elimina modello", + "clearModel": "Cancella modello", + "downloadModel": "Scarica modello", + "generating": "Generazione in corso...", + "generateFull": "Genera sottotitoli", + "regenerateFull": "Rigenera sottotitoli", + "clearFull": "Cancella sottotitoli", + "fontSettings": "Impostazioni font", + "defaultFont": "Predefinito", + "fontFamily": "Font", + "fontSize": "Dimensione font", + "rowCount": "Righe", + "animation": "Animazione", + "animationOff": "Off", + "animationFade": "Dissolvenza", + "animationRise": "Salita", + "animationPop": "Pop", + "bottomOffset": "Margine inferiore", + "maxWidth": "Larghezza massima", + "boxRadius": "Raggio riquadro", + "backgroundOpacity": "Opacità sfondo", + "textColor": "Colore testo" + }, + "crop": { + "title": "Ritaglia video", + "instruction": "Trascina su ogni lato per regolare l'area di ritaglio", + "top": "Alto", + "bottom": "Basso", + "left": "Sinistra", + "right": "Destra", + "openEditor": "Apri editor di ritaglio" + }, + "background": { + "title": "Sfondo", + "image": "Immagine", + "color": "Colore", + "gradient": "Gradiente", + "wallpaperPreview": "Anteprima sfondo", + "uploadCustom": "Carica personalizzato", + "uploadSuccess": "Immagine personalizzata caricata con successo!", + "uploadError": "Carica un file immagine JPG o JPEG.", + "uploadErrorDescription": "Sono supportate solo immagini JPG e JPEG." + }, + "export": { + "title": "Esporta", + "mp4": "MP4", + "gif": "GIF", + "quality": { + "low": "Bassa", + "medium": "Media", + "high": "Alta", + "original": "Originale" + }, + "fpsTitle": "FPS", + "loop": "Loop", + "outputDimensions": "Output: {{dimensions}}px", + "sizePresetOriginalShort": "Orig", + "sizePresetMediumShort": "Med", + "sizePresetLargeShort": "Gr", + "loadProject": "Carica progetto", + "saveProject": "Salva progetto", + "exportVideo": "Esporta {{format}}", + "reportBug": "Segnala bug", + "starOnGithub": "Metti una stella su GitHub" + }, + "audio": { + "title": "Audio", + "volumeTitle": "Audio", + "volume": "Volume", + "normalize": "Normalizza", + "sourceTracksTitle": "Audio sorgente clip", + "systemLabel": "Sorgente sistema", + "micLabel": "Sorgente microfono", + "mixedLabel": "Sorgente", + "deleteRegion": "Elimina audio" + } +} diff --git a/src/i18n/locales/it/shortcuts.json b/src/i18n/locales/it/shortcuts.json new file mode 100644 index 00000000..692a30a4 --- /dev/null +++ b/src/i18n/locales/it/shortcuts.json @@ -0,0 +1,16 @@ +{ + "actions": { + "addZoom": "Aggiungi zoom", + "addTrim": "Aggiungi taglio", + "addSpeed": "Aggiungi velocità", + "addAnnotation": "Aggiungi annotazione", + "addKeyframe": "Aggiungi keyframe", + "deleteSelected": "Elimina selezionato", + "playPause": "Riproduci / Pausa", + "cycleForward": "Scorri annotazioni avanti", + "cycleBackward": "Scorri annotazioni indietro", + "deleteSelectedAlt": "Elimina selezionato (alt)", + "panTimeline": "Sposta timeline", + "zoomTimeline": "Zoom timeline" + } +} diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json new file mode 100644 index 00000000..2f93eef0 --- /dev/null +++ b/src/i18n/locales/it/timeline.json @@ -0,0 +1,41 @@ +{ + "zoom": { + "cannotPlace": "Impossibile posizionare lo zoom qui", + "existsOrNoSpace": "Uno zoom esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "suggestHandlerUnavailable": "Gestore dei suggerimenti zoom non disponibile", + "noTelemetry": "Telemetria del cursore non disponibile", + "recordFirst": "Registra prima uno screencast per generare suggerimenti basati sul cursore.", + "noUsableTelemetry": "Telemetria del cursore non utilizzabile", + "notEnoughMovement": "La registrazione non include abbastanza dati di movimento del cursore.", + "noInteractionMoments": "Nessun momento di interazione chiaro trovato", + "tryRecording": "Prova una registrazione con pause o clic attorno alle azioni importanti.", + "noAutoZoomSlots": "Nessuno slot di auto-zoom disponibile", + "dwellPointsOverlap": "I punti di permanenza rilevati si sovrappongono a regioni di zoom esistenti.", + "addedSuggestions": "Aggiunti {{count}} suggerimenti di zoom basati sull'interazione", + "label": "Zoom {{index}}", + "addZoom": "Aggiungi zoom (Z)", + "suggestZooms": "Suggerisci zoom dal cursore" + }, + "trim": { + "cannotPlace": "Impossibile posizionare il taglio qui", + "existsOrNoSpace": "Un taglio esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "label": "Taglio {{index}}", + "addTrim": "Aggiungi taglio (T)" + }, + "speed": { + "cannotPlace": "Impossibile posizionare la velocità qui", + "existsOrNoSpace": "Una regione di velocità esiste già in questa posizione o non c'è abbastanza spazio disponibile.", + "label": "Velocità" + }, + "annotation": { + "label": "Annotazione", + "image": "Immagine", + "addAnnotation": "Aggiungi annotazione (A)" + }, + "audio": { + "label": "Audio" + }, + "addSpeed": "Aggiungi velocità (S)", + "resizeLeft": "Ridimensiona a sinistra", + "resizeRight": "Ridimensiona a destra" +} diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 8e98091e..fba88b6b 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -1488,23 +1488,166 @@ export class AudioProcessor { const source = ctx.createBufferSource(); const gainNode = ctx.createGain(); gainNode.gain.value = Math.max(0, Math.min(2, gain)); - source.buffer = buffer; - source.playbackRate.value = slice.speed; - source.connect(gainNode); - gainNode.connect(ctx.destination); const sourceOffsetSec = effectiveBufferStartSec + (audibleRange.startSec - (localOutputStartSec + chunkOutputStartSec)) * slice.speed; const localStartSec = audibleRange.startSec - chunkOutputStartSec; const sourceDurationSec = audibleDurationSec * slice.speed; - source.start(localStartSec, sourceOffsetSec, sourceDurationSec); + + const stretchedBuffer = this.stretchAudioBuffer( + buffer, + slice.speed, + sourceOffsetSec, + sourceDurationSec, + audibleDurationSec, + ctx, + ); + + source.buffer = stretchedBuffer; + source.playbackRate.value = 1; + source.connect(gainNode); + gainNode.connect(ctx.destination); + + source.start(localStartSec); } outputOffsetSec += sliceOutputDurationSec; } } + private stretchAudioBuffer( + originalBuffer: AudioBuffer, + speed: number, + sourceOffsetSec: number, + sourceDurationSec: number, + audibleDurationSec: number, + ctx: BaseAudioContext, + ): AudioBuffer { + const sampleRate = originalBuffer.sampleRate; + const channels = originalBuffer.numberOfChannels; + + const startSample = Math.max(0, Math.floor(sourceOffsetSec * sampleRate)); + const sourceSamples = Math.floor(sourceDurationSec * sampleRate); + const endSample = Math.min(originalBuffer.length, startSample + sourceSamples); + + const outSamples = Math.floor(audibleDurationSec * sampleRate); + if (outSamples <= 0 || startSample >= originalBuffer.length) { + return ctx.createBuffer(channels, 1, sampleRate); + } + + const outBuffer = ctx.createBuffer(channels, outSamples, sampleRate); + + if (Math.abs(speed - 1) < 0.001) { + const copyLength = Math.min(endSample - startSample, outSamples); + if (copyLength > 0) { + for (let c = 0; c < channels; c++) { + outBuffer.copyToChannel( + originalBuffer.getChannelData(c).subarray(startSample, startSample + copyLength), + c, + ); + } + } + return outBuffer; + } + + // WSOLA uses windowing which causes fade-in at the start and fade-out at the end. + // To avoid clicks at chunk boundaries, we render with 100ms of padding and trim it. + const paddingSec = 0.1; + const paddingOutSamples = Math.floor(sampleRate * paddingSec); + const paddingInSamples = Math.floor(paddingOutSamples * speed); + + const workStartIn = Math.max(0, startSample - paddingInSamples); + const workEndIn = Math.min(originalBuffer.length, endSample + paddingInSamples); + + const actualPaddingInStart = startSample - workStartIn; + // We expect the output offset for the requested start to be roughly: + const actualPaddingOutStart = Math.floor(actualPaddingInStart / speed); + + const windowSize = Math.floor(sampleRate * 0.04); + const hopOut = Math.floor(windowSize * 0.5); + const hopIn = Math.floor(hopOut * speed); + const searchRange = Math.floor(sampleRate * 0.015); + + const workOutSamples = Math.floor((workEndIn - workStartIn) / speed) + windowSize * 2; + const workOutBuffer = ctx.createBuffer(channels, workOutSamples, sampleRate); + + const inDataByChannel = Array.from({ length: channels }, (_, c) => originalBuffer.getChannelData(c)); + const workOutDataByChannel = Array.from({ length: channels }, (_, c) => workOutBuffer.getChannelData(c)); + + const window = new Float32Array(windowSize); + for (let i = 0; i < windowSize; i++) { + window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (windowSize - 1))); + } + + let inOffset = workStartIn; + let outOffset = 0; + + // Initial window + for (let i = 0; i < windowSize; i++) { + if (inOffset + i < workEndIn && outOffset + i < workOutSamples) { + for (let c = 0; c < channels; c++) { + workOutDataByChannel[c][outOffset + i] += inDataByChannel[c][inOffset + i] * window[i]; + } + } + } + + outOffset += hopOut; + inOffset += hopIn; + + while (outOffset + windowSize < workOutSamples && inOffset + windowSize < workEndIn) { + let bestOffset = inOffset; + const minSearch = Math.max(workStartIn, inOffset - searchRange); + const maxSearch = Math.min(workEndIn - windowSize, inOffset + searchRange); + + if (maxSearch > minSearch) { + let maxCorr = -Infinity; + let bestDelta = 0; + + for (let testOffset = minSearch; testOffset <= maxSearch; testOffset += 4) { + let corr = 0; + for (let i = 0; i < hopOut; i += 4) { + if (outOffset + i < workOutSamples && testOffset + i < workEndIn) { + for (let c = 0; c < channels; c++) { + corr += workOutDataByChannel[c][outOffset + i] * inDataByChannel[c][testOffset + i]; + } + } + } + if (corr > maxCorr) { + maxCorr = corr; + bestDelta = testOffset - inOffset; + } + } + bestOffset = inOffset + bestDelta; + } + + for (let i = 0; i < windowSize; i++) { + if (bestOffset + i < workEndIn && outOffset + i < workOutSamples) { + for (let c = 0; c < channels; c++) { + workOutDataByChannel[c][outOffset + i] += inDataByChannel[c][bestOffset + i] * window[i]; + } + } + } + + outOffset += hopOut; + inOffset += hopIn; + } + + // Transfer the stable middle portion to the final buffer + for (let c = 0; c < channels; c++) { + const finalData = outBuffer.getChannelData(c); + const tempData = workOutBuffer.getChannelData(c); + for (let i = 0; i < outSamples; i++) { + const srcIdx = actualPaddingOutStart + i; + if (srcIdx < workOutSamples) { + finalData[i] = tempData[srcIdx]; + } + } + } + + return outBuffer; + } + // Create a WAV file header for the given audio parameters. private createWavHeader( sampleRate: number, diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 3a01403e..4ebc1205 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1512,7 +1512,7 @@ export class FrameRenderer { : null, ); - this.drawFrame(); + this.drawFrame(temporalSnapshot.sceneTransform); if ( this.config.annotationRegions && @@ -1690,7 +1690,11 @@ export class FrameRenderer { this.compositeWithShadows(); // Draw device frame overlay on top of video content - this.drawFrame(); + this.drawFrame({ + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }); // Render annotations on top if present if ( @@ -2294,13 +2298,20 @@ export class FrameRenderer { this.drawWebcamOverlay(ctx, w, h); } - private drawFrame(): void { + private drawFrame(sceneTransform?: { scale: number; x: number; y: number }): void { if ((!this.frameImage && !this.frameDraw) || !this.compositeCtx || !this.layoutCache) return; const ctx = this.compositeCtx; const maskRect = this.layoutCache.maskRect; const insets = this.frameInsets; + const transform = sceneTransform ?? { scale: 1, x: 0, y: 0 }; + const drawWithTransform = (draw: () => void) => { + ctx.save(); + applyCanvasSceneTransform(ctx, transform); + draw(); + ctx.restore(); + }; if (!insets) { // No insets: draw frame spanning entire mask area @@ -2310,15 +2321,19 @@ export class FrameRenderer { c.height = Math.round(maskRect.height); const dCtx = c.getContext("2d"); if (dCtx) this.frameDraw(dCtx, c.width, c.height); - ctx.drawImage(c, maskRect.x, maskRect.y, maskRect.width, maskRect.height); + drawWithTransform(() => { + ctx.drawImage(c, maskRect.x, maskRect.y, maskRect.width, maskRect.height); + }); } else { - ctx.drawImage( - this.frameImage!, - maskRect.x, - maskRect.y, - maskRect.width, - maskRect.height, - ); + drawWithTransform(() => { + ctx.drawImage( + this.frameImage!, + maskRect.x, + maskRect.y, + maskRect.width, + maskRect.height, + ); + }); } return; } @@ -2338,9 +2353,13 @@ export class FrameRenderer { c.height = Math.round(frameH); const dCtx = c.getContext("2d"); if (dCtx) this.frameDraw(dCtx, c.width, c.height); - ctx.drawImage(c, frameX, frameY, frameW, frameH); + drawWithTransform(() => { + ctx.drawImage(c, frameX, frameY, frameW, frameH); + }); } else { - ctx.drawImage(this.frameImage!, frameX, frameY, frameW, frameH); + drawWithTransform(() => { + ctx.drawImage(this.frameImage!, frameX, frameY, frameW, frameH); + }); } } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index cf52faea..b71efe81 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -398,6 +398,7 @@ export class FrameRenderer { private cameraContainer: Container | null = null; private videoEffectsContainer: Container | null = null; private videoContainer: Container | null = null; + private frameContainer: Container | null = null; private cursorContainer: Container | null = null; private overlayContainer: Container | null = null; private annotationContainer: Container | null = null; @@ -447,6 +448,14 @@ export class FrameRenderer { private captionSprite: Sprite | null = null; private captionTextureSource: MutableVideoTextureSource | null = null; private captionRenderKey: string | null = null; + private frameSprite: Sprite | null = null; + private frameImage: HTMLImageElement | null = null; + private frameDraw: ((ctx: CanvasRenderingContext2D, width: number, height: number) => void) | null = + null; + private frameInsets: { top: number; right: number; bottom: number; left: number } | null = null; + private frameRasterCanvas: HTMLCanvasElement | null = null; + private frameRasterWidth = 0; + private frameRasterHeight = 0; private exportCompositeCanvas: ExportCompositeCanvasState | null = null; private temporalCompositeCanvas: ExportCompositeCanvasState | null = null; private outputCanvasOverride: HTMLCanvasElement | null = null; @@ -541,6 +550,7 @@ export class FrameRenderer { this.cameraContainer = new Container(); this.videoEffectsContainer = new Container(); this.videoContainer = new Container(); + this.frameContainer = new Container(); this.cursorContainer = new Container(); this.overlayContainer = new Container(); this.annotationContainer = new Container(); @@ -558,6 +568,7 @@ export class FrameRenderer { ); this.cameraContainer.addChild(this.videoEffectsContainer); + this.cameraContainer.addChild(this.frameContainer); this.cameraContainer.addChild(this.cursorContainer); this.videoEffectsContainer.addChild(this.videoContainer); this.videoEffectsContainer.filterArea = new Rectangle( @@ -608,6 +619,7 @@ export class FrameRenderer { } await this.setupBackground(); + await this.setupFrame(); await this.setupWebcamSource(); this.annotationScaleFactor = this.calculateAnnotationScaleFactor(); @@ -2074,6 +2086,37 @@ export class FrameRenderer { return getRenderableAssetUrl(wallpaperAsset); } + private async setupFrame(): Promise { + const frameId = this.config.frame; + if (!frameId) { + return; + } + + const frames = extensionHost.getFrames(); + const frame = frames.find((candidate) => candidate.id === frameId); + if (!frame) { + console.warn(`[ModernFrameRenderer] Device frame "${frameId}" not found`); + return; + } + + this.frameInsets = frame.screenInsets; + + if (frame.draw) { + this.frameDraw = frame.draw; + return; + } + + const image = new Image(); + image.crossOrigin = "anonymous"; + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => + reject(new Error(`[ModernFrameRenderer] Failed to load device frame image: ${frameId}`)); + image.src = frame.filePath; + }); + this.frameImage = image; + } + private async fallbackBackgroundForwardFrameSourceToMediaElement(): Promise { const sourceUrl = this.backgroundForwardFrameSourceUrl; this.backgroundForwardFrameSource?.cancel(); @@ -3437,6 +3480,7 @@ export class FrameRenderer { width, height, padding, + frameInsets: this.frameInsets, cropRegion, videoWidth, videoHeight, @@ -3484,6 +3528,85 @@ export class FrameRenderer { sourceCrop: cropRegion, }, }; + + this.updateFrameLayout(); + } + + private updateFrameLayout(): void { + if (!this.frameContainer || !this.layoutCache) { + return; + } + + if (!this.frameImage && !this.frameDraw) { + if (this.frameSprite) { + const texture = this.frameSprite.texture; + this.frameContainer.removeChild(this.frameSprite); + this.frameSprite.destroy(); + texture.destroy(true); + this.frameSprite = null; + } + return; + } + + const maskRect = this.layoutCache.maskRect; + const insets = this.frameInsets; + let frameX = maskRect.x; + let frameY = maskRect.y; + let frameWidth = maskRect.width; + let frameHeight = maskRect.height; + + if (insets) { + const screenWidth = maskRect.width; + const screenHeight = maskRect.height; + frameWidth = screenWidth / (1 - insets.left - insets.right); + frameHeight = screenHeight / (1 - insets.top - insets.bottom); + frameX = maskRect.x - insets.left * frameWidth; + frameY = maskRect.y - insets.top * frameHeight; + } + + if (this.frameDraw) { + const targetWidth = Math.max(1, Math.round(frameWidth)); + const targetHeight = Math.max(1, Math.round(frameHeight)); + if ( + !this.frameRasterCanvas || + this.frameRasterWidth !== targetWidth || + this.frameRasterHeight !== targetHeight + ) { + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + const context = configureHighQuality2DContext(canvas.getContext("2d")); + if (!context) { + return; + } + this.frameDraw(context, targetWidth, targetHeight); + this.frameRasterCanvas = canvas; + this.frameRasterWidth = targetWidth; + this.frameRasterHeight = targetHeight; + + const texture = Texture.from(canvas); + if (!this.frameSprite) { + this.frameSprite = new Sprite(texture); + this.frameContainer.addChild(this.frameSprite); + } else { + const previousTexture = this.frameSprite.texture; + this.frameSprite.texture = texture; + previousTexture.destroy(true); + } + } + } else if (this.frameImage && !this.frameSprite) { + const texture = Texture.from(this.frameImage); + this.frameSprite = new Sprite(texture); + this.frameContainer.addChild(this.frameSprite); + } + + if (!this.frameSprite) { + return; + } + + this.frameSprite.position.set(frameX, frameY); + this.frameSprite.width = frameWidth; + this.frameSprite.height = frameHeight; } private updateVideoShadowLayout(layout: { @@ -3724,6 +3847,9 @@ export class FrameRenderer { if (this.captionSprite?.texture) { texturesToDestroy.add(this.captionSprite.texture); } + if (this.frameSprite?.texture) { + texturesToDestroy.add(this.frameSprite.texture); + } for (const layer of this.videoShadowLayers) { if (layer.sprite?.texture) { texturesToDestroy.add(layer.sprite.texture); @@ -3772,6 +3898,7 @@ export class FrameRenderer { this.cameraContainer = null; this.videoEffectsContainer = null; this.videoContainer = null; + this.frameContainer = null; this.cursorContainer = null; this.overlayContainer = null; this.annotationContainer = null; @@ -3841,6 +3968,13 @@ export class FrameRenderer { this.captionSprite = null; this.captionTextureSource = null; this.captionRenderKey = null; + this.frameSprite = null; + this.frameImage = null; + this.frameDraw = null; + this.frameInsets = null; + this.frameRasterCanvas = null; + this.frameRasterWidth = 0; + this.frameRasterHeight = 0; this.exportCompositeCanvas = null; this.temporalCompositeCanvas = null; this.outputCanvasOverride = null; diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 4b90a104..1a6e3280 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -13,10 +13,27 @@ module.exports = { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" }, }, + shimmer: { + "100%": { + transform: "translateX(100%)", + }, + }, + "text-shimmer": { + "0%, 100%": { + "background-size": "200% 200%", + "background-position": "left center", + }, + "50%": { + "background-size": "200% 200%", + "background-position": "right center", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", + shimmer: "shimmer 2s infinite", + "text-shimmer": "text-shimmer 2.5s ease-out infinite", }, borderRadius: { lg: "var(--radius)",