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
13 changes: 12 additions & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,10 @@ interface Window {
error?: string;
canceled?: boolean;
}>;
openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{
openVideoFilePicker: (options?: {
includeProjects?: boolean;
preserveProjectPath?: boolean;
}) => Promise<{
success: boolean;
kind?: "media" | "project";
path?: string;
Expand All @@ -680,6 +683,14 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
importTimelineClip: (options: { sourcePath: string; clipPath: string }) => Promise<{
success: boolean;
outputPath?: string;
sourceDurationMs?: number;
importedDurationMs?: number;
totalDurationMs?: number;
message?: string;
}>;
openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
openWhisperExecutablePicker: () => Promise<{
success: boolean;
Expand Down
2 changes: 2 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerProjectHandlers } from "./register/project";
import { registerRecordingHandlers } from "./register/recording";
import { registerSettingsHandlers } from "./register/settings";
import { registerSourceHandlers } from "./register/sources";
import { registerTimelineClipImportHandlers } from "./register/timelineClipImport";
import {
selectedSource,
setNativeScreenRecordingActive,
Expand Down Expand Up @@ -69,6 +70,7 @@ export function registerIpcHandlers(
registerAssetHandlers();
registerExportHandlers();
registerCaptionHandlers();
registerTimelineClipImportHandlers();
registerProjectHandlers();
registerSettingsHandlers();
}
4 changes: 2 additions & 2 deletions electron/ipc/project/atomicSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function writeSyncedTemporaryFile(
}
}

async function syncExistingFile(filePath: string): Promise<void> {
export async function syncExistingFile(filePath: string): Promise<void> {
const handle = await fs.open(filePath, "r+");
try {
await handle.sync();
Expand All @@ -65,7 +65,7 @@ async function syncExistingFile(filePath: string): Promise<void> {
}
}

async function syncParentDirectory(parentDir: string): Promise<void> {
export async function syncParentDirectory(parentDir: string): Promise<void> {
if (process.platform === "win32") {
return;
}
Expand Down
3 changes: 2 additions & 1 deletion electron/ipc/register/captions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_

type OpenVideoFilePickerOptions = {
includeProjects?: boolean;
preserveProjectPath?: boolean;
};

export function registerCaptionHandlers() {
Expand Down Expand Up @@ -62,7 +63,7 @@ export function registerCaptionHandlers() {
}

approveUserPath(selectedPath);
setCurrentProjectPath(null);
if (!options?.preserveProjectPath) setCurrentProjectPath(null);
return {
success: true,
kind: "media",
Expand Down
43 changes: 43 additions & 0 deletions electron/ipc/register/timelineClipImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import path from "node:path";
import { ipcMain } from "electron";
import { isAllowedLocalReadPath } from "../project/manager";
import { importTimelineClip } from "../timelineClipImport";
import { approveUserPath, normalizeVideoSourcePath } from "../utils";

export function registerTimelineClipImportHandlers() {
ipcMain.handle(
"import-timeline-clip",
async (_, options?: { sourcePath?: unknown; clipPath?: unknown }) => {
try {
const sourcePath = normalizeVideoSourcePath(
typeof options?.sourcePath === "string" ? options.sourcePath : null,
);
const clipPath = normalizeVideoSourcePath(
typeof options?.clipPath === "string" ? options.clipPath : null,
);
if (!sourcePath || !clipPath) {
return { success: false, message: "Choose a video clip to import." };
}

const resolvedSourcePath = path.resolve(sourcePath);
const resolvedClipPath = path.resolve(clipPath);
if (
!isAllowedLocalReadPath(resolvedSourcePath) ||
!isAllowedLocalReadPath(resolvedClipPath)
) {
return { success: false, message: "The selected media path is not approved." };
}

const result = await importTimelineClip(resolvedSourcePath, resolvedClipPath);
if (result.outputPath) approveUserPath(result.outputPath);
return result;
} catch (error) {
console.error("[clip-import] Failed to import timeline clip:", error);
return {
success: false,
message: error instanceof Error ? error.message : "Unable to import clip.",
};
}
},
);
}
76 changes: 76 additions & 0 deletions electron/ipc/timelineClipImport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";

vi.mock("./export/native-video", () => ({ probeNativeVideoMetadata: vi.fn() }));
vi.mock("./ffmpeg/binary", () => ({ getFfmpegBinaryPath: vi.fn(() => "ffmpeg") }));
vi.mock("./recording/diagnostics", () => ({
getCompanionAudioFallbackInfo: vi.fn(async () => ({
paths: [],
startDelayMsByPath: {},
})),
}));
vi.mock("./utils", () => ({
getRecordingsDir: vi.fn(async () => "/recordings"),
getTelemetryPathForVideo: vi.fn((videoPath: string) => `${videoPath}.cursor.json`),
}));

import { buildTimelineClipImportArgs } from "./timelineClipImport";

const source = {
width: 1919,
height: 1079,
duration: 10,
frameRate: 60,
hasAudio: true,
};
const clip = {
width: 1280,
height: 720,
duration: 2.5,
frameRate: 30,
hasAudio: false,
};

describe("buildTimelineClipImportArgs", () => {
it("normalizes both videos to the source format and supplies silence when needed", () => {
const args = buildTimelineClipImportArgs({
sourcePath: "/recordings/source.mp4",
clipPath: "/home/user/clip.mov",
outputPath: "/recordings/composite.partial.mp4",
source,
clip,
});
const filter = args[args.indexOf("-filter_complex") + 1];

expect(filter).toContain("scale=1920:1080:force_original_aspect_ratio=decrease");
expect(filter).toContain("fps=60.000");
expect(filter).toContain("[0:a:0]");
expect(filter).toContain("anullsrc=r=48000:cl=stereo,atrim=duration=2.500000");
expect(filter).toContain("concat=n=2:v=1:a=1[vout][aout]");
expect(args.slice(args.indexOf("-fps_mode"), args.indexOf("-c:v"))).toEqual([
"-fps_mode",
"cfr",
"-r",
"60.000",
]);
expect(args.at(-1)).toBe("/recordings/composite.partial.mp4");
});

it("mixes delayed companion tracks and adds their input files", () => {
const args = buildTimelineClipImportArgs({
sourcePath: "/recordings/source.mp4",
clipPath: "/home/user/clip.mov",
outputPath: "/recordings/composite.partial.mp4",
source,
clip: { ...clip, hasAudio: true },
additionalInputPaths: ["/recordings/source.mic.m4a"],
sourceAudioInputs: [{ inputIndex: 0 }, { inputIndex: 2, startDelayMs: 125 }],
clipAudioInputs: [{ inputIndex: 1 }],
});
const filter = args[args.indexOf("-filter_complex") + 1];

expect(args).toContain("/recordings/source.mic.m4a");
expect(filter).toContain("[2:a:0]");
expect(filter).toContain("adelay=125|125");
expect(filter).toContain("amix=inputs=2:duration=longest:normalize=0");
});
});
Loading