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 = ( + { + if (open) { + setProjectSaveDialogOpen(true); + return; + } + + if (!isSavingProjectDialog) { + resolveProjectSaveDialog(false); + } + }} + > + +
void handleProjectSaveDialogSubmit(event)}> + + {t("editor.project.saveTitle", "Save Project")} + + {t( + "editor.project.saveDescription", + "Name this project. It will be saved in your Recordly Projects folder.", + )} + + +
+ +
+ setProjectSaveDialogDraft(event.target.value)} + disabled={isSavingProjectDialog} + className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" + aria-label={t("editor.project.saveNameLabel", "Project name")} + /> + + .recordly + +
+
+ + + + +
+
+
+ ); + 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");