diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f42a92d..1f85736 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,18 +20,15 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v3 - + - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '22' - + - name: Install dependencies run: npm ci - - - name: Install app dependencies - run: npx electron-builder install-app-deps - + - name: Build Windows app run: npm run build:win env: @@ -234,8 +231,10 @@ jobs: - name: Install dependencies run: npm ci - - name: Install app dependencies - run: npx electron-builder install-app-deps + # bsdtar (from libarchive-tools) is required by fpm to build pacman + # packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it. + - name: Install pacman build dependencies + run: sudo apt-get update && sudo apt-get install -y libarchive-tools - name: Build Linux app run: npm run build:linux @@ -250,4 +249,5 @@ jobs: release/**/*.AppImage release/**/*.zsync release/**/*.deb + release/**/*.pacman retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4194797..3c9e8ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm run test - run: npm run test:browser:install - run: npm run test:browser diff --git a/docs/tests/writing-tests.md b/docs/tests/writing-tests.md new file mode 100644 index 0000000..09ede7e --- /dev/null +++ b/docs/tests/writing-tests.md @@ -0,0 +1,149 @@ +# Writing Tests + +This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files. + +## Unit tests + +**Config:** `vitest.config.ts` +**Runs in:** jsdom (simulated DOM, no real browser) +**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts` +**CI command:** `npm run test` + +Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.). + +### File placement + +Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory. + +``` +src/lib/compositeLayout.ts +src/lib/compositeLayout.test.ts # co-located + +src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import { computeCompositeLayout } from "./compositeLayout"; + +describe("computeCompositeLayout", () => { + it("anchors the overlay in the lower-right corner", () => { + const layout = computeCompositeLayout({ + canvasSize: { width: 1920, height: 1080 }, + screenSize: { width: 1920, height: 1080 }, + webcamSize: { width: 1280, height: 720 }, + }); + + expect(layout).not.toBeNull(); + expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2); + expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); + }); +}); +``` + +### Path aliases + +The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths. + +```ts +import { SUPPORTED_LOCALES } from "@/i18n/config"; +``` + +### Running locally + +```bash +npm run test # run once +npm run test:watch # watch mode +``` + +--- + +## Browser tests + +**Config:** `vitest.browser.config.ts` +**Runs in:** real Chromium via Playwright (headless) +**File pattern:** `src/**/*.browser.test.ts` +**CI commands:** `npm run test:browser:install` then `npm run test:browser` + +Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc. + +### File placement + +Name the file `.browser.test.ts` and place it next to the source file. + +``` +src/lib/exporter/videoExporter.ts +src/lib/exporter/videoExporter.browser.test.ts +``` + +### Loading fixture assets + +Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server. + +```ts +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +``` + +### Example + +```ts +import { describe, expect, it } from "vitest"; +import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; +import { VideoExporter } from "./videoExporter"; + +describe("VideoExporter (real browser)", () => { + it("exports a valid MP4 blob from a real video", async () => { + const exporter = new VideoExporter({ + videoUrl: sampleVideoUrl, + width: 320, + height: 180, + frameRate: 15, + bitrate: 1_000_000, + wallpaper: "#1a1a2e", + zoomRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + }); + + const result = await exporter.export(); + + expect(result.success, result.error).toBe(true); + expect(result.blob).toBeInstanceOf(Blob); + }); +}); +``` + +### Timeouts + +Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast. + +### Running locally + +First install the browser (one-time): + +```bash +npm run test:browser:install +``` + +Then run the tests: + +```bash +npm run test:browser +``` + +--- + +## Choosing the right type + +| Situation | Use | +|---|---| +| Pure function / data transformation | Unit test | +| i18n key coverage | Unit test | +| React hook logic (no real browser APIs) | Unit test | +| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test | +| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test | +| File export producing a real `Blob` | Browser test | diff --git a/electron-builder.json5 b/electron-builder.json5 index ca053ef..d9fee6b 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -3,6 +3,11 @@ "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", "appId": "com.siddharthvaddem.openscreen", "asar": true, + // .node binaries can't be dlopen'd from inside an asar — must live unpacked. + "asarUnpack": [ + "node_modules/uiohook-napi/**/*", + "**/*.node" + ], "productName": "Openscreen", "npmRebuild": true, "buildDependenciesFromSource": true, @@ -46,13 +51,16 @@ "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", "NSCameraUseContinuityCameraDeviceType": true, "com.apple.security.device.audio-input": true } }, "linux": { "target": [ - "AppImage" + "AppImage", + "deb", + "pacman" ], "icon": "icons/icons/png", "artifactName": "${productName}-Linux-${version}.${ext}", diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d8294..744c2c7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -37,6 +37,11 @@ interface Window { status: string; error?: string; }>; + requestAccessibilityAccess: () => Promise<{ + success: boolean; + granted: boolean; + error?: string; + }>; assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, @@ -68,15 +73,31 @@ interface Window { getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; + clicks: number[]; message?: string; error?: string; }>; onStopRecordingFromTray: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; - saveExportedVideo: ( - videoData: ArrayBuffer, + pickExportSavePath: ( fileName: string, - ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + exportFolder?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + writeExportToPath: ( + videoData: ArrayBuffer, + filePath: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; setCurrentRecordingSession: ( @@ -143,7 +164,15 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + onRequestCloseConfirm: (callback: () => void) => () => void; + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; setLocale: (locale: string) => Promise; + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>; }; } diff --git a/electron/i18n.ts b/electron/i18n.ts index f75bd25..e16ac86 100644 --- a/electron/i18n.ts +++ b/electron/i18n.ts @@ -1,6 +1,8 @@ // Lightweight i18n for the Electron main process. // Imports the same JSON translation files used by the renderer. +import commonAr from "../src/i18n/locales/ar/common.json"; +import dialogsAr from "../src/i18n/locales/ar/dialogs.json"; import commonEn from "../src/i18n/locales/en/common.json"; import dialogsEn from "../src/i18n/locales/en/dialogs.json"; import commonEs from "../src/i18n/locales/es/common.json"; @@ -20,7 +22,7 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json"; import commonZhTw from "../src/i18n/locales/zh-TW/common.json"; import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json"; -type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "vi"; +type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi"; type Namespace = "common" | "dialogs"; type MessageMap = Record; @@ -33,6 +35,7 @@ const messages: Record> = { "ja-JP": { common: commonJa, dialogs: dialogsJa }, "ko-KR": { common: commonKo, dialogs: dialogsKo }, tr: { common: commonTr, dialogs: dialogsTr }, + ar: { common: commonAr, dialogs: dialogsAr }, vi: { common: commonVi, dialogs: dialogsVi }, }; @@ -48,6 +51,7 @@ export function setMainLocale(locale: string) { locale === "ja-JP" || locale === "ko-KR" || locale === "tr" || + locale === "ar" || locale === "vi" ) { currentLocale = locale; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797..82acdf9 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; + +const nodeRequire = createRequire(import.meta.url); + import { app, BrowserWindow, @@ -56,6 +61,21 @@ function isPathAllowed(filePath: string): boolean { return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir)); } +/** + * Helper function to build dialog options with a parent window only when it's valid. + * This prevents passing stale or destroyed BrowserWindow references to dialog calls. + */ +function buildDialogOptions( + baseOptions: T, + parentWindow: BrowserWindow | null, +): T & { parent?: BrowserWindow } { + const mainWindow = parentWindow; + if (mainWindow && !mainWindow.isDestroyed()) { + return { ...baseOptions, parent: mainWindow }; + } + return baseOptions; +} + function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } @@ -280,19 +300,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { const telemetryPath = `${screenVideoPath}.cursor.json`; const pendingBatch = cursorTelemetryBuffer.takeNextBatch(); - if (pendingBatch && pendingBatch.samples.length > 0) { + const pendingClicks = takeCursorClickTimestamps(); + if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) { try { await fs.writeFile( telemetryPath, JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples }, + { + version: CURSOR_TELEMETRY_VERSION, + samples: pendingBatch?.samples ?? [], + clicks: pendingClicks, + }, null, 2, ), "utf-8", ); } catch (err) { - cursorTelemetryBuffer.prependBatch(pendingBatch); + if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch); throw err; } } @@ -321,15 +346,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({ maxActiveSamples: MAX_CURSOR_SAMPLES, }); +// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility). +const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour +let cursorClickTimestampsMs: number[] = []; +let uioHookInstance: { + start: () => void; + stop: () => void; + on: (...a: unknown[]) => void; + off?: (...a: unknown[]) => void; + removeListener?: (...a: unknown[]) => void; +} | null = null; +let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null; +let uioHookFailureLogged = false; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +function loadUioHookForClicks(): typeof uioHookInstance { + try { + // Dynamic require + try/catch so a broken native binary doesn't crash startup. + const mod = nodeRequire("uiohook-napi"); + const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default; + if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") { + return candidate; + } + return null; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] uiohook-napi unavailable:", error); + } + return null; + } +} + +function startClickCapture() { + if (process.platform !== "darwin") return; + if (uioHookInstance) return; + + // Passive check — the prompt fires from the renderer when the user toggles + // "Only on clicks" so it doesn't stack with the screen-recording prompt. + try { + if (!systemPreferences.isTrustedAccessibilityClient(false)) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn( + "[clickCapture] Accessibility permission not granted — click capture disabled.", + ); + } + return; + } + } catch { + // fall through; uiohook will fail defensively below + } + + const hook = loadUioHookForClicks(); + if (!hook) return; + + uioHookMouseDownHandler = (event) => { + const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs); + void event; + if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return; + cursorClickTimestampsMs.push(elapsed); + }; + + try { + hook.on("mousedown", uioHookMouseDownHandler); + hook.start(); + uioHookInstance = hook; + } catch (error) { + if (!uioHookFailureLogged) { + uioHookFailureLogged = true; + console.warn("[clickCapture] failed to start uiohook:", error); + } + uioHookMouseDownHandler = null; + } +} + +function stopClickCapture() { + if (!uioHookInstance) return; + try { + if (uioHookMouseDownHandler) { + if (typeof uioHookInstance.off === "function") { + uioHookInstance.off("mousedown", uioHookMouseDownHandler); + } else if (typeof uioHookInstance.removeListener === "function") { + uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler); + } + } + uioHookInstance.stop(); + } catch (error) { + console.warn("[clickCapture] failed to stop uiohook:", error); + } + uioHookInstance = null; + uioHookMouseDownHandler = null; +} + +function takeCursorClickTimestamps(): number[] { + const out = cursorClickTimestampsMs; + cursorClickTimestampsMs = []; + return out; +} + function stopCursorCapture() { if (cursorCaptureInterval) { clearInterval(cursorCaptureInterval); cursorCaptureInterval = null; } + stopClickCapture(); } function sampleCursorPoint() { @@ -526,14 +650,27 @@ export function registerIpcHandlers( }); ipcMain.handle("get-sources", async (_, opts) => { + const ownWindowSourceIds = new Set( + BrowserWindow.getAllWindows() + .map((win) => { + try { + return win.getMediaSourceId(); + } catch { + return null; + } + }) + .filter((id): id is string => Boolean(id)), + ); const sources = await desktopCapturer.getSources(opts); - return sources.map((source) => ({ - id: source.id, - name: source.name, - display_id: source.display_id, - thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null, - })); + return sources + .filter((source) => !ownWindowSourceIds.has(source.id)) + .map((source) => ({ + id: source.id, + name: source.name, + display_id: source.display_id, + thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, + appIcon: source.appIcon ? source.appIcon.toDataURL() : null, + })); }); ipcMain.handle("select-source", (_, source: SelectedSource) => { @@ -581,6 +718,22 @@ export function registerIpcHandlers( } }); + // macOS Accessibility prompt for global click capture. First call shows the + // system dialog; the user has to toggle the app in System Settings (no + // programmatic grant exists for Accessibility). + ipcMain.handle("request-accessibility-access", () => { + if (process.platform !== "darwin") { + return { success: true, granted: true }; + } + try { + const granted = systemPreferences.isTrustedAccessibilityClient(true); + return { success: true, granted }; + } catch (error) { + console.error("Failed to request accessibility access:", error); + return { success: false, granted: false, error: String(error) }; + } + }); + ipcMain.handle("open-source-selector", () => { const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin) { @@ -710,6 +863,8 @@ export function registerIpcHandlers( const id = typeof recordingId === "number" ? recordingId : Date.now(); cursorTelemetryBuffer.startSession(id); cursorCaptureStartTimeMs = Date.now(); + cursorClickTimestampsMs = []; + startClickCapture(); sampleCursorPoint(); cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); } else { @@ -774,11 +929,19 @@ export function registerIpcHandlers( }) .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); - return { success: true, samples }; + const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : []; + const clicks: number[] = rawClicks + .map((value: unknown) => + typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null, + ) + .filter((v: number | null): v is number => v !== null) + .sort((a: number, b: number) => a - b); + + return { success: true, samples, clicks }; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === "ENOENT") { - return { success: true, samples: [] }; + return { success: true, samples: [], clicks: [] }; } console.error("Failed to load cursor telemetry:", error); return { @@ -786,6 +949,7 @@ export function registerIpcHandlers( message: "Failed to load cursor telemetry", error: String(error), samples: [], + clicks: [], }; } }); @@ -822,38 +986,72 @@ export function registerIpcHandlers( * @returns Object with success status, optional file path, and error details. */ - ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => { + ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => { try { - // Determine file type from extension const isGif = fileName.toLowerCase().endsWith(".gif"); const filters = isGif ? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }] : [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }]; - const result = await dialog.showSaveDialog({ - title: isGif - ? mainT("dialogs", "fileDialogs.saveGif") - : mainT("dialogs", "fileDialogs.saveVideo"), - defaultPath: path.join(app.getPath("downloads"), fileName), - filters, - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + // Prefer the user's last export folder if it still exists, otherwise fall + // back to ~/Downloads. Validation must happen here because the renderer + // can't stat the filesystem. + let defaultDir = app.getPath("downloads"); + if (exportFolder) { + try { + const stats = await fs.stat(exportFolder); + if (stats.isDirectory()) { + defaultDir = exportFolder; + } + } catch (err) { + console.warn( + `Could not access remembered export folder "${exportFolder}", falling back to Downloads:`, + err, + ); + } + } + const dialogOptions = buildDialogOptions( + { + title: isGif + ? mainT("dialogs", "fileDialogs.saveGif") + : mainT("dialogs", "fileDialogs.saveVideo"), + defaultPath: path.join(defaultDir, fileName), + filters, + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { - return { - success: false, - canceled: true, - message: "Export canceled", - }; + return { success: false, canceled: true, message: "Export canceled" }; } - // --- FIX: Normalize the path for Windows compatibility --- - const normalizedPath = path.normalize(result.filePath); + return { success: true, path: path.normalize(result.filePath) }; + } catch (error) { + console.error("Failed to show save dialog:", error); + return { + success: false, + message: "Failed to show save dialog", + error: String(error), + }; + } + }); - // Ensure the parent directory exists (Windows may fail if the folder is missing) + ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => { + try { + // Sanity-check the path. The renderer is trusted (contextIsolation is on), + // but a stale state bug shouldn't be able to clobber arbitrary files. + if (typeof filePath !== "string" || !path.isAbsolute(filePath)) { + return { success: false, message: "Invalid path" }; + } + const lower = filePath.toLowerCase(); + if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) { + return { success: false, message: "Invalid file type" }; + } + + const normalizedPath = path.normalize(filePath); await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); - // --- END FIX --- - await fs.writeFile(normalizedPath, Buffer.from(videoData)); return { @@ -862,7 +1060,7 @@ export function registerIpcHandlers( message: "Video exported successfully", }; } catch (error) { - console.error("Failed to save exported video:", error); + console.error("Failed to write exported video:", error); return { success: false, message: "Failed to save exported video", @@ -872,18 +1070,22 @@ export function registerIpcHandlers( }); ipcMain.handle("open-video-file-picker", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.selectVideo"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], - }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.selectVideo"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.videoFiles"), + extensions: ["webm", "mp4", "mov", "avi", "mkv"], + }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true }; @@ -962,18 +1164,22 @@ export function registerIpcHandlers( ? safeName : `${safeName}.${PROJECT_FILE_EXTENSION}`; - const result = await dialog.showSaveDialog({ - title: mainT("dialogs", "fileDialogs.saveProject"), - defaultPath: path.join(RECORDINGS_DIR, defaultName), - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - ], - properties: ["createDirectory", "showOverwriteConfirmation"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.saveProject"), + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + ], + properties: ["createDirectory", "showOverwriteConfirmation"], + }, + getMainWindow(), + ); + const result = await dialog.showSaveDialog(dialogOptions); if (result.canceled || !result.filePath) { return { @@ -1004,19 +1210,23 @@ export function registerIpcHandlers( ipcMain.handle("load-project-file", async () => { try { - const result = await dialog.showOpenDialog({ - title: mainT("dialogs", "fileDialogs.openProject"), - defaultPath: RECORDINGS_DIR, - filters: [ - { - name: mainT("dialogs", "fileDialogs.openscreenProject"), - extensions: [PROJECT_FILE_EXTENSION], - }, - { name: "JSON", extensions: ["json"] }, - { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, - ], - properties: ["openFile"], - }); + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.openProject"), + defaultPath: RECORDINGS_DIR, + filters: [ + { + name: mainT("dialogs", "fileDialogs.openscreenProject"), + extensions: [PROJECT_FILE_EXTENSION], + }, + { name: "JSON", extensions: ["json"] }, + { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, + ], + properties: ["openFile"], + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); if (result.canceled || result.filePaths.length === 0) { return { success: false, canceled: true, message: "Open project canceled" }; @@ -1138,4 +1348,45 @@ export function registerIpcHandlers( return { success: false, error: String(error) }; } }); + + ipcMain.handle( + "save-diagnostic", + async ( + _, + payload: { error: string; stack?: string; projectState: unknown; logs: string[] }, + ) => { + const { filePath, canceled } = await dialog.showSaveDialog({ + title: "Save Diagnostic File", + defaultPath: `openscreen-diagnostic-${Date.now()}.json`, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + + if (canceled || !filePath) return { success: false, canceled: true }; + + const diagnostic = { + timestamp: new Date().toISOString(), + appVersion: app.getVersion(), + platform: process.platform, + arch: process.arch, + osRelease: os.release(), + osVersion: os.version(), + totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024), + nodeVersion: process.versions.node, + electronVersion: process.versions.electron, + chromeVersion: process.versions.chrome, + error: payload.error, + stack: payload.stack, + projectState: payload.projectState, + recentLogs: payload.logs, + }; + + try { + await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8"); + return { success: true, path: filePath }; + } catch (error) { + console.error("Failed to write diagnostic file:", error); + return { success: false, error: String(error) }; + } + }, + ); } diff --git a/electron/main.ts b/electron/main.ts index ad0a33f..007df33 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - dialog, ipcMain, Menu, nativeImage, @@ -30,6 +29,18 @@ if (process.platform === "darwin") { app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } +// Enable Wayland support for proper screen capture and window management +// on Wayland compositors (Hyprland, GNOME, KDE, etc.) +if (process.platform === "linux") { + const isWayland = + process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined; + if (isWayland) { + app.commandLine.appendSwitch("ozone-platform", "wayland"); + // Enable WebRTCPipeWireCapturer for screen capture on Wayland + app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer"); + } +} + export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { @@ -124,15 +135,30 @@ function setupApplicationMenu() { template.push({ label: app.name, submenu: [ - { role: "about" }, + { + role: "about", + label: mainT("common", "actions.about") || "About OpenScreen", + }, { type: "separator" }, - { role: "services" }, + { + role: "services", + label: mainT("common", "actions.services") || "Services", + }, { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, + { + role: "hide", + label: mainT("common", "actions.hide") || "Hide OpenScreen", + }, + { + role: "hideOthers", + label: mainT("common", "actions.hideOthers") || "Hide Others", + }, + { + role: "unhide", + label: mainT("common", "actions.unhide") || "Show All", + }, { type: "separator" }, - { role: "quit" }, + { role: "quit", label: mainT("common", "actions.quit") || "Quit" }, ], }); } @@ -156,40 +182,89 @@ function setupApplicationMenu() { accelerator: "CmdOrCtrl+Shift+S", click: () => sendEditorMenuAction("menu-save-project-as"), }, - ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ...(isMac + ? [] + : [ + { type: "separator" as const }, + { + role: "quit" as const, + label: mainT("common", "actions.quit") || "Quit", + }, + ]), ], }, { label: mainT("common", "actions.edit") || "Edit", submenu: [ - { role: "undo" }, - { role: "redo" }, + { role: "undo", label: mainT("common", "actions.undo") || "Undo" }, + { role: "redo", label: mainT("common", "actions.redo") || "Redo" }, { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, + { role: "cut", label: mainT("common", "actions.cut") || "Cut" }, + { role: "copy", label: mainT("common", "actions.copy") || "Copy" }, + { role: "paste", label: mainT("common", "actions.paste") || "Paste" }, + { + role: "selectAll", + label: mainT("common", "actions.selectAll") || "Select All", + }, ], }, { label: mainT("common", "actions.view") || "View", submenu: [ - { role: "reload" }, - { role: "forceReload" }, - { role: "toggleDevTools" }, + { + role: "reload", + label: mainT("common", "actions.reload") || "Reload", + }, + { + role: "forceReload", + label: mainT("common", "actions.forceReload") || "Force Reload", + }, + { + role: "toggleDevTools", + label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools", + }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { + role: "resetZoom", + label: mainT("common", "actions.actualSize") || "Actual Size", + }, + { + role: "zoomIn", + label: mainT("common", "actions.zoomIn") || "Zoom In", + }, + { + role: "zoomOut", + label: mainT("common", "actions.zoomOut") || "Zoom Out", + }, { type: "separator" }, - { role: "togglefullscreen" }, + { + role: "togglefullscreen", + label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen", + }, ], }, { label: mainT("common", "actions.window") || "Window", submenu: isMac - ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] - : [{ role: "minimize" }, { role: "close" }], + ? [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { role: "zoom" }, + { type: "separator" }, + { role: "front" }, + ] + : [ + { + role: "minimize", + label: mainT("common", "actions.minimize") || "Minimize", + }, + { + role: "close", + label: mainT("common", "actions.close") || "Close", + }, + ], }, ); @@ -220,7 +295,11 @@ function getTrayIcon(filename: string, size: number) { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; + const trayToolTip = recording + ? mainT("common", "actions.recordingStatus", { + source: selectedSourceName, + }) || `Recording: ${selectedSourceName}` + : "OpenScreen"; const menuTemplate = recording ? [ { @@ -253,6 +332,7 @@ function updateTrayMenu(recording: boolean = false) { let editorHasUnsavedChanges = false; let isForceClosing = false; +let isCloseConfirmInFlight = false; ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; @@ -284,39 +364,35 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) return; + if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return; event.preventDefault(); - - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: "warning", - buttons: [ - mainT("dialogs", "unsavedChanges.saveAndClose"), - mainT("dialogs", "unsavedChanges.discardAndClose"), - mainT("common", "actions.cancel"), - ], - defaultId: 0, - cancelId: 2, - title: mainT("dialogs", "unsavedChanges.title"), - message: mainT("dialogs", "unsavedChanges.message"), - detail: mainT("dialogs", "unsavedChanges.detail"), - }); + isCloseConfirmInFlight = true; const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - if (choice === 0) { - // Save & Close — tell renderer to save, then close - windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { - if (!shouldClose) return; + // Ask renderer to show the custom in-app dialog + windowToClose.webContents.send("request-close-confirm"); + + ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { + if (event.sender.id !== windowToClose?.webContents.id) return; + isCloseConfirmInFlight = false; + if (!windowToClose || windowToClose.isDestroyed()) return; + + if (choice === "save") { + // Tell renderer to save the project, then close when done + windowToClose.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { + if (event.sender.id !== windowToClose?.webContents.id) return; + if (!shouldClose) return; + forceCloseEditorWindow(windowToClose); + }); + } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); - }); - } else if (choice === 1) { - // Discard & Close - forceCloseEditorWindow(windowToClose); - } - // choice === 2: Cancel — do nothing, window stays open + } + // "cancel": flag reset, window stays open + }); }); } @@ -340,10 +416,11 @@ function createCountdownOverlayWindowWrapper() { return countdownOverlayWindow; } -// On macOS, applications and their menu bar stay active until the user quits -// explicitly with Cmd + Q. +// Closing every window quits the app entirely (tray icon goes too). +// The in-app "Return to Recorder" button covers the editor → HUD round-trip, +// so closing the last window is an explicit "I'm done" signal. app.on("window-all-closed", () => { - // Keep app running (macOS behavior) + app.quit(); }); app.on("activate", () => { @@ -365,6 +442,13 @@ app.on("activate", () => { // Register all IPC handlers when app is ready app.whenReady().then(async () => { + // Force the app into "regular" activation policy so the Dock icon appears. + // The HUD overlay (transparent + frameless + skipTaskbar) is the first + // window we open, and AppKit otherwise classifies us as an accessory app. + if (process.platform === "darwin") { + app.dock?.show(); + } + // Allow microphone/media permission checks session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"]; diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f0..5334a00 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld("electronAPI", { requestCameraAccess: () => { return ipcRenderer.invoke("request-camera-access"); }, + requestAccessibilityAccess: () => { + return ipcRenderer.invoke("request-accessibility-access"); + }, storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); @@ -68,8 +71,11 @@ contextBridge.exposeInMainWorld("electronAPI", { openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke("save-exported-video", videoData, fileName); + pickExportSavePath: (fileName: string, exportFolder?: string) => { + return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder); + }, + writeExportToPath: (videoData: ArrayBuffer, filePath: string) => { + return ipcRenderer.invoke("write-export-to-path", videoData, filePath); }, openVideoFilePicker: () => { return ipcRenderer.invoke("open-video-file-picker"); @@ -131,6 +137,14 @@ contextBridge.exposeInMainWorld("electronAPI", { setLocale: (locale: string) => { return ipcRenderer.invoke("set-locale", locale); }, + saveDiagnostic: (payload: { + error: string; + stack?: string; + projectState: unknown; + logs: string[]; + }) => { + return ipcRenderer.invoke("save-diagnostic", payload); + }, setMicrophoneExpanded: (expanded: boolean) => { ipcRenderer.send("hud:setMicrophoneExpanded", expanded); }, @@ -163,4 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, + onRequestCloseConfirm: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("request-close-confirm", listener); + return () => ipcRenderer.removeListener("request-close-confirm", listener); + }, + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { + ipcRenderer.send("close-confirm-response", choice); + }, }); diff --git a/macos.entitlements b/macos.entitlements index 5c6ddcf..38d8b29 100644 --- a/macos.entitlements +++ b/macos.entitlements @@ -21,5 +21,9 @@ com.apple.security.device.camera + + + com.apple.security.device.screen-capture + diff --git a/nix/package.nix b/nix/package.nix index 13a8658..c0f582f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,7 +11,7 @@ buildNpmPackage { nodejs = nodejs_22; pname = "openscreen"; - version = "1.3.0"; + version = "1.4.0"; src = let @@ -33,7 +33,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U="; + npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; diff --git a/package-lock.json b/package-lock.json index 4f854bc..afe2091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.3.0", + "version": "1.4.0", + "hasInstallScript": true, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -25,6 +26,7 @@ "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", + "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", @@ -46,11 +48,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -184,6 +188,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -392,6 +397,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -715,6 +721,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -763,6 +770,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1077,25 +1085,18 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "got": "^11.7.0", - "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", - "node-gyp": "^11.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^7.5.6", - "yargs": "^17.0.1" + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" @@ -1104,19 +1105,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -1214,7 +1202,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1236,7 +1223,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1253,7 +1239,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1268,7 +1253,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1790,80 +1774,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2047,62 +1957,11 @@ "node": ">= 8" } }, - "node_modules/@npmcli/agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", - "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@npmcli/fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", - "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@pixi/color": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -2117,8 +1976,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2145,8 +2003,7 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2173,22 +2030,19 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2200,7 +2054,6 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2212,7 +2065,6 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", - "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -2223,17 +2075,6 @@ "url": "^0.11.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", @@ -3802,8 +3643,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3878,8 +3718,7 @@ "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/debug": { "version": "4.1.13", @@ -3917,8 +3756,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4017,6 +3855,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4028,6 +3867,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4073,6 +3913,36 @@ "@babel/runtime": ">=7.19.0" } }, + "node_modules/@uiw/react-color-alpha": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.9.6.tgz", + "integrity": "sha512-DNzEVHZ0Izp4NAwzKqTcl4rLdPjSFjyZCP6Q2vKJEglugZ/bdPsmZaos9IYOrgnd1kPDmTSKZ/p8nI7vBIATGw==", + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.6", + "@uiw/react-drag-event-interactive": "2.9.6" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-alpha/node_modules/@uiw/color-convert": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz", + "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0" + } + }, "node_modules/@uiw/react-color-block": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.10.1.tgz", @@ -4092,6 +3962,38 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@uiw/react-color-colorful": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.9.6.tgz", + "integrity": "sha512-h74zo+ve9Rpv7xwb1dRfoa23yN39b6eYScDIm7V2d5FzkXN6hR7jnnJ7ZUD9Joz/rdaCz1eFQD9ig+wp8+wSnQ==", + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.6", + "@uiw/react-color-alpha": "2.9.6", + "@uiw/react-color-hue": "2.9.6", + "@uiw/react-color-saturation": "2.9.6" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-colorful/node_modules/@uiw/color-convert": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz", + "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0" + } + }, "node_modules/@uiw/react-color-editable-input": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.10.1.tgz", @@ -4106,6 +4008,66 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@uiw/react-color-hue": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.9.6.tgz", + "integrity": "sha512-B99dW2/AHMD3py83BrXl94bhXeGCZR1FMpU/FNbIIbUrV9QTiIXDs2/SB/tMD9ltcSP59RD5Sc5m2vCb/8anjw==", + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.6", + "@uiw/react-color-alpha": "2.9.6" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-hue/node_modules/@uiw/color-convert": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz", + "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0" + } + }, + "node_modules/@uiw/react-color-saturation": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.9.6.tgz", + "integrity": "sha512-R1tiKbTG2WiJXerkmuaKnBFfzgyZUn08q9OjQSvNH1f3ov2/YeUVlOwQY9MbQE7ytZv+9x+1h0Lpk4QG7AdulQ==", + "license": "MIT", + "dependencies": { + "@uiw/color-convert": "2.9.6", + "@uiw/react-drag-event-interactive": "2.9.6" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@uiw/react-color-saturation/node_modules/@uiw/color-convert": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.9.6.tgz", + "integrity": "sha512-w8TpU3MRcquurQJxWR1daKcRygu/a0hLP/VGsLMA3ebb41sAZGxMQLHtS+zC/e3ciFNB7BbPrSPlzOcz6w6cRg==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0" + } + }, "node_modules/@uiw/react-color-swatch": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.10.1.tgz", @@ -4123,6 +4085,20 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@uiw/react-drag-event-interactive": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.9.6.tgz", + "integrity": "sha512-jXzt3Xis/BIYap2Hj2++gB3aEUD0mZoVNGfckurrwjAwxasxNiwkmTGxV5er3due0ZgaVKdOAfTRoYKlgZukSg==", + "license": "MIT", + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.19.0", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -4173,6 +4149,7 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -4327,13 +4304,13 @@ "license": "MIT" }, "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/acorn": { @@ -4365,6 +4342,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4836,18 +4814,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -4902,6 +4868,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4936,6 +4903,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5035,92 +5003,6 @@ "node": ">= 10.0.0" } }, - "node_modules/cacache": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", - "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^4.0.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -5168,7 +5050,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -5334,19 +5215,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -5411,16 +5279,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -5542,8 +5400,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5691,19 +5548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -5772,16 +5616,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -5856,6 +5690,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -5948,8 +5783,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -5998,15 +5832,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC", - "peer": true - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/ejs": { "version": "3.1.10", @@ -6189,7 +6015,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6210,7 +6035,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6259,17 +6083,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6464,8 +6277,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/expect-type": { "version": "1.3.0", @@ -6681,36 +6493,6 @@ "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6784,19 +6566,6 @@ "node": ">=6 <7 || >=8" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7329,17 +7098,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } + "license": "BSD-3-Clause", + "optional": true }, "node_modules/indent-string": { "version": "4.0.0", @@ -7370,16 +7130,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7438,16 +7188,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -7464,19 +7204,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -7506,22 +7233,6 @@ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", "license": "MIT" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -7865,23 +7576,6 @@ "dev": true, "license": "MIT" }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -7995,7 +7689,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8010,29 +7703,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", - "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1", - "ssri": "^12.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -8150,16 +7820,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -8229,136 +7889,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", - "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -8378,7 +7908,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8482,16 +8011,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8550,28 +8069,49 @@ } }, "node_modules/node-gyp": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", - "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" } }, "node_modules/node-gyp/node_modules/semver": { @@ -8587,6 +8127,32 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.37", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", @@ -8595,19 +8161,19 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -8655,7 +8221,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -8711,86 +8276,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8817,26 +8302,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parse-svg-path": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", @@ -8882,30 +8347,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8988,6 +8429,7 @@ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", "license": "MIT", + "peer": true, "workspaces": [ "examples", "playground" @@ -9033,6 +8475,7 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -9103,6 +8546,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9247,7 +8691,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9265,7 +8708,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9276,7 +8718,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9292,7 +8733,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9301,13 +8741,13 @@ } }, "node_modules/proc-log": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", - "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/progress": { @@ -9406,7 +8846,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -9465,6 +8904,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9477,6 +8917,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9513,8 +8954,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9648,21 +9088,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9850,7 +9275,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -9945,27 +9369,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10078,7 +9481,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -10098,7 +9500,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -10115,7 +9516,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10134,7 +9534,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -10226,41 +9625,12 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -10309,19 +9679,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", - "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10346,16 +9703,6 @@ "dev": true, "license": "MIT" }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -10381,35 +9728,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10439,20 +9757,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -10570,6 +9874,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10653,7 +9958,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -10968,6 +10272,19 @@ "node": ">=14.17" } }, + "node_modules/uiohook-napi": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz", + "integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -10985,32 +10302,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", - "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^5.0.0" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/unique-slug": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", - "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -11067,7 +10358,6 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -11080,8 +10370,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -11174,6 +10463,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11263,7 +10553,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -11286,6 +10577,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -11383,16 +10675,6 @@ "node": ">=18" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/web-demuxer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/web-demuxer/-/web-demuxer-4.0.0.tgz", @@ -11488,38 +10770,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", diff --git a/package.json b/package.json index dbd5862..2ccb0b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openscreen", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "packageManager": "npm@10.9.4", "engines": { @@ -21,15 +21,17 @@ "i18n:check": "node scripts/i18n-check.mjs", "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", - "build:win": "tsc && vite build && electron-builder --win", - "build:linux": "tsc && vite build && electron-builder --linux AppImage deb", + "build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false", + "build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false", "test": "vitest --run", "test:watch": "vitest", "build-vite": "tsc && vite build", "test:browser": "vitest --config vitest.browser.config.ts --run", "test:browser:install": "playwright install --with-deps chromium-headless-shell", "test:e2e": "playwright test", - "prepare": "husky" + "prepare": "husky", + "rebuild:native": "node ./scripts/rebuild-native.mjs", + "postinstall": "npm run rebuild:native" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", @@ -49,6 +51,7 @@ "@types/gif.js": "^0.2.5", "@uiw/color-convert": "^2.10.1", "@uiw/react-color-block": "^2.10.1", + "@uiw/react-color-colorful": "^2.9.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dnd-timeline": "^2.4.0", @@ -70,11 +73,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.5", "uuid": "^13.0.0", "web-demuxer": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.12", + "@electron/rebuild": "^4.0.4", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs new file mode 100644 index 0000000..e028602 --- /dev/null +++ b/scripts/rebuild-native.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +// uiohook-napi click capture is macOS-only at runtime (gated in +// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners +// without X11 dev headers don't fail npm install. The library's prebuilt +// .node binaries are still bundled and loadable; we just don't need a fresh +// build against Electron's ABI on platforms where we don't load it. +if (process.platform !== "darwin") { + console.log( + `[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`, + ); + process.exit(0); +} + +const result = spawnSync( + process.execPath, + ["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"], + { stdio: "inherit" }, +); +process.exit(result.status ?? 0); diff --git a/src/App.tsx b/src/App.tsx index 4045b5d..f5fa7d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,20 @@ export default function App() { document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } + + // HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow + // so the renderer can't introduce scrollbars (see issue #305). + if (type === "hud-overlay") { + document.documentElement.style.height = "100%"; + document.documentElement.style.overflow = "hidden"; + document.body.style.height = "100%"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + const root = document.getElementById("root"); + root?.style.setProperty("height", "100%"); + root?.style.setProperty("min-height", "0"); + root?.style.setProperty("overflow", "hidden"); + } }, [windowType]); useEffect(() => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9b7d809..bffbd9c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -314,7 +314,13 @@ export function LaunchWindow() { }; return ( -
+ // Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh): + // 100vw can exceed the inner layout width when scrollbars affect the + // viewport (notably on Windows), causing a horizontal scrollbar once the + // recording toolbar widened (issue #305). +
{systemLocaleSuggestion && (
void; +}; + +type ColorPickerProps = + | (BaseProps & { + clearBackgroundOption?: false; + translations: Record<"colorWheel" | "colorPalette", string>; + }) + | (BaseProps & { + clearBackgroundOption: true; + translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>; + }); + +export default function ColorPicker(props: ColorPickerProps) { + const { selectedColor, colorPalette, translations, onUpdateColor } = props; + const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel"); + const [hexInput, setHexInput] = useState(selectedColor); + const [transparentColorHSVA, setTransparentColorHSVA] = useState({ + h: 0, + s: 0, + v: 0, + a: 0, + }); + + useEffect(() => { + setHexInput(selectedColor); + }, [selectedColor]); + + const getTextColor = (color: string) => { + if (color === "transparent") return "#ffffff"; + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + if (luminance > 186) return "#000000"; + return "#ffffff"; + }; + + // Normalize the hex input. + // Adds a # at the beginning of the input if it's not there. + const normalizeHexDraft = (raw: string) => { + const trimmed = raw.trim(); + if (trimmed === "") return ""; + if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`; + return trimmed; + }; + + const handleColorInputChange = (e: React.ChangeEvent) => { + const normalized = normalizeHexDraft(e.target.value); + setHexInput(normalized); + // Check if the normalized hex is a valid hex color. + // It should follow the format #RRGGBB or #RGB. + const isValidHexColor = + /^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized); + if (isValidHexColor) { + onUpdateColor(normalized); + } + }; + + const toTransparent = (color: string) => { + if (color === "transparent") return; + const hsva = hexToHsva(color); + hsva.a = 0; + return hsva; + }; + return ( +
+
+ + +
+ {colorMode === "wheel" && ( + <> +
+ {selectedColor} +
+ { + onUpdateColor(color.hex); + }} + style={{ + borderRadius: "8px", + }} + disableAlpha={true} + /> + + + )} + {colorMode === "palette" && ( + { + onUpdateColor(color.hex); + }} + style={{ + width: "100%", + borderRadius: "8px", + }} + /> + )} + {props.clearBackgroundOption === true && ( + + )} +
+ ); +} diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 4c26c88..3f8064e 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -31,6 +31,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useScopedT } from "@/contexts/I18nContext"; import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; import { cn } from "@/lib/utils"; +import ColorPicker from "../ui/color-picker"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; import { getArrowComponent } from "./ArrowSvgs"; import { @@ -75,7 +76,6 @@ export function AnnotationSettingsPanel({ const t = useScopedT("settings"); const fileInputRef = useRef(null); const [customFonts, setCustomFonts] = useState([]); - const fontStyleLabels: Record = { classic: t("fontStyles.classic"), editor: t("fontStyles.editor"), @@ -388,15 +388,19 @@ export function AnnotationSettingsPanel({ - - { - onStyleChange({ color: color.hex }); + + { + onStyleChange({ color: color }); }} /> @@ -427,31 +431,23 @@ export function AnnotationSettingsPanel({ - - { - onStyleChange({ backgroundColor: color.hex }); + + { + onStyleChange({ backgroundColor: color }); }} /> -
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f21f018..c625f87 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,8 +1,10 @@ -import Block from "@uiw/react-color-block"; +import * as SliderPrimitive from "@radix-ui/react-slider"; import { Bug, + ChevronDown, Crop, Download, + FileDown, Film, Image, Lock, @@ -23,6 +25,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -41,6 +44,7 @@ import { cn } from "@/lib/utils"; import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper"; import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; +import ColorPicker from "../ui/color-picker"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { BlurSettingsPanel } from "./BlurSettingsPanel"; import { CropControl } from "./CropControl"; @@ -52,13 +56,24 @@ import type { CropRegion, FigureData, PlaybackSpeed, + Rotation3DPreset, WebcamLayoutPreset, WebcamMaskShape, WebcamSizePreset, ZoomDepth, + ZoomFocus, ZoomFocusMode, } from "./types"; -import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; +import { + DEFAULT_WEBCAM_SIZE_PRESET, + MAX_PLAYBACK_SPEED, + MAX_ZOOM_SCALE, + MIN_ZOOM_SCALE, + ROTATION_3D_PRESET_ORDER, + SPEED_OPTIONS, + ZOOM_DEPTH_SCALES, +} from "./types"; +import { getFocusBoundsForScale } from "./videoPlayback/focusUtils"; function CustomSpeedInput({ value, @@ -123,6 +138,58 @@ function CustomSpeedInput({ ); } +function ZoomFocusCoordInput({ + percent, + onChange, + onCommit, + disabled, + ariaLabel, +}: { + percent: number; + onChange: (nextPercent: number) => void; + onCommit?: () => void; + disabled?: boolean; + ariaLabel: string; +}) { + // While the input is focused (user is editing), show their draft text + // so partial entries like "5" or "" don't get overwritten by re-renders. + // When not focused, mirror the live prop value so external changes + // (dragging the overlay on the preview) update the displayed number in real time. + const [draft, setDraft] = useState(null); + const display = percent.toFixed(1); + + return ( + setDraft(display)} + onChange={(e) => { + const next = e.target.value; + setDraft(next); + const parsed = Number(next); + if (next !== "" && Number.isFinite(parsed)) { + const clamped = Math.min(100, Math.max(0, parsed)); + onChange(clamped); + } + }} + onBlur={() => { + setDraft(null); + onCommit?.(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + }} + className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed" + /> + ); +} + const GRADIENTS = [ "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", @@ -151,15 +218,29 @@ const GRADIENTS = [ ]; interface SettingsPanelProps { + cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig; + onCursorHighlightChange?: ( + next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig, + ) => void; + // macOS only — gates the "Only on clicks" toggle (needs uiohook). + cursorHighlightSupportsClicks?: boolean; selected: string; onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomCustomScale?: number | null; + onZoomCustomScaleChange?: (scale: number) => void; + onZoomCustomScaleCommit?: () => void; selectedZoomFocusMode?: ZoomFocusMode | null; onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; + selectedZoomFocus?: ZoomFocus | null; + onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void; + onZoomFocusCoordinateCommit?: () => void; hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; + selectedZoomRotationPreset?: Rotation3DPreset | null; + onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void; selectedTrimId?: string | null; onTrimDelete?: (id: string) => void; shadowIntensity?: number; @@ -224,6 +305,7 @@ interface SettingsPanelProps { webcamSizePreset?: WebcamSizePreset; onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; + onSaveDiagnostic?: () => Promise; } export default SettingsPanel; @@ -238,15 +320,26 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ ]; export function SettingsPanel({ + cursorHighlight, + onCursorHighlightChange, + cursorHighlightSupportsClicks = false, selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, + selectedZoomCustomScale, + onZoomCustomScaleChange, + onZoomCustomScaleCommit, selectedZoomFocusMode, onZoomFocusModeChange, + selectedZoomFocus, + onZoomFocusCoordinateChange, + onZoomFocusCoordinateCommit, hasCursorTelemetry = false, selectedZoomId, onZoomDelete, + selectedZoomRotationPreset, + onZoomRotationPresetChange, selectedTrimId, onTrimDelete, shadowIntensity = 0, @@ -306,6 +399,7 @@ export function SettingsPanel({ webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET, onWebcamSizePresetChange, onWebcamSizePresetCommit, + onSaveDiagnostic, }: SettingsPanelProps) { const t = useScopedT("settings"); // Resolved URLs are for DOM rendering only (backgroundImage). The canonical @@ -569,7 +663,9 @@ export function SettingsPanel({
{zoomEnabled && selectedZoomDepth && ( - {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + {selectedZoomCustomScale != null + ? `${selectedZoomCustomScale.toFixed(2)}×` + : ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} )} @@ -577,7 +673,10 @@ export function SettingsPanel({
{ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; + const effectiveScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null); + const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth]; return (
)} + {zoomEnabled && + selectedZoomFocusMode !== "auto" && + selectedZoomFocus && + onZoomFocusCoordinateChange && + (() => { + const effectiveZoomScale = + selectedZoomCustomScale ?? + (selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE); + const bounds = getFocusBoundsForScale(effectiveZoomScale); + const xRange = bounds.maxX - bounds.minX; + const yRange = bounds.maxY - bounds.minY; + const focusToPercentX = (cx: number) => + xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100)); + const focusToPercentY = (cy: number) => + yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100)); + const percentToFocusX = (p: number) => + xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange; + const percentToFocusY = (p: number) => + yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange; + return ( +
+ + {t("zoom.position.title")} + +
+
+ + + onZoomFocusCoordinateChange({ + cx: percentToFocusX(p), + cy: selectedZoomFocus.cy, + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+
+ + + onZoomFocusCoordinateChange({ + cx: selectedZoomFocus.cx, + cy: percentToFocusY(p), + }) + } + onCommit={onZoomFocusCoordinateCommit} + /> +
+ + {t("zoom.position.hint")} + +
+
+ ); + })()} + {zoomEnabled && ( +
+ + {t("zoom.threeD.title")} + +
+ {ROTATION_3D_PRESET_ORDER.map((preset) => { + const isActive = selectedZoomRotationPreset === preset; + return ( + + ); + })} +
+
+ )} + {zoomEnabled && (
+ {cursorHighlight && onCursorHighlightChange && ( +
+
+
+ {t("effects.cursorHighlight.title")} +
+ +
+
+ {(["dot", "ring"] as const).map((style) => ( + + ))} +
+
+
+
+ {t("effects.cursorHighlight.size")} +
+ + {cursorHighlight.sizePx}px + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + sizePx: values[0], + }) + } + min={10} + max={36} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ {cursorHighlightSupportsClicks && ( +
+
+ {t("effects.cursorHighlight.onlyOnClicks")} +
+ +
+ )} +
+
+ {t("effects.cursorHighlight.color")} +
+ + + + + + + onCursorHighlightChange({ + ...cursorHighlight, + color, + }) + } + /> + + +
+
+
+
+ {t("effects.cursorHighlight.offsetX")} +
+ + {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetXNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
+ {t("effects.cursorHighlight.offsetY")} +
+ + {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% + +
+ + onCursorHighlightChange({ + ...cursorHighlight, + offsetYNorm: values[0], + }) + } + min={-0.25} + max={0.25} + step={0.005} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ )} + + {onSaveDiagnostic && ( + + )} - - {hasConflict && conflict?.conflictWith.type === "configurable" && ( -
- - ⚠{" "} - {t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })} - -
- - -
+
+
+

+ {t("configurable")} +

+ {SHORTCUT_ACTIONS.map((action) => { + const isCapturing = captureFor === action; + const hasConflict = conflict?.forAction === action; + return ( +
+
+ {t(`actions.${action}`)} +
- )} + {hasConflict && conflict?.conflictWith.type === "configurable" && ( +
+ + ⚠{" "} + {t("alreadyUsedBy", { + action: t(`actions.${conflict.conflictWith.action}`), + })} + +
+ + +
+
+ )} +
+ ); + })} +
+ +
+

+ {t("fixed")} +

+ {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => ( +
+ + {t(`fixedActions.${i18nKey}`, { defaultValue: label })} + + + {display} +
- ); - })} + ))} +
+ +

