Merge branch 'webadderallorg:main' into main

This commit is contained in:
Shuwn Hsu
2026-04-26 01:45:21 +08:00
committed by GitHub
29 changed files with 1129 additions and 1437 deletions
+9 -5
View File
@@ -50,7 +50,11 @@ interface UpdateToastState {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
}
interface UpdateStatusSummary {
@@ -286,9 +290,7 @@ interface Window {
error?: string;
}>;
discardExportedTemp: (tempPath: string) => Promise<{ success: boolean; error?: string }>;
getVideoAudioFallbackPaths: (
videoPath: string,
) => Promise<{
getVideoAudioFallbackPaths: (videoPath: string) => Promise<{
success: boolean;
paths: string[];
startDelayMsByPath?: Record<string, number>;
@@ -486,7 +488,9 @@ interface Window {
error?: string;
}>;
installDownloadedUpdate: () => Promise<{ success: boolean }>;
downloadAvailableUpdate: () => Promise<{ success: boolean; message?: string }>;
downloadAvailableUpdate: (
installAfterDownload?: boolean,
) => Promise<{ success: boolean; message?: string }>;
deferDownloadedUpdate: (delayMs?: number) => Promise<{
success: boolean;
message?: string;
+87 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { appendSyncedAudioFilter, getAudioSyncAdjustment } from "./filters";
import {
appendSyncedAudioFilter,
applyRecordedAudioStartDelay,
getAudioSyncAdjustment,
} from "./filters";
describe("getAudioSyncAdjustment", () => {
it("does not speed up longer audio tracks that would advance speech", () => {
@@ -30,6 +34,15 @@ describe("getAudioSyncAdjustment", () => {
});
});
it("pads trailing silence instead of prepending extreme delay for very short audio tracks", () => {
expect(getAudioSyncAdjustment(600, 480)).toEqual({
mode: "pad",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 120000,
});
});
it("does not inject atempo when longer audio stays on the anchored path", () => {
const filterParts: string[] = [];
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 122.5));
@@ -47,4 +60,77 @@ describe("getAudioSyncAdjustment", () => {
"[1:a]atempo=0.975000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
]);
});
it("pads the tail for very short audio tracks", () => {
const filterParts: string[] = [];
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(600, 480));
expect(filterParts).toEqual([
"[1:a]apad=pad_dur=120.000,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
]);
});
it("can add a small gain boost before resampling", () => {
const filterParts: string[] = [];
appendSyncedAudioFilter(
filterParts,
"[1:a]",
"aout",
getAudioSyncAdjustment(120, 120),
1.4,
);
expect(filterParts).toEqual([
"[1:a]volume=1.400,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
]);
});
it("can prepend mic normalization filters before sync handling", () => {
const filterParts: string[] = [];
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", getAudioSyncAdjustment(120, 120), {
preFilters: ["loudnorm=I=-16:TP=-1.5:LRA=11"],
});
expect(filterParts).toEqual([
"[1:a]loudnorm=I=-16:TP=-1.5:LRA=11,aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS[aout]",
]);
});
});
describe("applyRecordedAudioStartDelay", () => {
it("pads the tail when recorded metadata says audio started on time", () => {
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 110), 0)).toEqual({
mode: "pad",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 10000,
});
});
it("prefers a measured start delay over a tempo-only heuristic", () => {
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 119.8), 275)).toEqual({
mode: "delay",
delayMs: 275,
tempoRatio: 1,
durationDeltaMs: 200,
});
});
it("applies a measured start delay even when durations already match", () => {
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 120), 275)).toEqual({
mode: "delay",
delayMs: 275,
tempoRatio: 1,
durationDeltaMs: 0,
});
});
it("leaves tempo correction alone when recorded metadata says there was no late start", () => {
expect(applyRecordedAudioStartDelay(getAudioSyncAdjustment(120, 117), 0)).toEqual({
mode: "tempo",
delayMs: 0,
tempoRatio: 0.975,
durationDeltaMs: 3000,
});
});
});
+53 -1
View File
@@ -1,5 +1,7 @@
import type { AudioSyncAdjustment, PauseSegment } from "../types";
const MAX_AUDIO_SYNC_DELAY_MS = 15000;
export function buildAtempoFilters(tempoRatio: number): string[] {
if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) {
return [];
@@ -58,16 +60,54 @@ export function getAudioSyncAdjustment(
return { mode: "tempo", delayMs: 0, tempoRatio, durationDeltaMs };
}
if (durationDeltaMs > MAX_AUDIO_SYNC_DELAY_MS) {
return { mode: "pad", delayMs: 0, tempoRatio: 1, durationDeltaMs };
}
return { mode: "delay", delayMs: durationDeltaMs, tempoRatio: 1, durationDeltaMs };
}
export function applyRecordedAudioStartDelay(
adjustment: AudioSyncAdjustment,
recordedStartDelayMs?: number | null,
): AudioSyncAdjustment {
if (!Number.isFinite(recordedStartDelayMs) || (recordedStartDelayMs ?? 0) < 0) {
return adjustment;
}
const delayMs = Math.max(0, Math.round(recordedStartDelayMs ?? 0));
if (delayMs > 20) {
return {
mode: "delay",
delayMs,
tempoRatio: 1,
durationDeltaMs: adjustment.durationDeltaMs,
};
}
if (adjustment.mode !== "delay" && adjustment.mode !== "pad") {
return adjustment;
}
return {
mode: "pad",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: adjustment.durationDeltaMs,
};
}
export function appendSyncedAudioFilter(
filterParts: string[],
inputLabel: string,
outputLabel: string,
adjustment: AudioSyncAdjustment,
options: number | { volumeMultiplier?: number; preFilters?: string[] } = 1,
) {
const filters: string[] = [];
const volumeMultiplier =
typeof options === "number" ? options : (options.volumeMultiplier ?? 1);
const preFilters = typeof options === "number" ? [] : (options.preFilters ?? []);
const filters: string[] = [...preFilters];
if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`);
@@ -77,6 +117,18 @@ export function appendSyncedAudioFilter(
filters.push(...buildAtempoFilters(adjustment.tempoRatio));
}
if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) {
filters.push(`apad=pad_dur=${formatFfmpegSeconds(adjustment.durationDeltaMs)}`);
}
if (
Number.isFinite(volumeMultiplier) &&
volumeMultiplier > 0 &&
Math.abs(volumeMultiplier - 1) > 0.0005
) {
filters.push(`volume=${volumeMultiplier.toFixed(3)}`);
}
filters.push("aresample=async=1:first_pts=0", "asetpts=PTS-STARTPTS");
filterParts.push(`${inputLabel}${filters.join(",")}[${outputLabel}]`);
}
@@ -166,6 +166,18 @@ describe("getCompanionAudioFallbackPaths", () => {
});
});
it("ignores invalid sidecar timing metadata values", async () => {
const micPath = path.join(tempRoot, "recording.mic.wav");
await Promise.all([
fs.writeFile(micPath, "mic"),
fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: -250 })),
]);
const { getCompanionAudioStartDelayMs } = await import("./diagnostics");
await expect(getCompanionAudioStartDelayMs(micPath)).resolves.toBeNull();
});
it("rejects tiny MP4 container-only outputs before they reach the editor", async () => {
const videoPath = path.join(tempRoot, "recording-123.mp4");
await fs.writeFile(videoPath, Buffer.alloc(261));
+14 -7
View File
@@ -119,6 +119,16 @@ async function readCompanionAudioTimingMetadata(
}
}
export async function getCompanionAudioStartDelayMs(companionPath: string) {
const metadata = await readCompanionAudioTimingMetadata(companionPath);
const startDelayMs = metadata?.startDelayMs;
if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
return null;
}
return Math.round(startDelayMs ?? 0);
}
export async function hasEmbeddedAudioStream(videoPath: string) {
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
@@ -172,22 +182,19 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) {
const metadataEntries = await Promise.all(
paths.map(async (audioPath) => {
const metadata = await readCompanionAudioTimingMetadata(audioPath);
const startDelayMs = metadata?.startDelayMs;
if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
const startDelayMs = await getCompanionAudioStartDelayMs(audioPath);
if (!Number.isFinite(startDelayMs)) {
return null;
}
return [audioPath, Math.round(startDelayMs ?? 0)] as const;
return [audioPath, startDelayMs] as const;
}),
);
return {
paths,
startDelayMsByPath: Object.fromEntries(
metadataEntries.filter(
(entry): entry is readonly [string, number] => entry !== null,
),
metadataEntries.filter((entry): entry is readonly [string, number] => entry !== null),
),
};
}
+45 -10
View File
@@ -7,27 +7,35 @@ import { BrowserWindow } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
appendSyncedAudioFilter,
applyRecordedAudioStartDelay,
buildPausedAudioFilter,
getAudioSyncAdjustment,
normalizePauseSegments,
} from "../ffmpeg/filters";
import { getWindowsCaptureExePath } from "../paths/binaries";
import {
selectedSource,
setWindowsCaptureProcess,
setWindowsCaptureStopRequested,
setWindowsNativeCaptureActive,
windowsCaptureOutputBuffer,
windowsCaptureStopRequested,
windowsCaptureTargetPath,
windowsNativeCaptureActive,
setWindowsNativeCaptureActive,
windowsCaptureStopRequested,
setWindowsCaptureStopRequested,
selectedSource,
} from "../state";
import type { AudioSyncAdjustment, PauseSegment } from "../types";
import { moveFileWithOverwrite } from "../utils";
import { probeMediaDurationSeconds, validateRecordedVideo } from "./diagnostics";
import {
getCompanionAudioStartDelayMs,
probeMediaDurationSeconds,
validateRecordedVideo,
} from "./diagnostics";
import { emitRecordingInterrupted } from "./events";
const execFileAsync = promisify(execFile);
// Match the browser path's "usable speech level" intent with a standard
// loudness pass on native Windows mic audio instead of a fixed tiny boost.
const WINDOWS_NATIVE_MIC_PRE_FILTERS = ["loudnorm=I=-16:TP=-1.5:LRA=11"];
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
if (process.platform !== "win32") return false;
@@ -191,9 +199,21 @@ export async function muxNativeWindowsVideoWithAudio(
if (videoDuration > 0) {
for (let i = 0; i < audioFilePaths.length; i++) {
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration);
const recordedStartDelayMs = await getCompanionAudioStartDelayMs(audioFilePaths[i]);
const adjustment = applyRecordedAudioStartDelay(
getAudioSyncAdjustment(videoDuration, audioDuration),
recordedStartDelayMs,
);
audioAdjustments.set(audioInputs[i], adjustment);
if (adjustment.mode === "tempo") {
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)}`,
);
@@ -201,6 +221,10 @@ export async function muxNativeWindowsVideoWithAudio(
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`,
);
}
}
}
@@ -245,7 +269,9 @@ export async function muxNativeWindowsVideoWithAudio(
const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]";
appendSyncedAudioFilter(filterParts, systemLabel, "s", systemAdjustment);
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment);
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment, {
preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS,
});
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
await execFileAsync(
@@ -291,7 +317,13 @@ export async function muxNativeWindowsVideoWithAudio(
filterParts.push(pauseFilter);
}
const srcLabel = pauseFilter ? "[trimmed_audio]" : "[1:a]";
appendSyncedAudioFilter(filterParts, srcLabel, "aout", singleAdjustment);
appendSyncedAudioFilter(
filterParts,
srcLabel,
"aout",
singleAdjustment,
audioInputs[0] === "mic" ? { preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS } : 1,
);
await execFileAsync(
ffmpegPath,
@@ -326,7 +358,10 @@ export async function muxNativeWindowsVideoWithAudio(
for (const audioPath of [systemAudioPath, micAudioPath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => undefined);
await Promise.all([
fs.rm(audioPath, { force: true }).catch(() => undefined),
fs.rm(`${audioPath}.json`, { force: true }).catch(() => undefined),
]);
}
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ export type UiohookModuleNamespace = {
};
export type AudioSyncAdjustment = {
mode: "none" | "tempo" | "delay";
mode: "none" | "tempo" | "delay" | "pad";
delayMs: number;
tempoRatio: number;
durationDeltaMs: number;
+20 -8
View File
@@ -496,13 +496,15 @@ function getUpdateNotificationTitle(payload: UpdateToastPayload) {
function getUpdateNotificationBody(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return "Click to download the update.";
return "Click to install the update and restart Recordly.";
case "downloading":
return "Recordly is downloading the update in the foreground.";
return "Recordly is downloading the update and will restart when it is ready.";
case "ready":
return "Click to install the downloaded update.";
return "Click to install the downloaded update and restart.";
case "error":
return "Click to retry checking for updates.";
return payload.primaryAction === "install-and-restart"
? "Click to try the install again."
: "Click to retry checking for updates.";
}
}
@@ -551,13 +553,21 @@ function sendUpdateToastToWindows(channel: "update-toast-state", payload: unknow
focusOrCreateMainWindow();
switch (updatePayload.phase) {
case "available":
void downloadAvailableUpdate(sendUpdateToastToWindows);
void downloadAvailableUpdate(sendUpdateToastToWindows, {
installAfterDownload: true,
});
break;
case "ready":
installDownloadedUpdateNow(sendUpdateToastToWindows);
break;
case "error":
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
if (updatePayload.primaryAction === "install-and-restart") {
void downloadAvailableUpdate(sendUpdateToastToWindows, {
installAfterDownload: true,
});
} else {
void checkForAppUpdates(getUpdateDialogWindow, { manual: true });
}
break;
default:
break;
@@ -625,8 +635,10 @@ ipcMain.handle("install-downloaded-update", () => {
return { success: true };
});
ipcMain.handle("download-available-update", () => {
return downloadAvailableUpdate(sendUpdateToastToWindows);
ipcMain.handle("download-available-update", (_event, installAfterDownload?: boolean) => {
return downloadAvailableUpdate(sendUpdateToastToWindows, {
installAfterDownload: Boolean(installAfterDownload),
});
});
ipcMain.handle("defer-downloaded-update", (_event, delayMs?: number) => {
@@ -1,21 +1,21 @@
{
"version": 1,
"platform": "win32",
"arch": "x64",
"helpers": {
"wgc-capture": {
"binaryName": "wgc-capture.exe",
"binarySha256": "013164c0a1391d334e5aa2fe0ff2e47b507407e36c7e44e7333a70a84b944643",
"sourceDir": "electron/native/wgc-capture",
"sourceFingerprint": "9e9bce082266ca5968cf5f0b535b469d47c4ec3a775a93726171c0dfdbcdaa44",
"updatedAt": "2026-03-29T02:15:34.516Z"
},
"cursor-monitor": {
"binaryName": "cursor-monitor.exe",
"binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0",
"sourceDir": "electron/native/cursor-monitor",
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
"updatedAt": "2026-03-29T02:15:38.286Z"
}
}
"version": 1,
"platform": "win32",
"arch": "x64",
"helpers": {
"wgc-capture": {
"binaryName": "wgc-capture.exe",
"binarySha256": "bb4c2aa4141e81e1a05b54bd49dbb114eb3a5ff4a666bf7d26525a9da585128b",
"sourceDir": "electron/native/wgc-capture",
"sourceFingerprint": "5bb02a69049a02909d38188cae1dfdfb94fd49465b51ed6ab78a98092b7520cc",
"updatedAt": "2026-04-25T09:17:16.237Z"
},
"cursor-monitor": {
"binaryName": "cursor-monitor.exe",
"binarySha256": "b0732abc06998a40c3e95078465ad750a6169901944571c39cfd7996effe39c0",
"sourceDir": "electron/native/cursor-monitor",
"sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e",
"updatedAt": "2026-03-29T02:15:38.286Z"
}
}
}
Binary file not shown.
+64
View File
@@ -14,6 +14,7 @@
#include <condition_variable>
#include <chrono>
#include <cstdio>
#include <fstream>
static std::atomic<bool> g_stopRequested{false};
static std::atomic<bool> g_pauseRequested{false};
@@ -156,6 +157,43 @@ static std::wstring utf8ToWide(const std::string& str) {
return wstr;
}
static void writeCompanionAudioTimingMetadata(
const std::string& audioPath,
int64_t firstVideoTimestampHns,
const WasapiCapture& capture
) {
if (audioPath.empty() || firstVideoTimestampHns < 0) {
return;
}
const int64_t firstPacketQpcHns = capture.firstPacketQpcHns();
if (firstPacketQpcHns < 0) {
return;
}
int64_t startDelayMs = (firstPacketQpcHns - firstVideoTimestampHns + 5000) / 10000;
if (startDelayMs < 0) {
startDelayMs = 0;
}
std::ofstream metadataFile(audioPath + ".json", std::ios::trunc);
if (!metadataFile) {
std::cerr << "WARNING: Failed to write audio timing metadata for " << audioPath << std::endl;
return;
}
metadataFile << "{\"startDelayMs\":" << startDelayMs;
const uint32_t discontinuityCount = capture.dataDiscontinuityCount();
if (discontinuityCount > 0) {
metadataFile << ",\"dataDiscontinuityCount\":" << discontinuityCount;
}
const uint32_t timestampErrorCount = capture.timestampErrorCount();
if (timestampErrorCount > 0) {
metadataFile << ",\"timestampErrorCount\":" << timestampErrorCount;
}
metadataFile << "}";
}
static void stdinListenerThread() {
std::string line;
while (std::getline(std::cin, line)) {
@@ -250,9 +288,12 @@ int main(int argc, char* argv[]) {
// Set up frame callback
std::atomic<int64_t> frameCount{0};
std::atomic<int64_t> firstVideoTimestampHns{-1};
std::atomic<bool> recordingStartedAnnounced{false};
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
g_lastFrameTimestampHns = timestampHns;
int64_t expectedFirstVideoTimestampHns = -1;
firstVideoTimestampHns.compare_exchange_strong(expectedFirstVideoTimestampHns, timestampHns);
if (g_stopRequested) return;
if (g_pauseRequested) return;
@@ -337,6 +378,19 @@ int main(int argc, char* argv[]) {
if (audioActive) loopback.stop();
if (micActive) micCapture.stop();
if (audioActive) {
writeCompanionAudioTimingMetadata(
config.audioOutputPath,
firstVideoTimestampHns.load(),
loopback);
}
if (micActive) {
writeCompanionAudioTimingMetadata(
config.micOutputPath,
firstVideoTimestampHns.load(),
micCapture);
}
if (frameCount.load() <= 0) {
std::cerr << "ERROR: No video frames were captured before stop" << std::endl;
DeleteFileW(outputPathW.c_str());
@@ -350,9 +404,19 @@ int main(int argc, char* argv[]) {
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
if (audioActive) {
if (loopback.dataDiscontinuityCount() > 0 || loopback.timestampErrorCount() > 0) {
std::cerr << "WARNING: System audio timing metadata includes discontinuities="
<< loopback.dataDiscontinuityCount()
<< " timestampErrors=" << loopback.timestampErrorCount() << std::endl;
}
std::cout << "Audio path: " << config.audioOutputPath << std::endl;
}
if (micActive) {
if (micCapture.dataDiscontinuityCount() > 0 || micCapture.timestampErrorCount() > 0) {
std::cerr << "WARNING: Microphone timing metadata includes discontinuities="
<< micCapture.dataDiscontinuityCount()
<< " timestampErrors=" << micCapture.timestampErrorCount() << std::endl;
}
std::cout << "Mic path: " << config.micOutputPath << std::endl;
}
std::cout.flush();
@@ -137,6 +137,9 @@ bool WasapiCapture::start() {
}
totalDataBytes_ = 0;
firstPacketQpcHns_ = -1;
dataDiscontinuityCount_ = 0;
timestampErrorCount_ = 0;
writeWavHeader(outputFile_, 0);
HRESULT hr = audioClient_->Start();
@@ -250,13 +253,37 @@ void WasapiCapture::captureThread() {
BYTE* data = nullptr;
UINT32 numFrames = 0;
DWORD flags = 0;
UINT64 devicePosition = 0;
UINT64 qpcPosition = 0;
hr = captureClient_->GetBuffer(&data, &numFrames, &flags, nullptr, nullptr);
hr = captureClient_->GetBuffer(
&data,
&numFrames,
&flags,
&devicePosition,
&qpcPosition);
if (FAILED(hr)) {
std::cerr << "WASAPI: GetBuffer failed hr=0x" << std::hex << hr << std::dec << std::endl;
break;
}
if ((flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) != 0) {
dataDiscontinuityCount_.fetch_add(1);
}
if ((flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) != 0) {
timestampErrorCount_.fetch_add(1);
}
if (
numFrames > 0 &&
qpcPosition > 0 &&
(flags & AUDCLNT_BUFFERFLAGS_TIMESTAMP_ERROR) == 0
) {
int64_t expected = -1;
firstPacketQpcHns_.compare_exchange_strong(
expected,
static_cast<int64_t>(qpcPosition));
}
UINT32 totalSamples = numFrames * channels;
if (flags & AUDCLNT_BUFFERFLAGS_SILENT) {
@@ -19,6 +19,9 @@ public:
bool pause();
bool resume();
void stop();
int64_t firstPacketQpcHns() const { return firstPacketQpcHns_.load(); }
uint32_t dataDiscontinuityCount() const { return dataDiscontinuityCount_.load(); }
uint32_t timestampErrorCount() const { return timestampErrorCount_.load(); }
private:
bool initializeCommon();
@@ -41,4 +44,7 @@ private:
DWORD streamFlags_ = 0;
UINT32 bufferFrameCount_ = 0;
std::atomic<int64_t> firstPacketQpcHns_{-1};
std::atomic<uint32_t> dataDiscontinuityCount_{0};
std::atomic<uint32_t> timestampErrorCount_{0};
};
+12 -4
View File
@@ -504,8 +504,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
installDownloadedUpdate: () => {
return ipcRenderer.invoke("install-downloaded-update");
},
downloadAvailableUpdate: () => {
return ipcRenderer.invoke("download-available-update");
downloadAvailableUpdate: (installAfterDownload?: boolean) => {
return ipcRenderer.invoke("download-available-update", installAfterDownload);
},
deferDownloadedUpdate: (delayMs?: number) => {
return ipcRenderer.invoke("defer-downloaded-update", delayMs);
@@ -537,7 +537,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
} | null,
) => void,
) => {
@@ -550,7 +554,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
} | null,
) => callback(payload);
ipcRenderer.on("update-toast-state", listener);
+109 -34
View File
@@ -17,6 +17,7 @@ const UPDATER_LOG_PATH =
const DEV_UPDATE_PREVIEW_VERSION = "9.9.9";
const DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS = 300;
const DEV_UPDATE_PREVIEW_PROGRESS_INCREMENT = 20;
const ONE_MEGABYTE = 1024 * 1024;
export type UpdateToastPhase = "available" | "downloading" | "ready" | "error";
@@ -43,7 +44,18 @@ export interface UpdateToastPayload {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
}
interface DownloadProgressSnapshot {
progressPercent?: number;
transferredBytes?: number;
totalBytes?: number;
bytesPerSecond?: number;
}
type UpdateToastSender = (
@@ -64,6 +76,7 @@ let downloadInProgress = false;
let downloadToastDismissed = false;
let skippedVersion: string | null = null;
let updateCheckErrorHandled = false;
let installAfterDownloadRequested = false;
let activeUpdateToastSender: UpdateToastSender | undefined;
let updateStatusSummary: UpdateStatusSummary = {
status: "idle",
@@ -180,26 +193,54 @@ function createAvailableUpdateToastPayload(version: string): UpdateToastPayload
return {
version,
phase: "available",
detail: "A new version is available. Download it now, or wait and we will remind you again in 3 hours.",
detail: "Install the latest version now, or remind yourself to come back to it later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
primaryAction: "install-and-restart",
};
}
function createDownloadingUpdateToastPayload(
version: string,
progressPercent = 0,
progress: DownloadProgressSnapshot = {},
): UpdateToastPayload {
const normalizedProgress = Math.max(0, Math.min(100, progressPercent));
const normalizedProgress = Math.max(
0,
Math.min(100, Math.round(progress.progressPercent ?? 0)),
);
const transferredBytes =
typeof progress.transferredBytes === "number" && Number.isFinite(progress.transferredBytes)
? Math.max(0, progress.transferredBytes)
: undefined;
const totalBytes =
typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes)
? Math.max(0, progress.totalBytes)
: undefined;
const remainingBytes =
totalBytes !== undefined && transferredBytes !== undefined
? Math.max(totalBytes - transferredBytes, 0)
: undefined;
const bytesPerSecond =
typeof progress.bytesPerSecond === "number" && Number.isFinite(progress.bytesPerSecond)
? Math.max(0, progress.bytesPerSecond)
: undefined;
const remainingMb =
remainingBytes !== undefined ? Math.max(0, remainingBytes / ONE_MEGABYTE) : null;
return {
version,
phase: "downloading",
detail:
normalizedProgress >= 100
? "Finishing the update download. You can keep using Recordly while this completes."
: `Downloading the update in the foreground: ${normalizedProgress.toFixed(0)}% complete.`,
? "Finishing the update download. Recordly will restart as soon as the installer is ready."
: remainingMb !== null
? `${remainingMb.toFixed(1)} MB left before Recordly restarts.`
: "Downloading the update now. Recordly will restart when it finishes.",
delayMs: UPDATE_REMINDER_DELAY_MS,
progressPercent: normalizedProgress,
transferredBytes,
totalBytes,
remainingBytes,
bytesPerSecond,
primaryAction: "install-and-restart",
};
}
@@ -207,9 +248,9 @@ function createDownloadedUpdateToastPayload(version: string): UpdateToastPayload
return {
version,
phase: "ready",
detail: "Install now to restart into the new version, or wait and we will remind you again in 3 hours.",
detail: "The update is ready. Install and restart now, or remind yourself later.",
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "install-update",
primaryAction: "install-and-restart",
};
}
@@ -217,9 +258,9 @@ function createUpdateErrorToastPayload(version: string, error: unknown): UpdateT
return {
version,
phase: "error",
detail: `The update download failed. ${String(error)}`,
detail: `The update could not be downloaded. ${String(error)}`,
delayMs: UPDATE_REMINDER_DELAY_MS,
primaryAction: "download-update",
primaryAction: "install-and-restart",
};
}
@@ -276,6 +317,7 @@ function resetDevPreviewState(sendToRenderer?: UpdateToastSender) {
downloadInProgress = false;
downloadToastDismissed = false;
skippedVersion = null;
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
}
@@ -289,7 +331,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
let progressPercent = 0;
emitUpdateToastState(sendToRenderer, {
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
progressPercent,
transferredBytes: 0,
totalBytes: 20 * ONE_MEGABYTE,
bytesPerSecond: 5 * ONE_MEGABYTE,
}),
isPreview: true,
});
@@ -313,7 +360,12 @@ function simulateDevPreviewDownload(sendToRenderer?: UpdateToastSender) {
}
emitUpdateToastState(sendToRenderer, {
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, progressPercent),
...createDownloadingUpdateToastPayload(DEV_UPDATE_PREVIEW_VERSION, {
progressPercent,
transferredBytes: (progressPercent / 100) * 20 * ONE_MEGABYTE,
totalBytes: 20 * ONE_MEGABYTE,
bytesPerSecond: 5 * ONE_MEGABYTE,
}),
isPreview: true,
});
}, DEV_UPDATE_PREVIEW_PROGRESS_STEP_MS);
@@ -331,6 +383,7 @@ export function dismissUpdateToast(
}
if (downloadInProgress) {
installAfterDownloadRequested = false;
downloadToastDismissed = true;
clearVisibleUpdateToast(sendToRenderer);
return { success: true };
@@ -360,13 +413,17 @@ export function installDownloadedUpdateNow(sendToRenderer?: UpdateToastSender) {
clearDeferredReminderTimer();
downloadToastDismissed = false;
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
setUpdateStatusSummary({ status: "ready", availableVersion: pendingDownloadedVersion });
writeUpdaterLog("Installing downloaded update.");
autoUpdater.quitAndInstall();
}
export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender) {
export async function downloadAvailableUpdate(
sendToRenderer?: UpdateToastSender,
options?: { installAfterDownload?: boolean },
) {
if (currentToastPayload?.isPreview) {
return simulateDevPreviewDownload(sendToRenderer);
}
@@ -386,12 +443,20 @@ export async function downloadAvailableUpdate(sendToRenderer?: UpdateToastSender
clearDeferredReminderTimer();
downloadInProgress = true;
downloadToastDismissed = false;
installAfterDownloadRequested =
Boolean(options?.installAfterDownload) || installAfterDownloadRequested;
setUpdateStatusSummary({
status: "downloading",
availableVersion,
detail: `Downloading Recordly ${availableVersion}`,
});
emitUpdateToastState(sendToRenderer, createDownloadingUpdateToastPayload(availableVersion, 0));
emitUpdateToastState(
sendToRenderer,
createDownloadingUpdateToastPayload(availableVersion, {
progressPercent: 0,
transferredBytes: 0,
}),
);
writeUpdaterLog(`Starting update download for ${availableVersion}.`);
try {
@@ -425,6 +490,7 @@ export function deferUpdateReminder(
}
clearDeferredReminderTimer();
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
deferredReminderTimer = setTimeout(() => {
const nextPayload = getReminderPayload();
@@ -462,6 +528,7 @@ export function skipAvailableUpdateVersion(sendToRenderer?: UpdateToastSender) {
}
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
clearDeferredReminderTimer();
clearVisibleUpdateToast(sendToRenderer);
@@ -475,6 +542,7 @@ export function previewUpdateToast(sendToRenderer: UpdateToastSender) {
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
return emitUpdateToastState(sendToRenderer, {
version: DEV_UPDATE_PREVIEW_VERSION,
phase: "available",
@@ -493,24 +561,19 @@ async function showAvailableUpdateDialog(
type: "info",
title: "Update Available",
message: `Recordly ${version} is available.`,
detail: "Download now, remind me in 3 hours, or skip this version.",
buttons: ["Download Update", "Remind Me in 3 Hours", "Skip This Version"],
detail: "Install and restart now, or remind me later.",
buttons: ["Install & Restart", "Later"],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (result.response === 0) {
await downloadAvailableUpdate(sendToRenderer);
await downloadAvailableUpdate(sendToRenderer, { installAfterDownload: true });
return;
}
if (result.response === 1) {
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
return;
}
skipAvailableUpdateVersion(sendToRenderer);
deferUpdateReminder(getMainWindow, sendToRenderer, UPDATE_REMINDER_DELAY_MS);
}
async function showDownloadedUpdateDialog(
@@ -527,8 +590,8 @@ async function showDownloadedUpdateDialog(
: `Recordly ${version} has been downloaded.`,
detail: isPreview
? "Development preview of the native update prompt. No real update will be installed."
: "Install now, remind me in 3 hours, or skip this version.",
buttons: ["Install Update", "Remind Me in 3 Hours", "Skip This Version"],
: "Install and restart now, or remind me later.",
buttons: ["Install & Restart", "Later"],
defaultId: 0,
cancelId: 1,
noLink: true,
@@ -558,14 +621,7 @@ async function showDownloadedUpdateDialog(
}
deferUpdateReminder(getMainWindow, undefined, UPDATE_REMINDER_DELAY_MS);
return;
}
if (isPreview) {
return;
}
skipAvailableUpdateVersion();
}
export async function checkForAppUpdates(
@@ -665,6 +721,7 @@ export function setupAutoUpdates(
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
setUpdateStatusSummary({
status: "available",
availableVersion: info.version,
@@ -694,6 +751,7 @@ export function setupAutoUpdates(
pendingDownloadedVersion = null;
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
setUpdateStatusSummary({
status: "up-to-date",
availableVersion: null,
@@ -727,7 +785,12 @@ export function setupAutoUpdates(
emitUpdateToastState(
sendToRenderer,
createDownloadingUpdateToastPayload(availableVersion, progress.percent),
createDownloadingUpdateToastPayload(availableVersion, {
progressPercent: progress.percent,
transferredBytes: progress.transferred,
totalBytes: progress.total,
bytesPerSecond: progress.bytesPerSecond,
}),
);
});
@@ -748,6 +811,7 @@ export function setupAutoUpdates(
if (downloadInProgress && availableVersion) {
downloadInProgress = false;
downloadToastDismissed = false;
installAfterDownloadRequested = false;
emitUpdateToastState(
sendToRenderer,
createUpdateErrorToastPayload(availableVersion, error),
@@ -767,6 +831,7 @@ export function setupAutoUpdates(
downloadInProgress = false;
downloadToastDismissed = false;
if (skippedVersion === info.version) {
installAfterDownloadRequested = false;
return;
}
availableVersion = info.version;
@@ -778,6 +843,16 @@ export function setupAutoUpdates(
});
clearDeferredReminderTimer();
if (installAfterDownloadRequested && !currentToastPayload?.isPreview) {
installAfterDownloadRequested = false;
clearVisibleUpdateToast(sendToRenderer);
writeUpdaterLog(`Auto-installing downloaded update: version=${info.version}`);
setImmediate(() => {
installDownloadedUpdateNow(sendToRenderer);
});
return;
}
if (
emitUpdateToastState(sendToRenderer, createDownloadedUpdateToastPayload(info.version))
) {
+2 -2
View File
@@ -34,8 +34,8 @@ const HUD_SHADOW_BLEED_DIP = 36;
const HUD_MIN_WINDOW_WIDTH = 560;
const HUD_COMPACT_HEIGHT = 96;
const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP;
const UPDATE_TOAST_WIDTH = 420;
const UPDATE_TOAST_HEIGHT = 212;
const UPDATE_TOAST_WIDTH = 456;
const UPDATE_TOAST_HEIGHT = 252;
const UPDATE_TOAST_GAP_DIP = 18;
let hudOverlayExpanded = false;
+1 -789
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -22,7 +22,7 @@
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"rebuild:native": "node ./node_modules/electron-rebuild/lib/src/cli.js --force --only uiohook-napi",
"rebuild:native": "node ./node_modules/@electron/rebuild/lib/cli.js --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
"build:whisper-runtime": "node scripts/build-whisper-runtime.mjs",
"build:platform-native-helpers": "npm run build:native-helpers && npm run build:windows-capture && npm run build:cursor-monitor && npm run build:whisper-runtime",
@@ -74,7 +74,7 @@
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"@electron/rebuild": "^4.0.3",
"emoji-picker-react": "^4.16.1",
"fast-check": "^4.5.2",
"fix-webm-duration": "^1.0.6",
+40 -63
View File
@@ -35,66 +35,6 @@
padding-right: 2px;
}
.updateBadge {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 12px;
border-radius: 11px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.01em;
transition: all 0.15s ease;
cursor: pointer;
flex-shrink: 0;
}
.updateBadge:disabled {
opacity: 0.72;
cursor: default;
}
.updateBadgeQuiet {
color: #a5b4c7;
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.035);
}
.updateBadgeQuiet:hover:not(:disabled) {
color: #d7dee8;
background: rgba(255, 255, 255, 0.06);
}
.updateBadgeHot {
color: #f8fbff;
border-color: rgba(125, 211, 252, 0.24);
background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.updateBadgeHot:hover:not(:disabled) {
color: #ffffff;
border-color: rgba(125, 211, 252, 0.36);
background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07));
transform: translateY(-1px);
}
.updateBadgeSpin {
animation: updateBadgeSpin 0.9s linear infinite;
}
@keyframes updateBadgeSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.barState {
display: flex;
align-items: center;
@@ -135,11 +75,11 @@
}
.ibActive {
color: #6360f5;
color: #3d8bff;
}
.ibActive:hover {
color: #7b78ff;
color: #62a4ff;
}
.ibRed {
@@ -274,7 +214,44 @@
}
.ddItemSelected {
color: #6360f5;
color: #3d8bff;
}
.finalizingState {
display: inline-flex;
align-items: center;
gap: 11px;
min-width: 238px;
color: #eeeef2;
}
.finalizingSpin {
color: #3d8bff;
animation: finalizingSpin 0.9s linear infinite;
}
.finalizingCopy {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
font-weight: 700;
line-height: 1.15;
}
.finalizingCopy small {
color: #8a8a96;
font-size: 10px;
font-weight: 600;
}
@keyframes finalizingSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.recBtn {
+19 -128
View File
@@ -1,7 +1,5 @@
import {
AppWindow,
ArrowCircleUp as ArrowUpCircle,
CheckCircle as CheckCircle2,
CaretUp as ChevronUp,
Eye,
EyeSlash as EyeOff,
@@ -165,6 +163,7 @@ export function LaunchWindow() {
const {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,
@@ -208,24 +207,6 @@ export function LaunchWindow() {
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
status:
| "idle"
| "checking"
| "up-to-date"
| "available"
| "downloading"
| "ready"
| "error";
currentVersion: string;
availableVersion: string | null;
detail?: string;
}>({
status: "idle",
currentVersion: "",
availableVersion: null,
});
const [updateActionPending, setUpdateActionPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
@@ -714,31 +695,6 @@ export function LaunchWindow() {
void preparePermissions({ startup: true });
}, [preparePermissions]);
useEffect(() => {
let mounted = true;
const refreshUpdateStatus = async () => {
try {
const summary = await window.electronAPI.getUpdateStatusSummary();
if (mounted) {
setUpdateStatus(summary);
}
} catch (error) {
console.error("Failed to load update status summary:", error);
}
};
void refreshUpdateStatus();
const pollTimer = window.setInterval(() => {
void refreshUpdateStatus();
}, 2500);
return () => {
mounted = false;
window.clearInterval(pollTimer);
};
}, []);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
@@ -1009,74 +965,6 @@ export function LaunchWindow() {
toggleDropdown("webcam");
};
const updateButtonLabel =
updateStatus.status === "up-to-date"
? t("recording.update.updated")
: t("recording.update.update");
const updateButtonTitle = (() => {
switch (updateStatus.status) {
case "up-to-date":
return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", {
version: updateStatus.currentVersion,
});
case "available":
case "ready":
return updateStatus.availableVersion
? t("recording.update.availableTitle", "Recordly {{version}} is available.", {
version: updateStatus.availableVersion,
})
: t("recording.update.availableGenericTitle");
case "downloading":
return updateStatus.detail ?? t("recording.update.downloadingTitle");
case "checking":
return t("recording.update.checkingTitle");
case "error":
return updateStatus.detail ?? t("recording.update.errorTitle");
default:
return t("recording.update.idleTitle");
}
})();
const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`;
const updateButtonIcon = (() => {
switch (updateStatus.status) {
case "up-to-date":
return <CheckCircle2 size={14} />;
case "checking":
case "downloading":
return <RefreshCw size={14} className={styles.updateBadgeSpin} />;
default:
return <ArrowUpCircle size={14} />;
}
})();
const handleUpdateButtonClick = async () => {
if (updateActionPending || updateStatus.status === "downloading") {
return;
}
setUpdateActionPending(true);
try {
switch (updateStatus.status) {
case "available":
await window.electronAPI.downloadAvailableUpdate();
break;
case "ready":
await window.electronAPI.installDownloadedUpdate();
break;
default:
await window.electronAPI.checkForAppUpdates();
break;
}
const summary = await window.electronAPI.getUpdateStatusSummary();
setUpdateStatus(summary);
} catch (error) {
console.error("Failed to handle update button action:", error);
} finally {
setUpdateActionPending(false);
}
};
const recordingControls = (
<>
<div className="flex items-center gap-[5px]">
@@ -1238,6 +1126,18 @@ export function LaunchWindow() {
</>
);
const finalizingControls = (
<div className={styles.finalizingState}>
<RefreshCw size={15} className={styles.finalizingSpin} />
<div className={styles.finalizingCopy}>
<span>{t("recording.preparing", "Preparing recording")}</span>
<small>{t("recording.preparingSubtitle", "Opening the editor in a moment")}</small>
</div>
</div>
);
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
return (
<div
className="w-full flex items-end justify-center bg-transparent overflow-visible pb-5"
@@ -1640,23 +1540,10 @@ export function LaunchWindow() {
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<button
type="button"
onClick={() => {
void handleUpdateButtonClick();
}}
className={updateButtonClassName}
title={updateButtonTitle}
disabled={updateActionPending}
>
{updateButtonIcon}
<span>{updateButtonLabel}</span>
</button>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={recording ? "recording" : "idle"}
key={hudMode}
layout={!showRecordingWebcamPreview}
className={styles.barState}
initial={{
@@ -1679,7 +1566,11 @@ export function LaunchWindow() {
}}
transition={hudStateTransition}
>
{recording ? recordingControls : idleControls}
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
+241 -287
View File
@@ -4,7 +4,7 @@ import {
Spinner as LoaderCircle,
Rocket,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
type UpdateToastPayload = {
version: string;
@@ -13,61 +13,73 @@ type UpdateToastPayload = {
delayMs: number;
isPreview?: boolean;
progressPercent?: number;
primaryAction?: "download-update" | "install-update" | "retry-check";
transferredBytes?: number;
totalBytes?: number;
remainingBytes?: number;
bytesPerSecond?: number;
primaryAction?: "install-and-restart" | "retry-check";
};
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
const DEFAULT_REMINDER_DELAY_MS = 3 * 60 * 60 * 1000;
const REMINDER_OPTIONS = [
{ label: "1 hour", value: 1 * 60 * 60 * 1000 },
{ label: "3 hours", value: 3 * 60 * 60 * 1000 },
{ label: "Tomorrow", value: 24 * 60 * 60 * 1000 },
{ label: "3 days", value: 3 * 24 * 60 * 60 * 1000 },
];
function formatDelayHours(delayMs: number) {
const hours = Math.max(1, Math.round(delayMs / (60 * 60 * 1000)));
return `${hours}h`;
function formatBytes(value: number | undefined) {
if (value === undefined || !Number.isFinite(value) || value <= 0) {
return null;
}
const megabytes = value / (1024 * 1024);
if (megabytes >= 1024) {
return `${(megabytes / 1024).toFixed(1)} GB`;
}
return `${megabytes.toFixed(megabytes >= 100 ? 0 : 1)} MB`;
}
function getToastTitle(payload: UpdateToastPayload) {
if (payload.isPreview) {
return "Update Toast Preview";
return "Update Prompt Preview";
}
switch (payload.phase) {
case "available":
return `Recordly ${payload.version} is available`;
case "downloading":
return `Downloading Recordly ${payload.version}`;
return `Installing Recordly ${payload.version}`;
case "ready":
return `Recordly ${payload.version} is ready`;
case "error":
return `Recordly ${payload.version} needs attention`;
return payload.primaryAction === "retry-check"
? "Could not check for updates"
: `Recordly ${payload.version} needs attention`;
}
}
function getPrimaryActionLabel(payload: UpdateToastPayload) {
switch (payload.primaryAction) {
case "download-update":
return "Download Update";
case "install-update":
return "Install Update";
case "retry-check":
return "Retry Check";
default:
return null;
function getPrimaryButtonLabel(payload: UpdateToastPayload) {
return payload.primaryAction === "retry-check" ? "Try Again" : "Install & Restart";
}
function getPhaseIcon(payload: UpdateToastPayload) {
switch (payload.phase) {
case "available":
return <Download size={20} />;
case "downloading":
return <LoaderCircle size={20} className="animate-spin" />;
case "ready":
return <Rocket size={20} />;
case "error":
return <AlertCircle size={20} />;
}
}
export function UpdateToastWindow() {
const [payload, setPayload] = useState<UpdateToastPayload | null>(null);
const [dragOffsetX, setDragOffsetX] = useState(0);
const dragResetKey = payload
? `${payload.phase}:${payload.version}:${payload.progressPercent ?? ""}:${payload.detail}:${payload.delayMs}:${payload.isPreview ? "1" : "0"}:${payload.primaryAction ?? ""}`
: "empty";
const dragState = useRef<{
pointerId: number | null;
startX: number;
active: boolean;
}>({
pointerId: null,
startX: 0,
active: false,
});
const [reminderDelayMs, setReminderDelayMs] = useState(DEFAULT_REMINDER_DELAY_MS);
useEffect(() => {
let mounted = true;
@@ -81,11 +93,9 @@ export function UpdateToastWindow() {
pollTimer = setInterval(() => {
void window.electronAPI.getCurrentUpdateToastPayload().then((nextPayload) => {
if (!mounted || !nextPayload) {
return;
if (mounted) {
setPayload(nextPayload);
}
setPayload((currentPayload) => currentPayload ?? nextPayload);
});
}, 750);
@@ -103,214 +113,160 @@ export function UpdateToastWindow() {
}, []);
useEffect(() => {
if (!dragResetKey) {
if (!payload) {
return;
}
setDragOffsetX(0);
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
}, [dragResetKey]);
setReminderDelayMs(payload.delayMs || DEFAULT_REMINDER_DELAY_MS);
}, [payload]);
const cardStyle = {
background: "#0d1117",
border: "1px solid rgba(125, 211, 252, 0.22)",
boxShadow: "0 24px 48px rgba(0, 0, 0, 0.45)",
borderRadius: 24,
padding: 16,
color: "#ffffff",
width: "100%",
maxWidth: 404,
display: "flex",
gap: 12,
alignItems: "flex-start",
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
} as const;
const normalizedProgress = Math.max(
0,
Math.min(100, Math.round(payload?.progressPercent ?? 0)),
);
const downloadedLabel = formatBytes(payload?.transferredBytes);
const totalLabel = formatBytes(payload?.totalBytes);
const remainingLabel = formatBytes(payload?.remainingBytes);
const speedLabel = formatBytes(payload?.bytesPerSecond);
const phaseStats: Array<{ label: string; value: string }> = [];
if (payload?.phase === "downloading") {
if (downloadedLabel && totalLabel) {
phaseStats.push({ label: "Downloaded", value: `${downloadedLabel} / ${totalLabel}` });
} else if (downloadedLabel) {
phaseStats.push({ label: "Downloaded", value: downloadedLabel });
}
if (remainingLabel) {
phaseStats.push({ label: "Left", value: remainingLabel });
}
if (speedLabel) {
phaseStats.push({ label: "Speed", value: `${speedLabel}/s` });
}
}
const isMacOS = /mac/i.test(navigator.platform);
const wrapperStyle = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
padding: 8,
padding: 10,
boxSizing: "border-box",
background: "transparent",
background: isMacOS ? "transparent" : "#0b1220",
} as const;
const secondaryTextStyle = {
color: "rgba(255, 255, 255, 0.74)",
fontSize: 14,
lineHeight: 1.45,
margin: "4px 0 0 0",
} as const;
const titleStyle = {
fontSize: 14,
fontWeight: 700,
lineHeight: 1.2,
margin: 0,
const cardStyle = {
width: "100%",
maxWidth: 440,
display: "flex",
gap: 14,
alignItems: "flex-start",
padding: "18px 18px 16px",
borderRadius: 24,
background:
"linear-gradient(180deg, rgba(12, 19, 34, 0.98) 0%, rgba(10, 17, 30, 0.98) 100%)",
border: "1px solid rgba(37, 99, 235, 0.24)",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.5), inset 0 1px 0 rgba(148, 163, 184, 0.08)",
color: "#ffffff",
fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif',
} as const;
const iconBoxStyle = {
width: 40,
height: 40,
minWidth: 40,
width: 42,
height: 42,
minWidth: 42,
borderRadius: 16,
background: "rgba(125, 211, 252, 0.15)",
color: "#7dd3fc",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: 2,
background: "rgba(37, 99, 235, 0.16)",
color: "#60a5fa",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.18)",
} as const;
const rowStyle = {
display: "flex",
flexWrap: "wrap" as const,
gap: 8,
marginTop: 12,
const titleStyle = {
fontSize: 15,
fontWeight: 700,
lineHeight: 1.25,
margin: 0,
color: "#f8fafc",
} as const;
const secondaryTextStyle = {
color: "rgba(226, 232, 240, 0.78)",
fontSize: 13,
lineHeight: 1.5,
margin: "6px 0 0 0",
} as const;
const subtleButtonStyle = {
border: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.92)",
height: 38,
borderRadius: 12,
padding: "8px 12px",
fontSize: 12,
padding: "0 14px",
border: "1px solid rgba(148, 163, 184, 0.16)",
background: "rgba(15, 23, 42, 0.72)",
color: "#e2e8f0",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
transition: "all 0.15s ease",
} as const;
const primaryButtonStyle = {
...subtleButtonStyle,
background: "#7dd3fc",
color: "#031a2c",
border: "none",
background: "linear-gradient(180deg, #3b82f6 0%, #2563eb 100%)",
color: "#ffffff",
boxShadow: "0 12px 24px rgba(37, 99, 235, 0.26)",
} as const;
const ghostButtonStyle = {
...subtleButtonStyle,
background: "transparent",
color: "rgba(255, 255, 255, 0.72)",
border: "1px solid rgba(125, 211, 252, 0.16)",
const selectStyle = {
height: 38,
borderRadius: 12,
padding: "0 34px 0 12px",
border: "1px solid rgba(37, 99, 235, 0.22)",
background:
"linear-gradient(180deg, rgba(18, 29, 51, 0.96) 0%, rgba(12, 22, 42, 0.96) 100%)",
color: "#dbeafe",
fontSize: 13,
fontWeight: 600,
outline: "none",
boxShadow: "inset 0 0 0 1px rgba(37, 99, 235, 0.06)",
cursor: "pointer",
} as const;
if (!payload) {
return (
<div style={wrapperStyle}>
<div style={{ ...cardStyle, alignItems: "center" }}>
<div style={iconBoxStyle}>
<LoaderCircle size={20} />
</div>
<div>
<p style={titleStyle}>Checking for updates</p>
<p style={secondaryTextStyle}>
Waiting for updater state from the main process.
</p>
</div>
</div>
</div>
);
}
const normalizedProgress = Math.max(0, Math.min(100, Math.round(payload.progressPercent ?? 0)));
const primaryActionLabel = getPrimaryActionLabel(payload);
const swipeThreshold = 96;
const handleSwipeDismiss = async () => {
setDragOffsetX(0);
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
await window.electronAPI.dismissUpdateToast();
};
const handlePrimaryAction = async () => {
switch (payload.primaryAction) {
case "download-update":
await window.electronAPI.downloadAvailableUpdate();
return;
case "install-update":
await window.electronAPI.installDownloadedUpdate();
return;
case "retry-check":
await window.electronAPI.checkForAppUpdates();
return;
default:
return;
if (!payload || payload.phase === "downloading") {
return;
}
if (payload.primaryAction === "retry-check") {
await window.electronAPI.checkForAppUpdates();
return;
}
if (payload.phase === "ready") {
await window.electronAPI.installDownloadedUpdate();
return;
}
await window.electronAPI.downloadAvailableUpdate(true);
};
const handleLater = async () => {
if (!payload) {
return;
}
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(reminderDelayMs);
};
if (!payload) {
return <div style={wrapperStyle} />;
}
return (
<div style={wrapperStyle}>
<div
className="pointer-events-auto select-none"
style={{
...cardStyle,
transform: `translateX(${dragOffsetX}px) rotate(${dragOffsetX / 30}deg)`,
opacity: Math.max(0.35, 1 - Math.min(1, Math.abs(dragOffsetX) / 180)),
}}
onPointerDown={(event) => {
const target = event.target as HTMLElement | null;
if (target?.closest("button")) {
return;
}
dragState.current = {
pointerId: event.pointerId,
startX: event.clientX,
active: true,
};
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (
!dragState.current.active ||
dragState.current.pointerId !== event.pointerId
) {
return;
}
setDragOffsetX(event.clientX - dragState.current.startX);
}}
onPointerUp={async (event) => {
if (
!dragState.current.active ||
dragState.current.pointerId !== event.pointerId
) {
return;
}
const nextOffset = event.clientX - dragState.current.startX;
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
if (Math.abs(nextOffset) >= swipeThreshold) {
await handleSwipeDismiss();
return;
}
setDragOffsetX(0);
}}
onPointerCancel={() => {
dragState.current = {
pointerId: null,
startX: 0,
active: false,
};
setDragOffsetX(0);
}}
>
<div style={iconBoxStyle}>
{payload.phase === "available" ? <Download size={20} /> : null}
{payload.phase === "downloading" ? (
<LoaderCircle size={20} className="animate-spin" />
) : null}
{payload.phase === "ready" ? <Rocket size={20} /> : null}
{payload.phase === "error" ? <AlertCircle size={20} /> : null}
</div>
<div style={cardStyle}>
<div style={iconBoxStyle}>{getPhaseIcon(payload)}</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<p style={titleStyle}>{getToastTitle(payload)}</p>
@@ -318,14 +274,14 @@ export function UpdateToastWindow() {
<span
style={{
borderRadius: 999,
border: "1px solid rgba(125, 211, 252, 0.2)",
background: "rgba(125, 211, 252, 0.1)",
padding: "2px 8px",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "#bae6fd",
color: "#93c5fd",
background: "rgba(37, 99, 235, 0.14)",
border: "1px solid rgba(37, 99, 235, 0.18)",
}}
>
Dev
@@ -335,104 +291,102 @@ export function UpdateToastWindow() {
<p style={secondaryTextStyle}>{payload.detail}</p>
{payload.phase === "downloading" ? (
<div style={{ marginTop: 12 }}>
<div style={{ marginTop: 14 }}>
<div
style={{
height: 8,
height: 10,
overflow: "hidden",
borderRadius: 999,
background: "rgba(255, 255, 255, 0.1)",
background: "rgba(148, 163, 184, 0.14)",
}}
>
<div
style={{
height: "100%",
borderRadius: 999,
background: "#7dd3fc",
width: `${normalizedProgress}%`,
borderRadius: 999,
background:
"linear-gradient(90deg, #60a5fa 0%, #2563eb 45%, #1d4ed8 100%)",
boxShadow: "0 0 22px rgba(37, 99, 235, 0.38)",
}}
/>
</div>
<p
<div
style={{
margin: "8px 0 0 0",
color: "rgba(224, 242, 254, 0.9)",
fontSize: 12,
fontWeight: 600,
display: "flex",
flexWrap: "wrap",
gap: 8,
marginTop: 10,
}}
>
{normalizedProgress}% downloaded
</p>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: "#dbeafe",
}}
>
{normalizedProgress}% complete
</span>
{phaseStats.map((stat) => (
<span
key={stat.label}
style={{
fontSize: 11,
fontWeight: 600,
color: "rgba(191, 219, 254, 0.9)",
background: "rgba(37, 99, 235, 0.12)",
borderRadius: 999,
padding: "4px 8px",
border: "1px solid rgba(37, 99, 235, 0.16)",
}}
>
{stat.label}: {stat.value}
</span>
))}
</div>
</div>
) : null}
<div style={rowStyle}>
{primaryActionLabel ? (
<button
type="button"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
{primaryActionLabel}
</button>
) : null}
{payload.phase === "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.dismissUpdateToast();
}}
style={subtleButtonStyle}
>
Hide
</button>
) : null}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
marginTop: 14,
alignItems: "center",
}}
>
{payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(payload.delayMs);
}}
style={subtleButtonStyle}
>
Later ({formatDelayHours(payload.delayMs)})
</button>
) : null}
{payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
if (payload.isPreview) {
await window.electronAPI.dismissUpdateToast();
return;
}
await window.electronAPI.deferDownloadedUpdate(THREE_DAYS_MS);
}}
style={subtleButtonStyle}
>
Later (3 days)
</button>
) : null}
{!payload.isPreview && payload.phase !== "downloading" ? (
<button
type="button"
onClick={async () => {
await window.electronAPI.skipUpdateVersion();
}}
style={ghostButtonStyle}
>
Skip This Version
</button>
<>
<button
type="button"
onClick={handlePrimaryAction}
style={primaryButtonStyle}
>
{getPrimaryButtonLabel(payload)}
</button>
<select
value={String(reminderDelayMs)}
onChange={(event) => {
setReminderDelayMs(Number.parseInt(event.target.value, 10));
}}
style={selectStyle}
>
{REMINDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
type="button"
onClick={handleLater}
style={subtleButtonStyle}
>
Later
</button>
</>
) : null}
</div>
</div>
+29 -13
View File
@@ -149,6 +149,7 @@ import {
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_OVERLAY,
DEFAULT_WEBCAM_TIME_OFFSET_MS,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
@@ -1569,6 +1570,7 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
});
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath);
@@ -1695,7 +1697,7 @@ export default function VideoEditor() {
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
const syncRecordingSessionWebcam = useCallback(
async (webcamPath: string | null) => {
async (webcamPath: string | null, timeOffsetMs?: number) => {
if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) {
return;
}
@@ -1703,9 +1705,15 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession({
videoPath: currentSourcePath,
webcamPath,
timeOffsetMs:
webcamPath && Number.isFinite(timeOffsetMs)
? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS)
: webcamPath
? webcam.timeOffsetMs
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
});
},
[currentSourcePath],
[currentSourcePath, webcam.timeOffsetMs],
);
const syncActiveVideoSource = useCallback(
@@ -1714,13 +1722,14 @@ export default function VideoEditor() {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath,
timeOffsetMs: webcam.timeOffsetMs,
});
return;
}
await window.electronAPI.setCurrentVideoPath(sourcePath);
},
[],
[webcam.timeOffsetMs],
);
const handleUploadWebcam = useCallback(async () => {
@@ -1733,9 +1742,10 @@ export default function VideoEditor() {
...prev,
enabled: true,
sourcePath: result.path ?? null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
await syncRecordingSessionWebcam(result.path);
await syncRecordingSessionWebcam(result.path, DEFAULT_WEBCAM_TIME_OFFSET_MS);
toast.success(t("settings.effects.webcamFootageAdded"));
}, [syncRecordingSessionWebcam, t]);
@@ -1744,6 +1754,7 @@ export default function VideoEditor() {
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
await syncRecordingSessionWebcam(null);
@@ -1838,6 +1849,7 @@ export default function VideoEditor() {
...prev,
enabled: !!smokeWebcamSourcePath,
sourcePath: smokeWebcamSourcePath,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
shadow:
smokeExportConfig.webcamShadow === undefined
? prev.shadow
@@ -1896,6 +1908,8 @@ export default function VideoEditor() {
...prev,
enabled: Boolean(sessionResult.session?.webcamPath),
sourcePath: sessionResult.session?.webcamPath ?? null,
timeOffsetMs:
sessionResult.session?.timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
return;
}
@@ -1913,6 +1927,7 @@ export default function VideoEditor() {
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
} else {
setError("No video to load. Please record or select a video.");
@@ -3594,14 +3609,14 @@ export default function VideoEditor() {
}
}
for (const audioPath of previewSourceAudioFallbackPaths) {
let audio = existing.get(audioPath);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
existing.set(audioPath, audio);
}
audio.dataset.sourceAudioPath = audioPath;
for (const audioPath of previewSourceAudioFallbackPaths) {
let audio = existing.get(audioPath);
if (!audio) {
audio = new Audio();
audio.preload = "auto";
existing.set(audioPath, audio);
}
audio.dataset.sourceAudioPath = audioPath;
if (sourceAudioElementResourcesRef.current.get(audioPath) !== audioPath) {
audio.pause();
@@ -5476,7 +5491,8 @@ export default function VideoEditor() {
audioRegions.length > 0
? Math.max(
...audioRegions.map(
(region) => region.trackIndex ?? 0,
(region) =>
region.trackIndex ?? 0,
),
) + 1
: 0;
+19 -13
View File
@@ -101,6 +101,7 @@ import {
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_SWAY,
DEFAULT_PADDING,
DEFAULT_WEBCAM_CORNER_RADIUS,
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_SHADOW,
@@ -110,7 +111,6 @@ import {
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_OUT_EASING,
DEFAULT_PADDING,
getDefaultCaptionFontFamily,
} from "./types";
import {
@@ -124,6 +124,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -1232,10 +1233,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
const targetTime = clampMediaTimeToDuration(
const targetTime = getWebcamPreviewTargetTimeSeconds({
currentTime,
Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
);
webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
timeOffsetMs: webcam.timeOffsetMs,
});
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
@@ -1470,7 +1472,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
layoutVideoContent();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking } =
const { handlePlay, handlePause, handleSeeked, handleSeeking, dispose } =
createVideoEventHandlers({
video,
isSeekingRef,
@@ -1496,10 +1498,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
video.removeEventListener("ended", handlePause);
video.removeEventListener("seeked", handleSeeked);
video.removeEventListener("seeking", handleSeeking);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
dispose();
if (videoSprite) {
videoContainer.removeChild(videoSprite);
@@ -1749,9 +1748,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
// through an intermediate RenderTexture at renderer resolution, which
// downsamples the native video and degrades preview quality.
// Hysteresis prevents flickering when motionIntensity oscillates near threshold.
const filtersActive = Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
const cameraIsMoving = filtersActive ? motionIntensity > 0.002 : motionIntensity > 0.008;
const needsFilters = zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
const filtersActive =
Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
const cameraIsMoving = filtersActive
? motionIntensity > 0.002
: motionIntensity > 0.008;
const needsFilters =
zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
if (needsFilters && !filtersActive && motionBlurFilterRef.current) {
videoContainer.filters = [motionBlurFilterRef.current];
} else if (!needsFilters && filtersActive) {
@@ -2451,12 +2454,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
})()}
</div>
)}
{/* Keep the source video off-screen instead of display:none so the
browser continues producing presented frames for Pixi and preview sync. */}
<video
ref={videoRef}
src={videoPath}
className="hidden"
className="pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"
preload="metadata"
playsInline
aria-hidden="true"
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={(e) => {
onDurationChange(e.currentTarget.duration);
+4 -4
View File
@@ -350,10 +350,10 @@ export interface Padding {
}
export const DEFAULT_PADDING: Padding = {
top: 50,
bottom: 50,
left: 50,
right: 50,
top: 20,
bottom: 20,
left: 20,
right: 20,
linked: true,
};
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/extensions", () => ({
extensionHost: {
emitEvent: vi.fn(),
},
}));
import { extensionHost } from "@/lib/extensions";
import { createVideoEventHandlers } from "./videoEventHandlers";
type PresentedFrameCallback = (now: DOMHighResTimeStamp, metadata: { mediaTime?: number }) => void;
type MockVideo = HTMLVideoElement & {
requestVideoFrameCallback?: (callback: PresentedFrameCallback) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
function createMutableRef<T>(value: T) {
return { current: value };
}
function createMockVideo(overrides: Partial<MockVideo> = {}): MockVideo {
const video = {
currentTime: 0.5,
duration: 10,
paused: false,
ended: false,
playbackRate: 1,
pause: vi.fn(),
} as unknown as MockVideo;
return Object.assign(video, overrides);
}
describe("createVideoEventHandlers", () => {
const emitEventMock = vi.mocked(extensionHost.emitEvent);
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
let cancelAnimationFrameMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
requestAnimationFrameMock = vi.fn(() => 11);
cancelAnimationFrameMock = vi.fn();
vi.stubGlobal("requestAnimationFrame", requestAnimationFrameMock);
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameMock);
emitEventMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("prefers requestVideoFrameCallback mediaTime when available", () => {
let presentedFrameCallback: PresentedFrameCallback | null = null;
const video = createMockVideo({
requestVideoFrameCallback: vi.fn((callback) => {
presentedFrameCallback = callback;
return 7;
}),
cancelVideoFrameCallback: vi.fn(),
});
const onPlayStateChange = vi.fn();
const onTimeUpdate = vi.fn();
const currentTimeRef = createMutableRef(0);
const timeUpdateAnimationRef = createMutableRef<number | null>(null);
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
expect(onPlayStateChange).toHaveBeenCalledWith(true);
expect(video.requestVideoFrameCallback).toHaveBeenCalledTimes(1);
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
presentedFrameCallback?.(0, { mediaTime: 1.25 });
expect(onTimeUpdate).toHaveBeenCalledWith(1.25);
expect(currentTimeRef.current).toBe(1250);
expect(emitEventMock).toHaveBeenLastCalledWith({
type: "playback:timeupdate",
timeMs: 1250,
});
});
it("falls back to requestAnimationFrame when requestVideoFrameCallback is unavailable", () => {
let animationFrameCallback: FrameRequestCallback | null = null;
requestAnimationFrameMock.mockImplementation((callback: FrameRequestCallback) => {
animationFrameCallback = callback;
return 19;
});
const video = createMockVideo({ currentTime: 0.75 });
const onTimeUpdate = vi.fn();
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef: createMutableRef(0),
timeUpdateAnimationRef: createMutableRef<number | null>(null),
onPlayStateChange: vi.fn(),
onTimeUpdate,
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
expect(requestAnimationFrameMock).toHaveBeenCalledTimes(1);
video.paused = true;
animationFrameCallback?.(0);
expect(onTimeUpdate).toHaveBeenCalledWith(0.75);
});
it("cancels a pending requestVideoFrameCallback on pause and dispose", () => {
const cancelVideoFrameCallback = vi.fn();
const video = createMockVideo({
requestVideoFrameCallback: vi.fn(() => 23),
cancelVideoFrameCallback,
});
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(false),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef: createMutableRef(0),
timeUpdateAnimationRef: createMutableRef<number | null>(null),
onPlayStateChange: vi.fn(),
onTimeUpdate: vi.fn(),
trimRegionsRef: createMutableRef([]),
speedRegionsRef: createMutableRef([]),
});
handlers.handlePlay();
handlers.handlePause();
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
cancelVideoFrameCallback.mockClear();
handlers.handlePlay();
handlers.dispose();
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
});
});
@@ -2,6 +2,17 @@ import type React from "react";
import { extensionHost } from "@/lib/extensions";
import type { SpeedRegion, TrimRegion } from "../types";
interface PresentedFrameMetadata {
mediaTime?: number;
}
type PresentedFrameVideoElement = HTMLVideoElement & {
requestVideoFrameCallback?: (
callback: (now: DOMHighResTimeStamp, metadata: PresentedFrameMetadata) => void,
) => number;
cancelVideoFrameCallback?: (handle: number) => void;
};
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
@@ -28,6 +39,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
trimRegionsRef,
speedRegionsRef,
} = params;
const presentedFrameVideo = video as PresentedFrameVideoElement;
let videoFrameRequestId: number | null = null;
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
@@ -66,10 +79,54 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
}
};
function updateTime() {
const cancelScheduledUpdate = () => {
if (timeUpdateAnimationRef.current !== null) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
if (
videoFrameRequestId !== null &&
typeof presentedFrameVideo.cancelVideoFrameCallback === "function"
) {
presentedFrameVideo.cancelVideoFrameCallback(videoFrameRequestId);
videoFrameRequestId = null;
}
};
const scheduleNextUpdate = () => {
if (video.paused || video.ended) {
return;
}
// Align editor state with the frame Chromium actually presented instead of
// polling `currentTime` on a generic animation frame.
if (typeof presentedFrameVideo.requestVideoFrameCallback === "function") {
videoFrameRequestId = presentedFrameVideo.requestVideoFrameCallback(
(_now, metadata) => {
videoFrameRequestId = null;
updateTime(metadata);
},
);
return;
}
timeUpdateAnimationRef.current = requestAnimationFrame(() => {
timeUpdateAnimationRef.current = null;
updateTime();
});
};
function getPresentedTime(metadata?: PresentedFrameMetadata): number {
const mediaTime = metadata?.mediaTime;
return Number.isFinite(mediaTime) ? (mediaTime ?? 0) : video.currentTime;
}
function updateTime(metadata?: PresentedFrameMetadata) {
if (!video) return;
const currentTimeMs = video.currentTime * 1000;
const presentedTime = getPresentedTime(metadata);
const currentTimeMs = presentedTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we're in a trim region during playback, skip to the end of it
@@ -79,12 +136,10 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
// Apply playback speed from active speed region
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
emitTime(presentedTime);
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
scheduleNextUpdate();
}
const handlePlay = () => {
@@ -95,19 +150,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
isPlayingRef.current = true;
onPlayStateChange(true);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
cancelScheduledUpdate();
scheduleNextUpdate();
};
const handlePause = () => {
isPlayingRef.current = false;
onPlayStateChange(false);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
cancelScheduledUpdate();
emitTime(video.currentTime);
};
@@ -131,6 +181,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
};
return {
dispose: cancelScheduledUpdate,
handlePlay,
handlePause,
handleSeeked,
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { getWebcamPreviewTargetTimeSeconds } from "./webcamSync";
describe("getWebcamPreviewTargetTimeSeconds", () => {
it("applies positive webcam offsets", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 10,
webcamDuration: 20,
timeOffsetMs: 250,
}),
).toBe(10.25);
});
it("clamps negative webcam offsets to zero", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 0.1,
webcamDuration: 20,
timeOffsetMs: -250,
}),
).toBe(0);
});
it("falls back to the unshifted time when the offset is invalid", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 3.5,
webcamDuration: 20,
timeOffsetMs: Number.NaN,
}),
).toBe(3.5);
});
it("clamps to the webcam duration", () => {
expect(
getWebcamPreviewTargetTimeSeconds({
currentTime: 8.9,
webcamDuration: 9,
timeOffsetMs: 500,
}),
).toBe(9);
});
});
@@ -0,0 +1,15 @@
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
export function getWebcamPreviewTargetTimeSeconds({
currentTime,
webcamDuration,
timeOffsetMs,
}: {
currentTime: number;
webcamDuration?: number | null;
timeOffsetMs?: number | null;
}): number {
const safeOffsetMs = Number.isFinite(timeOffsetMs) ? (timeOffsetMs ?? 0) : 0;
const shiftedTime = currentTime + safeOffsetMs / 1000;
return clampMediaTimeToDuration(shiftedTime, webcamDuration);
}
+15 -31
View File
@@ -59,6 +59,7 @@ type DesktopCaptureMediaDevices = {
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
finalizing: boolean;
countdownActive: boolean;
toggleRecording: () => void;
pauseRecording: () => void;
@@ -114,6 +115,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [starting, setStarting] = useState(false);
const [finalizing, setFinalizing] = useState(false);
const [countdownActive, setCountdownActive] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
@@ -149,36 +151,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const accumulatedPausedDurationMs = useRef(0);
const pauseStartedAtMs = useRef<number | null>(null);
const pauseSegmentsRef = useRef<PauseSegment[]>([]);
const recordingFinalizationToastId = useRef<string | number | null>(null);
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
const micFallbackChunks = useRef<Blob[]>([]);
const micFallbackStartDelayMs = useRef<number | null>(null);
const showRecordingFinalizationToast = useCallback((message = "Preparing recording...") => {
recordingFinalizationToastId.current = toast.loading(message, {
id: recordingFinalizationToastId.current ?? undefined,
duration: Number.POSITIVE_INFINITY,
});
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
setFinalizing(false);
toast.error(message, { duration: 10000 });
}, []);
const clearRecordingFinalizationToast = useCallback(() => {
const toastId = recordingFinalizationToastId.current;
if (toastId === null) {
return;
}
toast.dismiss(toastId);
recordingFinalizationToastId.current = null;
}, []);
const notifyRecordingFinalizationFailure = useCallback(
async (message: string) => {
clearRecordingFinalizationToast();
toast.error(message, { duration: 10000 });
},
[clearRecordingFinalizationToast],
);
const logNativeCaptureDiagnostics = useCallback(async (context: string) => {
if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") {
return;
@@ -436,10 +417,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
clearRecordingFinalizationToast();
setFinalizing(false);
await window.electronAPI.switchToEditor();
},
[clearRecordingFinalizationToast],
[],
);
const stopMicFallbackRecorder = useCallback((): Promise<Blob | null> => {
@@ -690,9 +671,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeScreenRecording.current) {
nativeScreenRecording.current = false;
setRecording(false);
setFinalizing(true);
void (async () => {
showRecordingFinalizationToast();
const fallbackStartDelayMs = micFallbackStartDelayMs.current;
const micFallbackBlobPromise = stopMicFallbackRecorder();
const webcamPath = await stopWebcamRecorder();
@@ -787,6 +768,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cleanupCapturedMedia();
recorder.stop();
setRecording(false);
setFinalizing(true);
window.electronAPI?.setRecordingState(false);
}
});
@@ -1341,9 +1323,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
recorder.onstop = async () => {
cleanupCapturedMedia();
if (chunks.current.length === 0) return;
showRecordingFinalizationToast();
if (chunks.current.length === 0) {
setFinalizing(false);
return;
}
const duration = getRecordingDurationMs(Date.now());
const recordedChunks = chunks.current;
@@ -1530,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, [cleanupCapturedMedia, markRecordingResumed, recording]);
const toggleRecording = async () => {
if (starting || countdownActive) {
if (starting || countdownActive || finalizing) {
return;
}
@@ -1558,6 +1541,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,