Merge origin/main into feat/export-caption-sidecars

This commit is contained in:
webadderall
2026-06-22 18:05:52 +10:00
17 changed files with 673 additions and 480 deletions
+101 -80
View File
@@ -1,22 +1,22 @@
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "dev.recordly.app",
"electronUpdaterCompatibility": ">=2.16",
"asar": true,
"asarUnpack": [
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "dev.recordly.app",
"electronUpdaterCompatibility": ">=2.16",
"asar": true,
"asarUnpack": [
"node_modules/ffmpeg-static/**",
"node_modules/ffprobe-static/**",
"node_modules/uiohook-napi/**",
"electron/native/**"
],
"productName": "Recordly",
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "normal",
"directories": {
"output": "release"
},
"electron/native/**"
],
"productName": "Recordly",
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "normal",
"directories": {
"output": "release"
},
"files": [
"dist",
"dist-electron",
@@ -27,67 +27,88 @@
"!electron/native/**/build/**",
"!*.png",
"!preview*.png",
"!*.md",
"!README.md",
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [
{
"provider": "github",
"owner": "webadderall",
"repo": "Recordly",
"tagNamePrefix": "v",
"publishAutoUpdate": true
}
],
"mac": {
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-${arch}.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "Recordly needs audio capture permission to record system audio.",
"NSCameraUsageDescription": "Recordly needs camera access to record webcam video.",
"NSMicrophoneUsageDescription": "Recordly needs microphone access to record voice audio.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
},
"linux": {
"target": [
"AppImage"
],
"icon": "icons/icons/png",
"artifactName": "${productName}-linux-x64.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
,
"executableName": "Recordly",
"artifactName": "${productName}-windows-${arch}.${ext}"
}
}
"!*.md",
"!README.md",
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [
{
"provider": "github",
"owner": "webadderall",
"repo": "Recordly",
"tagNamePrefix": "v",
"publishAutoUpdate": true
}
],
"mac": {
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-${arch}.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "Recordly needs audio capture permission to record system audio.",
"NSCameraUsageDescription": "Recordly needs camera access to record webcam video.",
"NSMicrophoneUsageDescription": "Recordly needs microphone access to record voice audio.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true,
"CFBundleDocumentTypes": [
{
"CFBundleTypeName": "Recordly Project",
"CFBundleTypeRole": "Editor",
"CFBundleTypeExtensions": ["recordly"],
"CFBundleTypeIconFile": "icon.icns",
"LSHandlerRank": "Owner",
"LSItemContentTypes": ["dev.recordly.app.project"]
}
],
"UTExportedTypeDeclarations": [
{
"UTTypeIdentifier": "dev.recordly.app.project",
"UTTypeDescription": "Recordly Project",
"UTTypeConformsTo": ["public.json"],
"UTTypeTagSpecification": {
"public.filename-extension": ["recordly"],
"public.mime-type": "application/x-recordly-project"
}
}
]
}
},
"linux": {
"target": [
"AppImage"
],
"icon": "icons/icons/png",
"artifactName": "${productName}-linux-x64.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
,
"executableName": "Recordly",
"artifactName": "${productName}-windows-${arch}.${ext}"
}
}
+11 -1
View File
@@ -649,7 +649,16 @@ interface Window {
error?: string;
canceled?: boolean;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{
success: boolean;
kind?: "media" | "project";
path?: string;
project?: unknown;
extension?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
openWhisperExecutablePicker: () => Promise<{
success: boolean;
@@ -743,6 +752,7 @@ interface Window {
projectData: unknown,
projectName: string,
thumbnailDataUrl?: string | null,
mode?: "rename" | "copy",
) => Promise<{
success: boolean;
path?: string;
+33 -6
View File
@@ -53,7 +53,9 @@ describe("local media path policy", () => {
await fs.mkdir(downloadsPath, { recursive: true });
await fs.writeFile(exportPath, "test-video");
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import("./manager");
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
"./manager"
);
await expect(isAllowedLocalMediaPath(exportPath)).resolves.toBe(false);
@@ -71,7 +73,9 @@ describe("local media path policy", () => {
it("allows approved media paths before the file exists", async () => {
const pendingExportPath = path.join(tempRoot, "Downloads", "pending-export.mp4");
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import("./manager");
const { isAllowedLocalMediaPath, rememberApprovedLocalReadPath } = await import(
"./manager"
);
await rememberApprovedLocalReadPath(pendingExportPath);
@@ -131,7 +135,9 @@ describe("local media path policy", () => {
throw error;
}
const { isAllowedLocalMediaPath, resolveApprovedLocalMediaPath } = await import("./manager");
const { isAllowedLocalMediaPath, resolveApprovedLocalMediaPath } = await import(
"./manager"
);
await expect(isAllowedLocalMediaPath(symlinkInsideUserData)).resolves.toBe(false);
await expect(resolveApprovedLocalMediaPath(symlinkInsideUserData)).resolves.toBeNull();
@@ -173,6 +179,29 @@ describe("local media path policy", () => {
expect(result.project).toMatchObject({ videoPath });
});
it("rejects invalid project payloads before approving media paths", async () => {
const downloadsPath = path.join(tempRoot, "Downloads");
const videoPath = path.join(downloadsPath, "recording.mp4");
const projectPath = path.join(tempPath, "invalid.recordly");
await fs.mkdir(downloadsPath, { recursive: true });
await fs.writeFile(videoPath, "test-video");
await fs.writeFile(
projectPath,
JSON.stringify({
videoPath,
editor: {},
}),
"utf-8",
);
const { loadProjectFromPath, resolveApprovedLocalMediaPath } = await import("./manager");
const result = await loadProjectFromPath(projectPath);
expect(result.success).toBe(false);
expect(result.message).toBe("Invalid project file format");
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBeNull();
});
it("approves editor audioRegions audioPath entries when loading a project", async () => {
const downloadsPath = path.join(tempRoot, "Downloads");
const videoPath = path.join(tempPath, "recording.mp4");
@@ -187,9 +216,7 @@ describe("local media path policy", () => {
version: 1,
videoPath,
editor: {
audioRegions: [
{ id: "a1", startMs: 0, endMs: 1000, audioPath, volume: 1 },
],
audioRegions: [{ id: "a1", startMs: 0, endMs: 1000, audioPath, volume: 1 }],
},
}),
"utf-8",
+30
View File
@@ -403,6 +403,29 @@ export async function listProjectLibraryEntries() {
};
}
function isLoadableProjectData(projectData: unknown) {
if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
return false;
}
const candidate = projectData as {
version?: unknown;
projectId?: unknown;
videoPath?: unknown;
editor?: unknown;
};
return (
typeof candidate.version === "number" &&
(candidate.projectId === undefined || typeof candidate.projectId === "string") &&
typeof candidate.videoPath === "string" &&
candidate.videoPath.trim().length > 0 &&
candidate.editor != null &&
typeof candidate.editor === "object" &&
!Array.isArray(candidate.editor)
);
}
export async function loadProjectFromPath(projectPath: string) {
const normalizedPath = normalizePath(projectPath);
let project: unknown;
@@ -416,6 +439,13 @@ export async function loadProjectFromPath(projectPath: string) {
message: `Failed to read project file: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!isLoadableProjectData(project)) {
return {
success: false,
canceled: false,
message: "Invalid project file format",
};
}
const mediaSources = await resolveProjectMediaSources(project);
if (!mediaSources.success) {
+229 -181
View File
@@ -1,207 +1,255 @@
import path from "node:path";
import { dialog, ipcMain } from "electron";
import { setCurrentProjectPath } from "../state";
import { generateAutoCaptionsFromVideo } from "../captions/generate";
import {
getWhisperSmallModelStatus,
downloadWhisperSmallModel,
deleteWhisperSmallModel,
downloadWhisperSmallModel,
getWhisperSmallModelStatus,
sendWhisperModelDownloadProgress,
} from "../captions/whisper";
import { generateAutoCaptionsFromVideo } from "../captions/generate";
import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants";
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];
type OpenVideoFilePickerOptions = {
includeProjects?: boolean;
};
export function registerCaptionHandlers() {
ipcMain.handle('open-video-file-picker', async () => {
try {
const recordingsDir = await getRecordingsDir()
const result = await dialog.showOpenDialog({
title: 'Select Video File',
defaultPath: recordingsDir,
filters: [
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
});
ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => {
try {
const includeProjects = Boolean(options?.includeProjects);
const recordingsDir = await getRecordingsDir();
const result = await dialog.showOpenDialog({
title: includeProjects ? "Import Media or Recordly Project" : "Select Video File",
defaultPath: recordingsDir,
filters: [
...(includeProjects
? [
{
name: "Media or Recordly Projects",
extensions: [
...VIDEO_FILE_EXTENSIONS,
...PROJECT_FILE_EXTENSIONS,
],
},
]
: []),
{ name: "Video Files", extensions: VIDEO_FILE_EXTENSIONS },
...(includeProjects
? [{ name: "Recordly Projects", extensions: PROJECT_FILE_EXTENSIONS }]
: []),
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
approveUserPath(result.filePaths[0])
setCurrentProjectPath(null)
return {
success: true,
path: result.filePaths[0]
};
} catch (error) {
console.error('Failed to open file picker:', error);
return {
success: false,
message: 'Failed to open file picker',
error: String(error)
};
}
});
const selectedPath = result.filePaths[0];
ipcMain.handle('open-audio-file-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Audio File',
filters: [
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
});
if (includeProjects && hasProjectFileExtension(selectedPath)) {
const projectResult = await loadProjectFromPath(selectedPath);
return projectResult.success
? { ...projectResult, kind: "project" }
: projectResult;
}
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
approveUserPath(selectedPath);
setCurrentProjectPath(null);
return {
success: true,
kind: "media",
path: selectedPath,
extension: path.extname(selectedPath).replace(/^\./, "").toLowerCase(),
};
} catch (error) {
console.error("Failed to open file picker:", error);
return {
success: false,
message: "Failed to open file picker",
error: String(error),
};
}
});
approveUserPath(result.filePaths[0])
return {
success: true,
path: result.filePaths[0]
};
} catch (error) {
console.error('Failed to open audio file picker:', error);
return {
success: false,
message: 'Failed to open audio file picker',
error: String(error)
};
}
});
ipcMain.handle("open-audio-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Audio File",
filters: [
{
name: "Audio Files",
extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"],
},
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
ipcMain.handle('open-whisper-executable-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Whisper Executable',
filters: [
{ name: 'Executables', extensions: process.platform === 'win32' ? ['exe', 'cmd', 'bat'] : ['*'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
approveUserPath(result.filePaths[0]);
return {
success: true,
path: result.filePaths[0],
};
} catch (error) {
console.error("Failed to open audio file picker:", error);
return {
success: false,
message: "Failed to open audio file picker",
error: String(error),
};
}
});
approveUserPath(result.filePaths[0])
return { success: true, path: result.filePaths[0] }
} catch (error) {
console.error('Failed to open Whisper executable picker:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle("open-whisper-executable-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Whisper Executable",
filters: [
{
name: "Executables",
extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"],
},
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
ipcMain.handle('open-whisper-model-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Whisper Model',
filters: [
{ name: 'Whisper Models', extensions: ['bin'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true }
}
approveUserPath(result.filePaths[0]);
return { success: true, path: result.filePaths[0] };
} catch (error) {
console.error("Failed to open Whisper executable picker:", error);
return { success: false, error: String(error) };
}
});
approveUserPath(result.filePaths[0])
return { success: true, path: result.filePaths[0] }
} catch (error) {
console.error('Failed to open Whisper model picker:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle("open-whisper-model-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Whisper Model",
filters: [
{ name: "Whisper Models", extensions: ["bin"] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
ipcMain.handle('get-whisper-small-model-status', async () => {
try {
return await getWhisperSmallModelStatus()
} catch (error) {
return { success: false, exists: false, path: null, error: String(error) }
}
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
ipcMain.handle('download-whisper-small-model', async (event) => {
try {
const existing = await getWhisperSmallModelStatus()
if (existing.exists) {
sendWhisperModelDownloadProgress(event.sender, {
status: 'downloaded',
progress: 100,
path: existing.path,
})
return { success: true, path: existing.path, alreadyDownloaded: true }
}
approveUserPath(result.filePaths[0]);
return { success: true, path: result.filePaths[0] };
} catch (error) {
console.error("Failed to open Whisper model picker:", error);
return { success: false, error: String(error) };
}
});
const modelPath = await downloadWhisperSmallModel(event.sender)
return { success: true, path: modelPath }
} catch (error) {
console.error('Failed to download Whisper small model:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle("get-whisper-small-model-status", async () => {
try {
return await getWhisperSmallModelStatus();
} catch (error) {
return { success: false, exists: false, path: null, error: String(error) };
}
});
ipcMain.handle('delete-whisper-small-model', async (event) => {
try {
await deleteWhisperSmallModel()
sendWhisperModelDownloadProgress(event.sender, {
status: 'idle',
progress: 0,
path: null,
})
return { success: true }
} catch (error) {
console.error('Failed to delete Whisper small model:', error)
// Verify whether the file was actually removed despite the error
const status = await getWhisperSmallModelStatus()
if (!status.exists) {
// File is gone — treat as success
sendWhisperModelDownloadProgress(event.sender, {
status: 'idle',
progress: 0,
path: null,
})
return { success: true }
}
sendWhisperModelDownloadProgress(event.sender, {
status: 'error',
progress: 0,
path: null,
error: String(error),
})
return { success: false, error: String(error) }
}
})
ipcMain.handle("download-whisper-small-model", async (event) => {
try {
const existing = await getWhisperSmallModelStatus();
if (existing.exists) {
sendWhisperModelDownloadProgress(event.sender, {
status: "downloaded",
progress: 100,
path: existing.path,
});
return { success: true, path: existing.path, alreadyDownloaded: true };
}
ipcMain.handle('generate-auto-captions', async (_, options: {
videoPath: string
whisperExecutablePath: string
whisperModelPath: string
language?: string
}) => {
try {
const result = await generateAutoCaptionsFromVideo(options)
return {
success: true,
cues: result.cues,
message: result.audioSourceLabel === 'recording'
? `Generated ${result.cues.length} caption cues.`
: `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
}
} catch (error) {
console.error('Failed to generate auto captions:', error)
return {
success: false,
error: String(error),
message: 'Failed to generate auto captions',
}
}
})
const modelPath = await downloadWhisperSmallModel(event.sender);
return { success: true, path: modelPath };
} catch (error) {
console.error("Failed to download Whisper small model:", error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("delete-whisper-small-model", async (event) => {
try {
await deleteWhisperSmallModel();
sendWhisperModelDownloadProgress(event.sender, {
status: "idle",
progress: 0,
path: null,
});
return { success: true };
} catch (error) {
console.error("Failed to delete Whisper small model:", error);
// Verify whether the file was actually removed despite the error
const status = await getWhisperSmallModelStatus();
if (!status.exists) {
// File is gone — treat as success
sendWhisperModelDownloadProgress(event.sender, {
status: "idle",
progress: 0,
path: null,
});
return { success: true };
}
sendWhisperModelDownloadProgress(event.sender, {
status: "error",
progress: 0,
path: null,
error: String(error),
});
return { success: false, error: String(error) };
}
});
ipcMain.handle(
"generate-auto-captions",
async (
_,
options: {
videoPath: string;
whisperExecutablePath: string;
whisperModelPath: string;
language?: string;
},
) => {
try {
const result = await generateAutoCaptionsFromVideo(options);
return {
success: true,
cues: result.cues,
message:
result.audioSourceLabel === "recording"
? `Generated ${result.cues.length} caption cues.`
: `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
};
} catch (error) {
console.error("Failed to generate auto captions:", error);
return {
success: false,
error: String(error),
message: "Failed to generate auto captions",
};
}
},
);
}
-115
View File
@@ -252,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean {
return candidate.startsWith(withSep);
}
type CaptionSidecarCue = {
startMs: number;
endMs: number;
text: string;
};
type CaptionSidecarPayload = {
format: "srt" | "vtt" | "both";
cues: CaptionSidecarCue[];
};
function toSrtTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
}
function toVttTimestamp(totalMs: number): string {
const ms = Math.max(0, Math.round(totalMs));
const hours = Math.floor(ms / 3_600_000);
const minutes = Math.floor((ms % 3_600_000) / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
const millis = ms % 1000;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
}
function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
if (!Array.isArray(cues)) {
return [];
}
return cues
.filter((cue): cue is CaptionSidecarCue => {
return (
typeof cue === "object" &&
cue !== null &&
typeof cue.startMs === "number" &&
typeof cue.endMs === "number" &&
typeof cue.text === "string" &&
Number.isFinite(cue.startMs) &&
Number.isFinite(cue.endMs) &&
cue.endMs > cue.startMs &&
cue.text.trim().length > 0
);
})
.map((cue) => ({
startMs: cue.startMs,
endMs: cue.endMs,
text: cue.text.replace(/\r\n/g, "\n").trim(),
}));
}
function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
if (typeof payload !== "object" || payload === null) {
return null;
}
const candidate = payload as {
format?: unknown;
cues?: unknown;
};
const format =
candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
? candidate.format
: null;
if (!format) {
return null;
}
const cues = normalizeCaptionSidecarCues(candidate.cues);
if (cues.length === 0) {
return null;
}
return { format, cues };
}
function serializeSrt(cues: CaptionSidecarCue[]): string {
return cues
.map((cue, index) => {
return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
}
function serializeVtt(cues: CaptionSidecarCue[]): string {
const body = cues
.map((cue) => {
return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
})
.join("\n\n");
return `WEBVTT\n\n${body}`;
}
async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) {
if (!payload) {
return;
}
const parsed = path.parse(videoPath);
const basePath = path.join(parsed.dir, parsed.name);
if (payload.format === "srt" || payload.format === "both") {
await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
}
if (payload.format === "vtt" || payload.format === "both") {
await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
}
}
export function registerExportHandlers() {
ipcMain.handle(
"native-video-export-start",
+4 -2
View File
@@ -671,8 +671,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
captionSidecar,
);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
openVideoFilePicker: (options?: { includeProjects?: boolean }) => {
return ipcRenderer.invoke("open-video-file-picker", options);
},
openAudioFilePicker: () => {
return ipcRenderer.invoke("open-audio-file-picker");
@@ -783,12 +783,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
projectData: unknown,
projectName: string,
thumbnailDataUrl?: string | null,
mode?: "rename" | "copy",
) => {
return ipcRenderer.invoke(
"save-project-file-named",
projectData,
projectName,
thumbnailDataUrl,
mode,
);
},
loadProjectFile: () => {
@@ -19,8 +19,12 @@ export function useLaunchWindowActions() {
}, []);
const openVideoFile = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker();
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
if (result.canceled) return;
if (result.success && result.kind === "project") {
await window.electronAPI.switchToEditor();
return;
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
@@ -4,11 +4,27 @@ import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import { type AnnotationRegion, BASE_PREVIEW_WIDTH, BLUR_ANNOTATION_STRENGTH } from "./types";
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type SceneTransform = {
scale: number;
x: number;
y: number;
};
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
isSelected: boolean;
containerWidth: number;
containerHeight: number;
recordingRect: Rect;
sceneTransform: SceneTransform;
interactionScale?: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onClick: (id: string) => void;
@@ -16,24 +32,69 @@ interface AnnotationOverlayProps {
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
}
function clampPercent(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(100, Math.max(0, value));
}
export function AnnotationOverlay({
annotation,
isSelected,
containerWidth,
containerHeight,
recordingRect,
sceneTransform,
interactionScale = 1,
onPositionChange,
onSizeChange,
onClick,
zIndex,
isSelectedBoost,
}: AnnotationOverlayProps) {
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const safeRecordingRect =
recordingRect.width > 0 && recordingRect.height > 0
? recordingRect
: { x: 0, y: 0, width: containerWidth, height: containerHeight };
const sceneX = safeRecordingRect.x + (annotation.position.x / 100) * safeRecordingRect.width;
const sceneY = safeRecordingRect.y + (annotation.position.y / 100) * safeRecordingRect.height;
const sceneWidth = (annotation.size.width / 100) * safeRecordingRect.width;
const sceneHeight = (annotation.size.height / 100) * safeRecordingRect.height;
const x = sceneX * sceneTransform.scale + sceneTransform.x;
const y = sceneY * sceneTransform.scale + sceneTransform.y;
const width = sceneWidth * sceneTransform.scale;
const height = sceneHeight * sceneTransform.scale;
const sizeScale = safeRecordingRect.width / BASE_PREVIEW_WIDTH;
const blurScaleFactor = sizeScale * sceneTransform.scale;
const isDraggingRef = useRef(false);
const screenRectToRecordingPercent = (rect: Rect) => {
const nextSceneX = (rect.x - sceneTransform.x) / sceneTransform.scale;
const nextSceneY = (rect.y - sceneTransform.y) / sceneTransform.scale;
const nextSceneWidth = rect.width / sceneTransform.scale;
const nextSceneHeight = rect.height / sceneTransform.scale;
return {
position: {
x: clampPercent(
((nextSceneX - safeRecordingRect.x) / Math.max(1, safeRecordingRect.width)) *
100,
),
y: clampPercent(
((nextSceneY - safeRecordingRect.y) / Math.max(1, safeRecordingRect.height)) *
100,
),
},
size: {
width: clampPercent((nextSceneWidth / Math.max(1, safeRecordingRect.width)) * 100),
height: clampPercent((nextSceneHeight / Math.max(1, safeRecordingRect.height)) * 100),
},
};
};
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
const color = annotation.figureData?.color || "#2563EB";
@@ -48,7 +109,7 @@ export function AnnotationOverlay({
case "text":
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
className="w-full h-full flex items-center overflow-hidden"
style={{
justifyContent:
annotation.style.textAlign === "left"
@@ -57,13 +118,14 @@ export function AnnotationOverlay({
? "flex-end"
: "center",
alignItems: "center",
padding: `${8 * sceneTransform.scale}px`,
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize}px`,
fontSize: `${annotation.style.fontSize * sceneTransform.scale}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
@@ -74,7 +136,7 @@ export function AnnotationOverlay({
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
padding: "0.1em 0.2em",
borderRadius: "4px",
borderRadius: `${4 * sceneTransform.scale}px`,
lineHeight: "1.4",
}}
>
@@ -116,9 +178,8 @@ export function AnnotationOverlay({
);
case "blur": {
const previewScaleFactor = containerWidth / BASE_PREVIEW_WIDTH;
const currentBlurStrength = annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH;
const blurPx = currentBlurStrength * previewScaleFactor;
const blurPx = currentBlurStrength * blurScaleFactor;
const blurStyle = `blur(${blurPx}px)`;
return (
@@ -128,7 +189,7 @@ export function AnnotationOverlay({
backdropFilter: blurStyle,
WebkitBackdropFilter: blurStyle,
backgroundColor: annotation.blurColor || "transparent",
borderRadius: `${(annotation.style.borderRadius ?? 0) * previewScaleFactor}px`,
borderRadius: `${(annotation.style.borderRadius ?? 0) * blurScaleFactor}px`,
}}
/>
);
@@ -143,13 +204,13 @@ export function AnnotationOverlay({
<Rnd
position={{ x, y }}
size={{ width, height }}
scale={interactionScale}
onDragStart={() => {
isDraggingRef.current = true;
}}
onDragStop={(_e, d) => {
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
const next = screenRectToRecordingPercent({ x: d.x, y: d.y, width, height });
onPositionChange(annotation.id, next.position);
// Reset dragging flag after a short delay to prevent click event
setTimeout(() => {
@@ -157,12 +218,14 @@ export function AnnotationOverlay({
}, 100);
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
const next = screenRectToRecordingPercent({
x: position.x,
y: position.y,
width: ref.offsetWidth,
height: ref.offsetHeight,
});
onPositionChange(annotation.id, next.position);
onSizeChange(annotation.id, next.size);
}}
onClick={() => {
if (isDraggingRef.current) return;
@@ -15,6 +15,7 @@ type ProjectBrowserDialogProps = {
onOpenChange: (open: boolean) => void;
entries: ProjectLibraryEntry[];
onOpenProject: (projectPath: string) => void;
onImportFile?: () => void;
anchorRef?: React.RefObject<HTMLElement | null>;
preferredDirection?: "up" | "down" | "auto";
onPanelHeightChange?: (height: number) => void;
@@ -25,6 +26,7 @@ export default function ProjectBrowserDialog({
onOpenChange,
entries,
onOpenProject,
onImportFile,
anchorRef,
preferredDirection = "auto",
onPanelHeightChange,
@@ -172,10 +174,21 @@ export default function ProjectBrowserDialog({
ref={panelRef}
role="dialog"
aria-label="Projects"
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
>
<div className="border-b border-foreground/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-foreground">
Projects
</div>
{onImportFile ? (
<button
type="button"
onClick={onImportFile}
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
>
Import
</button>
) : null}
</div>
<div className="max-h-[360px] overflow-y-auto px-2.5 py-2.5">
{visibleEntries.length > 0 ? (
@@ -242,8 +255,19 @@ export default function ProjectBrowserDialog({
style={{ top: `${position.top}px`, left: `${position.left}px` }}
className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150"
>
<div className="border-b border-foreground/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-foreground">
Projects
</div>
{onImportFile ? (
<button
type="button"
onClick={onImportFile}
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
>
Import
</button>
) : null}
</div>
<div
className="overflow-y-auto px-2.5 py-2.5"
@@ -3924,7 +3924,7 @@ export function SettingsPanel({
height: previewFrame
? getCropMatchedWebcamHeightPercent(
webcamWidth,
webcamWidth,
webcamHeight,
previewFrame.width,
previewFrame.height,
cropRegion,
@@ -64,4 +64,58 @@ describe("hasUnsavedProjectChanges", () => {
expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true);
});
it("ignores transient webcam media attachment changes", () => {
const saved = createProjectData({
editor: {
...createProjectData().editor,
webcam: {
enabled: false,
sourcePath: null,
timeOffsetMs: 0,
size: 28,
},
},
});
const current = createProjectData({
editor: {
...createProjectData().editor,
webcam: {
enabled: true,
sourcePath: "/Users/test/webcam.mp4",
timeOffsetMs: 125,
size: 28,
},
},
});
expect(hasUnsavedProjectChanges(current, saved)).toBe(false);
});
it("detects persistent webcam presentation changes", () => {
const saved = createProjectData({
editor: {
...createProjectData().editor,
webcam: {
enabled: true,
sourcePath: "/Users/test/webcam.mp4",
timeOffsetMs: 0,
size: 28,
},
},
});
const current = createProjectData({
editor: {
...createProjectData().editor,
webcam: {
enabled: true,
sourcePath: "/Users/test/webcam.mp4",
timeOffsetMs: 0,
size: 36,
},
},
});
expect(hasUnsavedProjectChanges(current, saved)).toBe(true);
});
});
@@ -42,12 +42,43 @@ function areDeepEqual(left: unknown, right: unknown): boolean {
return true;
}
function omitTransientWebcamMediaFields(project: EditorProjectData | null) {
if (!project?.editor || typeof project.editor !== "object") {
return project;
}
const editor = project.editor as Record<string, unknown>;
const webcam = editor.webcam;
if (!isComparableObject(webcam)) {
return project;
}
const {
enabled: _enabled,
sourcePath: _sourcePath,
timeOffsetMs: _timeOffsetMs,
...persistentWebcamFields
} = webcam;
return {
...project,
editor: {
...editor,
webcam: persistentWebcamFields,
},
};
}
export function hasUnsavedProjectChanges(
currentProjectSnapshot: EditorProjectData | null,
lastSavedSnapshot: EditorProjectData | null,
): boolean {
const comparableCurrentSnapshot = omitTransientWebcamMediaFields(currentProjectSnapshot);
const comparableLastSavedSnapshot = omitTransientWebcamMediaFields(lastSavedSnapshot);
return Boolean(
currentProjectSnapshot &&
(!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
comparableCurrentSnapshot &&
(!comparableLastSavedSnapshot ||
!areDeepEqual(comparableCurrentSnapshot, comparableLastSavedSnapshot)),
);
}
@@ -6,7 +6,7 @@ import {
ZOOM_OUT_EARLY_START_MS,
} from "./constants";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutZoom } from "./mathUtils";
import { clamp01, easeOutZoom } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1350;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
@@ -34,14 +34,6 @@ type ConnectedPanTransition = {
endScale: number;
};
function lerp(start: number, end: number, amount: number) {
return start + (end - start) * amount;
}
function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
export function computeRegionStrength(
region: ZoomRegion,
timeMs: number,
@@ -79,13 +71,6 @@ export function computeRegionStrength(
return 1 - easeOutZoom(progress);
}
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
return {
cx: lerp(start.cx, end.cx, amount),
cy: lerp(start.cy, end.cy, amount),
};
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
}
@@ -199,44 +184,6 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
if (timeMs < transitionStart || timeMs > transitionEnd) {
continue;
}
const transitionProgress = easeConnectedPan(
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
);
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
region: {
...nextRegion,
focus: transitionFocus,
},
strength: 1,
blendedScale: transitionScale,
transition: {
progress: transitionProgress,
startFocus: currentFocus,
endFocus: nextFocus,
startScale: currentScale,
endScale: nextScale,
},
};
}
return null;
}
export function findDominantRegion(
regions: ZoomRegion[],
timeMs: number,
+47 -8
View File
@@ -8,6 +8,35 @@ export interface AnnotationRenderAssets {
imageCache: Map<string, HTMLImageElement>;
}
interface AnnotationSceneTransform {
scale: number;
x: number;
y: number;
}
interface AnnotationCoordinateRect {
x: number;
y: number;
width: number;
height: number;
}
function transformAnnotationRect(
rect: { x: number; y: number; width: number; height: number },
sceneTransform?: AnnotationSceneTransform,
) {
if (!sceneTransform) {
return rect;
}
return {
x: rect.x * sceneTransform.scale + sceneTransform.x,
y: rect.y * sceneTransform.scale + sceneTransform.y,
width: rect.width * sceneTransform.scale,
height: rect.height * sceneTransform.scale,
};
}
const annotationImagePromiseCache = new Map<string, Promise<HTMLImageElement | null>>();
let blurBufferCanvas: HTMLCanvasElement | null = null;
@@ -334,22 +363,32 @@ export async function renderAnnotations(
currentTimeMs: number,
scaleFactor: number = 1.0,
assets?: AnnotationRenderAssets,
sceneTransform?: AnnotationSceneTransform,
coordinateRect?: AnnotationCoordinateRect,
): Promise<void> {
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
);
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
const annotationRect = coordinateRect ?? { x: 0, y: 0, width: canvasWidth, height: canvasHeight };
for (const annotation of sortedAnnotations) {
const x = (annotation.position.x / 100) * canvasWidth;
const y = (annotation.position.y / 100) * canvasHeight;
const width = (annotation.size.width / 100) * canvasWidth;
const height = (annotation.size.height / 100) * canvasHeight;
const rect = transformAnnotationRect(
{
x: annotationRect.x + (annotation.position.x / 100) * annotationRect.width,
y: annotationRect.y + (annotation.position.y / 100) * annotationRect.height,
width: (annotation.size.width / 100) * annotationRect.width,
height: (annotation.size.height / 100) * annotationRect.height,
},
sceneTransform,
);
const { x, y, width, height } = rect;
const effectiveScaleFactor = scaleFactor * (sceneTransform?.scale ?? 1);
switch (annotation.type) {
case "text":
renderText(ctx, annotation, x, y, width, height, scaleFactor);
renderText(ctx, annotation, x, y, width, height, effectiveScaleFactor);
break;
case "image":
@@ -367,20 +406,20 @@ export async function renderAnnotations(
y,
width,
height,
scaleFactor,
effectiveScaleFactor,
);
}
break;
case "blur": {
const blurStrength =
(annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * scaleFactor;
(annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * effectiveScaleFactor;
const padding = Math.ceil(blurStrength * 2);
ctx.save();
ctx.beginPath();
const borderRadius = (annotation.style.borderRadius ?? 0) * scaleFactor;
const borderRadius = (annotation.style.borderRadius ?? 0) * effectiveScaleFactor;
ctx.roundRect(x, y, width, height, borderRadius);
ctx.clip();
+3 -3
View File
@@ -448,14 +448,14 @@ export class FrameRenderer {
massMultiplier: this.config.cursorSpringMassMultiplier,
},
motionBlur: this.config.cursorMotionBlur ?? 0,
clickEffect:
this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect,
clickEffect: this.config.cursorClickEffect ?? DEFAULT_CURSOR_CONFIG.clickEffect,
clickEffectColor:
this.config.cursorClickEffectColor ?? DEFAULT_CURSOR_CONFIG.clickEffectColor,
clickEffectScale:
this.config.cursorClickEffectScale ?? DEFAULT_CURSOR_CONFIG.clickEffectScale,
clickEffectOpacity:
this.config.cursorClickEffectOpacity ?? DEFAULT_CURSOR_CONFIG.clickEffectOpacity,
this.config.cursorClickEffectOpacity ??
DEFAULT_CURSOR_CONFIG.clickEffectOpacity,
clickEffectDurationMs:
this.config.cursorClickEffectDurationMs ??
DEFAULT_CURSOR_CONFIG.clickEffectDurationMs,
+10 -2
View File
@@ -2917,11 +2917,19 @@ export class FrameRenderer {
const margin = webcam.margin ?? 24;
const widthPercent = webcam.width ?? webcam.size ?? 50;
const aspectSourceWidth =
liveSourceDimensions.width > 0
? liveSourceDimensions.width
: renderableWebcamSource.width;
const aspectSourceHeight =
liveSourceDimensions.height > 0
? liveSourceDimensions.height
: renderableWebcamSource.height;
const heightPercent = getCropMatchedWebcamHeightPercent(
widthPercent,
webcam.height ?? webcam.size ?? 50,
renderableWebcamSource.width,
renderableWebcamSource.height,
aspectSourceWidth,
aspectSourceHeight,
webcam.cropRegion,
);
const dimensions = getWebcamOverlayDimensionsPx({