Merge branch 'main' of https://github.com/webadderallorg/Recordly into fix/cursor-playhead-placement
@@ -0,0 +1,16 @@
|
||||
<svg width="452" height="452" viewBox="0 0 452 452" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="452" height="452" fill="url(#paint0_linear_89_8)"/>
|
||||
<g clip-path="url(#clip0_89_8)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M189.051 87.7287C197.092 75.8293 210.707 67.9996 226.137 67.9996C241.567 67.9996 255.182 75.8293 263.223 87.7287C276.137 81.4441 291.843 81.4712 305.206 89.1861C318.568 96.9009 326.444 110.489 327.459 124.815C341.785 125.83 355.373 133.706 363.088 147.068C370.803 160.431 370.83 176.137 364.545 189.051C376.445 197.092 384.274 210.707 384.274 226.137C384.274 241.566 376.445 255.182 364.545 263.223C370.83 276.137 370.803 291.843 363.088 305.206C355.373 318.568 341.785 326.444 327.459 327.459C326.444 341.785 318.568 355.373 305.206 363.088C291.843 370.803 276.137 370.83 263.223 364.545C255.182 376.445 241.567 384.274 226.137 384.274C210.707 384.274 197.092 376.445 189.051 364.545C176.137 370.83 160.431 370.803 147.068 363.088C133.706 355.373 125.83 341.785 124.815 327.459C110.489 326.444 96.9009 318.568 89.1861 305.206C81.4712 291.843 81.4441 276.137 87.7287 263.223C75.8293 255.182 67.9996 241.566 67.9996 226.137C67.9996 210.707 75.8293 197.092 87.7287 189.051C81.4441 176.137 81.4712 160.431 89.1861 147.068C96.9009 133.706 110.489 125.83 124.815 124.815C125.83 110.489 133.706 96.9009 147.068 89.1861C160.431 81.4712 176.137 81.4441 189.051 87.7287ZM183.329 119.899C178.901 112.229 169.079 109.598 161.41 114.026C153.74 118.454 151.108 128.276 155.536 135.946C158.782 141.567 157.847 148.667 153.257 153.257C148.667 157.847 141.567 158.782 135.946 155.536C128.276 151.108 118.454 153.74 114.026 161.41C109.598 169.079 112.229 178.901 119.899 183.329C125.521 186.575 128.261 193.191 126.581 199.461C124.901 205.731 119.219 210.091 112.729 210.091C103.872 210.091 96.6823 217.281 96.6823 226.137C96.6823 234.993 103.872 242.183 112.729 242.183C119.219 242.183 124.901 246.543 126.581 252.813C128.261 259.083 125.521 265.699 119.899 268.945C112.229 273.373 109.598 283.195 114.026 290.864C118.454 298.534 128.276 301.166 135.946 296.738C141.567 293.492 148.667 294.427 153.257 299.017C157.847 303.607 158.782 310.707 155.536 316.328C151.108 323.998 153.74 333.82 161.41 338.248C169.079 342.676 178.901 340.045 183.329 332.375C186.575 326.753 193.191 324.013 199.461 325.693C205.731 327.373 210.091 333.055 210.091 339.545C210.091 348.402 217.281 355.592 226.137 355.592C234.993 355.592 242.183 348.402 242.183 339.545C242.183 333.055 246.543 327.373 252.813 325.693C259.083 324.013 265.699 326.753 268.945 332.375C273.373 340.045 283.195 342.676 290.864 338.248C298.534 333.82 301.166 323.998 296.738 316.328C293.492 310.707 294.427 303.607 299.017 299.017C303.607 294.427 310.707 293.492 316.328 296.738C323.998 301.166 333.82 298.534 338.248 290.864C342.676 283.195 340.045 273.373 332.375 268.945C326.753 265.699 324.013 259.083 325.693 252.813C327.373 246.543 333.055 242.183 339.545 242.183C348.402 242.183 355.592 234.993 355.592 226.137C355.592 217.281 348.402 210.091 339.545 210.091C333.055 210.091 327.373 205.731 325.693 199.461C324.013 193.191 326.753 186.575 332.375 183.329C340.045 178.901 342.676 169.079 338.248 161.41C333.82 153.74 323.998 151.108 316.328 155.536C310.707 158.782 303.607 157.847 299.017 153.257C294.427 148.667 293.492 141.567 296.738 135.946C301.166 128.276 298.534 118.454 290.864 114.026C283.195 109.598 273.373 112.229 268.945 119.899C265.699 125.521 259.083 128.261 252.813 126.581C246.543 124.901 242.183 119.219 242.183 112.729C242.183 103.872 234.993 96.6823 226.137 96.6823C217.281 96.6823 210.091 103.872 210.091 112.729C210.091 119.219 205.731 124.901 199.461 126.581C193.191 128.261 186.575 125.521 183.329 119.899Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M199.619 162.117C201.863 150.458 212.979 141.269 226.137 141.269C239.296 141.269 250.412 150.458 252.655 162.117C262.486 155.46 276.844 156.822 286.149 166.126C295.453 175.431 296.815 189.789 290.157 199.619C301.816 201.863 311.006 212.979 311.006 226.137C311.006 239.296 301.816 250.412 290.157 252.655C296.815 262.486 295.453 276.844 286.149 286.148C276.844 295.453 262.486 296.815 252.655 290.157C250.412 301.816 239.296 311.006 226.137 311.006C212.979 311.006 201.863 301.816 199.619 290.157C189.789 296.815 175.431 295.453 166.126 286.148C156.822 276.844 155.46 262.486 162.117 252.655C150.458 250.412 141.269 239.296 141.269 226.137C141.269 212.979 150.458 201.863 162.117 199.619C155.46 189.789 156.822 175.431 166.126 166.126C175.431 156.822 189.789 155.46 199.619 162.117Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_89_8" x1="226" y1="6.73532e-06" x2="226" y2="452" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0050FF"/>
|
||||
<stop offset="1" stop-color="#5483E9"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_89_8">
|
||||
<rect width="316.4" height="316.4" fill="white" transform="translate(68 68)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -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;
|
||||
|
||||
@@ -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<number> {
|
||||
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<numbe
|
||||
if (duration !== null) {
|
||||
return duration;
|
||||
}
|
||||
} finally {
|
||||
console.log(
|
||||
`[PERF:MAIN] probeMediaDurationSeconds: COMPLETED in ${Date.now() - start}ms`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -265,6 +270,7 @@ export function parseFfprobeVideoStreamDuration(output: string): VideoStreamDura
|
||||
export async function probeVideoStreamDuration(
|
||||
filePath: string,
|
||||
): Promise<VideoStreamDurationProbe | null> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>((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<string, AudioSyncAdjustment> = 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]);
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -189,297 +169,74 @@ export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStrea
|
||||
});
|
||||
}
|
||||
|
||||
export async function extendNativeWindowsVideoToDuration(
|
||||
videoPath: string,
|
||||
targetDurationMs: number | null | undefined,
|
||||
): Promise<NativeWindowsVideoPaddingResult> {
|
||||
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<NativeWindowsAudioMuxResult> {
|
||||
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<string, AudioSyncAdjustment> = 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -197,9 +197,14 @@ function getExistingEditorWindow(): BrowserWindow | null {
|
||||
let defaultTrayIcon: ReturnType<typeof getTrayIcon> | null = null;
|
||||
let recordingTrayIcon: ReturnType<typeof getTrayIcon> | 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();
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 967 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 853 KiB |
|
Before Width: | Height: | Size: 776 KiB |
|
Before Width: | Height: | Size: 534 KiB |
|
Before Width: | Height: | Size: 627 KiB |
|
Before Width: | Height: | Size: 460 KiB |
@@ -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() {
|
||||
<div className="flex h-full w-full items-center justify-center bg-editor-bg text-foreground">
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-foreground/10 bg-foreground/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl">
|
||||
<img
|
||||
src="/app-icons/recordly-128.png"
|
||||
src={appIconSrc}
|
||||
alt={t("app.name", "Recordly")}
|
||||
className="h-12 w-12 rounded-xl"
|
||||
/>
|
||||
|
||||
@@ -515,6 +515,7 @@ function LaunchWindowContent() {
|
||||
<div
|
||||
ref={recordingWebcamPreviewContainerRef}
|
||||
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag} pointer-events-auto`}
|
||||
data-hud-interactive
|
||||
title={t("recording.webcam")}
|
||||
style={{
|
||||
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
|
||||
|
||||
@@ -11,23 +11,54 @@ export function useLaunchHudInteractionState({
|
||||
isWebcamPreviewDraggingRef: RefObject<boolean>;
|
||||
webcamPreviewDragStartRef: RefObject<unknown>;
|
||||
}) {
|
||||
const anyPopoverOpenRef = useRef(false);
|
||||
const isMouseOverHudRef = useRef(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleHudMouseLeave = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ const LOCALE_LABELS: Record<string, string> = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
ko: "한국어",
|
||||
"pt-BR": "Português",
|
||||
|
||||
@@ -80,6 +80,7 @@ export function HudPopover({
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
|
||||
data-hud-interactive
|
||||
unstyled
|
||||
side="top"
|
||||
align={align}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const skeletonVariants = cva(
|
||||
"relative overflow-hidden rounded-md transition-shadow flex items-center justify-center",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted/40",
|
||||
glass: "bg-white/5 backdrop-blur-sm border border-white/10",
|
||||
dark: "bg-black/20",
|
||||
subtle: "bg-foreground/[0.03]",
|
||||
clip: "bg-primary/5 border border-primary/10 shadow-inner",
|
||||
},
|
||||
animation: {
|
||||
none: "",
|
||||
pulse: "animate-pulse",
|
||||
shimmer:
|
||||
"before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-foreground/[0.05] before:to-transparent",
|
||||
"shimmer-glass":
|
||||
"before:absolute before:inset-0 before:-translate-x-full before:animate-shimmer before:bg-gradient-to-r before:from-transparent before:via-white/[0.08] before:to-transparent",
|
||||
"shimmer-premium":
|
||||
"before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/[0.1] before:to-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
animation: "shimmer",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface SkeletonProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof skeletonVariants> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A magnificent Skeleton component designed with Apple-inspired aesthetics.
|
||||
* Supports shimmer animations, pulse effects, and labels.
|
||||
*/
|
||||
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
||||
({ className, variant, animation, label, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(skeletonVariants({ variant, animation, className }))}
|
||||
{...props}
|
||||
>
|
||||
{(label || children) && (
|
||||
<div className="relative z-10 flex flex-col items-center gap-2 px-4 py-2">
|
||||
{label && (
|
||||
<div className="flex items-center">
|
||||
{label.split("").map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"text-[11px] font-medium tracking-tight bg-gradient-to-r from-foreground/30 via-foreground/70 to-foreground/30 bg-clip-text text-transparent animate-text-shimmer whitespace-pre",
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
animationDuration: "2.5s",
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Skeleton.displayName = "Skeleton";
|
||||
|
||||
export { Skeleton, skeletonVariants };
|
||||
@@ -642,6 +642,7 @@ const APP_LANGUAGE_LABELS: Record<AppLocale, string> = {
|
||||
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)}%`}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const valueTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
const boundsRef = useRef<DOMRect | null>(null);
|
||||
const requestRef = useRef<number | null>(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
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08] shadow-[0_4px_10px_0_rgba(0,0,0,0.18)] transition-none"
|
||||
style={{
|
||||
width: pct > 0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0,
|
||||
width: "calc(var(--slider-pct) * (100% - 6px))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -136,14 +181,19 @@ export function SliderControl({
|
||||
"pointer-events-none absolute bottom-[18%] top-[18%] z-10 w-[2px] rounded-full transition-none",
|
||||
dividerClass,
|
||||
)}
|
||||
style={{ left: `calc(${pct}% - 8px)` }}
|
||||
style={{
|
||||
left: "calc(var(--slider-pct) * (100% - 6px) - 6px)",
|
||||
}}
|
||||
/>
|
||||
<span className="pointer-events-none relative z-10 flex-1 pl-3 text-[12px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-foreground">
|
||||
<span
|
||||
ref={valueTextRef}
|
||||
className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-foreground"
|
||||
>
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<TimelineEditor
|
||||
ref={timelineRef}
|
||||
hideToolbar
|
||||
videoDuration={timelineDuration}
|
||||
currentTime={currentTime}
|
||||
playheadTime={timelinePlayheadTime}
|
||||
onSeek={handleTimelineSeek}
|
||||
videoPath={videoPath}
|
||||
videoSourcePath={videoSourcePath}
|
||||
cursorTelemetrySourcePath={cursorTelemetrySourcePath}
|
||||
cursorTelemetry={normalizedCursorTelemetry}
|
||||
autoSuggestZoomsTrigger={autoSuggestZoomsTrigger}
|
||||
onAutoSuggestZoomsConsumed={handleAutoSuggestZoomsConsumed}
|
||||
disableSuggestedZooms={!autoApplyFreshRecordingAutoZooms}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
@@ -6390,7 +6420,6 @@ export default function VideoEditor() {
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
aspectRatio={aspectRatio}
|
||||
showSourceAudioTrack={clipRegions.some((c) => c.showSourceAudio)}
|
||||
sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings}
|
||||
getSourceAudioTrackSettingsForClip={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,17 @@ workerScope.onmessage = (e: MessageEvent<WaveformWorkerRequest>) => {
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...itemStyle,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
data-timeline-item="true"
|
||||
onMouseDownCapture={(event) => event.stopPropagation()}
|
||||
onClickCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Skeleton
|
||||
variant="clip"
|
||||
animation="shimmer-premium"
|
||||
label={loadingLabel || "Loading..."}
|
||||
className="w-full"
|
||||
style={{ height: "85%", minHeight: 22 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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?.();
|
||||
|
||||
@@ -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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<Record<string, Span>>({});
|
||||
const liveZoomPreview = useMemo(() => {
|
||||
const previewSpans: Record<string, Span> = { ...liveSpanPreviewById };
|
||||
@@ -272,7 +245,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
() => (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<SourceAudioTrackWithPeaks[]>(() => {
|
||||
if (systemSidecarPeaks || micSidecarPeaks) {
|
||||
const tracks: SourceAudioTrackWithPeaks[] = [];
|
||||
@@ -319,6 +292,17 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
]
|
||||
: [];
|
||||
}, [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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<HTMLInputElement>) => {
|
||||
// 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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
handleItemSpanChange,
|
||||
canPlaceZoomAtMs,
|
||||
addZoomAtMs,
|
||||
handleAddZoom,
|
||||
handleSuggestZooms,
|
||||
handleSplitClip,
|
||||
handleAddAudio,
|
||||
handleAddAnnotation,
|
||||
} = useTimelineEditorRuntime({
|
||||
ref,
|
||||
videoDuration,
|
||||
@@ -441,12 +373,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
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<TimelineEditorHandle, TimelineEditorProps>(
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
|
||||
{hideToolbar ? null : (
|
||||
<TimelineToolbar
|
||||
aspectRatio={aspectRatio}
|
||||
isCropped={isCropped}
|
||||
scrollLabels={scrollLabels}
|
||||
customAspectWidth={customAspectWidth}
|
||||
customAspectHeight={customAspectHeight}
|
||||
onCustomAspectWidthChange={setCustomAspectWidth}
|
||||
onCustomAspectHeightChange={setCustomAspectHeight}
|
||||
onCustomAspectRatioKeyDown={handleCustomAspectRatioKeyDown}
|
||||
onApplyCustomAspectRatio={applyCustomAspectRatio}
|
||||
onAspectRatioChange={onAspectRatioChange}
|
||||
onOpenCropEditor={onOpenCropEditor}
|
||||
onAddZoom={handleAddZoom}
|
||||
onSuggestZooms={handleSuggestZooms}
|
||||
onAddAnnotation={handleToolbarAddAnnotation}
|
||||
onAddAudio={handleToolbarAddAudio}
|
||||
onSplitClip={handleSplitClip}
|
||||
cropLabel={t("sections.crop", "Crop")}
|
||||
addZoomLabel={tTimeline("zoom.addZoom", "Add Zoom (Z)")}
|
||||
suggestZoomsLabel={tTimeline("zoom.suggestZooms", "Suggest Zooms from Cursor")}
|
||||
addAnnotationLabel={tTimeline("annotation.addAnnotation", "Add Annotation (A)")}
|
||||
addAudioLabel={tTimeline("audio.label", "Audio")}
|
||||
splitClipLabel={tEditor("toolbar.splitClip", "Split Clip (C)")}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
|
||||
@@ -573,6 +473,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
showSourceAudioTrack={showSourceAudioTrack}
|
||||
liveSpanPreviewById={liveZoomPreview.previewSpans}
|
||||
liveHiddenItemIds={Array.from(liveZoomPreview.hiddenZoomIds)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface PlaybackCursorProps {
|
||||
onSeek?: (time: number) => void;
|
||||
timelineRef: RefObject<HTMLDivElement>;
|
||||
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({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none",
|
||||
isDragging ? "opacity-100" : "opacity-0",
|
||||
"absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-foreground/10 shadow-lg pointer-events-none transition-opacity",
|
||||
(isDragging || isLoading) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="leading-5">{formatPlayheadTime(clampedTime)}</span>
|
||||
<div className="flex items-center">
|
||||
{formatPlayheadTime(clampedTime).split("").map((char, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"leading-5 whitespace-pre",
|
||||
isLoading && "bg-gradient-to-r from-white/40 via-white to-white/40 bg-clip-text text-transparent animate-text-shimmer"
|
||||
)}
|
||||
style={isLoading ? {
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
animationDuration: "2.5s",
|
||||
} : undefined}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@ interface TimelineCanvasProps {
|
||||
showSourceAudioTrack?: boolean;
|
||||
liveSpanPreviewById?: Record<string, { start: number; end: number }>;
|
||||
liveHiddenItemIds?: string[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineHoverParams {
|
||||
@@ -250,6 +251,7 @@ interface TimelineCanvasRowsProps {
|
||||
onZoomRowMouseLeave: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||
onZoomRowClick: MouseEventHandler<HTMLDivElement>;
|
||||
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}
|
||||
</Item>
|
||||
@@ -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 && (
|
||||
<div
|
||||
@@ -775,6 +782,7 @@ export default function TimelineCanvas({
|
||||
onZoomRowMouseLeave={handleZoomRowMouseLeave}
|
||||
onZoomRowMouseDown={handleZoomRowMouseDown}
|
||||
onZoomRowClick={handleZoomRowClick}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AudioPeaksData | null>(null);
|
||||
): TimelineAudioPeaksResult {
|
||||
const [peaks, setPeaks] = useState<AudioPeaksData | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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<AppLocale, LocaleBundle> = {
|
||||
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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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 <strong>{{action}}</strong>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>((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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||