diff --git a/electron-builder.json5 b/electron-builder.json5 index 1aaa56af..a333a872 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -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}" + } +} + diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6dc7d396..2aa748b4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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; diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts index 3fa810da..50873092 100644 --- a/electron/ipc/project/manager.test.ts +++ b/electron/ipc/project/manager.test.ts @@ -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", diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 2f6e4fc2..7828fa8b 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -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) { diff --git a/electron/ipc/register/captions.ts b/electron/ipc/register/captions.ts index 9d9b7f06..fe93afd7 100644 --- a/electron/ipc/register/captions.ts +++ b/electron/ipc/register/captions.ts @@ -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", + }; + } + }, + ); } diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index ceb53dbf..c4410a27 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -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", diff --git a/electron/preload.ts b/electron/preload.ts index 6f941e80..9a15edb9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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: () => { diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts index 90acfe57..7dba168e 100644 --- a/src/components/launch/hooks/useLaunchWindowActions.ts +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -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(); diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 4081cd21..8316468d 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -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 (
@@ -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({ { 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; diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index e9e034fd..ab71fd6b 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -15,6 +15,7 @@ type ProjectBrowserDialogProps = { onOpenChange: (open: boolean) => void; entries: ProjectLibraryEntry[]; onOpenProject: (projectPath: string) => void; + onImportFile?: () => void; anchorRef?: React.RefObject; 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" > -
-
Projects
+
+
+ Projects +
+ {onImportFile ? ( + + ) : null}
{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" > -
-
Projects
+
+
+ Projects +
+ {onImportFile ? ( + + ) : null}
{ 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); + }); }); diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts index 78fcee84..16858f12 100644 --- a/src/components/video-editor/projectDirtyState.ts +++ b/src/components/video-editor/projectDirtyState.ts @@ -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; + 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)), ); } diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 8e1cc6bf..ab1af945 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -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, diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index a8822a81..773803fb 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -8,6 +8,35 @@ export interface AnnotationRenderAssets { imageCache: Map; } +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>(); 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 { 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(); diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 1ba7f355..3fa2c1f3 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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, diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index ddb52a7d..529b2109 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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({