{t("helpText")}

-
-

- {t("fixed")} -

- {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => ( -
- - {t(`fixedActions.${i18nKey}`, { defaultValue: label })} - - - {display} - -
- ))} -
- -

{t("helpText")}

- - + + + +
+ + + ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558..12832ad 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,7 +31,12 @@ import { import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; +import { + getExportFolder, + loadUserPreferences, + parentDirectoryOf, + saveUserPreferences, +} from "@/lib/userPreferences"; import { BackgroundLoadError } from "@/lib/wallpaper"; import { getAspectRatioValue, @@ -67,13 +72,16 @@ import { DEFAULT_ZOOM_DEPTH, type FigureData, type PlaybackSpeed, + type Rotation3DPreset, type SpeedRegion, type TrimRegion, + ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, type ZoomFocusMode, type ZoomRegion, } from "./types"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { @@ -103,6 +111,7 @@ export default function VideoEditor() { webcamMaskShape, webcamSizePreset, webcamPosition, + cursorHighlight, } = editorState; // ── Non-undoable state @@ -121,6 +130,7 @@ export default function VideoEditor() { const durationRef = useRef(duration); durationRef.current = duration; const [cursorTelemetry, setCursorTelemetry] = useState([]); + const [cursorClickTimestamps, setCursorClickTimestamps] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); @@ -144,6 +154,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -153,6 +164,12 @@ export default function VideoEditor() { const nextSpeedIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); + // Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for + // renderers while keeping the persisted value intact for round-tripping. + const effectiveCursorHighlight = useMemo( + () => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }), + [cursorHighlight, isMac], + ); const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); @@ -430,7 +447,7 @@ export default function VideoEditor() { return false; } - const projectData = createProjectData(currentProjectMedia, { + const editorState = { wallpaper, shadowIntensity, showBlur, @@ -452,14 +469,18 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, - }); + cursorHighlight, + }; + const projectData = createProjectData(currentProjectMedia, editorState); const fileNameBase = currentProjectMedia.screenVideoPath .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - const projectSnapshot = JSON.stringify(projectData); + // Match the normalization path used by `currentProjectSnapshot` so the + // post-save baseline compares equal and `hasUnsavedChanges` clears. + const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState); const result = await window.electronAPI.saveProjectFile( projectData, fileNameBase, @@ -510,6 +531,7 @@ export default function VideoEditor() { videoPath, t, webcamSizePreset, + cursorHighlight, ], ); @@ -524,6 +546,28 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + useEffect(() => { + const cleanup = window.electronAPI.onRequestCloseConfirm(() => { + setShowCloseConfirmDialog(true); + }); + return () => cleanup(); + }, []); + + const handleCloseConfirmSave = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("save"); + }, []); + + const handleCloseConfirmDiscard = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("discard"); + }, []); + + const handleCloseConfirmCancel = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("cancel"); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -584,6 +628,7 @@ export default function VideoEditor() { if (!sourcePath) { if (mounted) { setCursorTelemetry([]); + setCursorClickTimestamps([]); } return; } @@ -592,11 +637,13 @@ export default function VideoEditor() { const result = await window.electronAPI.getCursorTelemetry(sourcePath); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); + setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []); } } catch (telemetryError) { console.warn("Unable to load cursor telemetry:", telemetryError); if (mounted) { setCursorTelemetry([]); + setCursorClickTimestamps([]); } } } @@ -686,6 +733,7 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, + customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: { cx: 0.5, cy: 0.5 }, }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); @@ -705,6 +753,7 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), depth: DEFAULT_ZOOM_DEPTH, + customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH), }; pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] })); @@ -788,6 +837,7 @@ export default function VideoEditor() { ? { ...region, depth, + customScale: ZOOM_DEPTH_SCALES[depth], focus: clampFocusToDepth(region.focus, depth), } : region, @@ -797,6 +847,24 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomCustomScaleChange = useCallback( + (scale: number) => { + if (!selectedZoomId) return; + const rounded = Math.round(scale * 100) / 100; + if (!Number.isFinite(rounded)) return; + updateState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === selectedZoomId ? { ...region, customScale: rounded } : region, + ), + })); + }, + [selectedZoomId, updateState], + ); + + const handleZoomCustomScaleCommit = useCallback(() => { + commitState(); + }, [commitState]); + const handleZoomFocusModeChange = useCallback( (focusMode: ZoomFocusMode) => { if (!selectedZoomId) return; @@ -821,6 +889,23 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomRotationPresetChange = useCallback( + (preset: Rotation3DPreset | null) => { + if (!selectedZoomId) return; + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => { + if (region.id !== selectedZoomId) return region; + if (preset === null) { + const { rotationPreset: _p, ...rest } = region; + return rest; + } + return { ...region, rotationPreset: preset }; + }), + })); + }, + [selectedZoomId, pushState], + ); + const handleTrimDelete = useCallback( (id: string) => { pushState((prev) => ({ @@ -1285,6 +1370,10 @@ export default function VideoEditor() { const handleExportSaved = useCallback( (formatLabel: "GIF" | "Video", filePath: string) => { setExportedFilePath(filePath); + const folder = parentDirectoryOf(filePath); + if (folder) { + saveUserPreferences({ exportFolder: folder }); + } toast.success( t("export.exportedSuccessfully", { format: formatLabel, @@ -1306,13 +1395,19 @@ export default function VideoEditor() { const handleSaveUnsavedExport = useCallback(async () => { if (!unsavedExport) return; try { - const saveResult = await window.electronAPI.saveExportedVideo( - unsavedExport.arrayBuffer, + const pickResult = await window.electronAPI.pickExportSavePath( unsavedExport.fileName, + getExportFolder(), ); - if (saveResult.canceled) { + if (pickResult.canceled || !pickResult.success || !pickResult.path) { toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + return; + } + const saveResult = await window.electronAPI.writeExportToPath( + unsavedExport.arrayBuffer, + pickResult.path, + ); + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path); } else { @@ -1337,6 +1432,21 @@ export default function VideoEditor() { return; } + // Ask the user where to save BEFORE starting the export. This avoids the + // post-export save dialog getting hidden behind other windows after a + // long-running export. + const isGifFormat = settings.format === "gif"; + const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`; + const pickResult = await window.electronAPI.pickExportSavePath( + targetFileName, + getExportFolder(), + ); + if (pickResult.canceled || !pickResult.success || !pickResult.path) { + setShowExportDialog(false); + return; + } + const targetPath = pickResult.path; + setIsExporting(true); setExportProgress(null); setExportError(null); @@ -1391,6 +1501,8 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, + cursorClickTimestamps, + cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1401,8 +1513,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.gif`; if (result.warnings) { for (const warning of result.warnings) { @@ -1410,15 +1520,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "gif" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("GIF", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" }); setExportError(saveResult.message || "Failed to save GIF"); toast.error(saveResult.message || "Failed to save GIF"); } @@ -1434,18 +1542,19 @@ export default function VideoEditor() { let bitrate: number; if (quality === "source") { - // Use source resolution exportWidth = sourceWidth; exportHeight = sourceHeight; + // Use the source's longer dimension as the long axis of the export so + // a landscape recording can still fill a portrait target (and vice versa). + const sourceLongDim = Math.max(sourceWidth, sourceHeight); + if (aspectRatioValue === 1) { - // Square (1:1): use smaller dimension to avoid codec limits const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2; exportWidth = baseDimension; exportHeight = baseDimension; } else if (aspectRatioValue > 1) { - // Landscape: find largest even dimensions that exactly match aspect ratio - const baseWidth = Math.floor(sourceWidth / 2) * 2; + const baseWidth = Math.floor(sourceLongDim / 2) * 2; let found = false; for (let w = baseWidth; w >= 100 && !found; w -= 2) { const h = Math.round(w / aspectRatioValue); @@ -1460,8 +1569,7 @@ export default function VideoEditor() { exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2; } } else { - // Portrait: find largest even dimensions that exactly match aspect ratio - const baseHeight = Math.floor(sourceHeight / 2) * 2; + const baseHeight = Math.floor(sourceLongDim / 2) * 2; let found = false; for (let h = baseHeight; h >= 100 && !found; h -= 2) { const w = Math.round(h * aspectRatioValue); @@ -1477,7 +1585,6 @@ export default function VideoEditor() { } } - // Calculate visually lossless bitrate matching screen recording optimization const totalPixels = exportWidth * exportHeight; bitrate = 30_000_000; if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) { @@ -1486,14 +1593,18 @@ export default function VideoEditor() { bitrate = 80_000_000; } } else { - // Use quality-based target resolution - const targetHeight = quality === "medium" ? 720 : 1080; + // Quality presets target the SHORT side; the long side derives from the + // aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080. + const targetShortDim = quality === "medium" ? 720 : 1080; - // Calculate dimensions maintaining aspect ratio - exportHeight = Math.floor(targetHeight / 2) * 2; - exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + if (aspectRatioValue >= 1) { + exportHeight = Math.floor(targetShortDim / 2) * 2; + exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2; + } else { + exportWidth = Math.floor(targetShortDim / 2) * 2; + exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2; + } - // Adjust bitrate for lower resolutions const totalPixels = exportWidth * exportHeight; if (totalPixels <= 1280 * 720) { bitrate = 10_000_000; @@ -1531,6 +1642,8 @@ export default function VideoEditor() { previewWidth, previewHeight, cursorTelemetry, + cursorClickTimestamps, + cursorHighlight: effectiveCursorHighlight, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1541,8 +1654,6 @@ export default function VideoEditor() { if (result.success && result.blob) { const arrayBuffer = await result.blob.arrayBuffer(); - const timestamp = Date.now(); - const fileName = `export-${timestamp}.mp4`; if (result.warnings) { for (const warning of result.warnings) { @@ -1550,15 +1661,13 @@ export default function VideoEditor() { } } - const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath); - if (saveResult.canceled) { - setUnsavedExport({ arrayBuffer, fileName, format: "mp4" }); - toast.info("Export canceled"); - } else if (saveResult.success && saveResult.path) { + if (saveResult.success && saveResult.path) { setUnsavedExport(null); handleExportSaved("Video", saveResult.path); } else { + setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" }); setExportError(saveResult.message || "Failed to save video"); toast.error(saveResult.message || "Failed to save video"); } @@ -1614,6 +1723,8 @@ export default function VideoEditor() { exportQuality, handleExportSaved, cursorTelemetry, + cursorClickTimestamps, + effectiveCursorHighlight, t, ], ); @@ -1690,6 +1801,19 @@ export default function VideoEditor() { } }, []); + const handleSaveDiagnostic = useCallback(async () => { + const result = await window.electronAPI.saveDiagnostic({ + error: exportError ?? "Manual diagnostic export", + projectState: editorState, + logs: [], + }); + if (result.success) { + toast.success("Diagnostic file saved"); + } else if (!result.canceled) { + toast.error("Failed to save diagnostic file"); + } + }, [exportError, editorState]); + if (loading) { return (
@@ -1871,6 +1995,8 @@ export default function VideoEditor() { onBlurDataChange={handleBlurDataPreviewChange} onBlurDataCommit={commitState} cursorTelemetry={cursorTelemetry} + cursorHighlight={effectiveCursorHighlight} + cursorClickTimestamps={cursorClickTimestamps} />
@@ -1954,21 +2080,46 @@ export default function VideoEditor() { {/* Right section: settings panel */}
pushState({ cursorHighlight: next })} + cursorHighlightSupportsClicks={isMac} selected={wallpaper} onWallpaperChange={(w) => pushState({ wallpaper: w })} selectedZoomDepth={ selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomCustomScale={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) + : null + } + onZoomCustomScaleChange={handleZoomCustomScaleChange} + onZoomCustomScaleCommit={handleZoomCustomScaleCommit} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") : null } onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)} + selectedZoomFocus={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) + : null + } + onZoomFocusCoordinateChange={(focus) => + selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) + } + onZoomFocusCoordinateCommit={commitState} hasCursorTelemetry={cursorTelemetry.length > 0} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} + selectedZoomRotationPreset={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null) + : null + } + onZoomRotationPresetChange={handleZoomRotationPresetChange} selectedTrimId={selectedTrimId} onTrimDelete={handleTrimDelete} shadowIntensity={shadowIntensity} @@ -2049,6 +2200,7 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} + onSaveDiagnostic={handleSaveDiagnostic} />
@@ -2066,6 +2218,13 @@ export default function VideoEditor() { exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } /> + + ); } diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 35e0077..f863f83 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -36,10 +36,14 @@ import { AnnotationOverlay } from "./AnnotationOverlay"; import { type AnnotationRegion, type BlurData, + computeRotation3DContainScale, + DEFAULT_ROTATION_3D, + getZoomScale, + isRotation3DIdentity, + lerpRotation3D, + rotation3DPerspective, type SpeedRegion, type TrimRegion, - ZOOM_DEPTH_SCALES, - type ZoomDepth, type ZoomFocus, type ZoomRegion, } from "./types"; @@ -51,8 +55,18 @@ import { ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX, } from "./videoPlayback/constants"; -import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils"; -import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; +import { + adaptiveSmoothFactor, + interpolateCursorAt, + smoothCursorFocus, +} from "./videoPlayback/cursorFollowUtils"; +import { + type CursorHighlightConfig, + clickEmphasisAlpha, + DEFAULT_CURSOR_HIGHLIGHT, + drawCursorHighlightGraphics, +} from "./videoPlayback/cursorHighlight"; +import { clampFocusToScale } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; @@ -110,6 +124,8 @@ interface VideoPlaybackProps { onBlurDataChange?: (id: string, blurData: BlurData) => void; onBlurDataCommit?: () => void; cursorTelemetry?: import("./types").CursorTelemetryPoint[]; + cursorHighlight?: CursorHighlightConfig; + cursorClickTimestamps?: number[]; } export interface VideoPlaybackRef { @@ -168,6 +184,8 @@ const VideoPlayback = forwardRef( onBlurDataChange, onBlurDataCommit, cursorTelemetry = [], + cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT, + cursorClickTimestamps = [], }, ref, ) => { @@ -186,11 +204,16 @@ const VideoPlayback = forwardRef( const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); + const composite3DRef = useRef(null); + const outerWrapperRef = useRef(null); const [webcamLayout, setWebcamLayout] = useState(null); const [webcamDimensions, setWebcamDimensions] = useState(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const cursorTelemetryRef = useRef([]); + const cursorHighlightRef = useRef(DEFAULT_CURSOR_HIGHLIGHT); + const cursorClickTimestampsRef = useRef([]); + const cursorHighlightGraphicsRef = useRef(null); const selectedZoomIdRef = useRef(null); const animationStateRef = useRef({ scale: 1, @@ -215,6 +238,9 @@ const VideoPlayback = forwardRef( const maskGraphicsRef = useRef(null); const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); + const isScrubbingRef = useRef(false); + const scrubEndTimerRef = useRef(null); + const [isScrubbing, setIsScrubbing] = useState(false); const allowPlaybackRef = useRef(false); const lockedVideoDimensionsRef = useRef<{ width: number; @@ -231,10 +257,6 @@ const VideoPlayback = forwardRef( const smoothedAutoFocusRef = useRef(null); const prevTargetProgressRef = useRef(0); - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); - }, []); - const updateOverlayForRegion = useCallback( (region: ZoomRegion | null, focusOverride?: ZoomFocus) => { const overlayEl = overlayRef.current; @@ -415,7 +437,7 @@ const VideoPlayback = forwardRef( cx: clamp01(localX / stageWidth), cy: clamp01(localY / stageHeight), }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region)); onZoomFocusChange(region.id, clampedFocus); updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); @@ -515,6 +537,17 @@ const VideoPlayback = forwardRef( cursorTelemetryRef.current = cursorTelemetry; }, [cursorTelemetry]); + useEffect(() => { + cursorHighlightRef.current = cursorHighlight; + if (cursorHighlightGraphicsRef.current) { + drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight); + } + }, [cursorHighlight]); + + useEffect(() => { + cursorClickTimestampsRef.current = cursorClickTimestamps; + }, [cursorClickTimestamps]); + useEffect(() => { selectedZoomIdRef.current = selectedZoomId; }, [selectedZoomId]); @@ -583,6 +616,24 @@ const VideoPlayback = forwardRef( }; }, [pixiReady, videoReady, layoutVideoContent]); + // Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is + // navigating, not previewing) and restore native DPR on play/idle so the + // preview stays faithful. Mutating renderer.resolution per-frame would + // thrash texture uploads; we only do it on scrub-state transitions. + useEffect(() => { + if (!pixiReady) return; + const app = appRef.current; + const container = containerRef.current; + if (!app || !container) return; + + const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1; + if (app.renderer.resolution === targetResolution) return; + + app.renderer.resolution = targetResolution; + app.renderer.resize(container.clientWidth, container.clientHeight); + layoutVideoContentRef.current?.(); + }, [isScrubbing, pixiReady]); + useEffect(() => { if (!pixiReady || !videoReady) return; updateOverlayForRegion(selectedZoom); @@ -738,6 +789,12 @@ const VideoPlayback = forwardRef( videoContainer.mask = maskGraphics; maskGraphicsRef.current = maskGraphics; + const cursorHighlightGraphics = new Graphics(); + cursorHighlightGraphics.visible = false; + videoContainer.addChild(cursorHighlightGraphics); + cursorHighlightGraphicsRef.current = cursorHighlightGraphics; + drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current); + animationStateRef.current = { scale: 1, focusX: DEFAULT_FOCUS.cx, @@ -770,6 +827,9 @@ const VideoPlayback = forwardRef( onTimeUpdate: (time) => onTimeUpdateRef.current(time), trimRegionsRef, speedRegionsRef, + isScrubbingRef, + scrubEndTimerRef, + onScrubChange: (scrubbing) => setIsScrubbing(scrubbing), }); video.addEventListener("play", handlePlay); @@ -797,6 +857,11 @@ const VideoPlayback = forwardRef( videoContainer.removeChild(maskGraphics); maskGraphics.destroy(); } + if (cursorHighlightGraphicsRef.current) { + videoContainer.removeChild(cursorHighlightGraphicsRef.current); + cursorHighlightGraphicsRef.current.destroy(); + cursorHighlightGraphicsRef.current = null; + } videoContainer.mask = null; maskGraphicsRef.current = null; if (blurFilterRef.current) { @@ -858,8 +923,10 @@ const VideoPlayback = forwardRef( }; let lastMotionBlurActive: boolean | null = null; + let lastTransformIsIdentity = true; + let lastPerspectiveValue = 0; const ticker = () => { - const { region, strength, blendedScale, transition } = findDominantRegion( + const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, { @@ -879,7 +946,7 @@ const VideoPlayback = forwardRef( const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + const zoomScale = blendedScale ?? getZoomScale(region); const regionFocus = region.focus; targetScaleFactor = zoomScale; @@ -1016,7 +1083,41 @@ const VideoPlayback = forwardRef( motionVector, ); - const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current; + const cursorGraphics = cursorHighlightGraphicsRef.current; + const cursorConfig = cursorHighlightRef.current; + const lockedDims = lockedVideoDimensionsRef.current; + if (cursorGraphics) { + if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) { + const emphasisAlpha = clickEmphasisAlpha( + currentTimeRef.current, + cursorClickTimestampsRef.current, + cursorConfig, + ); + const cursorPoint = + emphasisAlpha > 0 + ? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current) + : null; + if (cursorPoint) { + const baseScale = baseScaleRef.current; + const baseOffset = baseOffsetRef.current; + const cx = cursorPoint.cx + cursorConfig.offsetXNorm; + const cy = cursorPoint.cy + cursorConfig.offsetYNorm; + cursorGraphics.position.set( + baseOffset.x + cx * lockedDims.width * baseScale, + baseOffset.y + cy * lockedDims.height * baseScale, + ); + cursorGraphics.alpha = emphasisAlpha; + cursorGraphics.visible = true; + } else { + cursorGraphics.visible = false; + } + } else { + cursorGraphics.visible = false; + } + } + + const isMotionBlurActive = + (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current; if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) { if (isMotionBlurActive) { @@ -1032,6 +1133,44 @@ const VideoPlayback = forwardRef( lastMotionBlurActive = false; } } + + const composite3D = composite3DRef.current; + const outerWrapper = outerWrapperRef.current; + if (composite3D && outerWrapper) { + const effectiveRotation = + region && targetProgress > 0 && !shouldShowUnzoomedView + ? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress) + : DEFAULT_ROTATION_3D; + const isIdentity = isRotation3DIdentity(effectiveRotation); + if (isIdentity) { + if (!lastTransformIsIdentity) { + composite3D.style.transform = ""; + composite3D.style.willChange = "auto"; + lastTransformIsIdentity = true; + } + if (lastPerspectiveValue !== 0) { + outerWrapper.style.perspective = ""; + lastPerspectiveValue = 0; + } + } else { + const wrapperW = outerWrapper.clientWidth || 1; + const wrapperH = outerWrapper.clientHeight || 1; + const persp = rotation3DPerspective(wrapperW, wrapperH); + const containScale = computeRotation3DContainScale( + effectiveRotation, + wrapperW, + wrapperH, + persp, + ); + composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`; + composite3D.style.willChange = "transform"; + lastTransformIsIdentity = false; + if (persp !== lastPerspectiveValue) { + outerWrapper.style.perspective = `${persp}px`; + lastPerspectiveValue = persp; + } + } + } }; app.ticker.add(ticker); @@ -1153,6 +1292,10 @@ const VideoPlayback = forwardRef( cancelAnimationFrame(videoReadyRafRef.current); videoReadyRafRef.current = null; } + if (scrubEndTimerRef.current !== null) { + window.clearTimeout(scrubEndTimerRef.current); + scrubEndTimerRef.current = null; + } }; }, []); @@ -1169,6 +1312,7 @@ const VideoPlayback = forwardRef( return (
( }} />
0 - ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` - : "none", + transformStyle: "preserve-3d", + transformOrigin: "center center", }} - /> - {webcamVideoPath && - (() => { - const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); - const useClipPath = !!clipPath; - return ( -
-
- ); - })()} - {/* Only render overlay after PIXI and video are fully initialized */} - {pixiReady && videoReady && ( + >
-
- {(() => { - const filteredAnnotations = (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 filteredBlurRegions = (blurRegions || []).filter((blurRegion) => { - if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number") - return false; - - if (blurRegion.id === selectedBlurId) return true; - - const timeMs = Math.round(currentTime * 1000); - return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs; - }); - - const sorted = [ - ...filteredAnnotations.map((annotation) => ({ - kind: "annotation" as const, - region: annotation, - })), - ...filteredBlurRegions.map((blurRegion) => ({ - kind: "blur" as const, - region: blurRegion, - })), - ].sort((a, b) => a.region.zIndex - b.region.zIndex); - const previewSnapshotCanvas = - filteredBlurRegions.length > 0 - ? (() => { - const app = appRef.current; - if (!app?.renderer?.extract) return null; - try { - return app.renderer.extract.canvas(app.stage); - } catch { - return null; - } - })() - : null; - - // 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 && filteredAnnotations.length > 1) { - // Find current index and cycle to next - const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId); - const nextIndex = (currentIndex + 1) % filteredAnnotations.length; - onSelectAnnotation(filteredAnnotations[nextIndex].id); - } else { - // First click or clicking different annotation - onSelectAnnotation(clickedId); - } - }; - - const handleBlurClick = (clickedId: string) => { - if (!onSelectBlur) return; - - if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) { - const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId); - const nextIndex = (currentIndex + 1) % filteredBlurRegions.length; - onSelectBlur(filteredBlurRegions[nextIndex].id); - } else { - onSelectBlur(clickedId); - } - }; - - return sorted.map((item) => ( - `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}` - : `${item.region.id}-${overlaySize.width}-${overlaySize.height}` - } - annotation={item.region} - isSelected={ - item.kind === "blur" - ? item.region.id === selectedBlurId - : item.region.id === selectedAnnotationId - } - containerWidth={overlaySize.width} - containerHeight={overlaySize.height} - onPositionChange={(id, position) => - item.kind === "blur" - ? onBlurPositionChange?.(id, position) - : onAnnotationPositionChange?.(id, position) - } - onSizeChange={(id, size) => - item.kind === "blur" - ? onBlurSizeChange?.(id, size) - : onAnnotationSizeChange?.(id, size) - } - onBlurDataChange={ - item.kind === "blur" - ? (id, blurData) => onBlurDataChange?.(id, blurData) - : undefined - } - onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined} - onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick} - zIndex={item.region.zIndex} - isSelectedBoost={ - item.kind === "blur" - ? item.region.id === selectedBlurId - : item.region.id === selectedAnnotationId - } - previewSourceCanvas={previewSnapshotCanvas} - previewFrameVersion={Math.round(currentTime * 1000)} - /> - )); + ref={containerRef} + className="absolute inset-0" + style={{ + filter: + showShadow && shadowIntensity > 0 + ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` + : "none", + }} + /> + {webcamVideoPath && + (() => { + const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle"); + const useClipPath = !!clipPath; + return ( +
+
+ ); })()} -
- )} + {/* Only render overlay after PIXI and video are fully initialized */} + {pixiReady && videoReady && ( +
+
+ {(() => { + const filteredAnnotations = (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 filteredBlurRegions = (blurRegions || []).filter((blurRegion) => { + if ( + typeof blurRegion.startMs !== "number" || + typeof blurRegion.endMs !== "number" + ) + return false; + + if (blurRegion.id === selectedBlurId) return true; + + const timeMs = Math.round(currentTime * 1000); + return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs; + }); + + const sorted = [ + ...filteredAnnotations.map((annotation) => ({ + kind: "annotation" as const, + region: annotation, + })), + ...filteredBlurRegions.map((blurRegion) => ({ + kind: "blur" as const, + region: blurRegion, + })), + ].sort((a, b) => a.region.zIndex - b.region.zIndex); + const previewSnapshotCanvas = + filteredBlurRegions.length > 0 + ? (() => { + const app = appRef.current; + if (!app?.renderer?.extract) return null; + try { + return app.renderer.extract.canvas(app.stage); + } catch { + return null; + } + })() + : null; + + // 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 && filteredAnnotations.length > 1) { + // Find current index and cycle to next + const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId); + const nextIndex = (currentIndex + 1) % filteredAnnotations.length; + onSelectAnnotation(filteredAnnotations[nextIndex].id); + } else { + // First click or clicking different annotation + onSelectAnnotation(clickedId); + } + }; + + const handleBlurClick = (clickedId: string) => { + if (!onSelectBlur) return; + + if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) { + const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId); + const nextIndex = (currentIndex + 1) % filteredBlurRegions.length; + onSelectBlur(filteredBlurRegions[nextIndex].id); + } else { + onSelectBlur(clickedId); + } + }; + + return sorted.map((item) => ( + `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}` + : `${item.region.id}-${overlaySize.width}-${overlaySize.height}` + } + annotation={item.region} + isSelected={ + item.kind === "blur" + ? item.region.id === selectedBlurId + : item.region.id === selectedAnnotationId + } + containerWidth={overlaySize.width} + containerHeight={overlaySize.height} + onPositionChange={(id, position) => + item.kind === "blur" + ? onBlurPositionChange?.(id, position) + : onAnnotationPositionChange?.(id, position) + } + onSizeChange={(id, size) => + item.kind === "blur" + ? onBlurSizeChange?.(id, size) + : onAnnotationSizeChange?.(id, size) + } + onBlurDataChange={ + item.kind === "blur" + ? (id, blurData) => onBlurDataChange?.(id, blurData) + : undefined + } + onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined} + onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick} + zIndex={item.region.zIndex} + isSelectedBoost={ + item.kind === "blur" + ? item.region.id === selectedBlurId + : item.region.id === selectedAnnotationId + } + previewSourceCanvas={previewSnapshotCanvas} + previewFrameVersion={Math.round(currentTime * 1000)} + /> + )); + })()} +
+ )} +