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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ interface Window {
getAssetBasePath: () => Promise<string | null>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openRecordingHud: () => Promise<{ success: boolean }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource>;
showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>;
Expand Down Expand Up @@ -593,6 +594,7 @@ interface Window {
onRecordingSessionChanged: (
callback: (session: RendererRecordingSessionData | null) => void,
) => () => void;
onRecordingHudClosed: (callback: () => void) => () => void;
onRecordingInterrupted: (
callback: (state: { reason: string; message: string }) => void,
) => () => void;
Expand Down Expand Up @@ -659,6 +661,12 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
stitchVideoSources: (options: { basePath: string; appendPath: string }) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
openWhisperExecutablePicker: () => Promise<{
success: boolean;
Expand Down
115 changes: 115 additions & 0 deletions electron/ipc/register/captions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { dialog, ipcMain } from "electron";
import { generateAutoCaptionsFromVideo } from "../captions/generate";
import {
Expand All @@ -8,17 +11,30 @@ import {
sendWhisperModelDownloadProgress,
} from "../captions/whisper";
import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager";
import { setCurrentProjectPath } from "../state";
import { approveUserPath, getRecordingsDir } from "../utils";

const VIDEO_FILE_EXTENSIONS = ["webm", "mp4", "mov", "avi", "mkv"];
const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS];
const execFileAsync = promisify(execFile);

type OpenVideoFilePickerOptions = {
includeProjects?: boolean;
};

function getConcatListLine(filePath: string) {
return `file '${filePath.replace(/'/g, "'\\''")}'`;
}

function isInsideDirectory(candidatePath: string, directoryPath: string) {
const relativePath = path.relative(directoryPath, candidatePath);
return Boolean(
relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath),
);
}

export function registerCaptionHandlers() {
ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => {
try {
Expand Down Expand Up @@ -79,6 +95,105 @@ export function registerCaptionHandlers() {
}
});

ipcMain.handle(
"stitch-video-sources",
async (_, options: { basePath?: string; appendPath?: string }) => {
try {
const basePath =
typeof options?.basePath === "string" ? path.resolve(options.basePath) : "";
const appendPath =
typeof options?.appendPath === "string" ? path.resolve(options.appendPath) : "";

if (!basePath || !appendPath) {
return { success: false, message: "Choose two recordings to stitch." };
}

const recordingsDir = await getRecordingsDir();
await fs.mkdir(recordingsDir, { recursive: true });
const realRecordingsDir = await fs.realpath(recordingsDir);
const realBasePath = await fs.realpath(basePath);
const realAppendPath = await fs.realpath(appendPath);

if (
!isInsideDirectory(realBasePath, realRecordingsDir) ||
!isInsideDirectory(realAppendPath, realRecordingsDir)
) {
return {
success: false,
message: "Only Recordly-managed recordings can be stitched.",
};
}

const baseExtension = path.extname(realBasePath).toLowerCase();
const appendExtension = path.extname(realAppendPath).toLowerCase();
const supportedConcatExtensions = new Set([".mp4", ".mov", ".webm"]);
if (
!supportedConcatExtensions.has(baseExtension) ||
!supportedConcatExtensions.has(appendExtension)
) {
return {
success: false,
message: "Only MP4, MOV, and WebM recordings can be stitched.",
};
}

if (baseExtension !== appendExtension) {
return {
success: false,
message:
"Recordings must use the same file format before they can be stitched.",
};
}

const timestamp = Date.now();
const outputPath = path.join(
recordingsDir,
`stitched-recording-${timestamp}${baseExtension}`,
);
const listPath = path.join(recordingsDir, `stitched-recording-${timestamp}.txt`);
const listContent = `${getConcatListLine(realBasePath)}\n${getConcatListLine(realAppendPath)}\n`;

await fs.writeFile(listPath, listContent, "utf8");

try {
await execFileAsync(
getFfmpegBinaryPath(),
[
"-y",
"-hide_banner",
"-f",
"concat",
"-safe",
"0",
"-i",
listPath,
"-c",
"copy",
outputPath,
],
{ timeout: 15 * 60 * 1000, maxBuffer: 1024 * 1024 * 16 },
);
} finally {
await fs.rm(listPath, { force: true }).catch(() => undefined);
}

approveUserPath(outputPath);
return {
success: true,
path: outputPath,
};
} catch (error) {
console.error("Failed to stitch video sources:", error);
return {
success: false,
message:
"Failed to stitch recordings. Make sure both clips are Recordly recordings with the same format and compatible encoding settings.",
error: String(error),
};
}
},
);

ipcMain.handle("open-audio-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
Expand Down
47 changes: 40 additions & 7 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,33 @@ ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
});

function showOrCreateHudOverlayWindow() {
if (!app.isReady()) {
void app.whenReady().then(() => {
showOrCreateHudOverlayWindow();
});
return null;
}

const existingHudWindow = getHudOverlayWindow();
if (existingHudWindow) {
restoreWindowSafely(existingHudWindow);
return existingHudWindow;
}

const createdHudWindow = createHudOverlayWindow();
if (!mainWindow || mainWindow.isDestroyed() || !isEditorWindow(mainWindow)) {
mainWindow = createdHudWindow;
createdHudWindow.once("closed", () => {
if (mainWindow === createdHudWindow) {
mainWindow = null;
}
});
}

return createdHudWindow;
}

function createWindow() {
if (!app.isReady()) {
void app.whenReady().then(() => {
Expand Down Expand Up @@ -272,13 +299,7 @@ function createWindow() {
}

isCreatingMainWindow = true;
const createdHudWindow = createHudOverlayWindow();
mainWindow = createdHudWindow;
createdHudWindow.once("closed", () => {
if (mainWindow === createdHudWindow) {
mainWindow = null;
}
});
showOrCreateHudOverlayWindow();
isCreatingMainWindow = false;
}

Expand Down Expand Up @@ -923,6 +944,12 @@ app.whenReady().then(async () => {
hud.close();
}

for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send("recording-hud-closed");
}
}

// 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(() => {
Expand All @@ -933,6 +960,12 @@ app.whenReady().then(async () => {
}
}, 100);
});

ipcMain.handle("open-recording-hud", () => {
const hud = showOrCreateHudOverlayWindow();
return { success: Boolean(hud && !hud.isDestroyed()) };
});

syncDockIcon();
createTray();
updateTrayMenu();
Expand Down
11 changes: 11 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
openRecordingHud: () => {
return ipcRenderer.invoke("open-recording-hud");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
Expand Down Expand Up @@ -674,6 +677,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
openVideoFilePicker: (options?: { includeProjects?: boolean }) => {
return ipcRenderer.invoke("open-video-file-picker", options);
},
stitchVideoSources: (options: { basePath: string; appendPath: string }) => {
return ipcRenderer.invoke("stitch-video-sources", options);
},
openAudioFilePicker: () => {
return ipcRenderer.invoke("open-audio-file-picker");
},
Expand Down Expand Up @@ -748,6 +754,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("recording-session-changed", listener);
return () => ipcRenderer.removeListener("recording-session-changed", listener);
},
onRecordingHudClosed: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("recording-hud-closed", listener);
return () => ipcRenderer.removeListener("recording-hud-closed", listener);
},
getCurrentRecordingSession: () => {
return ipcRenderer.invoke("get-current-recording-session");
},
Expand Down
Loading