mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
Merge origin/main into feat/export-caption-sidecars
This commit is contained in:
+101
-80
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
-1
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -403,6 +403,29 @@ export async function listProjectLibraryEntries() {
|
||||
};
|
||||
}
|
||||
|
||||
function isLoadableProjectData(projectData: unknown) {
|
||||
if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = projectData as {
|
||||
version?: unknown;
|
||||
projectId?: unknown;
|
||||
videoPath?: unknown;
|
||||
editor?: unknown;
|
||||
};
|
||||
|
||||
return (
|
||||
typeof candidate.version === "number" &&
|
||||
(candidate.projectId === undefined || typeof candidate.projectId === "string") &&
|
||||
typeof candidate.videoPath === "string" &&
|
||||
candidate.videoPath.trim().length > 0 &&
|
||||
candidate.editor != null &&
|
||||
typeof candidate.editor === "object" &&
|
||||
!Array.isArray(candidate.editor)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadProjectFromPath(projectPath: string) {
|
||||
const normalizedPath = normalizePath(projectPath);
|
||||
let project: unknown;
|
||||
@@ -416,6 +439,13 @@ export async function loadProjectFromPath(projectPath: string) {
|
||||
message: `Failed to read project file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
if (!isLoadableProjectData(project)) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: false,
|
||||
message: "Invalid project file format",
|
||||
};
|
||||
}
|
||||
const mediaSources = await resolveProjectMediaSources(project);
|
||||
|
||||
if (!mediaSources.success) {
|
||||
|
||||
+229
-181
@@ -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",
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,121 +252,6 @@ function isTempPathSafe(tempPath: string): boolean {
|
||||
return candidate.startsWith(withSep);
|
||||
}
|
||||
|
||||
type CaptionSidecarCue = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type CaptionSidecarPayload = {
|
||||
format: "srt" | "vtt" | "both";
|
||||
cues: CaptionSidecarCue[];
|
||||
};
|
||||
|
||||
function toSrtTimestamp(totalMs: number): string {
|
||||
const ms = Math.max(0, Math.round(totalMs));
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
||||
const seconds = Math.floor((ms % 60_000) / 1000);
|
||||
const millis = ms % 1000;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")},${String(millis).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function toVttTimestamp(totalMs: number): string {
|
||||
const ms = Math.max(0, Math.round(totalMs));
|
||||
const hours = Math.floor(ms / 3_600_000);
|
||||
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
||||
const seconds = Math.floor((ms % 60_000) / 1000);
|
||||
const millis = ms % 1000;
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function normalizeCaptionSidecarCues(cues: unknown): CaptionSidecarCue[] {
|
||||
if (!Array.isArray(cues)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return cues
|
||||
.filter((cue): cue is CaptionSidecarCue => {
|
||||
return (
|
||||
typeof cue === "object" &&
|
||||
cue !== null &&
|
||||
typeof cue.startMs === "number" &&
|
||||
typeof cue.endMs === "number" &&
|
||||
typeof cue.text === "string" &&
|
||||
Number.isFinite(cue.startMs) &&
|
||||
Number.isFinite(cue.endMs) &&
|
||||
cue.endMs > cue.startMs &&
|
||||
cue.text.trim().length > 0
|
||||
);
|
||||
})
|
||||
.map((cue) => ({
|
||||
startMs: cue.startMs,
|
||||
endMs: cue.endMs,
|
||||
text: cue.text.replace(/\r\n/g, "\n").trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseCaptionSidecarPayload(payload: unknown): CaptionSidecarPayload | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = payload as {
|
||||
format?: unknown;
|
||||
cues?: unknown;
|
||||
};
|
||||
|
||||
const format =
|
||||
candidate.format === "srt" || candidate.format === "vtt" || candidate.format === "both"
|
||||
? candidate.format
|
||||
: null;
|
||||
if (!format) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cues = normalizeCaptionSidecarCues(candidate.cues);
|
||||
if (cues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { format, cues };
|
||||
}
|
||||
|
||||
function serializeSrt(cues: CaptionSidecarCue[]): string {
|
||||
return cues
|
||||
.map((cue, index) => {
|
||||
return `${index + 1}\n${toSrtTimestamp(cue.startMs)} --> ${toSrtTimestamp(cue.endMs)}\n${cue.text}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function serializeVtt(cues: CaptionSidecarCue[]): string {
|
||||
const body = cues
|
||||
.map((cue) => {
|
||||
return `${toVttTimestamp(cue.startMs)} --> ${toVttTimestamp(cue.endMs)}\n${cue.text}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
return `WEBVTT\n\n${body}`;
|
||||
}
|
||||
|
||||
async function writeCaptionSidecars(videoPath: string, payload: CaptionSidecarPayload | null) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = path.parse(videoPath);
|
||||
const basePath = path.join(parsed.dir, parsed.name);
|
||||
|
||||
if (payload.format === "srt" || payload.format === "both") {
|
||||
await fs.writeFile(`${basePath}.srt`, serializeSrt(payload.cues), "utf8");
|
||||
}
|
||||
|
||||
if (payload.format === "vtt" || payload.format === "both") {
|
||||
await fs.writeFile(`${basePath}.vtt`, serializeVtt(payload.cues), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExportHandlers() {
|
||||
ipcMain.handle(
|
||||
"native-video-export-start",
|
||||
|
||||
+4
-2
@@ -671,8 +671,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
captionSidecar,
|
||||
);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
openVideoFilePicker: (options?: { includeProjects?: boolean }) => {
|
||||
return ipcRenderer.invoke("open-video-file-picker", options);
|
||||
},
|
||||
openAudioFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-audio-file-picker");
|
||||
@@ -783,12 +783,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
projectData: unknown,
|
||||
projectName: string,
|
||||
thumbnailDataUrl?: string | null,
|
||||
mode?: "rename" | "copy",
|
||||
) => {
|
||||
return ipcRenderer.invoke(
|
||||
"save-project-file-named",
|
||||
projectData,
|
||||
projectName,
|
||||
thumbnailDataUrl,
|
||||
mode,
|
||||
);
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
|
||||
@@ -19,8 +19,12 @@ export function useLaunchWindowActions() {
|
||||
}, []);
|
||||
|
||||
const openVideoFile = useCallback(async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
|
||||
if (result.canceled) return;
|
||||
if (result.success && result.kind === "project") {
|
||||
await window.electronAPI.switchToEditor();
|
||||
return;
|
||||
}
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
|
||||
@@ -4,11 +4,27 @@ import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import { type AnnotationRegion, BASE_PREVIEW_WIDTH, BLUR_ANNOTATION_STRENGTH } from "./types";
|
||||
|
||||
type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type SceneTransform = {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
isSelected: boolean;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
recordingRect: Rect;
|
||||
sceneTransform: SceneTransform;
|
||||
interactionScale?: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onClick: (id: string) => void;
|
||||
@@ -16,24 +32,69 @@ interface AnnotationOverlayProps {
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
}
|
||||
|
||||
function clampPercent(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
annotation,
|
||||
isSelected,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
recordingRect,
|
||||
sceneTransform,
|
||||
interactionScale = 1,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
const safeRecordingRect =
|
||||
recordingRect.width > 0 && recordingRect.height > 0
|
||||
? recordingRect
|
||||
: { x: 0, y: 0, width: containerWidth, height: containerHeight };
|
||||
const sceneX = safeRecordingRect.x + (annotation.position.x / 100) * safeRecordingRect.width;
|
||||
const sceneY = safeRecordingRect.y + (annotation.position.y / 100) * safeRecordingRect.height;
|
||||
const sceneWidth = (annotation.size.width / 100) * safeRecordingRect.width;
|
||||
const sceneHeight = (annotation.size.height / 100) * safeRecordingRect.height;
|
||||
const x = sceneX * sceneTransform.scale + sceneTransform.x;
|
||||
const y = sceneY * sceneTransform.scale + sceneTransform.y;
|
||||
const width = sceneWidth * sceneTransform.scale;
|
||||
const height = sceneHeight * sceneTransform.scale;
|
||||
const sizeScale = safeRecordingRect.width / BASE_PREVIEW_WIDTH;
|
||||
const blurScaleFactor = sizeScale * sceneTransform.scale;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const screenRectToRecordingPercent = (rect: Rect) => {
|
||||
const nextSceneX = (rect.x - sceneTransform.x) / sceneTransform.scale;
|
||||
const nextSceneY = (rect.y - sceneTransform.y) / sceneTransform.scale;
|
||||
const nextSceneWidth = rect.width / sceneTransform.scale;
|
||||
const nextSceneHeight = rect.height / sceneTransform.scale;
|
||||
|
||||
return {
|
||||
position: {
|
||||
x: clampPercent(
|
||||
((nextSceneX - safeRecordingRect.x) / Math.max(1, safeRecordingRect.width)) *
|
||||
100,
|
||||
),
|
||||
y: clampPercent(
|
||||
((nextSceneY - safeRecordingRect.y) / Math.max(1, safeRecordingRect.height)) *
|
||||
100,
|
||||
),
|
||||
},
|
||||
size: {
|
||||
width: clampPercent((nextSceneWidth / Math.max(1, safeRecordingRect.width)) * 100),
|
||||
height: clampPercent((nextSceneHeight / Math.max(1, safeRecordingRect.height)) * 100),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
const color = annotation.figureData?.color || "#2563EB";
|
||||
@@ -48,7 +109,7 @@ export function AnnotationOverlay({
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
className="w-full h-full flex items-center overflow-hidden"
|
||||
style={{
|
||||
justifyContent:
|
||||
annotation.style.textAlign === "left"
|
||||
@@ -57,13 +118,14 @@ export function AnnotationOverlay({
|
||||
? "flex-end"
|
||||
: "center",
|
||||
alignItems: "center",
|
||||
padding: `${8 * sceneTransform.scale}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: annotation.style.color,
|
||||
backgroundColor: annotation.style.backgroundColor,
|
||||
fontSize: `${annotation.style.fontSize}px`,
|
||||
fontSize: `${annotation.style.fontSize * sceneTransform.scale}px`,
|
||||
fontFamily: annotation.style.fontFamily,
|
||||
fontWeight: annotation.style.fontWeight,
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
@@ -74,7 +136,7 @@ export function AnnotationOverlay({
|
||||
boxDecorationBreak: "clone",
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
padding: "0.1em 0.2em",
|
||||
borderRadius: "4px",
|
||||
borderRadius: `${4 * sceneTransform.scale}px`,
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
>
|
||||
@@ -116,9 +178,8 @@ export function AnnotationOverlay({
|
||||
);
|
||||
|
||||
case "blur": {
|
||||
const previewScaleFactor = containerWidth / BASE_PREVIEW_WIDTH;
|
||||
const currentBlurStrength = annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH;
|
||||
const blurPx = currentBlurStrength * previewScaleFactor;
|
||||
const blurPx = currentBlurStrength * blurScaleFactor;
|
||||
const blurStyle = `blur(${blurPx}px)`;
|
||||
|
||||
return (
|
||||
@@ -128,7 +189,7 @@ export function AnnotationOverlay({
|
||||
backdropFilter: blurStyle,
|
||||
WebkitBackdropFilter: blurStyle,
|
||||
backgroundColor: annotation.blurColor || "transparent",
|
||||
borderRadius: `${(annotation.style.borderRadius ?? 0) * previewScaleFactor}px`,
|
||||
borderRadius: `${(annotation.style.borderRadius ?? 0) * blurScaleFactor}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -143,13 +204,13 @@ export function AnnotationOverlay({
|
||||
<Rnd
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
scale={interactionScale}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
const next = screenRectToRecordingPercent({ x: d.x, y: d.y, width, height });
|
||||
onPositionChange(annotation.id, next.position);
|
||||
|
||||
// Reset dragging flag after a short delay to prevent click event
|
||||
setTimeout(() => {
|
||||
@@ -157,12 +218,14 @@ export function AnnotationOverlay({
|
||||
}, 100);
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
|
||||
const next = screenRectToRecordingPercent({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
onPositionChange(annotation.id, next.position);
|
||||
onSizeChange(annotation.id, next.size);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
|
||||
@@ -15,6 +15,7 @@ type ProjectBrowserDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entries: ProjectLibraryEntry[];
|
||||
onOpenProject: (projectPath: string) => void;
|
||||
onImportFile?: () => void;
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
preferredDirection?: "up" | "down" | "auto";
|
||||
onPanelHeightChange?: (height: number) => void;
|
||||
@@ -25,6 +26,7 @@ export default function ProjectBrowserDialog({
|
||||
onOpenChange,
|
||||
entries,
|
||||
onOpenProject,
|
||||
onImportFile,
|
||||
anchorRef,
|
||||
preferredDirection = "auto",
|
||||
onPanelHeightChange,
|
||||
@@ -172,10 +174,21 @@ export default function ProjectBrowserDialog({
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label="Projects"
|
||||
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
|
||||
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
|
||||
>
|
||||
<div className="border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">
|
||||
Projects
|
||||
</div>
|
||||
{onImportFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportFile}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto px-2.5 py-2.5">
|
||||
{visibleEntries.length > 0 ? (
|
||||
@@ -242,8 +255,19 @@ export default function ProjectBrowserDialog({
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||
className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150"
|
||||
>
|
||||
<div className="border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">
|
||||
Projects
|
||||
</div>
|
||||
{onImportFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportFile}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto px-2.5 py-2.5"
|
||||
|
||||
@@ -3924,7 +3924,7 @@ export function SettingsPanel({
|
||||
height: previewFrame
|
||||
? getCropMatchedWebcamHeightPercent(
|
||||
webcamWidth,
|
||||
webcamWidth,
|
||||
webcamHeight,
|
||||
previewFrame.width,
|
||||
previewFrame.height,
|
||||
cropRegion,
|
||||
|
||||
@@ -64,4 +64,58 @@ describe("hasUnsavedProjectChanges", () => {
|
||||
|
||||
expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores transient webcam media attachment changes", () => {
|
||||
const saved = createProjectData({
|
||||
editor: {
|
||||
...createProjectData().editor,
|
||||
webcam: {
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: 0,
|
||||
size: 28,
|
||||
},
|
||||
},
|
||||
});
|
||||
const current = createProjectData({
|
||||
editor: {
|
||||
...createProjectData().editor,
|
||||
webcam: {
|
||||
enabled: true,
|
||||
sourcePath: "/Users/test/webcam.mp4",
|
||||
timeOffsetMs: 125,
|
||||
size: 28,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasUnsavedProjectChanges(current, saved)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects persistent webcam presentation changes", () => {
|
||||
const saved = createProjectData({
|
||||
editor: {
|
||||
...createProjectData().editor,
|
||||
webcam: {
|
||||
enabled: true,
|
||||
sourcePath: "/Users/test/webcam.mp4",
|
||||
timeOffsetMs: 0,
|
||||
size: 28,
|
||||
},
|
||||
},
|
||||
});
|
||||
const current = createProjectData({
|
||||
editor: {
|
||||
...createProjectData().editor,
|
||||
webcam: {
|
||||
enabled: true,
|
||||
sourcePath: "/Users/test/webcam.mp4",
|
||||
timeOffsetMs: 0,
|
||||
size: 36,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hasUnsavedProjectChanges(current, saved)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,43 @@ function areDeepEqual(left: unknown, right: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function omitTransientWebcamMediaFields(project: EditorProjectData | null) {
|
||||
if (!project?.editor || typeof project.editor !== "object") {
|
||||
return project;
|
||||
}
|
||||
|
||||
const editor = project.editor as Record<string, unknown>;
|
||||
const webcam = editor.webcam;
|
||||
if (!isComparableObject(webcam)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const {
|
||||
enabled: _enabled,
|
||||
sourcePath: _sourcePath,
|
||||
timeOffsetMs: _timeOffsetMs,
|
||||
...persistentWebcamFields
|
||||
} = webcam;
|
||||
|
||||
return {
|
||||
...project,
|
||||
editor: {
|
||||
...editor,
|
||||
webcam: persistentWebcamFields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hasUnsavedProjectChanges(
|
||||
currentProjectSnapshot: EditorProjectData | null,
|
||||
lastSavedSnapshot: EditorProjectData | null,
|
||||
): boolean {
|
||||
const comparableCurrentSnapshot = omitTransientWebcamMediaFields(currentProjectSnapshot);
|
||||
const comparableLastSavedSnapshot = omitTransientWebcamMediaFields(lastSavedSnapshot);
|
||||
|
||||
return Boolean(
|
||||
currentProjectSnapshot &&
|
||||
(!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
|
||||
comparableCurrentSnapshot &&
|
||||
(!comparableLastSavedSnapshot ||
|
||||
!areDeepEqual(comparableCurrentSnapshot, comparableLastSavedSnapshot)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ZOOM_OUT_EARLY_START_MS,
|
||||
} from "./constants";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutZoom } from "./mathUtils";
|
||||
import { clamp01, easeOutZoom } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1350;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
@@ -34,14 +34,6 @@ type ConnectedPanTransition = {
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function lerp(start: number, end: number, amount: number) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(
|
||||
region: ZoomRegion,
|
||||
timeMs: number,
|
||||
@@ -79,13 +71,6 @@ export function computeRegionStrength(
|
||||
return 1 - easeOutZoom(progress);
|
||||
}
|
||||
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
return {
|
||||
cx: lerp(start.cx, end.cx, amount),
|
||||
cy: lerp(start.cy, end.cy, amount),
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
}
|
||||
@@ -199,44 +184,6 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
if (timeMs < transitionStart || timeMs > transitionEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...nextRegion,
|
||||
focus: transitionFocus,
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
endFocus: nextFocus,
|
||||
startScale: currentScale,
|
||||
endScale: nextScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
|
||||
@@ -8,6 +8,35 @@ export interface AnnotationRenderAssets {
|
||||
imageCache: Map<string, HTMLImageElement>;
|
||||
}
|
||||
|
||||
interface AnnotationSceneTransform {
|
||||
scale: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface AnnotationCoordinateRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function transformAnnotationRect(
|
||||
rect: { x: number; y: number; width: number; height: number },
|
||||
sceneTransform?: AnnotationSceneTransform,
|
||||
) {
|
||||
if (!sceneTransform) {
|
||||
return rect;
|
||||
}
|
||||
|
||||
return {
|
||||
x: rect.x * sceneTransform.scale + sceneTransform.x,
|
||||
y: rect.y * sceneTransform.scale + sceneTransform.y,
|
||||
width: rect.width * sceneTransform.scale,
|
||||
height: rect.height * sceneTransform.scale,
|
||||
};
|
||||
}
|
||||
|
||||
const annotationImagePromiseCache = new Map<string, Promise<HTMLImageElement | null>>();
|
||||
|
||||
let blurBufferCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -334,22 +363,32 @@ export async function renderAnnotations(
|
||||
currentTimeMs: number,
|
||||
scaleFactor: number = 1.0,
|
||||
assets?: AnnotationRenderAssets,
|
||||
sceneTransform?: AnnotationSceneTransform,
|
||||
coordinateRect?: AnnotationCoordinateRect,
|
||||
): Promise<void> {
|
||||
const activeAnnotations = annotations.filter(
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
|
||||
);
|
||||
|
||||
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const annotationRect = coordinateRect ?? { x: 0, y: 0, width: canvasWidth, height: canvasHeight };
|
||||
|
||||
for (const annotation of sortedAnnotations) {
|
||||
const x = (annotation.position.x / 100) * canvasWidth;
|
||||
const y = (annotation.position.y / 100) * canvasHeight;
|
||||
const width = (annotation.size.width / 100) * canvasWidth;
|
||||
const height = (annotation.size.height / 100) * canvasHeight;
|
||||
const rect = transformAnnotationRect(
|
||||
{
|
||||
x: annotationRect.x + (annotation.position.x / 100) * annotationRect.width,
|
||||
y: annotationRect.y + (annotation.position.y / 100) * annotationRect.height,
|
||||
width: (annotation.size.width / 100) * annotationRect.width,
|
||||
height: (annotation.size.height / 100) * annotationRect.height,
|
||||
},
|
||||
sceneTransform,
|
||||
);
|
||||
const { x, y, width, height } = rect;
|
||||
const effectiveScaleFactor = scaleFactor * (sceneTransform?.scale ?? 1);
|
||||
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
renderText(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
renderText(ctx, annotation, x, y, width, height, effectiveScaleFactor);
|
||||
break;
|
||||
|
||||
case "image":
|
||||
@@ -367,20 +406,20 @@ export async function renderAnnotations(
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
scaleFactor,
|
||||
effectiveScaleFactor,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "blur": {
|
||||
const blurStrength =
|
||||
(annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * scaleFactor;
|
||||
(annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * effectiveScaleFactor;
|
||||
const padding = Math.ceil(blurStrength * 2);
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.beginPath();
|
||||
const borderRadius = (annotation.style.borderRadius ?? 0) * scaleFactor;
|
||||
const borderRadius = (annotation.style.borderRadius ?? 0) * effectiveScaleFactor;
|
||||
ctx.roundRect(x, y, width, height, borderRadius);
|
||||
ctx.clip();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user