diff --git a/electron-builder.json5 b/electron-builder.json5
index 1aaa56af1..a333a8723 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 6dc7d3966..fdc19ccbc 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;
diff --git a/electron/ipc/project/manager.test.ts b/electron/ipc/project/manager.test.ts
index 3fa810da8..508730924 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 2f6e4fc21..7828fa8b6 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 9d9b7f060..fe93afd70 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";
-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']
- });
-
- 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)
- };
- }
- });
-
- 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 (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)
- };
- }
- });
-
- 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 }
- }
-
- 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-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 }
- }
-
- 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('get-whisper-small-model-status', async () => {
- try {
- return await getWhisperSmallModelStatus()
- } catch (error) {
- return { success: false, exists: false, path: null, 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 }
- }
-
- 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',
- }
- }
- })
+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 (_, 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 };
+ }
+
+ const selectedPath = result.filePaths[0];
+
+ if (includeProjects && hasProjectFileExtension(selectedPath)) {
+ const projectResult = await loadProjectFromPath(selectedPath);
+ return projectResult.success
+ ? { ...projectResult, kind: "project" }
+ : projectResult;
+ }
+
+ 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),
+ };
+ }
+ });
+
+ 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 (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),
+ };
+ }
+ });
+
+ 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 };
+ }
+
+ 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-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 };
+ }
+
+ 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("get-whisper-small-model-status", async () => {
+ try {
+ return await getWhisperSmallModelStatus();
+ } catch (error) {
+ return { success: false, exists: false, path: null, 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 };
+ }
+
+ 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/preload.ts b/electron/preload.ts
index 6f941e802..c339ad3ce 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");
diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts
index 90acfe577..7dba168e7 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 4081cd21c..8316468d8 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 e9e034fdf..ab71fd6b0 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}
void;
+};
+
async function writeSmokeExportReport(
outputPath: string | null,
report: Record
,
@@ -382,6 +387,9 @@ export default function VideoEditor() {
const [isEditingProjectName, setIsEditingProjectName] = useState(false);
const [projectNameDraft, setProjectNameDraft] = useState("");
const [isSavingProjectName, setIsSavingProjectName] = useState(false);
+ const [projectSaveDialogOpen, setProjectSaveDialogOpen] = useState(false);
+ const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState("");
+ const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -529,7 +537,7 @@ export default function VideoEditor() {
const [autoCaptionSettings, setAutoCaptionSettings] = useState(
DEFAULT_AUTO_CAPTION_SETTINGS,
);
- const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(true);
+ const [includeCaptionSidecar, setIncludeCaptionSidecar] = useState(false);
const [whisperExecutablePath, setWhisperExecutablePath] = useState(
initialEditorPreferences.whisperExecutablePath,
);
@@ -648,6 +656,7 @@ export default function VideoEditor() {
const projectBrowserTriggerRef = useRef(null);
const projectBrowserFallbackTriggerRef = useRef(null);
const projectNameInputRef = useRef(null);
+ const projectSaveDialogInputRef = useRef(null);
const nextZoomIdRef = useRef(1);
const nextClipIdRef = useRef(1);
const nextAudioIdRef = useRef(1);
@@ -668,6 +677,7 @@ export default function VideoEditor() {
const mp4SupportRequestRef = useRef(0);
const smokeExportStartedRef = useRef(false);
const projectAutosaveTimeoutRef = useRef(null);
+ const pendingProjectSaveDialogRef = useRef(null);
const projectSaveQueueRef = useRef>(Promise.resolve());
const smokeExportReadyStateRef = useRef>({});
const [historyVersion, setHistoryVersion] = useState(0);
@@ -1734,6 +1744,21 @@ export default function VideoEditor() {
};
}, [isEditingProjectName]);
+ useEffect(() => {
+ if (!projectSaveDialogOpen) {
+ return;
+ }
+
+ const frameId = window.requestAnimationFrame(() => {
+ projectSaveDialogInputRef.current?.focus();
+ projectSaveDialogInputRef.current?.select();
+ });
+
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ };
+ }, [projectSaveDialogOpen]);
+
const currentPersistedEditorState = useMemo(
() =>
buildPersistedEditorState({
@@ -2127,6 +2152,25 @@ export default function VideoEditor() {
);
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
+ const resolveProjectSaveDialog = useCallback((saved: boolean) => {
+ const pendingDialog = pendingProjectSaveDialogRef.current;
+ pendingProjectSaveDialogRef.current = null;
+ setProjectSaveDialogOpen(false);
+ setIsSavingProjectDialog(false);
+ pendingDialog?.resolve(saved);
+ }, []);
+
+ const openProjectSaveDialog = useCallback((initialName: string) => {
+ pendingProjectSaveDialogRef.current?.resolve(false);
+ setProjectSaveDialogDraft(initialName);
+ setProjectSaveDialogOpen(true);
+ setIsSavingProjectDialog(false);
+
+ return new Promise((resolve) => {
+ pendingProjectSaveDialogRef.current = { resolve };
+ });
+ }, []);
+
const syncRecordingSessionWebcam = useCallback(
async (webcamPath: string | null, timeOffsetMs?: number) => {
if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) {
@@ -2765,7 +2809,6 @@ export default function VideoEditor() {
}
setAutoCaptions(result.cues);
- setAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
toast.success(result.message || `Generated ${result.cues.length} captions`);
} catch (error) {
toast.error(getErrorMessage(error));
@@ -2788,6 +2831,14 @@ export default function VideoEditor() {
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
}, []);
+ const handleSaveAutoCaptionEdit = useCallback(
+ (target: CaptionEditTarget, text: string) => {
+ setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
+ toast.success(t("settings.captions.editSaved", "Caption updated"));
+ },
+ [t],
+ );
+
const saveProject = useCallback(
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
clearPendingProjectAutosave();
@@ -2831,6 +2882,14 @@ export default function VideoEditor() {
}
}
+ if (forceSaveAs || !targetProjectPath) {
+ if (options?.silent) {
+ return false;
+ }
+
+ return openProjectSaveDialog(projectDisplayName || fileNameBase);
+ }
+
const thumbnailDataUrl = shouldCaptureThumbnail
? await captureProjectThumbnail()
: undefined;
@@ -2891,6 +2950,8 @@ export default function VideoEditor() {
currentProjectSnapshot,
currentPersistedEditorState,
lastSavedSnapshot?.projectId,
+ openProjectSaveDialog,
+ projectDisplayName,
queueProjectSave,
refreshProjectLibrary,
remountPreview,
@@ -3013,6 +3074,38 @@ export default function VideoEditor() {
],
);
+ const handleProjectSaveDialogSubmit = useCallback(
+ async (event?: React.FormEvent) => {
+ event?.preventDefault();
+ const trimmedProjectName = projectSaveDialogDraft.trim();
+
+ if (!trimmedProjectName) {
+ toast.error("Project name is required");
+ projectSaveDialogInputRef.current?.focus();
+ return;
+ }
+
+ setIsSavingProjectDialog(true);
+ let saved = false;
+ try {
+ saved = await saveProjectWithName(trimmedProjectName);
+ } catch (error) {
+ toast.error(getErrorMessage(error));
+ } finally {
+ setIsSavingProjectDialog(false);
+ }
+
+ if (saved) {
+ resolveProjectSaveDialog(true);
+ return;
+ }
+
+ projectSaveDialogInputRef.current?.focus();
+ projectSaveDialogInputRef.current?.select();
+ },
+ [projectSaveDialogDraft, resolveProjectSaveDialog, saveProjectWithName],
+ );
+
/**
* Resets the inline project-name editor back to the current saved display name.
*/
@@ -3080,6 +3173,74 @@ export default function VideoEditor() {
[applyLoadedProject, refreshProjectLibrary],
);
+ const handleImportMediaOrProject = useCallback(async () => {
+ const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
+
+ if (result.canceled) {
+ return;
+ }
+
+ if (!result.success) {
+ toast.error(result.message || "Failed to import file");
+ return;
+ }
+
+ if (result.kind === "project" || result.project) {
+ const restored = await applyLoadedProject(result.project, result.path ?? null);
+ if (!restored) {
+ toast.error("Invalid project file format");
+ return;
+ }
+
+ setProjectBrowserOpen(false);
+ await refreshProjectLibrary();
+ toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded");
+ return;
+ }
+
+ if (!result.path) {
+ toast.error("No media file selected");
+ return;
+ }
+
+ const sourcePath = fromFileUrl(result.path);
+ const sourceVideoUrl = await resolveVideoUrl(sourcePath);
+ try {
+ videoPlaybackRef.current?.pause();
+ } catch {
+ // no-op
+ }
+
+ setIsPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
+ setVideoSourcePath(sourcePath);
+ setVideoPath(sourceVideoUrl);
+ setCurrentProjectPath(null);
+ setLastSavedSnapshot(null);
+ resetSourceScopedEditorState();
+ pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
+ ? sourceVideoUrl
+ : null;
+ setWebcam((prev) => ({
+ ...prev,
+ enabled: false,
+ sourcePath: null,
+ timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
+ }));
+ applySessionPresentation(null);
+ await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false });
+ setProjectBrowserOpen(false);
+ await refreshProjectLibrary();
+ toast.success("Media imported");
+ }, [
+ applyLoadedProject,
+ applySessionPresentation,
+ autoApplyFreshRecordingAutoZooms,
+ refreshProjectLibrary,
+ resetSourceScopedEditorState,
+ ]);
+
const handleOpenProjectBrowser = useCallback(async () => {
if (projectBrowserOpen) {
setProjectBrowserOpen(false);
@@ -3398,7 +3559,9 @@ export default function VideoEditor() {
const handlePreviewSkipBack = useCallback(() => {
const currentMs = timelinePlayheadTime * 1000;
const keyframes = timelineRef.current?.keyframes ?? [];
- const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50);
+ const previous = [...keyframes]
+ .reverse()
+ .find((keyframe) => keyframe.time < currentMs - 50);
handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5));
}, [handleSeek, timelinePlayheadTime]);
@@ -3406,9 +3569,7 @@ export default function VideoEditor() {
const currentMs = timelinePlayheadTime * 1000;
const keyframes = timelineRef.current?.keyframes ?? [];
const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50);
- handleSeek(
- next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5),
- );
+ handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5));
}, [handleSeek, timelineDuration, timelinePlayheadTime]);
const handleSelectZoom = useCallback((id: string | null) => {
@@ -5184,6 +5345,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
autoCaptions={autoCaptions}
autoCaptionSettings={autoCaptionSettings}
+ onEditAutoCaption={handleSaveAutoCaptionEdit}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
@@ -5215,21 +5377,84 @@ export default function VideoEditor() {
volume={
audio.shouldMutePreviewVideo || audio.isCurrentClipMuted
? 0
- : Math.max(
- 0,
- Math.min(1, previewVolume * audio.embeddedSourcePreviewGain),
- )
+ : Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain))
}
suspendRendering={suspendRendering}
/>
);
+ const projectSaveDialog = (
+
+ );
+
const projectBrowser = (
{
+ void handleImportMediaOrProject();
+ }}
onOpenProject={(projectPath) => {
void handleOpenProjectFromLibrary(projectPath);
}}
@@ -5269,6 +5494,7 @@ export default function VideoEditor() {
Loading video...
{projectBrowser}
+ {projectSaveDialog}
{nativeCaptureUnavailableDialog}
@@ -5289,6 +5515,7 @@ export default function VideoEditor() {
{projectBrowser}
+ {projectSaveDialog}
{nativeCaptureUnavailableDialog}
@@ -5736,7 +5963,9 @@ export default function VideoEditor() {
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
- showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"}
+ showCaptionSidecarOption={
+ hasCaptionsForSidecar && exportFormat === "mp4"
+ }
includeCaptionSidecar={includeCaptionSidecar}
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
mp4OutputDimensions={mp4OutputDimensions}
@@ -6415,6 +6644,7 @@ export default function VideoEditor() {
) : null}
{projectBrowser}
+ {projectSaveDialog}
{nativeCaptureUnavailableDialog}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 488bc3a9d..9e3a22287 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -22,6 +22,7 @@ import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
+import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing";
import { buildActiveCaptionLayout } from "./captionLayout";
import {
CAPTION_FONT_WEIGHT,
@@ -169,10 +170,7 @@ import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focu
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
-import {
- getWebcamMediaTargetTimeSeconds,
- shouldSeekWebcamMedia,
-} from "./videoPlayback/webcamSync";
+import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -197,6 +195,24 @@ type PlaybackAnimationState = {
y: number;
};
+type CaptionEditSession = {
+ target: CaptionEditTarget;
+ draft: string;
+};
+
+type SceneTransformState = {
+ scale: number;
+ x: number;
+ y: number;
+};
+
+type AnnotationRecordingRect = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
function createPlaybackAnimationState(): PlaybackAnimationState {
return {
scale: 1,
@@ -361,6 +377,7 @@ interface VideoPlaybackProps {
annotationRegions?: AnnotationRegion[];
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
+ onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
@@ -444,6 +461,7 @@ const VideoPlayback = forwardRef
(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
+ onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -496,6 +514,19 @@ const VideoPlayback = forwardRef(
null,
);
const [frameUpdateCounter, setFrameUpdateCounter] = useState(0);
+ const [annotationSceneTransform, setAnnotationSceneTransform] =
+ useState({
+ scale: 1,
+ x: 0,
+ y: 0,
+ });
+ const [annotationRecordingRect, setAnnotationRecordingRect] =
+ useState({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
useEffect(() => {
let framesSignature = getRegisteredFramesSignature();
@@ -525,6 +556,11 @@ const VideoPlayback = forwardRef(
height: number;
} | null>(null);
const captionBoxRef = useRef(null);
+ const captionEditInputRef = useRef(null);
+ const captionEditSessionRef = useRef(null);
+ const [captionEditSession, setCaptionEditSession] = useState(
+ null,
+ );
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const selectedZoomIdRef = useRef(null);
@@ -728,6 +764,127 @@ const VideoPlayback = forwardRef(
measureText: (text) => measurementContext.measureText(text).width,
});
}, [autoCaptionSettings, autoCaptions, currentTime]);
+ const isCaptionEditing = captionEditSession !== null;
+ const captionEditDraft = captionEditSession?.draft ?? "";
+ const captionEditTargetId = captionEditSession?.target.id ?? null;
+ const captionEditTextMetrics = useMemo(() => {
+ if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") {
+ return null;
+ }
+
+ const overlayWidth = overlayRef.current?.clientWidth || 960;
+ const fontSize = getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ );
+ const maxTextWidthPx = getCaptionTextMaxWidth(
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ fontSize,
+ );
+ const measurementCanvas = document.createElement("canvas");
+ const measurementContext = measurementCanvas.getContext("2d");
+ if (!measurementContext) {
+ return null;
+ }
+
+ measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`;
+ const measuredWidth = Math.max(
+ ...captionEditSession.draft
+ .split(/\r?\n/)
+ .map((line) => measurementContext.measureText(line || " ").width),
+ );
+
+ return {
+ fontSize,
+ maxTextWidthPx,
+ widthPx: Math.ceil(
+ Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)),
+ ),
+ };
+ }, [autoCaptionSettings, captionEditSession]);
+ const captionEditSizeKey = captionEditSession
+ ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}`
+ : "";
+
+ const beginCaptionEdit = useCallback(() => {
+ if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) {
+ return;
+ }
+
+ videoRef.current?.pause();
+ onPlayStateChange(false);
+ const nextSession = {
+ target: activeCaptionLayout.editTarget,
+ draft: activeCaptionLayout.editTarget.text,
+ };
+ captionEditSessionRef.current = nextSession;
+ setCaptionEditSession(nextSession);
+ }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]);
+
+ const commitCaptionEdit = useCallback(() => {
+ const session = captionEditSessionRef.current;
+ if (!session || !onEditAutoCaption) {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ return;
+ }
+
+ const normalizedDraft = normalizeCaptionEditText(session.draft);
+ captionEditSessionRef.current = null;
+ if (!normalizedDraft) {
+ setCaptionEditSession(null);
+ return;
+ }
+
+ if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) {
+ onEditAutoCaption(session.target, session.draft);
+ }
+ setCaptionEditSession(null);
+ }, [onEditAutoCaption]);
+
+ const cancelCaptionEdit = useCallback(() => {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ }, []);
+
+ useEffect(() => {
+ if (!captionEditTargetId) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.focus();
+ const cursorPosition = input.value.length;
+ input.setSelectionRange(cursorPosition, cursorPosition);
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditTargetId]);
+
+ useEffect(() => {
+ if (!captionEditSizeKey) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.style.height = "auto";
+ input.style.height = `${input.scrollHeight}px`;
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditSizeKey]);
useEffect(() => {
const captionBox = captionBoxRef.current;
@@ -997,6 +1154,23 @@ const VideoPlayback = forwardRef(
renderWidth: result.maskRect.width * renderResolution,
renderHeight: result.maskRect.height * renderResolution,
};
+ setAnnotationRecordingRect((current) => {
+ if (
+ Math.abs(current.x - result.maskRect.x) < 0.1 &&
+ Math.abs(current.y - result.maskRect.y) < 0.1 &&
+ Math.abs(current.width - result.maskRect.width) < 0.1 &&
+ Math.abs(current.height - result.maskRect.height) < 0.1
+ ) {
+ return current;
+ }
+
+ return {
+ x: result.maskRect.x,
+ y: result.maskRect.y,
+ width: result.maskRect.width,
+ height: result.maskRect.height,
+ };
+ });
cropBoundsRef.current = result.cropBounds;
// Sync extension cursor effects canvas resolution with renderer
@@ -2193,6 +2367,21 @@ const VideoPlayback = forwardRef(
state.x = appliedTransform.x;
state.y = appliedTransform.y;
state.appliedScale = appliedTransform.scale;
+ setAnnotationSceneTransform((current) => {
+ if (
+ Math.abs(current.scale - appliedTransform.scale) < 0.001 &&
+ Math.abs(current.x - appliedTransform.x) < 0.1 &&
+ Math.abs(current.y - appliedTransform.y) < 0.1
+ ) {
+ return current;
+ }
+
+ return {
+ scale: appliedTransform.scale,
+ x: appliedTransform.x,
+ y: appliedTransform.y,
+ };
+ });
};
const ticker = () => {
@@ -2951,9 +3140,10 @@ const VideoPlayback = forwardRef(
) : null}
{activeCaptionLayout && autoCaptionSettings ? (
(
>
{
+ event.stopPropagation();
+ if (!isCaptionEditing) {
+ beginCaptionEdit();
+ }
+ }}
+ onPointerDown={(event) => {
+ event.stopPropagation();
+ }}
+ onKeyDown={(event) => {
+ if (!onEditAutoCaption || isCaptionEditing) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ beginCaptionEdit();
+ }
+ }}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
@@ -3004,98 +3226,240 @@ const VideoPlayback = forwardRef
(
),
)}px`,
boxSizing: "border-box",
+ cursor:
+ onEditAutoCaption && !isCaptionEditing
+ ? "text"
+ : undefined,
+ pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
- {activeCaptionLayout.visibleLines.map((line) => (
- {
+ const draft = event.target.value;
+ setCaptionEditSession((session) => {
+ const nextSession = session
+ ? { ...session, draft }
+ : session;
+ captionEditSessionRef.current = nextSession;
+ return nextSession;
+ });
+ }}
+ onBlur={commitCaptionEdit}
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancelCaptionEdit();
+ return;
+ }
+
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ }}
+ rows={Math.max(
+ 1,
+ activeCaptionLayout.visibleLines.length,
+ )}
+ aria-label="Edit current caption"
style={{
- display: "flex",
- justifyContent: "center",
- flexWrap: "nowrap",
- whiteSpace: "nowrap",
+ display: "block",
+ width: `${
+ captionEditTextMetrics?.widthPx ??
+ Math.max(
+ 48,
+ activeCaptionLayout.visibleLines.reduce(
+ (width, line) =>
+ Math.max(width, line.width),
+ 0,
+ ),
+ )
+ }px`,
+ maxWidth: `${
+ captionEditTextMetrics?.maxTextWidthPx ??
+ getCaptionTextMaxWidth(
+ overlayRef.current?.clientWidth || 960,
+ autoCaptionSettings.maxWidth,
+ getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayRef.current?.clientWidth ||
+ 960,
+ autoCaptionSettings.maxWidth,
+ ),
+ )
+ }px`,
+ minHeight: `${
+ Math.max(
+ 1,
+ activeCaptionLayout.visibleLines.length,
+ ) *
+ (
+ captionEditTextMetrics?.fontSize ??
+ getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayRef.current
+ ?.clientWidth || 960,
+ autoCaptionSettings.maxWidth,
+ )
+ ) *
+ CAPTION_LINE_HEIGHT
+ }px`,
+ resize: "none",
+ border: "0",
+ outline: "0",
+ padding: "0",
+ margin: "0",
+ overflow: "hidden",
+ background: "transparent",
+ color: autoCaptionSettings.textColor,
+ font: "inherit",
+ lineHeight: "inherit",
+ textAlign: "center",
}}
- >
- {line.words.map((word) => {
- const visualState = getCaptionWordVisualState(
- activeCaptionLayout.hasWordTimings,
- word.state,
- );
-
- return (
-
- {`${word.leadingSpace ? " " : ""}${word.text}`}
-
- );
- })}
-
- ))}
+ />
+ ) : (
+ activeCaptionLayout.visibleLines.map((line) => (
+
+ {line.words.map((word) => {
+ const visualState =
+ getCaptionWordVisualState(
+ activeCaptionLayout.hasWordTimings,
+ word.state,
+ );
+
+ return (
+
+ {`${word.leadingSpace ? " " : ""}${word.text}`}
+
+ );
+ })}
+
+ ))
+ )}
) : null}
- {(() => {
- const filtered = (annotationRegions || []).filter((annotation) => {
- if (
- typeof annotation.startMs !== "number" ||
- typeof annotation.endMs !== "number"
- )
- return false;
-
- if (annotation.id === selectedAnnotationId) return true;
-
- const timeMs = Math.round(currentTime * 1000);
- return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
- });
-
- // Sort by z-index (lowest to highest) so higher z-index renders on top
- const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
-
- // Handle click-through cycling: when clicking same annotation, cycle to next
- const handleAnnotationClick = (clickedId: string) => {
- if (!onSelectAnnotation) return;
-
- // If clicking on already selected annotation and there are multiple overlapping
- if (clickedId === selectedAnnotationId && sorted.length > 1) {
- // Find current index and cycle to next
- const currentIndex = sorted.findIndex(
- (a) => a.id === clickedId,
+
+
+ {(() => {
+ const filtered = (annotationRegions || []).filter((annotation) => {
+ if (
+ typeof annotation.startMs !== "number" ||
+ typeof annotation.endMs !== "number"
+ )
+ return false;
+
+ if (annotation.id === selectedAnnotationId) return true;
+
+ const timeMs = Math.round(currentTime * 1000);
+ return (
+ timeMs >= annotation.startMs && timeMs <= annotation.endMs
);
- const nextIndex = (currentIndex + 1) % sorted.length;
- onSelectAnnotation(sorted[nextIndex].id);
- } else {
- // First click or clicking different annotation
- onSelectAnnotation(clickedId);
- }
- };
-
- return sorted.map((annotation) => (
-
- onAnnotationPositionChange?.(id, position)
+ });
+
+ // Sort by z-index (lowest to highest) so higher z-index renders on top
+ const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
+
+ // Handle click-through cycling: when clicking same annotation, cycle to next
+ const handleAnnotationClick = (clickedId: string) => {
+ if (!onSelectAnnotation) return;
+
+ // If clicking on already selected annotation and there are multiple overlapping
+ if (clickedId === selectedAnnotationId && sorted.length > 1) {
+ // Find current index and cycle to next
+ const currentIndex = sorted.findIndex(
+ (a) => a.id === clickedId,
+ );
+ const nextIndex = (currentIndex + 1) % sorted.length;
+ onSelectAnnotation(sorted[nextIndex].id);
+ } else {
+ // First click or clicking different annotation
+ onSelectAnnotation(clickedId);
}
- onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
- onClick={handleAnnotationClick}
- zIndex={annotation.zIndex}
- isSelectedBoost={annotation.id === selectedAnnotationId}
- />
- ));
- })()}
+ };
+
+ return sorted.map((annotation) => (
+
+ onAnnotationPositionChange?.(id, position)
+ }
+ onSizeChange={(id, size) =>
+ onAnnotationSizeChange?.(id, size)
+ }
+ onClick={handleAnnotationClick}
+ zIndex={annotation.zIndex}
+ isSelectedBoost={annotation.id === selectedAnnotationId}
+ />
+ ));
+ })()}
+
+
)}
{/* Keep the source video off-screen instead of display:none so the
diff --git a/src/components/video-editor/captionEditing.test.ts b/src/components/video-editor/captionEditing.test.ts
index 7cfc18314..48d3ce950 100644
--- a/src/components/video-editor/captionEditing.test.ts
+++ b/src/components/video-editor/captionEditing.test.ts
@@ -114,4 +114,31 @@ describe("captionEditing", () => {
expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues);
});
+
+ it("keeps sound-effect style captions editable when word entries are blank", () => {
+ const cues: CaptionCue[] = [
+ {
+ id: "sound-effect",
+ startMs: 1_000,
+ endMs: 2_000,
+ text: "clears throat",
+ words: [{ text: "", startMs: 1_000, endMs: 2_000 }],
+ },
+ ];
+
+ const layout = buildActiveCaptionLayout({
+ cues,
+ timeMs: 1_500,
+ settings: DEFAULT_AUTO_CAPTION_SETTINGS,
+ maxWidthPx: 500,
+ measureText: (text) => text.length * 10,
+ });
+
+ expect(layout?.editTarget.text).toBe("clears throat");
+
+ const updated = updateCaptionCuesForEditedTarget(cues, layout!.editTarget, "coughs");
+
+ expect(updated[0].text).toBe("coughs");
+ expect(updated[0].words).toEqual([{ text: "coughs", startMs: 1_000, endMs: 2_000 }]);
+ });
});
diff --git a/src/components/video-editor/captionEditing.ts b/src/components/video-editor/captionEditing.ts
index b5084426f..9ff55befa 100644
--- a/src/components/video-editor/captionEditing.ts
+++ b/src/components/video-editor/captionEditing.ts
@@ -56,13 +56,19 @@ function buildCaptionWordsForEditedText(
}
function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
+ const validSourceWords = Array.isArray(cue.words)
+ ? cue.words.filter((word): word is CaptionCueWord =>
+ Boolean(
+ word && typeof word.text === "string" && normalizeCaptionEditText(word.text),
+ ),
+ )
+ : [];
const sourceWords =
- Array.isArray(cue.words) && cue.words.length > 0
- ? cue.words
+ validSourceWords.length > 0
+ ? validSourceWords
: buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs);
return sourceWords
- .filter((word): word is CaptionCueWord => Boolean(word && typeof word.text === "string"))
.map((word) => {
const startMs = Math.max(
cue.startMs,
diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts
index 6c0d987e9..9d383294c 100644
--- a/src/components/video-editor/captionLayout.ts
+++ b/src/components/video-editor/captionLayout.ts
@@ -95,7 +95,7 @@ function splitCaptionWordsFromText(text: string) {
function splitCaptionWords(cue: CaptionCue) {
if (Array.isArray(cue.words) && cue.words.length > 0) {
- return cue.words
+ const words = cue.words
.filter((word): word is CaptionCueWord =>
Boolean(word && typeof word.text === "string"),
)
@@ -109,6 +109,10 @@ function splitCaptionWords(cue: CaptionCue) {
endMs: word.endMs,
}))
.filter((word) => word.text.length > 0);
+
+ if (words.length > 0) {
+ return words;
+ }
}
return splitCaptionWordsFromText(cue.text).map((word) => ({
diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts
index 4cfb5257a..68c5650f4 100644
--- a/src/components/video-editor/projectDirtyState.test.ts
+++ b/src/components/video-editor/projectDirtyState.test.ts
@@ -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);
+ });
});
diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts
index 78fcee844..16858f124 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 8e1cc6bf5..ab1af945e 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 a8822a81b..773803fb5 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 049c25bbc..4ee42e6a1 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -5,8 +5,8 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
- CursorClickEffectStyle,
CropRegion,
+ CursorClickEffectStyle,
CursorStyle,
CursorTelemetryPoint,
Padding,
@@ -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,
@@ -1547,6 +1547,9 @@ export class FrameRenderer {
this.config.height,
temporalSnapshot.timeMs,
scaleFactor,
+ undefined,
+ temporalSnapshot.sceneTransform,
+ this.layoutCache?.maskRect,
);
}
@@ -1731,6 +1734,13 @@ export class FrameRenderer {
this.config.height,
timeMs,
scaleFactor,
+ undefined,
+ {
+ scale: this.animationState.appliedScale,
+ x: this.animationState.x,
+ y: this.animationState.y,
+ },
+ this.layoutCache?.maskRect,
);
}
diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts
index 6ab918d8a..028dcbad6 100644
--- a/src/lib/exporter/modernFrameRenderer.ts
+++ b/src/lib/exporter/modernFrameRenderer.ts
@@ -15,8 +15,8 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
- CursorClickEffectStyle,
CropRegion,
+ CursorClickEffectStyle,
CursorStyle,
CursorTelemetryPoint,
Padding,
@@ -598,8 +598,8 @@ export class FrameRenderer {
this.webcamRootContainer.addChild(this.webcamContainer);
this.webcamRootContainer.visible = false;
+ this.cameraContainer.addChild(this.annotationContainer);
this.overlayContainer.addChild(this.webcamRootContainer);
- this.overlayContainer.addChild(this.annotationContainer);
this.overlayContainer.addChild(this.captionContainer);
this.videoMaskGraphics = new Graphics();
@@ -622,14 +622,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,
@@ -1586,6 +1586,12 @@ export class FrameRenderer {
timeMs,
this.annotationScaleFactor,
this.annotationAssets ?? undefined,
+ {
+ scale: this.animationState.appliedScale,
+ x: this.animationState.x,
+ y: this.animationState.y,
+ },
+ this.layoutCache?.maskRect,
);
this.drawCaptionOverlay(context);
@@ -1609,10 +1615,16 @@ export class FrameRenderer {
);
for (const annotation of annotations) {
- const x = (annotation.position.x / 100) * this.config.width;
- const y = (annotation.position.y / 100) * this.config.height;
- const width = (annotation.size.width / 100) * this.config.width;
- const height = (annotation.size.height / 100) * this.config.height;
+ const annotationRect = this.layoutCache?.maskRect ?? {
+ x: 0,
+ y: 0,
+ width: this.config.width,
+ height: this.config.height,
+ };
+ const x = annotationRect.x + (annotation.position.x / 100) * annotationRect.width;
+ const y = annotationRect.y + (annotation.position.y / 100) * annotationRect.height;
+ const width = (annotation.size.width / 100) * annotationRect.width;
+ const height = (annotation.size.height / 100) * annotationRect.height;
if (width <= 0 || height <= 0) {
continue;
@@ -2549,15 +2561,10 @@ export class FrameRenderer {
const usesDefaultCropRegion = isWebcamCropRegionDefault(this.config.webcam?.cropRegion);
const needsCacheBackedSource =
!usesDefaultCropRegion ||
- (typeof HTMLVideoElement !== "undefined" &&
- liveSource instanceof HTMLVideoElement);
+ (typeof HTMLVideoElement !== "undefined" && liveSource instanceof HTMLVideoElement);
if (needsCacheBackedSource) {
- this.refreshWebcamFrameCache(
- liveSource,
- liveSourceWidth,
- liveSourceHeight,
- );
+ this.refreshWebcamFrameCache(liveSource, liveSourceWidth, liveSourceHeight);
const cachedSource = this.getCachedWebcamRenderSource();
if (cachedSource) {
this.setWebcamRenderMode("live");