From ca1cc7d01d1d8d44f0dc37a951848ce917cf64da Mon Sep 17 00:00:00 2001 From: Shanjin <121328191+shanjin666666@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:10:39 +0800 Subject: [PATCH 1/4] feat: add configurable storage workspace --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/winget-releaser.yml | 2 +- electron-builder.json5 | 18 +- electron/appPaths.ts | 6 + electron/electron-env.d.ts | 38 ++ electron/ipc/project/manager.ts | 7 +- electron/ipc/recording/prune.ts | 5 +- electron/ipc/register/project.ts | 189 +++++++- electron/ipc/utils.ts | 20 +- electron/main.ts | 31 +- electron/preload.ts | 12 + electron/storageSettings.test.ts | 126 +++++ electron/storageSettings.ts | 451 ++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 12 + .../hooks/useLaunchWindowSystemState.ts | 30 ++ .../launch/popovers/MorePopover.tsx | 33 ++ src/i18n/locales/de/launch.json | 3 + src/i18n/locales/en/launch.json | 3 + src/i18n/locales/es/launch.json | 3 + src/i18n/locales/fr/launch.json | 3 + src/i18n/locales/it/launch.json | 3 + src/i18n/locales/ko/launch.json | 3 + src/i18n/locales/nl/launch.json | 3 + src/i18n/locales/pt-BR/launch.json | 3 + src/i18n/locales/ru/launch.json | 5 +- src/i18n/locales/zh-CN/launch.json | 3 + src/i18n/locales/zh-TW/launch.json | 3 + 28 files changed, 987 insertions(+), 32 deletions(-) create mode 100644 electron/storageSettings.test.ts create mode 100644 electron/storageSettings.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78f1ddae1..224051986 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npx vite build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - npx electron-builder --win dir nsis --x64 --publish never + npx electron-builder --win dir nsis portable --x64 --publish never - name: Smoke test packaged Windows paths run: npm run smoke:packaged-binaries diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 148bed0dd..0f9f79b5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -449,7 +449,7 @@ jobs: npm run build:platform-native-helpers npx tsc npx vite build - npx electron-builder --win dir nsis --x64 --publish never + npx electron-builder --win dir nsis portable --x64 --publish never - name: Smoke test packaged Windows x64 paths run: npm run smoke:packaged-binaries diff --git a/.github/workflows/winget-releaser.yml b/.github/workflows/winget-releaser.yml index 741d48fd3..91bf50942 100644 --- a/.github/workflows/winget-releaser.yml +++ b/.github/workflows/winget-releaser.yml @@ -20,7 +20,7 @@ jobs: - uses: vedantmgoyal9/winget-releaser@v2 with: identifier: Webadderall.Recordly - installers-regex: 'windows\-x64\.exe$' + installers-regex: 'windows\-setup\-x64\.exe$' version: ${{ steps.tag.outputs.tag }} release-tag: ${{ steps.tag.outputs.tag }} token: ${{ secrets.WINGET_ACC_TOKEN }} diff --git a/electron-builder.json5 b/electron-builder.json5 index a333a8723..857764a8f 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -103,12 +103,18 @@ }, "win": { "target": [ - "nsis" + "nsis", + "portable" ], - "icon": "icons/icons/win/icon.ico" - , - "executableName": "Recordly", - "artifactName": "${productName}-windows-${arch}.${ext}" + "icon": "icons/icons/win/icon.ico", + "executableName": "Recordly" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "artifactName": "${productName}-windows-setup-${arch}.${ext}" + }, + "portable": { + "artifactName": "${productName}-windows-portable-${arch}.${ext}" } } - diff --git a/electron/appPaths.ts b/electron/appPaths.ts index 68d6cfc39..ac8774b91 100644 --- a/electron/appPaths.ts +++ b/electron/appPaths.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { app } from "electron"; +import { getActiveWorkspaceLayout, initializeWorkspaceStorage } from "./storageSettings"; if (process.env["VITE_DEV_SERVER_URL"]) { const devUserDataPath = path.join(app.getPath("appData"), "Recordly-dev"); @@ -8,4 +9,9 @@ if (process.env["VITE_DEV_SERVER_URL"]) { } export const USER_DATA_PATH = app.getPath("userData"); +export const INITIAL_WORKSPACE_LAYOUT = initializeWorkspaceStorage(app, USER_DATA_PATH); export const RECORDINGS_DIR = path.join(USER_DATA_PATH, "recordings"); + +export function getConfiguredWorkspaceLayout() { + return getActiveWorkspaceLayout(); +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..6c9507a02 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -858,6 +858,44 @@ interface Window { message?: string; error?: string; }>; + getStorageStatus: () => Promise<{ + success: boolean; + workspaceRoot?: string | null; + recordingsDir?: string; + projectsDir?: string; + tempDir?: string; + cacheDir?: string; + configuredTempDir?: string; + configuredCacheDir?: string; + restartRequired?: boolean; + initializationError?: string | null; + usage?: { + recordingsBytes: number; + projectsBytes: number; + tempBytes: number; + cacheBytes: number; + totalBytes: number; + } | null; + error?: string; + }>; + chooseWorkspaceDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + workspaceRoot?: string; + recordingsDir?: string; + projectsDir?: string; + tempDir?: string; + cacheDir?: string; + restartRequired?: boolean; + error?: string; + }>; + openWorkspaceDirectory: () => Promise<{ success: boolean; error?: string }>; + cleanupRecordlyTemporaryFiles: () => Promise<{ + success: boolean; + removedCount?: number; + removedBytes?: number; + error?: string; + }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; getAppSetting: (key: string) => unknown; diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 7828fa8b6..f1765d3e3 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -9,7 +9,6 @@ import { MAX_RECENT_PROJECTS, PROJECT_FILE_EXTENSION, PROJECT_THUMBNAIL_SUFFIX, - PROJECTS_DIRECTORY_NAME, RECENT_PROJECTS_FILE, RECORDINGS_SETTINGS_FILE, } from "../constants"; @@ -24,7 +23,7 @@ import { } from "../state"; import type { ProjectLibraryEntry, RecordingSessionData } from "../types"; import { - getRecordingsDir, + getProjectsStorageDir, normalizePath, normalizeVideoSourcePath, parseJsonWithByteOrderMark, @@ -239,9 +238,7 @@ export async function resolveProjectMediaSources( } export async function getProjectsDir() { - const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME); - await fs.mkdir(projectsDir, { recursive: true }); - return projectsDir; + return getProjectsStorageDir(); } export async function persistRecordingsDirectorySetting(nextDir: string) { diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index d8004bd14..694931681 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -6,11 +6,11 @@ import { COMPANION_AUDIO_LAYOUTS, LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION, - PROJECTS_DIRECTORY_NAME, } from "../constants"; import { currentVideoPath } from "../state"; import { getRecordingsDir, + getProjectsStorageDir, getTelemetryPathForVideo, isAutoRecordingPath, normalizePath, @@ -39,8 +39,7 @@ export async function hasSiblingProjectFile(videoPath: string) { export { isAutoRecordingPath }; async function loadSavedProjectMediaPaths() { - const recordingsDir = await getRecordingsDir(); - const projectsDir = path.join(recordingsDir, PROJECTS_DIRECTORY_NAME); + const projectsDir = await getProjectsStorageDir(); const protectedPaths = new Set(); const candidateExtensions = new Set([ PROJECT_FILE_EXTENSION, diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index f1fa43e26..b7fb5e825 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -2,9 +2,20 @@ import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { BrowserWindow, dialog, ipcMain, shell } from "electron"; -import { RECORDINGS_DIR } from "../../appPaths"; +import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +import { getConfiguredWorkspaceLayout, RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths"; import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer"; +import { + cleanupRecordlyTempArtifacts, + deleteMigratedSourceFiles, + getStorageMigrationUsage, + getWorkspaceInitializationError, + getWorkspaceRootFromSelectedDirectory, + getWorkspaceUsage, + migrateStorageData, + persistWorkspaceRoot, + resolveWorkspaceLayout, +} from "../../storageSettings"; import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION, @@ -295,6 +306,180 @@ export function registerProjectHandlers() { } }) + ipcMain.handle("get-storage-status", async () => { + try { + const workspace = getConfiguredWorkspaceLayout(); + const recordingsDir = await getRecordingsDir(); + const projectsDir = await getProjectsDir(); + return { + success: true, + workspaceRoot: workspace?.root ?? null, + recordingsDir, + projectsDir, + tempDir: app.getPath("temp"), + cacheDir: app.getPath("sessionData"), + configuredTempDir: workspace?.temp ?? app.getPath("temp"), + configuredCacheDir: workspace?.cache ?? app.getPath("sessionData"), + restartRequired: Boolean( + workspace && + (path.resolve(app.getPath("temp")) !== path.resolve(workspace.temp) || + path.resolve(app.getPath("sessionData")) !== path.resolve(workspace.cache)), + ), + initializationError: getWorkspaceInitializationError(), + usage: workspace ? await getWorkspaceUsage(workspace) : null, + }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("choose-workspace-directory", async () => { + try { + const currentWorkspace = getConfiguredWorkspaceLayout(); + const sourceRecordings = await getRecordingsDir(); + const sourceProjects = await getProjectsDir(); + const result = await dialog.showOpenDialog({ + title: "Choose a parent folder for RecordlyData", + defaultPath: currentWorkspace?.root ?? app.getPath("documents"), + properties: ["openDirectory", "createDirectory", "promptToCreate"], + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + + const workspaceRoot = getWorkspaceRootFromSelectedDirectory(result.filePaths[0]); + const proposedLayout = resolveWorkspaceLayout(workspaceRoot); + let migrationResult: Awaited> | null = null; + const storageChanged = + path.resolve(sourceRecordings) !== path.resolve(proposedLayout.recordings) || + path.resolve(sourceProjects) !== path.resolve(proposedLayout.projects); + + if (storageChanged) { + const existingBytes = await getStorageMigrationUsage({ + recordings: sourceRecordings, + projects: sourceProjects, + }); + if (existingBytes > 0) { + const existingMegabytes = (existingBytes / (1024 * 1024)).toFixed(1); + const migrationPrompt = await dialog.showMessageBox({ + type: "question", + title: "Move Recordly storage", + message: `Copy ${existingMegabytes} MB of existing recordings and projects?`, + detail: "Recordly copies first and never overwrites files already present in the new location. You can choose whether to remove the copied originals after the copy succeeds.", + buttons: ["Copy existing files", "Use new location only", "Cancel"], + defaultId: 0, + cancelId: 2, + }); + if (migrationPrompt.response === 2) { + return { success: false, canceled: true }; + } + if (migrationPrompt.response === 0) { + migrationResult = await migrateStorageData( + { recordings: sourceRecordings, projects: sourceProjects }, + proposedLayout, + ); + } + } + } + + const layout = await persistWorkspaceRoot(USER_DATA_PATH, workspaceRoot); + // Keep the existing recordings setting compatible with older Recordly + // versions while making new captures use the workspace immediately. + await persistRecordingsDirectorySetting(layout.recordings); + + let deletedOriginalBytes = 0; + if (migrationResult && migrationResult.copiedCount > 0) { + const copiedMegabytes = (migrationResult.copiedBytes / (1024 * 1024)).toFixed(1); + const deletePrompt = await dialog.showMessageBox({ + type: "question", + title: "Existing files copied", + message: `${migrationResult.copiedCount} files (${copiedMegabytes} MB) were copied successfully`, + detail: + migrationResult.skippedConflicts > 0 + ? `${migrationResult.skippedConflicts} conflicting files were kept only in the old location. Delete the originals that were copied successfully?` + : "Delete the originals that were copied successfully to free space on the old drive?", + buttons: ["Delete copied originals", "Keep originals"], + defaultId: 1, + cancelId: 1, + }); + if (deletePrompt.response === 0) { + const deleted = await deleteMigratedSourceFiles(migrationResult.copiedFiles, [ + sourceRecordings, + sourceProjects, + ]); + deletedOriginalBytes = deleted.deletedBytes; + } + } + + const prompt = await dialog.showMessageBox({ + type: "info", + title: "Storage location updated", + message: "RecordlyData is ready", + detail: `New recordings and projects will use:\n${layout.root}\n\nRestart Recordly now to move recording temporary files and cache to this drive too.${ + deletedOriginalBytes > 0 + ? `\n\n${(deletedOriginalBytes / (1024 * 1024)).toFixed(1)} MB was removed from the old location.` + : "" + }`, + buttons: ["Restart now", "Later"], + defaultId: 0, + cancelId: 1, + }); + + if (prompt.response === 0) { + setTimeout(() => { + app.relaunch(); + app.exit(0); + }, 100); + } + + return { + success: true, + workspaceRoot: layout.root, + recordingsDir: layout.recordings, + projectsDir: layout.projects, + tempDir: layout.temp, + cacheDir: layout.cache, + restartRequired: prompt.response !== 0, + }; + } catch (error) { + return { + success: false, + error: String(error), + message: "Failed to configure RecordlyData", + }; + } + }); + + ipcMain.handle("open-workspace-directory", async () => { + try { + const workspace = getConfiguredWorkspaceLayout(); + const targetPath = workspace?.root ?? path.dirname(await getRecordingsDir()); + const openPathResult = await shell.openPath(targetPath); + return openPathResult + ? { success: false, error: openPathResult } + : { success: true, path: targetPath }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + ipcMain.handle("cleanup-recordly-temporary-files", async () => { + try { + const result = await cleanupRecordlyTempArtifacts(app.getPath("temp")); + const megabytes = (result.removedBytes / (1024 * 1024)).toFixed(1); + await dialog.showMessageBox({ + type: "info", + title: "Temporary files cleaned", + message: `Removed ${result.removedCount} stale item${result.removedCount === 1 ? "" : "s"}`, + detail: `${megabytes} MB was released. Files modified within the last hour were kept to avoid interrupting active work.`, + }); + return { success: true, ...result }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => { try { const projectsDir = await getProjectsDir() diff --git a/electron/ipc/utils.ts b/electron/ipc/utils.ts index 3f2efb065..8f5997d8d 100644 --- a/electron/ipc/utils.ts +++ b/electron/ipc/utils.ts @@ -3,8 +3,12 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { app } from "electron"; -import { RECORDINGS_DIR } from "../appPaths"; -import { AUTO_RECORDING_PREFIX, RECORDINGS_SETTINGS_FILE } from "./constants"; +import { getConfiguredWorkspaceLayout, RECORDINGS_DIR } from "../appPaths"; +import { + AUTO_RECORDING_PREFIX, + PROJECTS_DIRECTORY_NAME, + RECORDINGS_SETTINGS_FILE, +} from "./constants"; import { approvedLocalReadPaths, customRecordingsDir, @@ -108,7 +112,16 @@ async function loadRecordingsDirectorySetting() { export async function getRecordingsDir() { await loadRecordingsDirectorySetting(); - const targetDir = customRecordingsDir ?? RECORDINGS_DIR; + const targetDir = + customRecordingsDir ?? getConfiguredWorkspaceLayout()?.recordings ?? RECORDINGS_DIR; + await fs.mkdir(targetDir, { recursive: true }); + return targetDir; +} + +export async function getProjectsStorageDir() { + const workspaceProjectsDir = getConfiguredWorkspaceLayout()?.projects; + const targetDir = + workspaceProjectsDir ?? path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME); await fs.mkdir(targetDir, { recursive: true }); return targetDir; } @@ -129,4 +142,3 @@ export function approveUserPath(filePath: string | null | undefined): void { // Ignore invalid paths; later reads will surface the underlying error. } } - diff --git a/electron/main.ts b/electron/main.ts index 38f4333ff..045281496 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { @@ -16,7 +15,6 @@ import { Tray, webContents as electronWebContents, } from "electron"; -import { RECORDINGS_DIR } from "./appPaths"; import { showCursor } from "./cursorHider"; import { registerExtensionIpcHandlers } from "./extensions/extensionIpc"; import { getGpuSwitches } from "./gpuSwitches"; @@ -27,6 +25,7 @@ import { killWindowsCaptureProcess, registerIpcHandlers, } from "./ipc/handlers"; +import { getProjectsStorageDir, getRecordingsDir } from "./ipc/utils"; import { ensureMediaServer } from "./mediaServer"; import { shouldGrantDisplayCapture, shouldGrantMediaPermission } from "./permissionPolicy"; import { ensurePackagedRendererServer, getPackagedRendererBaseUrl } from "./rendererServer"; @@ -34,6 +33,7 @@ import { hardenWebContentsNavigation, shouldHardenWebContentsType, } from "./navigationPolicy"; +import { cleanupRecordlyTempArtifacts } from "./storageSettings"; import type { UpdateToastPayload } from "./updater"; import { checkForAppUpdates, @@ -116,13 +116,19 @@ async function logSmokeExportGpuDiagnostics() { configureGpuAccelerationSwitches(); -async function ensureRecordingsDir() { +async function ensureStorageDirectories() { try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); - console.log("RECORDINGS_DIR:", RECORDINGS_DIR); + const [recordingsDir, projectsDir] = await Promise.all([ + getRecordingsDir(), + getProjectsStorageDir(), + ]); + console.log("Recordings directory:", recordingsDir); + console.log("Projects directory:", projectsDir); + console.log("Temporary directory:", app.getPath("temp")); + console.log("Cache directory:", app.getPath("sessionData")); console.log("User Data Path:", app.getPath("userData")); } catch (error) { - console.error("Failed to create recordings directory:", error); + console.error("Failed to create storage directories:", error); } } @@ -1006,8 +1012,17 @@ app.whenReady().then(async () => { createTray(); updateTrayMenu(); setupApplicationMenu(); - // Ensure recordings directory exists - await ensureRecordingsDir(); + await ensureStorageDirectories(); + try { + const cleanup = await cleanupRecordlyTempArtifacts(app.getPath("temp")); + if (cleanup.removedCount > 0) { + console.log( + `Removed ${cleanup.removedCount} stale Recordly temporary files (${cleanup.removedBytes} bytes)`, + ); + } + } catch (error) { + console.warn("Failed to clean stale Recordly temporary files:", error); + } if (!VITE_DEV_SERVER_URL) { try { diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..47881a1e8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -925,6 +925,18 @@ contextBridge.exposeInMainWorld("electronAPI", { chooseRecordingsDirectory: () => { return ipcRenderer.invoke("choose-recordings-directory"); }, + getStorageStatus: () => { + return ipcRenderer.invoke("get-storage-status"); + }, + chooseWorkspaceDirectory: () => { + return ipcRenderer.invoke("choose-workspace-directory"); + }, + openWorkspaceDirectory: () => { + return ipcRenderer.invoke("open-workspace-directory"); + }, + cleanupRecordlyTemporaryFiles: () => { + return ipcRenderer.invoke("cleanup-recordly-temporary-files"); + }, getShortcuts: () => { return ipcRenderer.invoke("get-shortcuts"); }, diff --git a/electron/storageSettings.test.ts b/electron/storageSettings.test.ts new file mode 100644 index 000000000..e75cba06a --- /dev/null +++ b/electron/storageSettings.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + cleanupRecordlyTempArtifacts, + deleteMigratedSourceFiles, + getActiveWorkspaceLayout, + getWorkspaceRootFromSelectedDirectory, + initializeWorkspaceStorage, + migrateStorageData, + persistWorkspaceRoot, + resolveWorkspaceLayout, +} from "./storageSettings"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +async function createTemporaryRoot(prefix: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} + +describe("workspace storage settings", () => { + it("creates an isolated layout below the selected workspace", () => { + const layout = resolveWorkspaceLayout(path.join("tmp", "RecordlyData")); + + expect(layout.recordings).toBe(path.join(layout.root, "Recordings")); + expect(layout.projects).toBe(path.join(layout.root, "Projects")); + expect(layout.temp).toBe(path.join(layout.root, "Temp")); + expect(layout.cache).toBe(path.join(layout.root, "Cache")); + }); + + it("uses an existing RecordlyData folder or creates one below a selected parent", () => { + const parent = path.resolve("tmp", "DiskD"); + expect(getWorkspaceRootFromSelectedDirectory(parent)).toBe( + path.join(parent, "RecordlyData"), + ); + expect(getWorkspaceRootFromSelectedDirectory(path.join(parent, "RecordlyData"))).toBe( + path.join(parent, "RecordlyData"), + ); + }); + + it("restores temp and cache paths before Electron becomes ready", async () => { + const root = await createTemporaryRoot("recordly-storage-settings-"); + const userDataPath = path.join(root, "UserData"); + const workspaceRoot = path.join(root, "DiskD", "RecordlyData"); + await persistWorkspaceRoot(userDataPath, workspaceRoot); + + const appliedPaths = new Map(); + const layout = initializeWorkspaceStorage( + { + getPath: (name) => path.join(root, name), + setPath: (name, value) => appliedPaths.set(name, value), + }, + userDataPath, + ); + + expect(layout).toEqual(resolveWorkspaceLayout(workspaceRoot)); + expect(appliedPaths.get("temp")).toBe(path.join(workspaceRoot, "Temp")); + expect(appliedPaths.get("sessionData")).toBe(path.join(workspaceRoot, "Cache")); + expect(getActiveWorkspaceLayout()?.root).toBe(path.resolve(workspaceRoot)); + }); + + it("removes only stale Recordly temporary artifacts", async () => { + const root = await createTemporaryRoot("recordly-temp-cleanup-"); + const staleArtifact = path.join(root, "recordly-native-old.mp4"); + const recentArtifact = path.join(root, "recordly-export-current.mp4"); + const unrelatedArtifact = path.join(root, "another-app.tmp"); + await Promise.all([ + fs.writeFile(staleArtifact, "stale-recordly-data"), + fs.writeFile(recentArtifact, "active-recordly-data"), + fs.writeFile(unrelatedArtifact, "other-data"), + ]); + const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1_000); + await fs.utimes(staleArtifact, staleTime, staleTime); + + const result = await cleanupRecordlyTempArtifacts(root, 60 * 60 * 1_000); + + expect(result.removedCount).toBe(1); + await expect(fs.access(staleArtifact)).rejects.toThrow(); + await expect(fs.access(recentArtifact)).resolves.toBeUndefined(); + await expect(fs.access(unrelatedArtifact)).resolves.toBeUndefined(); + }); + + it("copies existing storage, rewrites project paths, and deletes only copied sources", async () => { + const root = await createTemporaryRoot("recordly-storage-migration-"); + const sourceRecordings = path.join(root, "old", "recordings"); + const sourceProjects = path.join(sourceRecordings, "Projects"); + const sourceVideo = path.join(sourceRecordings, "session.webm"); + const sourceProject = path.join(sourceProjects, "session.recordly"); + await fs.mkdir(sourceProjects, { recursive: true }); + await fs.writeFile(sourceVideo, "recording-data"); + await fs.writeFile( + sourceProject, + JSON.stringify({ videoPath: sourceVideo, nested: { sourcePath: sourceVideo } }), + ); + + const destination = resolveWorkspaceLayout(path.join(root, "new", "RecordlyData")); + const migration = await migrateStorageData( + { recordings: sourceRecordings, projects: sourceProjects }, + destination, + ); + + expect(migration.copiedCount).toBe(2); + expect(migration.skippedConflicts).toBe(0); + const migratedProject = JSON.parse( + await fs.readFile(path.join(destination.projects, "session.recordly"), "utf-8"), + ) as { videoPath: string; nested: { sourcePath: string } }; + const migratedVideo = path.join(destination.recordings, "session.webm"); + expect(migratedProject.videoPath).toBe(migratedVideo); + expect(migratedProject.nested.sourcePath).toBe(migratedVideo); + + const deleted = await deleteMigratedSourceFiles(migration.copiedFiles, [sourceRecordings]); + expect(deleted.deletedCount).toBe(2); + await expect(fs.access(sourceVideo)).rejects.toThrow(); + await expect(fs.access(sourceProject)).rejects.toThrow(); + await expect(fs.access(migratedVideo)).resolves.toBeUndefined(); + }); +}); diff --git a/electron/storageSettings.ts b/electron/storageSettings.ts new file mode 100644 index 000000000..6acfcb224 --- /dev/null +++ b/electron/storageSettings.ts @@ -0,0 +1,451 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import path from "node:path"; + +export const STORAGE_SETTINGS_FILE_NAME = "storage-settings.json"; +export const WORKSPACE_DIRECTORY_NAME = "RecordlyData"; + +export interface WorkspaceLayout { + root: string; + recordings: string; + projects: string; + temp: string; + cache: string; +} + +interface StorageSettingsFile { + version: 1; + workspaceRoot: string; +} + +interface AppPathController { + getPath(name: "temp" | "sessionData"): string; + setPath(name: "temp" | "sessionData", value: string): void; +} + +let activeWorkspaceRoot: string | null = null; +let workspaceInitializationError: string | null = null; + +export function resolveWorkspaceLayout(workspaceRoot: string): WorkspaceLayout { + const root = path.resolve(workspaceRoot); + return { + root, + recordings: path.join(root, "Recordings"), + projects: path.join(root, "Projects"), + temp: path.join(root, "Temp"), + cache: path.join(root, "Cache"), + }; +} + +export function getStorageSettingsPath(userDataPath: string) { + return path.join(userDataPath, STORAGE_SETTINGS_FILE_NAME); +} + +export function normalizeWorkspaceRoot(value: unknown): string | null { + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + + return path.resolve(value.trim()); +} + +export function readWorkspaceRootSync(userDataPath: string): string | null { + try { + const content = fs.readFileSync(getStorageSettingsPath(userDataPath), "utf-8"); + const parsed = JSON.parse(content) as Partial; + return normalizeWorkspaceRoot(parsed.workspaceRoot); + } catch { + return null; + } +} + +function ensureWorkspaceLayoutSync(layout: WorkspaceLayout) { + for (const directoryPath of Object.values(layout)) { + fs.mkdirSync(directoryPath, { recursive: true }); + } +} + +export async function ensureWorkspaceLayout(layout: WorkspaceLayout) { + await Promise.all( + Object.values(layout).map((directoryPath) => + fsPromises.mkdir(directoryPath, { recursive: true }), + ), + ); +} + +export function initializeWorkspaceStorage( + app: AppPathController, + userDataPath: string, +): WorkspaceLayout | null { + const configuredRoot = readWorkspaceRootSync(userDataPath); + if (!configuredRoot) { + activeWorkspaceRoot = null; + workspaceInitializationError = null; + return null; + } + + const layout = resolveWorkspaceLayout(configuredRoot); + try { + ensureWorkspaceLayoutSync(layout); + app.setPath("temp", layout.temp); + app.setPath("sessionData", layout.cache); + activeWorkspaceRoot = layout.root; + workspaceInitializationError = null; + return layout; + } catch (error) { + // A removable drive or network share may be unavailable during startup. + // Keep Recordly usable with Electron's default paths and surface the error + // through the storage status IPC instead of failing app startup. + activeWorkspaceRoot = null; + workspaceInitializationError = error instanceof Error ? error.message : String(error); + return null; + } +} + +export function getActiveWorkspaceLayout(): WorkspaceLayout | null { + return activeWorkspaceRoot ? resolveWorkspaceLayout(activeWorkspaceRoot) : null; +} + +export function getWorkspaceInitializationError() { + return workspaceInitializationError; +} + +export async function persistWorkspaceRoot(userDataPath: string, workspaceRoot: string) { + const layout = resolveWorkspaceLayout(workspaceRoot); + await ensureWorkspaceLayout(layout); + await fsPromises.mkdir(userDataPath, { recursive: true }); + + const settingsPath = getStorageSettingsPath(userDataPath); + const temporaryPath = `${settingsPath}.tmp`; + const settings: StorageSettingsFile = { + version: 1, + workspaceRoot: layout.root, + }; + + await fsPromises.writeFile(temporaryPath, JSON.stringify(settings, null, 2), "utf-8"); + await fsPromises.rename(temporaryPath, settingsPath); + activeWorkspaceRoot = layout.root; + workspaceInitializationError = null; + return layout; +} + +export function getWorkspaceRootFromSelectedDirectory(selectedDirectory: string) { + const selectedPath = path.resolve(selectedDirectory); + return path.basename(selectedPath).toLowerCase() === WORKSPACE_DIRECTORY_NAME.toLowerCase() + ? selectedPath + : path.join(selectedPath, WORKSPACE_DIRECTORY_NAME); +} + +async function getPathSize(targetPath: string): Promise { + let stats: fs.Stats; + try { + stats = await fsPromises.lstat(targetPath); + } catch { + return 0; + } + if (stats.isSymbolicLink()) { + return 0; + } + + if (!stats.isDirectory()) { + return stats.size; + } + + const entries = await fsPromises.readdir(targetPath, { withFileTypes: true }); + const sizes = await Promise.all( + entries.map((entry) => getPathSize(path.join(targetPath, entry.name))), + ); + return sizes.reduce((total, size) => total + size, 0); +} + +function getRelativePathInside(parentPath: string, candidatePath: string): string | null { + const relativePath = path.relative(path.resolve(parentPath), path.resolve(candidatePath)); + if (relativePath === "") { + return ""; + } + if ( + relativePath.startsWith(`..${path.sep}`) || + relativePath === ".." || + path.isAbsolute(relativePath) + ) { + return null; + } + return relativePath; +} + +function replaceStoredPathPrefix( + value: string, + sourcePath: string, + destinationPath: string, +): string { + const relativePath = getRelativePathInside(sourcePath, value); + return relativePath === null ? value : path.join(destinationPath, relativePath); +} + +function rewriteStoredPaths( + value: unknown, + mappings: Array<{ source: string; destination: string }>, +): unknown { + if (typeof value === "string" && path.isAbsolute(value)) { + return mappings.reduce( + (current, mapping) => + replaceStoredPathPrefix(current, mapping.source, mapping.destination), + value, + ); + } + if (Array.isArray(value)) { + return value.map((item) => rewriteStoredPaths(item, mappings)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, rewriteStoredPaths(item, mappings)]), + ); + } + return value; +} + +export interface WorkspaceMigrationSource { + recordings: string; + projects: string; +} + +export interface WorkspaceMigrationCopy { + source: string; + destination: string; +} + +export interface WorkspaceMigrationResult { + copiedCount: number; + copiedBytes: number; + skippedConflicts: number; + skippedLinks: number; + copiedFiles: WorkspaceMigrationCopy[]; +} + +export async function getStorageMigrationUsage(source: WorkspaceMigrationSource) { + const projectsInsideRecordings = getRelativePathInside(source.recordings, source.projects); + const recordingsBytes = await getPathSize(source.recordings); + const projectsBytes = + projectsInsideRecordings === null ? await getPathSize(source.projects) : 0; + return recordingsBytes + projectsBytes; +} + +async function rewriteMigratedProjectFile( + projectPath: string, + source: WorkspaceMigrationSource, + destination: WorkspaceLayout, +) { + if (!/[.](?:recordly|json)$/i.test(projectPath)) { + return; + } + + try { + const content = await fsPromises.readFile(projectPath, "utf-8"); + const project = JSON.parse(content) as unknown; + const rewritten = rewriteStoredPaths(project, [ + { source: source.projects, destination: destination.projects }, + { source: source.recordings, destination: destination.recordings }, + ]); + const temporaryPath = `${projectPath}.migration.tmp`; + await fsPromises.writeFile(temporaryPath, JSON.stringify(rewritten, null, 2), "utf-8"); + await fsPromises.rename(temporaryPath, projectPath); + } catch { + // Not every JSON file in Projects is a Recordly project. Leave unknown files unchanged. + } +} + +export async function migrateStorageData( + source: WorkspaceMigrationSource, + destination: WorkspaceLayout, +): Promise { + const result: WorkspaceMigrationResult = { + copiedCount: 0, + copiedBytes: 0, + skippedConflicts: 0, + skippedLinks: 0, + copiedFiles: [], + }; + + for (const sourceRoot of [source.recordings, source.projects]) { + if (getRelativePathInside(sourceRoot, destination.root) !== null) { + throw new Error( + "The new RecordlyData folder cannot be inside the current storage folder.", + ); + } + } + + const copyTree = async ( + sourceDirectory: string, + destinationDirectory: string, + excludedDirectory?: string, + ): Promise => { + let entries: fs.Dirent[]; + try { + entries = await fsPromises.readdir(sourceDirectory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } + + await fsPromises.mkdir(destinationDirectory, { recursive: true }); + for (const entry of entries) { + const sourcePath = path.join(sourceDirectory, entry.name); + if (excludedDirectory && path.resolve(sourcePath) === path.resolve(excludedDirectory)) { + continue; + } + + const destinationPath = path.join(destinationDirectory, entry.name); + if (entry.isDirectory()) { + await copyTree(sourcePath, destinationPath, excludedDirectory); + continue; + } + if (!entry.isFile()) { + result.skippedLinks += 1; + continue; + } + + await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true }); + try { + await fsPromises.copyFile(sourcePath, destinationPath, fs.constants.COPYFILE_EXCL); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + result.skippedConflicts += 1; + continue; + } + throw error; + } + + const stats = await fsPromises.stat(sourcePath); + result.copiedCount += 1; + result.copiedBytes += stats.size; + result.copiedFiles.push({ source: sourcePath, destination: destinationPath }); + } + }; + + const projectsInsideRecordings = getRelativePathInside(source.recordings, source.projects); + if (path.resolve(source.recordings) !== path.resolve(destination.recordings)) { + await copyTree( + source.recordings, + destination.recordings, + projectsInsideRecordings === null ? undefined : source.projects, + ); + } + if (path.resolve(source.projects) !== path.resolve(destination.projects)) { + const copiedBeforeProjects = result.copiedFiles.length; + await copyTree(source.projects, destination.projects); + for (const copiedFile of result.copiedFiles.slice(copiedBeforeProjects)) { + await rewriteMigratedProjectFile(copiedFile.destination, source, destination); + } + } + + return result; +} + +async function removeEmptyDirectories(directoryPath: string, keepRoot: boolean): Promise { + let entries: fs.Dirent[]; + try { + entries = await fsPromises.readdir(directoryPath, { withFileTypes: true }); + } catch { + return false; + } + + for (const entry of entries) { + if (entry.isDirectory()) { + await removeEmptyDirectories(path.join(directoryPath, entry.name), false); + } + } + + const remainingEntries = await fsPromises.readdir(directoryPath).catch(() => ["unavailable"]); + if (remainingEntries.length > 0 || keepRoot) { + return false; + } + await fsPromises.rmdir(directoryPath).catch(() => undefined); + return true; +} + +export async function deleteMigratedSourceFiles( + copiedFiles: WorkspaceMigrationCopy[], + sourceRoots: string[], +) { + let deletedCount = 0; + let deletedBytes = 0; + + for (const copiedFile of copiedFiles) { + if (!sourceRoots.some((root) => getRelativePathInside(root, copiedFile.source) !== null)) { + continue; + } + const destinationStats = await fsPromises.stat(copiedFile.destination).catch(() => null); + const sourceStats = await fsPromises.stat(copiedFile.source).catch(() => null); + if (!destinationStats?.isFile() || !sourceStats?.isFile()) { + continue; + } + await fsPromises.unlink(copiedFile.source); + deletedCount += 1; + deletedBytes += sourceStats.size; + } + + for (const sourceRoot of sourceRoots) { + await removeEmptyDirectories(sourceRoot, true); + } + return { deletedCount, deletedBytes }; +} + +export async function getWorkspaceUsage(layout: WorkspaceLayout) { + const [recordingsBytes, projectsBytes, tempBytes, cacheBytes] = await Promise.all([ + getPathSize(layout.recordings), + getPathSize(layout.projects), + getPathSize(layout.temp), + getPathSize(layout.cache), + ]); + + return { + recordingsBytes, + projectsBytes, + tempBytes, + cacheBytes, + totalBytes: recordingsBytes + projectsBytes + tempBytes + cacheBytes, + }; +} + +const RECORDLY_TEMP_ARTIFACT_PREFIXES = ["recordly-"]; + +export async function cleanupRecordlyTempArtifacts( + tempDirectory: string, + minimumAgeMs = 60 * 60 * 1_000, +) { + const now = Date.now(); + let removedBytes = 0; + let removedCount = 0; + let entries: fs.Dirent[]; + + try { + entries = await fsPromises.readdir(tempDirectory, { withFileTypes: true }); + } catch { + return { removedBytes, removedCount }; + } + + for (const entry of entries) { + if (!RECORDLY_TEMP_ARTIFACT_PREFIXES.some((prefix) => entry.name.startsWith(prefix))) { + continue; + } + + const artifactPath = path.join(tempDirectory, entry.name); + const stats = await fsPromises.stat(artifactPath).catch(() => null); + if (!stats || now - stats.mtimeMs < minimumAgeMs) { + continue; + } + + const artifactBytes = await getPathSize(artifactPath); + try { + await fsPromises.rm(artifactPath, { force: true, recursive: true }); + removedBytes += artifactBytes; + removedCount += 1; + } catch { + // The artifact may still be held by an encoder. Leave it for the next pass. + } + } + + return { removedBytes, removedCount }; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 09cca63ef..90b64aa9a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -112,6 +112,9 @@ function LaunchWindowContent() { appVersion, hideHudFromCapture, chooseRecordingsDirectory, + chooseWorkspaceDirectory, + openWorkspaceDirectory, + cleanupTemporaryFiles, toggleHudCaptureProtection, } = useLaunchWindowSystemState(preparePermissions); @@ -377,6 +380,15 @@ function LaunchWindowContent() { onToggleHudCaptureProtection={() => { void toggleHudCaptureProtection(); }} + onChooseWorkspaceDirectory={() => { + void chooseWorkspaceDirectory(); + }} + onOpenWorkspaceDirectory={() => { + void openWorkspaceDirectory(); + }} + onCleanupTemporaryFiles={() => { + void cleanupTemporaryFiles(); + }} onChooseRecordingsDirectory={() => { void chooseRecordingsDirectory(); }} diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 56793fa36..7c8c17c7a 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -113,6 +113,33 @@ export function useLaunchWindowSystemState( } }, []); + const chooseWorkspaceDirectory = useCallback(async () => { + try { + const result = await window.electronAPI.chooseWorkspaceDirectory(); + if (result.success && result.recordingsDir) { + setRecordingsDirectory(result.recordingsDir); + } + } catch (error) { + console.error("Failed to choose workspace directory:", error); + } + }, []); + + const openWorkspaceDirectory = useCallback(async () => { + try { + await window.electronAPI.openWorkspaceDirectory(); + } catch (error) { + console.error("Failed to open workspace directory:", error); + } + }, []); + + const cleanupTemporaryFiles = useCallback(async () => { + try { + await window.electronAPI.cleanupRecordlyTemporaryFiles(); + } catch (error) { + console.error("Failed to clean temporary files:", error); + } + }, []); + const toggleHudCaptureProtection = useCallback(async () => { const nextValue = !hideHudFromCapture; setHideHudFromCapture(nextValue); @@ -137,6 +164,9 @@ export function useLaunchWindowSystemState( hideHudFromCapture, setHideHudFromCapture, chooseRecordingsDirectory, + chooseWorkspaceDirectory, + openWorkspaceDirectory, + cleanupTemporaryFiles, toggleHudCaptureProtection, }; } diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a52905..18b56049f 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -38,6 +38,9 @@ export function MorePopover({ supportsHudCaptureProtection, hideHudFromCapture, onToggleHudCaptureProtection, + onChooseWorkspaceDirectory, + onOpenWorkspaceDirectory, + onCleanupTemporaryFiles, onChooseRecordingsDirectory, onOpenVideoFile, onOpenProjectBrowser, @@ -49,6 +52,9 @@ export function MorePopover({ supportsHudCaptureProtection: boolean; hideHudFromCapture: boolean; onToggleHudCaptureProtection: () => void; + onChooseWorkspaceDirectory: () => void; + onOpenWorkspaceDirectory: () => void; + onCleanupTemporaryFiles: () => void; onChooseRecordingsDirectory: () => void; onOpenVideoFile: () => void; onOpenProjectBrowser: () => void; @@ -86,6 +92,33 @@ export function MorePopover({ : t("recording.showHudInVideo")} )} + } + onClick={() => { + requestClose(POPOVER_ID); + onChooseWorkspaceDirectory(); + }} + > + {t("recording.workspaceFolder", "Set storage location")} + + } + onClick={() => { + requestClose(POPOVER_ID); + onOpenWorkspaceDirectory(); + }} + > + {t("recording.openWorkspaceFolder", "Open RecordlyData")} + + } + onClick={() => { + requestClose(POPOVER_ID); + onCleanupTemporaryFiles(); + }} + > + {t("recording.cleanTemporaryFiles", "Clean temporary files")} + } onClick={() => { diff --git a/src/i18n/locales/de/launch.json b/src/i18n/locales/de/launch.json index 8574ff13b..900561513 100644 --- a/src/i18n/locales/de/launch.json +++ b/src/i18n/locales/de/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Webcam zum Aktivieren auswählen", "noWebcamsFound": "Keine Webcams gefunden", "recordingsFolder": "Pfad zu den Aufzeichnungen", + "workspaceFolder": "Speicherort festlegen", + "openWorkspaceFolder": "RecordlyData öffnen", + "cleanTemporaryFiles": "Temporäre Dateien bereinigen", "language": "Sprache", "paused": "PAUSIERT", "rec": "AUFNAHME", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9dfcbe038..6d4f637cd 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Select a webcam to enable", "noWebcamsFound": "No webcams found", "recordingsFolder": "Recordings Path", + "workspaceFolder": "Set storage location", + "openWorkspaceFolder": "Open RecordlyData", + "cleanTemporaryFiles": "Clean temporary files", "language": "Language", "paused": "PAUSED", "rec": "REC", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index a458844a2..2200ab6c0 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Selecciona una cámara para activar", "noWebcamsFound": "No se encontraron cámaras", "recordingsFolder": "Carpeta de grabaciones", + "workspaceFolder": "Establecer ubicación de almacenamiento", + "openWorkspaceFolder": "Abrir RecordlyData", + "cleanTemporaryFiles": "Limpiar archivos temporales", "language": "Idioma", "paused": "PAUSADO", "rec": "REC", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index df2e43b2b..b4538e5ed 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Sélectionnez une webcam à activer", "noWebcamsFound": "Aucune webcam trouvée", "recordingsFolder": "Chemin des enregistrements", + "workspaceFolder": "Définir l’emplacement de stockage", + "openWorkspaceFolder": "Ouvrir RecordlyData", + "cleanTemporaryFiles": "Nettoyer les fichiers temporaires", "language": "Langue", "paused": "EN PAUSE", "rec": "REC", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index c27abc3ad..24d89bea6 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Seleziona una webcam da abilitare", "noWebcamsFound": "Nessuna webcam trovata", "recordingsFolder": "Percorso registrazioni", + "workspaceFolder": "Imposta posizione di archiviazione", + "openWorkspaceFolder": "Apri RecordlyData", + "cleanTemporaryFiles": "Pulisci i file temporanei", "language": "Lingua", "paused": "IN PAUSA", "rec": "REC", diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 345000399..a00bb723b 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "사용할 웹캠을 선택하세요", "noWebcamsFound": "웹캠을 찾을 수 없습니다", "recordingsFolder": "녹화 폴더", + "workspaceFolder": "저장 위치 설정", + "openWorkspaceFolder": "RecordlyData 열기", + "cleanTemporaryFiles": "임시 파일 정리", "language": "언어", "paused": "일시 정지", "rec": "녹화 중", diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index d3d5870c9..213538a45 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Selecteer een webcam om in te schakelen", "noWebcamsFound": "Geen webcams gevonden", "recordingsFolder": "Opnamepad", + "workspaceFolder": "Opslaglocatie instellen", + "openWorkspaceFolder": "RecordlyData openen", + "cleanTemporaryFiles": "Tijdelijke bestanden opschonen", "language": "Taal", "paused": "GEPAUZEERD", "rec": "OPN", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 8d19ac7db..e2a2585f7 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Selecione uma webcam para ativar", "noWebcamsFound": "Nenhuma webcam encontrada", "recordingsFolder": "Caminho das gravações", + "workspaceFolder": "Definir local de armazenamento", + "openWorkspaceFolder": "Abrir RecordlyData", + "cleanTemporaryFiles": "Limpar arquivos temporários", "language": "Idioma", "paused": "PAUSADO", "rec": "GRAV", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 65bd57b8c..db792fc3a 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "Выберите веб-камеру", "noWebcamsFound": "Нет доступных веб-камер", "recordingsFolder": "Место сохранения", + "workspaceFolder": "Выбрать место хранения", + "openWorkspaceFolder": "Открыть RecordlyData", + "cleanTemporaryFiles": "Очистить временные файлы", "language": "Язык", "paused": "ПАУЗА", "rec": "ЗАПИСЬ", @@ -77,4 +80,4 @@ "failedToStart": "Не удалось начать запись: {{error}}", "failedToStartGeneric": "Не удалось начать запись" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 164c02afe..58d0517d5 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "选择一个摄像头以启用", "noWebcamsFound": "未找到摄像头", "recordingsFolder": "录制文件夹", + "workspaceFolder": "设置存储位置", + "openWorkspaceFolder": "打开 RecordlyData", + "cleanTemporaryFiles": "清理临时文件", "language": "语言", "paused": "已暂停", "rec": "录制中", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 12ec494d0..142424e49 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -35,6 +35,9 @@ "selectWebcamToEnable": "選擇要啟用的網路攝影機", "noWebcamsFound": "找不到網路攝影機", "recordingsFolder": "錄影儲存路徑", + "workspaceFolder": "設定儲存位置", + "openWorkspaceFolder": "開啟 RecordlyData", + "cleanTemporaryFiles": "清理暫存檔案", "language": "語言", "paused": "已暫停", "rec": "錄製中", From b9d3e2cacb70902421a68dcac1d1ea3d81cfcaa3 Mon Sep 17 00:00:00 2001 From: Shanjin <121328191+shanjin666666@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:06:21 +0800 Subject: [PATCH 2/4] fix: harden Windows capture temp handling --- electron/ipc/recording/windows.test.ts | 53 ++++++++++++- electron/ipc/recording/windows.ts | 94 ++++++++++++++++++++++-- electron/ipc/register/recording.ts | 25 +++++-- electron/native/wgc-capture/src/main.cpp | 51 ++++++++++++- src/hooks/useScreenRecorder.ts | 11 +-- 5 files changed, 208 insertions(+), 26 deletions(-) diff --git a/electron/ipc/recording/windows.test.ts b/electron/ipc/recording/windows.test.ts index 73a1e0c24..096fe892b 100644 --- a/electron/ipc/recording/windows.test.ts +++ b/electron/ipc/recording/windows.test.ts @@ -1,8 +1,15 @@ import { EventEmitter } from "node:events"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { PassThrough } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state"; -import { waitForWindowsCaptureStop } from "./windows"; +import { + describeWindowsCaptureStartFailure, + prepareWindowsCaptureTempDirectory, + waitForWindowsCaptureStop, +} from "./windows"; vi.mock("electron", () => ({ app: { @@ -25,6 +32,48 @@ class FakeCaptureProcess extends EventEmitter { }); } +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +describe("Windows capture temporary storage", () => { + it("accepts a writable Unicode temporary directory and removes its probe file", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-windows-temp-")); + temporaryRoots.push(root); + const unicodeDirectory = path.join(root, "录屏 临时目录"); + + const status = await prepareWindowsCaptureTempDirectory(unicodeDirectory, 0); + + expect(status.directory).toBe(path.resolve(unicodeDirectory)); + expect(await fs.readdir(unicodeDirectory)).toEqual([]); + }); + + it("rejects a temporary directory that does not have the required free space", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-windows-space-")); + temporaryRoots.push(root); + + await expect( + prepareWindowsCaptureTempDirectory(root, Number.MAX_SAFE_INTEGER), + ).rejects.toThrow("Recordly needs at least"); + }); + + it("prefers the native helper error and includes an actionable temporary path", () => { + const detail = describeWindowsCaptureStartFailure( + new Error("Native helper exited"), + "INFO: Initializing\nERROR: Failed to initialize Media Foundation encoder\n", + "D:\\RecordlyTemp", + ); + + expect(detail).toContain("ERROR: Failed to initialize Media Foundation encoder"); + expect(detail).toContain("D:\\RecordlyTemp"); + expect(detail).toContain("choose another Recordly storage location"); + }); +}); + describe("waitForWindowsCaptureStop", () => { beforeEach(() => { setWindowsCaptureOutputBuffer(""); diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index 262d26daa..82f0a54c3 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -1,6 +1,8 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; +import path from "node:path"; import { BrowserWindow } from "electron"; import { getWindowsCaptureExePath } from "../paths/binaries"; import { @@ -13,13 +15,91 @@ import { windowsCaptureTargetPath, windowsNativeCaptureActive, } from "../state"; -import { - AudioSyncAdjustment, -} from "../types"; +import { AudioSyncAdjustment } from "../types"; import { moveFileWithOverwrite } from "../utils"; import { emitRecordingInterrupted } from "./events"; const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000; +export const MIN_WINDOWS_CAPTURE_TEMP_FREE_BYTES = 512 * 1024 * 1024; + +export type WindowsCaptureTempStatus = { + directory: string; + freeBytes: number | null; +}; + +function formatStorageSize(bytes: number) { + if (!Number.isFinite(bytes) || bytes < 0) { + return "an unknown amount of space"; + } + + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + + return `${Math.max(0, Math.round(bytes / (1024 * 1024)))} MB`; +} + +export async function prepareWindowsCaptureTempDirectory( + tempDirectory: string, + minimumFreeBytes = MIN_WINDOWS_CAPTURE_TEMP_FREE_BYTES, +): Promise { + const directory = path.resolve(tempDirectory); + let probePath: string | null = null; + + try { + await fs.mkdir(directory, { recursive: true }); + probePath = path.join(directory, `.recordly-write-test-${process.pid}-${randomUUID()}.tmp`); + await fs.writeFile(probePath, "Recordly temporary storage probe", { flag: "wx" }); + } catch (error) { + throw new Error( + `Recordly cannot write to its temporary folder (${directory}). Choose another storage location or check the folder permissions. ${String(error)}`, + ); + } finally { + if (probePath) { + await fs.rm(probePath, { force: true }).catch(() => undefined); + } + } + + let freeBytes: number | null = null; + try { + const stats = await fs.statfs(directory); + const reportedFreeBytes = Number(stats.bavail) * Number(stats.bsize); + freeBytes = Number.isFinite(reportedFreeBytes) ? reportedFreeBytes : null; + } catch { + // Older Windows filesystems may not report capacity through statfs. The + // successful write probe is still enough to safely attempt capture. + } + + if (freeBytes !== null && freeBytes < minimumFreeBytes) { + throw new Error( + `Recordly needs at least ${formatStorageSize(minimumFreeBytes)} free in its temporary folder (${directory}), but only ${formatStorageSize(freeBytes)} is available. Free disk space or choose another storage location.`, + ); + } + + return { directory, freeBytes }; +} + +export function describeWindowsCaptureStartFailure( + error: unknown, + processOutput: string, + tempDirectory: string, +) { + const outputLines = processOutput + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const helperError = [...outputLines] + .reverse() + .find((line) => line.startsWith("ERROR:") || line.startsWith("WARNING:")); + const errorMessage = error instanceof Error ? error.message : String(error); + const detail = helperError ?? errorMessage; + + if (/temporary folder|free disk space|folder permissions/iu.test(detail)) { + return detail; + } + + return `${detail} Temporary folder: ${path.resolve(tempDirectory)}. If this folder is on a full or restricted drive, choose another Recordly storage location.`; +} export type NativeWindowsVideoPaddingResult = { padded: boolean; @@ -135,7 +215,9 @@ export function waitForWindowsCaptureStop( const onClose = (code: number | null) => { finish(() => { - const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + const match = windowsCaptureOutputBuffer.match( + /Recording stopped\. Output path: (.+)/, + ); if (match?.[1]) { resolve(match[1].trim()); return; @@ -254,9 +336,7 @@ export async function muxNativeWindowsVideoWithAudio( } } - console.log( - `[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`, - ); + console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`); return { muxed: false, diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84f..e677a104c 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -71,8 +71,10 @@ import { import { resolveRecordedVideoStoragePath } from "../recording/storagePath"; import { attachWindowsCaptureLifecycle, + describeWindowsCaptureStartFailure, isNativeWindowsCaptureAvailable, muxNativeWindowsVideoWithAudio, + prepareWindowsCaptureTempDirectory, waitForWindowsCaptureStart, waitForWindowsCaptureStop, } from "../recording/windows"; @@ -429,14 +431,16 @@ export function registerRecordingHandlers( let tempVideoPath: string | null = null; let tempSystemAudioPath: string | null = null; let tempMicPath: string | null = null; + let captureOutput = ""; + const tempDirectory = app.getPath("temp"); try { const exePath = getWindowsCaptureExePath(); const recordingsDir = await getRecordingsDir(); + await prepareWindowsCaptureTempDirectory(tempDirectory); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); - tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); + tempVideoPath = path.join(tempDirectory, `recordly-native-${timestamp}.mp4`); - let captureOutput = ""; let systemAudioPath: string | null = null; let microphonePath: string | null = null; let orphanedMicAudioPath: string | null = null; @@ -496,7 +500,7 @@ export function registerRecordingHandlers( `recording-${timestamp}.system.wav`, ); tempSystemAudioPath = path.join( - app.getPath("temp"), + tempDirectory, `recordly-native-${timestamp}.system.wav`, ); config.captureSystemAudio = true; @@ -508,7 +512,7 @@ export function registerRecordingHandlers( if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); - tempMicPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mic.wav`); + tempMicPath = path.join(tempDirectory, `recordly-native-${timestamp}.mic.wav`); config.captureMic = true; config.micOutputPath = tempMicPath; if (options.microphoneLabel) { @@ -593,6 +597,11 @@ export function registerRecordingHandlers( }); return { success: true, microphoneFallbackRequired }; } catch (error) { + const failureDetail = describeWindowsCaptureStartFailure( + error, + captureOutput || windowsCaptureOutputBuffer, + tempDirectory, + ); recordNativeCaptureDiagnostics({ backend: "windows-wgc", phase: "start", @@ -602,10 +611,10 @@ export function registerRecordingHandlers( outputPath: windowsCaptureTargetPath, systemAudioPath: windowsSystemAudioPath, microphonePath: windowsMicAudioPath, - processOutput: windowsCaptureOutputBuffer.trim() || undefined, - error: String(error), + processOutput: (captureOutput || windowsCaptureOutputBuffer).trim() || undefined, + error: failureDetail, }); - console.error("Failed to start native Windows capture:", error); + console.error("Failed to start native Windows capture:", failureDetail, error); try { if (wcProc) wcProc.kill(); } catch { @@ -634,7 +643,7 @@ export function registerRecordingHandlers( return { success: false, message: "Failed to start native Windows capture", - error: String(error), + error: failureDetail, }; } } diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 0f007826b..e15e995c0 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -150,12 +150,54 @@ static bool parseSimpleJson(const std::string& json, CaptureConfig& config) { static std::wstring utf8ToWide(const std::string& str) { if (str.empty()) return L""; - int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + int len = MultiByteToWideChar( + CP_UTF8, + MB_ERR_INVALID_CHARS, + str.c_str(), + static_cast(str.size()), + nullptr, + 0); + if (len <= 0) return L""; std::wstring wstr(len, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + if (MultiByteToWideChar( + CP_UTF8, + MB_ERR_INVALID_CHARS, + str.c_str(), + static_cast(str.size()), + &wstr[0], + len) <= 0) { + return L""; + } return wstr; } +static std::string wideToUtf8(const std::wstring& str) { + if (str.empty()) return ""; + int len = WideCharToMultiByte( + CP_UTF8, + WC_ERR_INVALID_CHARS, + str.c_str(), + static_cast(str.size()), + nullptr, + 0, + nullptr, + nullptr); + if (len <= 0) return ""; + std::string utf8(len, '\0'); + if (WideCharToMultiByte( + CP_UTF8, + WC_ERR_INVALID_CHARS, + str.c_str(), + static_cast(str.size()), + &utf8[0], + len, + nullptr, + nullptr) <= 0) { + return ""; + } + return utf8; +} + static int64_t queryPerformanceCounterHns() { LARGE_INTEGER counter; LARGE_INTEGER frequency; @@ -274,7 +316,7 @@ static void stdinListenerThread() { g_stopCv.notify_all(); } -int main(int argc, char* argv[]) { +int wmain(int argc, wchar_t* argv[]) { if (argc < 2) { std::cerr << "ERROR: Missing JSON config argument" << std::endl; return 1; @@ -283,7 +325,8 @@ int main(int argc, char* argv[]) { winrt::init_apartment(winrt::apartment_type::multi_threaded); CaptureConfig config; - if (!parseSimpleJson(argv[1], config)) { + const std::string configJson = wideToUtf8(argv[1]); + if (configJson.empty() || !parseSimpleJson(configJson, config)) { std::cerr << "ERROR: Failed to parse config JSON" << std::endl; return 1; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..7a70148d5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -213,10 +213,7 @@ export function resolveBrowserCaptureCursorPolicy({ export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { - return ( - source?.id?.startsWith("screen:") === true || - source?.id?.startsWith("window:") === true - ); + return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true; } export function createProcessedMicrophoneConstraints( @@ -1472,8 +1469,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void logNativeCaptureDiagnostics("start-native-screen-recording"); if (!hasShownNativeWindowsFallbackToast.current) { hasShownNativeWindowsFallbackToast.current = true; + const failureDetail = nativeResult.error ?? nativeResult.message; toast.warning( - "Native Windows capture failed to start. Falling back to browser capture.", + failureDetail + ? `Native Windows capture failed to start. ${failureDetail} Falling back to browser capture.` + : "Native Windows capture failed to start. Falling back to browser capture.", + { duration: 15000 }, ); } } else if (!nativeResult.userNotified) { From 6fbb15baac9939e1b4fc7f2a9dc24fe0f869fd7d Mon Sep 17 00:00:00 2001 From: Shanjin <121328191+shanjin666666@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:34:44 +0800 Subject: [PATCH 3/4] fix: preserve source frames when moving clips --- src/components/video-editor/VideoEditor.tsx | 100 +++++++++-------- .../video-editor/audio/clipAudio.ts | 20 ++-- .../video-editor/projectPersistence.ts | 4 + .../video-editor/timeline/TimelineEditor.tsx | 21 ++-- .../timeline/model/timelineModel.test.ts | 85 ++++++++++++-- .../timeline/model/timelineModel.ts | 7 +- src/components/video-editor/types.test.ts | 64 ++++++++++- src/components/video-editor/types.ts | 105 +++++++++++++++--- src/lib/exporter/audioEncoder.ts | 10 +- 9 files changed, 313 insertions(+), 103 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c2e16ed60..50e9a4950 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -221,6 +221,7 @@ import { extendAutoFullTrackClip, type FigureData, getClipSourceEndMs, + getClipSourceStartMs, getTimelineDurationMs, type Padding, mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime, @@ -228,6 +229,7 @@ import { type SpeedRegion, type TrimRegion, trimsToClips, + updateClipTimelineSpan, type WebcamOverlaySettings, type ZoomDepth, type ZoomFocus, @@ -1188,7 +1190,7 @@ export default function VideoEditor() { .filter((clip) => clip.speed !== 1) .map((clip) => ({ id: `clip-speed-${clip.id}`, - startMs: clip.startMs, + startMs: getClipSourceStartMs(clip), endMs: getClipSourceEndMs(clip), speed: clip.speed as SpeedRegion["speed"], })); @@ -3591,7 +3593,7 @@ export default function VideoEditor() { .filter((clip) => clip.speed !== 1) .map((clip) => ({ id: `clip-speed-${clip.id}`, - startMs: clip.startMs, + startMs: getClipSourceStartMs(clip), endMs: getClipSourceEndMs(clip), speed: clip.speed as SpeedRegion["speed"], })); @@ -4060,15 +4062,25 @@ export default function VideoEditor() { id: leftId, startMs: target.startMs, endMs: Math.round(splitMs), + sourceStartMs: getClipSourceStartMs(target), speed: target.speed, muted: target.muted, + showSourceAudio: target.showSourceAudio, }; const right: ClipRegion = { id: rightId, startMs: Math.round(splitMs), endMs: target.endMs, + sourceStartMs: Math.round( + getClipSourceStartMs(target) + + (Math.round(splitMs) - target.startMs) * + (Number.isFinite(target.speed) && target.speed > 0 + ? target.speed + : 1), + ), speed: target.speed, muted: target.muted, + showSourceAudio: target.showSourceAudio, }; if (selectedClipId === target.id) { setSelectedClipId(leftId); @@ -4082,65 +4094,55 @@ export default function VideoEditor() { const handleClipSpanChange = useCallback( (id: string, span: Span) => { const oldClip = clipRegions.find((c) => c.id === id); + if (!oldClip) return; + const newStart = Math.round(span.start); const newEnd = Math.round(span.end); - const removedSegments = oldClip - ? [ + const startDelta = newStart - oldClip.startMs; + const endDelta = newEnd - oldClip.endMs; + const isMove = Math.abs(startDelta - endDelta) < 1; + const removedSegments = isMove + ? [] + : [ ...(newStart > oldClip.startMs ? [{ startMs: oldClip.startMs, endMs: newStart }] : []), ...(newEnd < oldClip.endMs ? [{ startMs: newEnd, endMs: oldClip.endMs }] : []), - ] - : []; - - if (oldClip) { - const startDelta = newStart - oldClip.startMs; - const endDelta = newEnd - oldClip.endMs; - const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; - - if (isMove) { - const delta = startDelta; - setZoomRegions((prev) => - prev.map((zoom) => { - const overlaps = - zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; - if (overlaps) { - return { - ...zoom, - startMs: zoom.startMs + delta, - endMs: zoom.endMs + delta, - }; - } - return zoom; - }), - ); + ]; + + const updateRegions = (regions: T[]) => { + if (isMove && Math.abs(startDelta) > 0) { + return regions.map((region) => { + const contained = + region.startMs >= oldClip.startMs && region.endMs <= oldClip.endMs; + return contained + ? { + ...region, + startMs: region.startMs + startDelta, + endMs: region.endMs + startDelta, + } + : region; + }); } - } - if (removedSegments.length > 0) { - const removeTrimmedRegions = ( - regions: T[], - ): T[] => - regions.filter( - (region) => - !removedSegments.some( - (segment) => - region.startMs < segment.endMs && - region.endMs > segment.startMs, - ), - ); - setZoomRegions((prev) => removeTrimmedRegions(prev)); - setAnnotationRegions((prev) => removeTrimmedRegions(prev)); - setSpeedRegions((prev) => removeTrimmedRegions(prev)); - setAudioRegions((prev) => removeTrimmedRegions(prev)); - } + if (removedSegments.length === 0) return regions; + return regions.filter( + (region) => + !removedSegments.some( + (segment) => + region.startMs < segment.endMs && region.endMs > segment.startMs, + ), + ); + }; + setZoomRegions(updateRegions); + setAnnotationRegions(updateRegions); + setSpeedRegions(updateRegions); + setAudioRegions(updateRegions); setClipRegions((prev) => - prev.map((clip) => - clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip, - ), + prev.map((clip) => (clip.id === id ? updateClipTimelineSpan(clip, span) : clip)), ); }, [clipRegions], diff --git a/src/components/video-editor/audio/clipAudio.ts b/src/components/video-editor/audio/clipAudio.ts index c6bf74094..cc03da305 100644 --- a/src/components/video-editor/audio/clipAudio.ts +++ b/src/components/video-editor/audio/clipAudio.ts @@ -1,18 +1,18 @@ -import { getClipSourceEndMs, sortClipRegions } from "../types"; import type { ClipRegion } from "../types"; +import { getClipSourceEndMs, getClipSourceStartMs, sortClipRegionsBySource } from "../types"; export function getActiveClipIdAtSourceTime( - sourceTimeSeconds: number, - clipRegions: ClipRegion[], + sourceTimeSeconds: number, + clipRegions: ClipRegion[], ): string | null { - const sourceMs = Math.round(sourceTimeSeconds * 1000); - const activeClip = sortClipRegions(clipRegions).find( - (clip) => sourceMs >= clip.startMs && sourceMs < getClipSourceEndMs(clip), - ); - return activeClip?.id ?? null; + const sourceMs = Math.round(sourceTimeSeconds * 1000); + const activeClip = sortClipRegionsBySource(clipRegions).find( + (clip) => sourceMs >= getClipSourceStartMs(clip) && sourceMs < getClipSourceEndMs(clip), + ); + return activeClip?.id ?? null; } export function isClipMutedById(clipId: string | null, clipRegions: ClipRegion[]): boolean { - if (!clipId) return false; - return clipRegions.find((clip) => clip.id === clipId)?.muted ?? false; + if (!clipId) return false; + return clipRegions.find((clip) => clip.id === clipId)?.muted ?? false; } diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 9810d5fb3..cf7228c54 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -519,10 +519,14 @@ export function normalizeProjectEditor(editor: Partial): Pro : rawStart + 1000; const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); + const sourceStartMs = isFiniteNumber(region.sourceStartMs) + ? Math.max(0, Math.round(region.sourceStartMs)) + : startMs; return { id: region.id, startMs, endMs, + sourceStartMs, speed: isFiniteNumber(region.speed) ? region.speed : 1, muted: typeof region.muted === "boolean" ? region.muted : false, showSourceAudio: diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 46a610f29..f203885b5 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -209,16 +209,21 @@ const TimelineEditor = forwardRef( const newStart = Math.round(previewSpan.start); const newEnd = Math.round(previewSpan.end); - const removedSegments = [ - ...(newStart > oldClip.startMs - ? [{ startMs: oldClip.startMs, endMs: newStart }] - : []), - ...(newEnd < oldClip.endMs ? [{ startMs: newEnd, endMs: oldClip.endMs }] : []), - ]; - const startDelta = newStart - oldClip.startMs; const endDelta = newEnd - oldClip.endMs; - const isMove = Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0; + const isMove = Math.abs(startDelta - endDelta) < 1; + const removedSegments = [ + ...(isMove + ? [] + : newStart > oldClip.startMs + ? [{ startMs: oldClip.startMs, endMs: newStart }] + : []), + ...(isMove + ? [] + : newEnd < oldClip.endMs + ? [{ startMs: newEnd, endMs: oldClip.endMs }] + : []), + ]; if (isMove) { const delta = startDelta; diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts index 1f20052e4..0713b32b7 100644 --- a/src/components/video-editor/timeline/model/timelineModel.test.ts +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -35,10 +35,22 @@ describe("timeline model", () => { ], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], annotationRegions: [ - { ...BASE_ANNOTATION, type: "text" as const, content: "Hello timeline", trackIndex: 1 }, + { + ...BASE_ANNOTATION, + type: "text" as const, + content: "Hello timeline", + trackIndex: 1, + }, ], audioRegions: [ - { id: "au1", startMs: 500, endMs: 2000, audioPath: "/tmp/foo.mp3", volume: 1, trackIndex: 0 }, + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "/tmp/foo.mp3", + volume: 1, + trackIndex: 0, + }, ], }); @@ -62,6 +74,28 @@ describe("timeline model", () => { }); }); + it("renders the original source span after a clip is moved on the timeline", () => { + const items = buildTimelineItems({ + zoomRegions: [], + clipRegions: [ + { + id: "c1", + startMs: 5_000, + endMs: 10_000, + sourceStartMs: 8_000, + speed: 1, + }, + ], + annotationRegions: [], + audioRegions: [], + }); + + expect(items[0]).toMatchObject({ + span: { start: 5_000, end: 10_000 }, + sourceSpan: { start: 8_000, end: 13_000 }, + }); + }); + it("builds all variant labels for annotation and audio", () => { expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe( "Empty text", @@ -80,8 +114,18 @@ describe("timeline model", () => { "Annotation", ); - expect(getAudioLabel({ id: "1", startMs: 0, endMs: 1, audioPath: "C:\\x\\y\\z.wav", volume: 1 })).toBe("z"); - expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe("Audio"); + expect( + getAudioLabel({ + id: "1", + startMs: 0, + endMs: 1, + audioPath: "C:\\x\\y\\z.wav", + volume: 1, + }), + ).toBe("z"); + expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe( + "Audio", + ); }); it("builds row spans for dnd constraints", () => { @@ -91,7 +135,14 @@ describe("timeline model", () => { ], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], audioRegions: [ - { id: "au1", startMs: 500, endMs: 2000, audioPath: "x.wav", volume: 1, trackIndex: 2 }, + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "x.wav", + volume: 1, + trackIndex: 2, + }, ], }); expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]); @@ -99,9 +150,27 @@ describe("timeline model", () => { it("keeps items in their domain rows during dnd", () => { const items = [ - { id: "a1", rowId: "row-annotation-1", span: { start: 0, end: 1 }, label: "A", variant: "annotation" as const }, - { id: "au1", rowId: "row-audio-2", span: { start: 0, end: 1 }, label: "X", variant: "audio" as const }, - { id: "z1", rowId: "row-zoom", span: { start: 0, end: 1 }, label: "Z", variant: "zoom" as const }, + { + id: "a1", + rowId: "row-annotation-1", + span: { start: 0, end: 1 }, + label: "A", + variant: "annotation" as const, + }, + { + id: "au1", + rowId: "row-audio-2", + span: { start: 0, end: 1 }, + label: "X", + variant: "audio" as const, + }, + { + id: "z1", + rowId: "row-zoom", + span: { start: 0, end: 1 }, + label: "Z", + variant: "zoom" as const, + }, ]; expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1"); expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3"); diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index e1589fd60..2141e098a 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -6,6 +6,7 @@ import type { ClipRegion, ZoomRegion, } from "../../types"; +import { getClipSourceEndMs, getClipSourceStartMs } from "../../types"; import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; import { getAnnotationTrackIndex, @@ -61,16 +62,16 @@ export function buildTimelineItems(params: { })); const clips: TimelineRenderItem[] = clipRegions.map((region, index) => { - const displayDurationMs = Math.max(0, region.endMs - region.startMs); const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1; - const sourceEndMs = region.startMs + displayDurationMs * speed; + const sourceStartMs = getClipSourceStartMs(region); + const sourceEndMs = getClipSourceEndMs(region); const speedLabel = formatClipSpeedLabel(speed); return { id: region.id, rowId: CLIP_ROW_ID, span: { start: region.startMs, end: region.endMs }, - sourceSpan: { start: region.startMs, end: sourceEndMs }, + sourceSpan: { start: sourceStartMs, end: sourceEndMs }, label: speedLabel ? `Clip ${index + 1} ${speedLabel}` : `Clip ${index + 1}`, speedValue: speedLabel ? speed : undefined, showSourceAudio: region.showSourceAudio, diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts index 6da7a8da7..4fc139736 100644 --- a/src/components/video-editor/types.test.ts +++ b/src/components/video-editor/types.test.ts @@ -2,12 +2,16 @@ import { describe, expect, it } from "vitest"; import { deriveNextId } from "./projectPersistence"; import { + clipsToTrims, extendAutoFullTrackClip, findClipAtTimelineTime, + getClipSourceEndMs, + getClipSourceStartMs, getTimelineDurationMs, mapSourceTimeToTimelineTime, mapTimelineTimeToSourceTime, trimsToClips, + updateClipTimelineSpan, } from "./types"; describe("extendAutoFullTrackClip", () => { @@ -145,6 +149,42 @@ describe("clip timeline mapping", () => { expect(findClipAtTimelineTime(5_000, clips)).toBeNull(); }); + it("keeps source content stable when a later clip is moved into a timeline gap", () => { + const clips = [ + { id: "clip-1", startMs: 0, endMs: 5_000, sourceStartMs: 0, speed: 1 }, + { id: "clip-2", startMs: 5_000, endMs: 10_000, sourceStartMs: 8_000, speed: 1 }, + ]; + + expect(mapTimelineTimeToSourceTime(5_500, clips)).toBe(8_500); + expect(mapSourceTimeToTimelineTime(8_500, clips)).toBe(5_500); + expect(clipsToTrims(clips, 13_000)).toEqual([ + { id: "trim-gap-1", startMs: 5_000, endMs: 8_000 }, + ]); + }); + + it("preserves source bounds for moves and adjusts them only for edge resizes", () => { + const clip = { + id: "clip-2", + startMs: 8_000, + endMs: 13_000, + sourceStartMs: 8_000, + speed: 1, + }; + + const moved = updateClipTimelineSpan(clip, { start: 5_000, end: 10_000 }); + expect(moved).toMatchObject({ startMs: 5_000, endMs: 10_000, sourceStartMs: 8_000 }); + expect(getClipSourceStartMs(moved)).toBe(8_000); + expect(getClipSourceEndMs(moved)).toBe(13_000); + + const resizedLeft = updateClipTimelineSpan(clip, { start: 9_000, end: 13_000 }); + expect(resizedLeft).toMatchObject({ sourceStartMs: 9_000 }); + expect(getClipSourceEndMs(resizedLeft)).toBe(13_000); + + const resizedRight = updateClipTimelineSpan(clip, { start: 8_000, end: 14_000 }); + expect(resizedRight).toMatchObject({ sourceStartMs: 8_000 }); + expect(getClipSourceEndMs(resizedRight)).toBe(14_000); + }); + it("derives the next clip id after converting trim gaps into clip ids", () => { const clipsFromTrims = trimsToClips( [ @@ -155,11 +195,28 @@ describe("clip timeline mapping", () => { ); expect(clipsFromTrims.map((clip) => clip.id)).toEqual(["clip-1", "clip-2", "clip-3"]); - expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4); + expect( + deriveNextId( + "clip", + clipsFromTrims.map((clip) => clip.id), + ), + ).toBe(4); }); }); describe("getTimelineDurationMs", () => { + it("uses the reflowed timeline end after a clip is moved", () => { + expect( + getTimelineDurationMs( + [ + { id: "clip-1", startMs: 0, endMs: 5_000, sourceStartMs: 0, speed: 1 }, + { id: "clip-2", startMs: 5_000, endMs: 10_000, sourceStartMs: 8_000, speed: 1 }, + ], + 13_000, + ), + ).toBe(10_000); + }); + it("extends the timeline when a slow clip becomes longer than the source duration", () => { expect( getTimelineDurationMs( @@ -171,10 +228,7 @@ describe("getTimelineDurationMs", () => { it("keeps the source duration when speed edits make clips shorter", () => { expect( - getTimelineDurationMs( - [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], - 10_000, - ), + getTimelineDurationMs([{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], 10_000), ).toBe(10_000); }); }); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 98eefb3fc..fe666a117 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -217,17 +217,63 @@ export interface TrimRegion { export interface ClipRegion { id: string; + /** Position of the clip on the edited timeline. */ startMs: number; endMs: number; + /** + * Start position in the original media. Older projects omit this field; + * in that case the timeline start is also the source start. + */ + sourceStartMs?: number; speed: number; muted?: boolean; showSourceAudio?: boolean; } +export function getClipSourceStartMs(clip: ClipRegion): number { + const sourceStartMs = Number.isFinite(clip.sourceStartMs) + ? Math.round(clip.sourceStartMs as number) + : Math.round(clip.startMs); + return Math.max(0, sourceStartMs); +} + +export function getClipDisplayDurationMs(clip: ClipRegion): number { + return Math.max(0, Math.round(clip.endMs) - Math.round(clip.startMs)); +} + export function getClipSourceEndMs(clip: ClipRegion): number { - const displayDurationMs = Math.max(0, clip.endMs - clip.startMs); + const displayDurationMs = getClipDisplayDurationMs(clip); const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; - return Math.round(clip.startMs + displayDurationMs * speed); + return Math.round(getClipSourceStartMs(clip) + displayDurationMs * speed); +} + +/** + * Apply a timeline move/resize without changing the source material on a move. + * Resizing the left edge trims from (or restores) the source start, while a + * pure drag only changes the edited-timeline coordinates. + */ +export function updateClipTimelineSpan( + clip: ClipRegion, + span: { start: number; end: number }, +): ClipRegion { + const startMs = Math.round(Number.isFinite(span.start) ? span.start : clip.startMs); + const endMs = Math.max( + startMs + 1, + Math.round(Number.isFinite(span.end) ? span.end : clip.endMs), + ); + const startDelta = startMs - clip.startMs; + const endDelta = endMs - clip.endMs; + const isMove = Math.abs(startDelta - endDelta) < 1; + const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; + const sourceStartMs = getClipSourceStartMs(clip); + const nextSourceStartMs = isMove ? sourceStartMs : sourceStartMs + startDelta * speed; + + return { + ...clip, + startMs, + endMs, + sourceStartMs: Math.max(0, Math.round(nextSourceStartMs)), + }; } export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: number): number { @@ -236,6 +282,16 @@ export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: num return baseDurationMs; } + // Once a clip has been reflowed, its timeline position is no longer its + // source position. In that mode the visible timeline ends at the last + // edited clip instead of retaining the original source tail. + if (clips.some((clip) => getClipSourceStartMs(clip) !== Math.round(clip.startMs))) { + return clips.reduce( + (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), + 0, + ); + } + return clips.reduce( (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), baseDurationMs, @@ -246,6 +302,13 @@ export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] { return [...clips].sort((left, right) => left.startMs - right.startMs); } +export function sortClipRegionsBySource(clips: ClipRegion[]): ClipRegion[] { + return [...clips].sort((left, right) => { + const sourceStartDelta = getClipSourceStartMs(left) - getClipSourceStartMs(right); + return sourceStartDelta !== 0 ? sourceStartDelta : left.startMs - right.startMs; + }); +} + function getSafeClipSpeed(clip: ClipRegion) { return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; } @@ -259,16 +322,24 @@ function clampToNearestClipBoundary( let nearestDistance = Number.POSITIVE_INFINITY; for (const clip of clips) { + const sourceStartMs = getClipSourceStartMs(clip); + const sourceEndMs = getClipSourceEndMs(clip); const boundaries = kind === "timeline" - ? [clip.startMs, clip.endMs] - : [clip.startMs, getClipSourceEndMs(clip)]; + ? [ + { timeMs: clip.startMs, mappedMs: sourceStartMs }, + { timeMs: clip.endMs, mappedMs: sourceEndMs }, + ] + : [ + { timeMs: sourceStartMs, mappedMs: clip.startMs }, + { timeMs: sourceEndMs, mappedMs: clip.endMs }, + ]; for (const boundary of boundaries) { - const distance = Math.abs(timeMs - boundary); + const distance = Math.abs(timeMs - boundary.timeMs); if (distance < nearestDistance) { nearestDistance = distance; - nearestTimeMs = Math.round(boundary); + nearestTimeMs = Math.round(boundary.mappedMs); } } } @@ -285,7 +356,9 @@ export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]) continue; } - return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip)); + return Math.round( + getClipSourceStartMs(clip) + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip), + ); } if (sortedClips.length === 0) { @@ -297,15 +370,16 @@ export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]) export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]): number { const roundedTimeMs = Math.round(timeMs); - const sortedClips = sortClipRegions(clips); + const sortedClips = sortClipRegionsBySource(clips); for (const clip of sortedClips) { + const sourceStartMs = getClipSourceStartMs(clip); const sourceEndMs = getClipSourceEndMs(clip); - if (roundedTimeMs < clip.startMs || roundedTimeMs > sourceEndMs) { + if (roundedTimeMs < sourceStartMs || roundedTimeMs > sourceEndMs) { continue; } - return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip)); + return Math.round(clip.startMs + (roundedTimeMs - sourceStartMs) / getSafeClipSpeed(clip)); } if (sortedClips.length === 0) { @@ -344,6 +418,7 @@ export function extendAutoFullTrackClip( if ( clip.id !== autoClipId || clip.startMs !== 0 || + getClipSourceStartMs(clip) !== 0 || clip.speed !== 1 || clip.endMs !== previousAutoEndMs ) { @@ -356,15 +431,17 @@ export function extendAutoFullTrackClip( /** Convert clip regions (kept segments) to trim regions (gaps to remove). */ export function clipsToTrims(clips: ClipRegion[], totalDurationMs: number): TrimRegion[] { if (clips.length === 0) return []; - const sorted = [...clips].sort((a, b) => a.startMs - b.startMs); + const sorted = sortClipRegionsBySource(clips); const trims: TrimRegion[] = []; let cursor = 0; let trimId = 1; for (const clip of sorted) { - if (clip.startMs > cursor) { - trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: clip.startMs }); + const sourceStartMs = getClipSourceStartMs(clip); + const sourceEndMs = getClipSourceEndMs(clip); + if (sourceStartMs > cursor) { + trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: sourceStartMs }); } - cursor = getClipSourceEndMs(clip); + cursor = Math.max(cursor, sourceEndMs); } if (cursor < totalDurationMs) { trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: totalDurationMs }); diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts index 6f1ca0757..f8bf8ef05 100644 --- a/src/lib/exporter/audioEncoder.ts +++ b/src/lib/exporter/audioEncoder.ts @@ -7,6 +7,7 @@ import type { SpeedRegion, TrimRegion, } from "@/components/video-editor/types"; +import { getClipSourceEndMs, getClipSourceStartMs } from "@/components/video-editor/types"; import { buildResolvedAudioPlan, SourceTrackId } from "@/lib/exporter/audioRoutingEngine"; import { estimateCompanionAudioStartDelaySeconds } from "@/lib/mediaTiming"; import { resolveMediaElementSource } from "./localMediaSource"; @@ -811,14 +812,11 @@ export class AudioProcessor { const mutedSourceOutputRangesSec = (clipRegions ?? []) .filter( (clip) => - Boolean(clip.muted) && - Number.isFinite(clip.startMs) && - Number.isFinite(clip.endMs) && - clip.endMs > clip.startMs, + Boolean(clip.muted) && getClipSourceEndMs(clip) > getClipSourceStartMs(clip), ) .map((clip) => ({ - startSec: Math.max(0, clip.startMs / 1000), - endSec: Math.max(0, clip.endMs / 1000), + startSec: Math.max(0, getClipSourceStartMs(clip) / 1000), + endSec: Math.max(0, getClipSourceEndMs(clip) / 1000), })); return { From df9c4bdb1d6fcd8ec0dd9c2851ef757b9bf0e759 Mon Sep 17 00:00:00 2001 From: Shanjin <121328191+shanjin666666@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:39:31 +0800 Subject: [PATCH 4/4] fix: keep clip overlay moves consistent --- src/components/video-editor/VideoEditor.tsx | 54 +++++++++++---------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 50e9a4950..74010693e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -4112,35 +4112,39 @@ export default function VideoEditor() { : []), ]; - const updateRegions = (regions: T[]) => { - if (isMove && Math.abs(startDelta) > 0) { - return regions.map((region) => { - const contained = - region.startMs >= oldClip.startMs && region.endMs <= oldClip.endMs; - return contained + if (isMove && Math.abs(startDelta) > 0) { + setZoomRegions((prev) => + prev.map((zoom) => { + const overlaps = + zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs; + return overlaps ? { - ...region, - startMs: region.startMs + startDelta, - endMs: region.endMs + startDelta, + ...zoom, + startMs: zoom.startMs + startDelta, + endMs: zoom.endMs + startDelta, } - : region; - }); - } - - if (removedSegments.length === 0) return regions; - return regions.filter( - (region) => - !removedSegments.some( - (segment) => - region.startMs < segment.endMs && region.endMs > segment.startMs, - ), + : zoom; + }), ); - }; + } - setZoomRegions(updateRegions); - setAnnotationRegions(updateRegions); - setSpeedRegions(updateRegions); - setAudioRegions(updateRegions); + if (removedSegments.length > 0) { + const removeTrimmedRegions = ( + regions: T[], + ): T[] => + regions.filter( + (region) => + !removedSegments.some( + (segment) => + region.startMs < segment.endMs && + region.endMs > segment.startMs, + ), + ); + setZoomRegions((prev) => removeTrimmedRegions(prev)); + setAnnotationRegions((prev) => removeTrimmedRegions(prev)); + setSpeedRegions((prev) => removeTrimmedRegions(prev)); + setAudioRegions((prev) => removeTrimmedRegions(prev)); + } setClipRegions((prev) => prev.map((clip) => (clip.id === id ? updateClipTimelineSpan(clip, span) : clip)), );