From e60b770f505808b200aad19108042c72acc2b609 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 16:53:01 +0500 Subject: [PATCH 01/28] WIP: packaging, GPU picker, project format presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the uncommitted working-tree work before merging upstream v0.3.0: - Packaging: bundled runtime (ffmpeg/node/python), electron-builder pack, /opt install script, runtime-env PATH shim, INSTALL.md. - GPU selection: electron/gpu.ts enumerates DRM render nodes; a Settings dialog lets the user pick a GPU, applied at startup via render-node-override (+ PRIME/x11 for NVIDIA). Self-healing: a trial choice reverts to auto if the renderer never loads, so a bad pick can't brick the app. - Project format presets: New Project dialog + a "match video / preset" prompt when media is dropped into an empty project (newProject(dims), PROJECT_PRESETS, format resolver in the import funnel). - Cross-aspect export fix (drawFrame renderSize) — SUPERSEDED by upstream v0.2.2 on merge; kept here only so the branch is self-contained. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + INSTALL.md | 99 + build/icon.png | Bin 0 -> 38488 bytes build/icon.svg | 22 + electron-builder.yml | 59 + electron/ffmpeg.ts | 35 +- electron/fragments.ts | 40 +- electron/gpu.ts | 133 ++ electron/main.ts | 27 +- electron/preload.ts | 9 +- electron/runtime-env.ts | 62 + electron/transcribe.ts | 25 +- package-lock.json | 3007 +++++++++++++++++++++++- package.json | 12 +- scripts/install-desktop.sh | 63 + scripts/pack.sh | 171 ++ shared/types.ts | 28 +- src/App.tsx | 59 +- src/components/CaptionsDialog.tsx | 8 +- src/components/ProjectFormatDialog.tsx | 71 + src/components/SettingsDialog.tsx | 111 + src/components/TextTools.tsx | 8 +- src/engine/exporter.ts | 4 +- src/engine/mediaImport.ts | 31 + src/engine/player.ts | 11 +- src/engine/proxy.ts | 16 +- src/engine/subtitles.ts | 2 +- src/gl/compositor.ts | 17 +- src/i18n.ts | 34 +- src/presets.ts | 20 + src/state/store.ts | 12 +- 31 files changed, 4038 insertions(+), 160 deletions(-) create mode 100644 INSTALL.md create mode 100644 build/icon.png create mode 100644 build/icon.svg create mode 100644 electron-builder.yml create mode 100644 electron/gpu.ts create mode 100644 electron/runtime-env.ts create mode 100755 scripts/install-desktop.sh create mode 100755 scripts/pack.sh create mode 100644 src/components/ProjectFormatDialog.tsx create mode 100644 src/components/SettingsDialog.tsx diff --git a/.gitignore b/.gitignore index b9276bc..35bafc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ out/ dist/ +runtime/ +kadr-fragments-seed/ *.log .claude/ *.autosave.kadr diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..7f00d49 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,99 @@ +# Установка Kadr + +## Для пользователя — готовый архив (рекомендуется) + +Не нужен ни `npm install`, ни компиляторы, ни системный ffmpeg. Особенно +актуально для immutable-дистрибутивов (Bazzite, Silverblue, Kinoite). + +**tar.gz:** +```bash +tar -xzf kadr-<версия>-linux-x64.tar.gz +cd kadr-<версия>-linux-x64 +./kadr +``` + +**AppImage:** +```bash +chmod +x Kadr-<версия>-linux-x86_64.AppImage +./Kadr-<версия>-linux-x86_64.AppImage +# нет FUSE? → ./Kadr-*.AppImage --appimage-extract-and-run +``` + +Что работает сразу, без доустановки: +- **Ядро** (импорт / превью / экспорт) — встроены статические `ffmpeg`/`ffprobe`. +- **Субтитры** (faster-whisper) — встроен переносимый Python. При первом + использовании один раз качается ML-модель (нужна сеть). +- **Remotion-фрагменты** — встроены Node и пред-установленные зависимости. + Если сборка не «прогрела» headless-Chromium, он один раз докачается при + первом рендере фрагмента (сеть; но уже без системного Node). + +Опционально (ставит сам пользователь): **Claude-панель** требует установленного +`claude` CLI в `PATH` — это личный инструмент/аккаунт, встроить его нельзя. + +Только `linux-x64` (glibc). Для arm64 / musl нужна отдельная сборка. + +### Запуск из меню приложений (без терминала) + +**AppImage** — уже запускается двойным кликом из файлового менеджера (нужен бит +«исполняемый»: ПКМ → Свойства → Разрешения). Чтобы появилась постоянная иконка в +меню приложений и автообновление — интегрируй его через **Gearlever** (штатный +GUI-способ для Bazzite/атомарных систем): +```bash +flatpak install flathub it.mijorus.gearlever +``` +Открой Gearlever → перетащи в него `Kadr-*.AppImage` → «Add to menu». Дальше Kadr +запускается из меню как обычное приложение. + +**tar.gz** — распакуй, положи папку в постоянное место и один раз запусти +`install.sh`: +```bash +tar -xzf kadr-<версия>-linux-x64.tar.gz +sudo mv kadr-<версия>-linux-x64 /opt/kadr # любое постоянное место +bash /opt/kadr/install.sh # БЕЗ sudo — пишет в ~/.local +``` +После этого «Kadr» появляется в меню/поиске KDE с логотипом и запускается кликом, +без терминала — и всё (звук, экспорт, субтитры, Remotion, Claude-панель) работает +без ручной настройки. `install.sh` запускай **своим пользователем, не через sudo** +(он пишет только в `~/.local/share`, даже если приложение лежит в `/opt`). +Перенёс папку — запусти `bash /opt/kadr/install.sh` ещё раз. Убрать ярлык: +`bash /opt/kadr/install.sh --uninstall`. + +--- + +## Для мейнтейнера — сборка архива + +Сборку нужно делать в окружении с тулчейном (компилятор для `node-pty`), а не на +голом immutable-хосте. Штатный путь — distrobox: + +```bash +distrobox create --name kadr-build --image fedora:42 +distrobox enter kadr-build +sudo dnf install -y gcc-c++ make python3 nodejs npm tar xz which curl + +cd ~/Projects/kadr +npm run pack # = bash scripts/pack.sh +``` + +Артефакты появятся в `dist/`. Скрипт сам: +1. собирает приложение (`npm ci` + `electron-vite build`); +2. кладёт статический ffmpeg/ffprobe, переносимый Node и Python+faster-whisper в + `runtime/`; +3. пред-устанавливает зависимости Remotion в `kadr-fragments-seed/`; +4. пересобирает `node-pty` под ABI встроенного Electron и пакует + tar.gz + AppImage через electron-builder. + +Флаги `scripts/pack.sh`: +- `--lite` — только ядро + ffmpeg (без whisper и Remotion; ~в разы меньше размер); +- `--model ` — предвложить модель субтитров (`base`, `small`, …) для + полностью офлайн-субтитров; +- `--skip-build` — переиспользовать существующий `out/`. + +Версии вложенных Node / Python / ffmpeg закреплены вверху `scripts/pack.sh` — +меняются там. Размер полного архива ориентировочно ~0.5–0.7 ГБ. + +> Как это устроено под капотом: `electron/runtime-env.ts` (первый импорт +> `main.ts`) в упакованной сборке добавляет `resources/runtime/bin` в `PATH` и +> выставляет `KADR_FFMPEG`/`KADR_FFPROBE`, поэтому все внешние вызовы +> (`ffmpeg`, `python3`, `npm`, `npx`, `node`) резолвятся во вложенные бинарники. +> Упаковка идёт без asar — `electron/mcp-bridge.cjs` и `scripts/transcribe.py` +> запускаются внешними процессами по реальному пути и внутри asar не читались бы. diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..05dbfb3cda42bf0d3b2704809797435d7aee495c GIT binary patch literal 38488 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 WH3^@G$oDBjy&|V7n| + + + + + + + + + + + + + + + + + + + + + diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..effd879 --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,59 @@ +# electron-builder config for the self-contained Linux archive. +# Run via scripts/pack.sh (inside a distrobox with a build toolchain), which +# stages runtime/ and kadr-fragments-seed/ before invoking electron-builder. +appId: com.blacktriangle.kadr +productName: Kadr +copyright: © Black Triangle — GPL-3.0 + +directories: + output: dist + buildResources: build + +# node/python/npx read these files by real on-disk path (app.getAppPath()), +# and external processes can't read inside an asar — so ship unpacked. +asar: false + +# Whitelist the app payload. electron-builder always adds production +# node_modules (pruned of devDependencies) on top of this, which mcp-bridge.cjs +# needs at runtime (@modelcontextprotocol/sdk). +files: + - out/**/* + - electron/mcp-bridge.cjs + - scripts/transcribe.py + - package.json + +# Bundled runtime + Remotion seed land under resources/ (process.resourcesPath). +# Both dirs are produced by scripts/pack.sh; guarded there so a "lite" build can +# omit kadr-fragments-seed. +extraResources: + - from: runtime + to: runtime + - from: kadr-fragments-seed + to: kadr-fragments-seed + +linux: + target: + - tar.gz + - AppImage + category: AudioVideo + icon: build/icon.png + synopsis: GPU-accelerated multi-track video editor + artifactName: ${name}-${version}-linux-${arch}.${ext} + # WM_CLASS of the Electron window — lets KDE/GNOME match the running window + # to this launcher (correct task-bar icon, grouping). + desktop: + entry: + StartupWMClass: Kadr + Categories: AudioVideo;AudioVideoEditing;Video; + +# The AppImage is self-contained (its icon + .desktop are embedded — integrate +# it with Gearlever/appimaged). The tar.gz is just files, so ship a one-shot +# helper + icon at the archive root that registers a menu entry per-user. +extraFiles: + - from: scripts/install-desktop.sh + to: install.sh + - from: build/icon.png + to: kadr.png + +# node-pty is rebuilt against the bundled Electron's ABI at pack time. +npmRebuild: true diff --git a/electron/ffmpeg.ts b/electron/ffmpeg.ts index 9e1fe44..db43947 100644 --- a/electron/ffmpeg.ts +++ b/electron/ffmpeg.ts @@ -44,7 +44,8 @@ export async function probeMedia(path: string): Promise { width: video?.width || 0, height: video?.height || 0, fps: fps || 30, - hasAudio: !!audio + hasAudio: !!audio, + audioCodec: audio?.codec_name } if (kind !== 'audio') { @@ -123,17 +124,29 @@ export function makeProxy( src: string, out: string, duration: number, - onProgress?: (p: number) => void + onProgress?: (p: number) => void, + audioOnly = false ): Promise { - const args = [ - '-y', '-v', 'error', '-progress', 'pipe:1', - '-i', src, - '-vf', "scale=-2:'min(540,ih)'", - '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '96k', - '-movflags', '+faststart', - out - ] + // audioOnly: the source has no usable video (a music/voice file) but an audio + // codec Chromium can't decode (ac3/dts/…) — transcode just the audio to AAC + // so the preview element has sound. Otherwise a normal 540p video+AAC proxy. + const args = audioOnly + ? [ + '-y', '-v', 'error', '-progress', 'pipe:1', + '-i', src, + '-vn', '-c:a', 'aac', '-b:a', '160k', + '-movflags', '+faststart', + out + ] + : [ + '-y', '-v', 'error', '-progress', 'pipe:1', + '-i', src, + '-vf', "scale=-2:'min(540,ih)'", + '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '96k', + '-movflags', '+faststart', + out + ] return new Promise((resolve, reject) => { const child = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] }) let err = '' diff --git a/electron/fragments.ts b/electron/fragments.ts index a66b8f4..310a058 100644 --- a/electron/fragments.ts +++ b/electron/fragments.ts @@ -277,21 +277,33 @@ async function ensureWorkspace( let installed = false if (!existsSync(join(WORKSPACE, 'node_modules', 'remotion'))) { onProgress?.('install', 0) - const extraEnv = await netEnv() - await new Promise((resolve, reject) => { - const child = spawn('npm', ['install', '--no-audit', '--no-fund'], { - cwd: WORKSPACE, - env: { ...process.env, ...extraEnv }, - stdio: ['ignore', 'pipe', 'pipe'] + // Packaged builds ship a pre-installed node_modules (resources/ + // kadr-fragments-seed) so the first fragment needs neither host npm nor a + // ~150 MB network install. Copy it once; if the seed's browser cache came + // along inside node_modules it travels for free. No seed (dev, or a lite + // build) → fall back to a live `npm install` with the bundled node/npm. + const seed = app.isPackaged + ? join(process.resourcesPath, 'kadr-fragments-seed', 'node_modules') + : '' + if (seed && existsSync(join(seed, 'remotion'))) { + await fs.cp(seed, join(WORKSPACE, 'node_modules'), { recursive: true }) + } else { + const extraEnv = await netEnv() + await new Promise((resolve, reject) => { + const child = spawn('npm', ['install', '--no-audit', '--no-fund'], { + cwd: WORKSPACE, + env: { ...process.env, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let err = '' + child.stderr.on('data', (c) => { err += c }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`npm install failed (${code}): ${err.slice(-600)}`)) + }) }) - let err = '' - child.stderr.on('data', (c) => { err += c }) - child.on('error', reject) - child.on('close', (code) => { - if (code === 0) resolve() - else reject(new Error(`npm install failed (${code}): ${err.slice(-600)}`)) - }) - }) + } installed = true onProgress?.('install', 1) } diff --git a/electron/gpu.ts b/electron/gpu.ts new file mode 100644 index 0000000..67eccd9 --- /dev/null +++ b/electron/gpu.ts @@ -0,0 +1,133 @@ +// GPU selection. Which physical GPU Chromium's GPU process uses is decided +// ONCE at startup (before app 'ready'), so the user's choice is persisted to +// userData/gpu.json and re-applied here on every launch; changing it needs an +// app relaunch. On this Optimus-style laptop the default is the integrated +// Intel iGPU — forcing the discrete GPU speeds up the WebGL compositor (preview +// + the offline export composite), though the final H.264 encode stays on x264. +// +// SAFETY: a bad choice (e.g. NVIDIA that won't come up under this compositor) +// must never brick the app. A newly-picked GPU is applied as a 'trial': the +// file is flipped to 'failed' BEFORE the window is created, and only flipped to +// 'ok' once the renderer actually finishes loading (confirmGpuTrial, wired to +// did-finish-load). So if the trial launch shows no window, the NEXT launch +// sees 'failed' and reverts to auto — the app heals itself. +import { app } from 'electron' +import { readFileSync, writeFileSync } from 'fs' +import { readdirSync } from 'fs' +import { join } from 'path' +import type { GpuInfo } from '@shared/types' + +const VENDOR_NAME: Record = { + '0x10de': 'NVIDIA', + '0x8086': 'Intel', + '0x1002': 'AMD', + '0x1022': 'AMD' +} + +export type GpuStatus = 'trial' | 'ok' | 'failed' +export interface GpuChoice { + node?: string // /dev/dri/renderD129 ; absent/empty = auto (no override) + status?: GpuStatus +} + +// same file the renderer writes via writeUserStore('gpu', …) — see main's +// userStorePath(): userData/.json +const gpuStorePath = () => join(app.getPath('userData'), 'gpu.json') + +export function readGpuChoice(): GpuChoice { + try { + return JSON.parse(readFileSync(gpuStorePath(), 'utf8')) as GpuChoice + } catch { + return {} + } +} + +function writeGpuChoice(c: GpuChoice): void { + try { + writeFileSync(gpuStorePath(), JSON.stringify(c, null, 1)) + } catch { + /* best effort; a read failure next launch just falls back to auto */ + } +} + +/** Enumerate DRM render nodes into user-choosable GPUs (Linux only). */ +export function enumerateGpus(): GpuInfo[] { + let nodes: string[] + try { + nodes = readdirSync('/dev/dri').filter((n) => /^renderD\d+$/.test(n)) + } catch { + return [] + } + const read = (base: string, f: string) => { + try { + return readFileSync(join(base, f), 'utf8').trim() + } catch { + return '' + } + } + return nodes.sort().map((n) => { + const base = `/sys/class/drm/${n}/device` + const vendorId = read(base, 'vendor') + const deviceId = read(base, 'device') + const driver = read(base, 'uevent').match(/DRIVER=(\S+)/)?.[1] ?? '' + // boot_vga=1 marks the primary display adapter — the integrated GPU on + // laptops. Absent (0/missing) plus a discrete driver ⇒ the dGPU. + const integrated = read(base, 'boot_vga') === '1' || driver === 'i915' + return { + node: `/dev/dri/${n}`, + driver, + vendorId, + deviceId, + vendor: VENDOR_NAME[vendorId] ?? driver ?? 'GPU', + integrated + } + }) +} + +// set when THIS launch applied a not-yet-proven GPU; confirmGpuTrial() promotes +// it to 'ok' only after the renderer actually loads. +let trialNode: string | null = null + +function applyToChromium(gpu: GpuInfo): void { + app.commandLine.appendSwitch('render-node-override', gpu.node) + if (gpu.driver === 'nvidia') { + // NVIDIA's GL/EGL stack does not drive a Wayland Chromium GPU process + // reliably on Optimus; XWayland + GLX PRIME offload is the path that works. + app.commandLine.appendSwitch('ozone-platform', 'x11') + process.env.__NV_PRIME_RENDER_OFFLOAD = '1' + process.env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia' + process.env.__VK_LAYER_NV_optimus = 'NVIDIA_only' + } + process.env.KADR_GPU_POWER = gpu.integrated ? 'low-power' : 'high-performance' +} + +/** + * Read the persisted choice and, before app 'ready', point Chromium's GPU + * process at that render node. Must run at main module load. Never applies an + * unproven choice twice: a 'trial' is consumed (→ 'failed') before use, so a + * launch that never reaches the renderer heals to auto next time. + */ +export function applyGpuChoiceAtStartup(): void { + process.env.KADR_GPU_POWER = 'default' + const choice = readGpuChoice() + if (!choice.node) return + const gpu = enumerateGpus().find((g) => g.node === choice.node) + if (!gpu) return + + if (choice.status === 'ok') { + applyToChromium(gpu) // proven-good on a previous launch + } else if (choice.status === 'trial') { + writeGpuChoice({ node: gpu.node, status: 'failed' }) // heal to auto if we don't load + trialNode = gpu.node + applyToChromium(gpu) + } + // 'failed' or anything else → leave at auto (the self-heal path) +} + +/** Call once the renderer has actually loaded — promotes a surviving trial to + * 'ok' so it sticks on future launches. No-op unless this launch was a trial. */ +export function confirmGpuTrial(): void { + if (!trialNode) return + writeGpuChoice({ node: trialNode, status: 'ok' }) + trialNode = null +} diff --git a/electron/main.ts b/electron/main.ts index a6d2b34..2306085 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,6 @@ +// Side-effect import — MUST stay first so bundled-runtime PATH/env is set +// before './ffmpeg' (which captures KADR_FFMPEG/FFPROBE at load) is imported. +import './runtime-env' import { app, BrowserWindow, ipcMain, dialog, protocol, net, clipboard } from 'electron' import { join, dirname, basename } from 'path' import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' @@ -8,6 +11,7 @@ import { probeMedia, makeProxy, makeReversed, ExportMuxer, RawVideoEncoder } fro import { registerClaudeIpc } from './claude' import { registerTranscribeIpc } from './transcribe' import { registerFragmentIpc } from './fragments' +import { enumerateGpus, applyGpuChoiceAtStartup, confirmGpuTrial } from './gpu' import type { ExportJob, Project } from '@shared/types' // Streamed local media under a privileged scheme so the renderer can play @@ -52,6 +56,11 @@ app.commandLine.appendSwitch( 'VaapiVideoEncoder,VaapiVideoDecoder,VaapiVideoDecodeLinuxGL,AcceleratedVideoEncoder' ) +// Point the GPU process at the user's chosen render node (else Chromium's +// default = the integrated GPU). MUST run before app 'ready'. Also sets +// KADR_GPU_POWER for the renderer's WebGL powerPreference. +applyGpuChoiceAtStartup() + // Last line of defense: a stray async error (e.g. a stream racing a request // abort) must be logged, not shown as a modal error dialog over the editor. process.on('uncaughtException', (err) => { @@ -88,6 +97,8 @@ function createWindow() { app.exit(1) } }) + // the renderer loaded → a pending GPU trial is proven good and may stick + win.webContents.once('did-finish-load', () => confirmGpuTrial()) if (process.env.ELECTRON_RENDERER_URL) { win.loadURL(process.env.ELECTRON_RENDERER_URL) } else { @@ -236,10 +247,10 @@ const userStorePath = (name: string) => const proxyDir = () => join(app.getPath('userData'), 'proxies') let proxyChain: Promise = Promise.resolve() -async function requestProxy(srcPath: string, duration: number): Promise { +async function requestProxy(srcPath: string, duration: number, audioOnly = false): Promise { const stat = statSync(srcPath) const key = createHash('sha1') - .update(`${srcPath}:${stat.size}:${Math.round(stat.mtimeMs)}`) + .update(`${srcPath}:${stat.size}:${Math.round(stat.mtimeMs)}:${audioOnly ? 'a' : 'v'}`) .digest('hex') .slice(0, 20) const out = join(proxyDir(), `${key}.mp4`) @@ -257,7 +268,7 @@ async function requestProxy(srcPath: string, duration: number): Promise try { await makeProxy(srcPath, tmp, duration, (p) => { win?.webContents.send('proxy:progress', { path: srcPath, progress: p }) - }) + }, audioOnly) await fs.rename(tmp, out) } catch (err) { fs.unlink(tmp).catch(() => { /* nothing to clean */ }) @@ -346,8 +357,8 @@ async function rememberDir(kind: string, filePath: string) { } function registerIpc() { - ipcMain.handle('proxy:request', (_e, srcPath: string, duration: number) => - requestProxy(srcPath, duration) + ipcMain.handle('proxy:request', (_e, srcPath: string, duration: number, audioOnly?: boolean) => + requestProxy(srcPath, duration, audioOnly) ) ipcMain.handle( @@ -369,6 +380,12 @@ function registerIpc() { await fs.writeFile(userStorePath(name), JSON.stringify(data, null, 1)) }) + ipcMain.handle('gpu:list', () => enumerateGpus()) + ipcMain.handle('gpu:relaunch', () => { + app.relaunch() + app.exit(0) + }) + ipcMain.handle('media:open-dialog', async () => { const r = await dialog.showOpenDialog(win!, { properties: ['openFile', 'multiSelections'], diff --git a/electron/preload.ts b/electron/preload.ts index c009d4d..94f0cc5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -87,7 +87,7 @@ const api: KadrApi = { ipcRenderer.on('reverse:progress', handler) return () => ipcRenderer.removeListener('reverse:progress', handler) }, - requestProxy: (path, duration) => ipcRenderer.invoke('proxy:request', path, duration), + requestProxy: (path, duration, audioOnly) => ipcRenderer.invoke('proxy:request', path, duration, audioOnly), onProxyProgress: (cb) => { const handler = (_e: unknown, p: { path: string; progress: number }) => cb(p) ipcRenderer.on('proxy:progress', handler) @@ -129,6 +129,7 @@ const api: KadrApi = { return () => ipcRenderer.removeListener('fragment:progress', handler) }, + defaultWhisperModel: process.env.KADR_WHISPER_MODEL || '', transcribe: (req) => ipcRenderer.invoke('transcribe:run', req), transcribeCancel: () => ipcRenderer.invoke('transcribe:cancel'), onTranscribeProgress: (cb) => { @@ -153,7 +154,11 @@ const api: KadrApi = { const handler = (_e: unknown, code: number) => cb(code) ipcRenderer.on('claude:exit', handler) return () => ipcRenderer.removeListener('claude:exit', handler) - } + }, + + defaultGpuPower: process.env.KADR_GPU_POWER || '', + gpuList: () => ipcRenderer.invoke('gpu:list'), + relaunchApp: () => { void ipcRenderer.invoke('gpu:relaunch') } } // contextIsolation is off (see main.ts: export frames pass by reference), diff --git a/electron/runtime-env.ts b/electron/runtime-env.ts new file mode 100644 index 0000000..812983e --- /dev/null +++ b/electron/runtime-env.ts @@ -0,0 +1,62 @@ +// Bundled-runtime wiring for packaged builds. MUST be imported before any +// module that reads KADR_FFMPEG/KADR_FFPROBE or resolves a spawned binary from +// PATH at load time — notably `./ffmpeg` (its FFMPEG/FFPROBE consts are captured +// at import). Keep this the very first import in electron/main.ts. +// +// The self-contained archive ships ffmpeg/ffprobe/node/npm/npx/python3 under +// resources/runtime/bin. Prepending that dir to PATH makes every literal spawn +// (`python3` in transcribe.ts, `npm`/`npx` in fragments.ts, `node` for the MCP +// bridge) resolve to the bundled copy first, and pointing KADR_FFMPEG/FFPROBE at +// it covers both the main-process ffmpeg.ts and the renderer's preload encoder +// (the renderer inherits this env when its process is spawned). In dev +// (`app.isPackaged` false) nothing changes — the host toolchain is used as before. +import { app } from 'electron' +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' + +// Register with PipeWire/PulseAudio as "Kadr" instead of Chromium's default +// "Chromium" (Electron reports the Chromium product name). Beyond looking right +// in the mixer, this gives Kadr its OWN per-app volume: otherwise it shares the +// stream-restore entry of everything named "Chromium", so a remembered 0% there +// silences Kadr's output even though its audio graph runs. OVERRIDE (REPLACE) +// forces over Chromium's own proplist value; PULSE_PROP is a belt-and-braces +// fallback. Set for dev too, and before any window so the audio service — a +// later child process — inherits it. `??=` leaves a user-set value untouched. +// (Only application.name is settable this way — it's a client/context property. +// application.icon_name is stamped by Chromium on the stream itself and can't be +// overridden via env, so the mixer icon stays Chromium's; the Kadr logo still +// shows in the app menu, task bar and window from the packaged icon.) +process.env.PULSE_PROP_OVERRIDE ??= 'application.name=Kadr' +process.env.PULSE_PROP ??= 'application.name=Kadr' + +if (app.isPackaged) { + const runtime = join(process.resourcesPath, 'runtime') + const bin = join(runtime, 'bin') + // Desktop/menu launches (e.g. an /opt copy started from the app menu) inherit + // a minimal PATH that usually omits the user's own bin dirs, so a + // user-installed `claude` CLI (the embedded AI panel) can't be found. Prepend + // the bundled runtime (our ffmpeg must win); append the common user locations + // so `claude` resolves however Kadr was launched. Missing dirs are harmless. + const home = homedir() + const userBins = [ + join(home, '.local', 'bin'), + join(home, 'bin'), + join(home, '.npm-global', 'bin'), + join(home, '.claude', 'local'), + '/home/linuxbrew/.linuxbrew/bin', + '/usr/local/bin' + ] + process.env.PATH = [bin, process.env.PATH ?? '', ...userBins].filter(Boolean).join(':') + process.env.KADR_FFMPEG ??= join(bin, 'ffmpeg') + process.env.KADR_FFPROBE ??= join(bin, 'ffprobe') + // Name of the whisper model pre-bundled by `pack.sh --model `. The + // renderer reads this (via preload) to default subtitles to the model that + // was actually shipped, and transcribe.ts seeds it into the writable HF cache + // (~/.cache/huggingface) on first use — so it's found, not re-downloaded, and + // the read-only /opt copy is never written to. Absent → normal on-demand DL. + try { + const m = readFileSync(join(runtime, 'whisper-model.txt'), 'utf8').trim() + if (m) process.env.KADR_WHISPER_MODEL = m + } catch { /* no model bundled */ } +} diff --git a/electron/transcribe.ts b/electron/transcribe.ts index 53affae..4b0adda 100644 --- a/electron/transcribe.ts +++ b/electron/transcribe.ts @@ -4,18 +4,39 @@ // text to the renderer. One job at a time. import { app, ipcMain, BrowserWindow } from 'electron' import { spawn, ChildProcess } from 'child_process' -import { promises as fs } from 'fs' +import { promises as fs, existsSync } from 'fs' import { join } from 'path' -import { tmpdir } from 'os' +import { tmpdir, homedir } from 'os' import { ExportMuxer } from './ffmpeg' import type { TranscribeRequest, TranscribeResult, TranscribeSegment } from '@shared/types' let current: { muxer: ExportMuxer | null; py: ChildProcess | null; cancelled: boolean } | null = null +// A whisper model bundled by `pack.sh --model` (resources/runtime/whisper-seed) +// is copied into the user's writable HF cache on first use, so faster-whisper +// finds it there instead of downloading — and the read-only /opt copy is never +// written to. One-time; dev or no-bundle → no-op, falling back to on-demand DL. +let seededModels = false +async function seedBundledModels(): Promise { + if (seededModels || !app.isPackaged) return + seededModels = true + try { + const srcHub = join(process.resourcesPath, 'runtime', 'whisper-seed', 'hub') + if (!existsSync(srcHub)) return + const dstHub = join(homedir(), '.cache', 'huggingface', 'hub') + await fs.mkdir(dstHub, { recursive: true }) + for (const name of await fs.readdir(srcHub)) { + if (!name.startsWith('models--') || existsSync(join(dstHub, name))) continue + await fs.cp(join(srcHub, name), join(dstHub, name), { recursive: true }) + } + } catch { /* fall back to on-demand download */ } +} + async function run(win: BrowserWindow, req: TranscribeRequest): Promise { if (current) throw new Error('transcription already running') const job = { muxer: null as ExportMuxer | null, py: null as ChildProcess | null, cancelled: false } current = job + await seedBundledModels() const wav = join(tmpdir(), `kadr-transcribe-${Date.now()}.wav`) const send = (progress: number, text: string) => win.webContents.send('transcribe:progress', { progress, text }) diff --git a/package-lock.json b/package-lock.json index 1fe0513..996a7ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "kadr", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kadr", - "version": "0.1.0", + "version": "0.2.1", "hasInstallScript": true, "license": "GPL-3.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "mp4-muxer": "^5.1.5", + "mediabunny": "^1.50.8", + "mp4-muxer": "^5.2.2", "mp4box": "^2.3.0", "node-pty": "^1.1.0", "react": "^18.3.1", @@ -30,6 +31,7 @@ "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^5.2.0", "electron": "^42.4.0", + "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", "typescript": "^5.5.4", "vite": "^7.3.1" @@ -343,6 +345,86 @@ "node": ">=22.12.0" } }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/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/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@electron/get": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", @@ -390,6 +472,72 @@ "node": ">=10" } }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/@electron/rebuild": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", @@ -411,6 +559,112 @@ "node": ">=22.12.0" } }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/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/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/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/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -951,6 +1205,38 @@ "node": ">= 12.13.0" } }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -991,6 +1277,71 @@ } } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1348,6 +1699,32 @@ "win32" ] }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1393,10 +1770,42 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, "node_modules/@types/dom-webcodecs": { - "version": "0.1.18", - "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz", - "integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", "license": "MIT" }, "node_modules/@types/estree": { @@ -1406,6 +1815,40 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.42", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", @@ -1444,6 +1887,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/wicg-file-system-access": { "version": "2020.9.8", "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", @@ -1481,6 +1934,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -1528,6 +1991,16 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1561,19 +2034,325 @@ } } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/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/app-builder-lib/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, "engines": { "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1614,6 +2393,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1648,6 +2449,53 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1657,6 +2505,16 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1667,6 +2525,35 @@ "node": ">=8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1717,6 +2604,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1727,6 +2631,117 @@ "node": ">=18" } }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1740,102 +2755,297 @@ "url": "https://opencollective.com/express" } }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.4.0" } }, - "node_modules/convert-source-map": { + "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "optional": true }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, "license": "MIT", "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/dir-compare/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/dir-compare/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">= 8" + "node": "*" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=6.0" + "node": ">=12" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, "node_modules/dunder-proto": { @@ -1852,12 +3062,38 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron": { "version": "42.4.0", "resolved": "https://registry.npmjs.org/electron/-/electron-42.4.0.tgz", @@ -1877,6 +3113,63 @@ "node": ">= 22.12.0" } }, + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-publish": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.370", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz", @@ -1914,6 +3207,66 @@ } } }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -1931,6 +3284,13 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1940,6 +3300,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -1950,6 +3320,13 @@ "node": ">=6" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1980,6 +3357,30 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2038,6 +3439,20 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2176,6 +3591,46 @@ } } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/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/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -2197,6 +3652,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2215,6 +3710,28 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2249,6 +3766,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2286,6 +3813,126 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/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/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2298,6 +3945,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2305,6 +3978,30 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2317,6 +4014,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2338,6 +4051,46 @@ "node": ">=16.9.0" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/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/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2358,6 +4111,60 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2382,18 +4189,76 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -2409,6 +4274,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2422,6 +4310,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2434,6 +4329,14 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2447,6 +4350,43 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2459,6 +4399,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2479,6 +4429,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2497,6 +4461,24 @@ "node": ">= 0.8" } }, + "node_modules/mediabunny": { + "version": "1.50.8", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.50.8.tgz", + "integrity": "sha512-LgykLyQzhdpo0V2yw3UXmOpj+b4JAGdpHBwsPE6kjSt8Za0d1VllD+FV7EGHBcdV4+oHUAo+yrqbVAWxNSDCPQ==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -2509,6 +4491,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2534,6 +4529,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2557,6 +4588,20 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mp4-muxer": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", @@ -2731,6 +4776,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", @@ -2767,6 +4819,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2788,6 +4853,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2809,6 +4885,32 @@ "wrappy": "1" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2818,6 +4920,16 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2837,6 +4949,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2866,6 +4993,52 @@ "node": ">=16.20.0" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2895,6 +5068,36 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2905,6 +5108,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2915,6 +5125,32 @@ "node": ">=0.4.0" } }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2928,6 +5164,37 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2943,6 +5210,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3008,36 +5288,144 @@ "react": "^18.3.1" } }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 4" } }, - "node_modules/read-binary-file-arch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", - "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "MIT", + "license": "ISC", + "peer": true, "dependencies": { - "debug": "^4.3.4" + "glob": "^7.1.3" }, "bin": { - "read-binary-file-arch": "cli.js" + "rimraf": "bin.js" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8.0" } }, "node_modules/rollup": { @@ -3101,12 +5489,39 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3126,6 +5541,14 @@ "semver": "bin/semver.js" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -3152,6 +5575,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -3270,6 +5710,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3280,6 +5763,35 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3289,6 +5801,44 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/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/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/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -3302,6 +5852,19 @@ "node": ">= 8.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar": { "version": "7.5.19", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", @@ -3329,6 +5892,52 @@ "node": ">=18" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3346,6 +5955,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3355,6 +5984,37 @@ "node": ">=0.6" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -3418,6 +6078,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3427,6 +6097,35 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3467,6 +6166,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4035,6 +6748,20 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4050,6 +6777,24 @@ "node": ">= 8" } }, + "node_modules/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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -4077,6 +6822,26 @@ } } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4084,6 +6849,48 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 374c7ae..b312e5a 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,15 @@ "build": "electron-vite build", "start": "electron-vite preview", "typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json", + "pack": "bash scripts/pack.sh", "postinstall": "electron-rebuild -f -w node-pty" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "mp4-muxer": "^5.1.5", + "mediabunny": "^1.50.8", + "mp4-muxer": "^5.2.2", "mp4box": "^2.3.0", "node-pty": "^1.1.0", "react": "^18.3.1", @@ -27,6 +29,7 @@ }, "devDependencies": { "@electron/rebuild": "^4.0.4", + "electron-builder": "^26.0.12", "@types/node": "^20.14.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -39,5 +42,10 @@ }, "contributors": [ "Claude (Anthropic)" - ] + ], + "allowScripts": { + "esbuild@0.25.12": true, + "esbuild@0.28.1": true, + "node-pty@1.1.0": true + } } diff --git a/scripts/install-desktop.sh b/scripts/install-desktop.sh new file mode 100755 index 0000000..2a1d50a --- /dev/null +++ b/scripts/install-desktop.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Register (or remove) a Kadr menu entry so the app launches from the +# application launcher / by double-click — no terminal. Ships as install.sh at +# the root of the tar.gz archive next to the `kadr` executable. Run once after +# putting the folder in its final location (e.g. /opt/kadr): +# +# bash ./install.sh # add to the app menu +# bash ./install.sh --uninstall # remove it +# +# Run as your normal user (NOT sudo) even if the app lives in /opt — it writes +# only under ~/.local/share, and stores the ABSOLUTE path of the app in the +# .desktop, so re-run it if you move the folder. +set -euo pipefail + +DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)" +APPS="$HOME/.local/share/applications" +ICONS="$HOME/.local/share/icons/hicolor/512x512/apps" +DESKTOP="$APPS/kadr.desktop" +ICON_DST="$ICONS/kadr.png" + +refresh() { + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$APPS" || true + command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -q -t "$HOME/.local/share/icons/hicolor" || true + command -v kbuildsycoca6 >/dev/null 2>&1 && kbuildsycoca6 --noincremental >/dev/null 2>&1 || \ + command -v kbuildsycoca5 >/dev/null 2>&1 && kbuildsycoca5 --noincremental >/dev/null 2>&1 || true +} + +if [ "${1:-}" = "--uninstall" ]; then + rm -f "$DESKTOP" "$ICON_DST" + refresh + echo "Removed Kadr menu entry." + exit 0 +fi + +[ -x "$DIR/kadr" ] || { echo "error: 'kadr' executable not found next to this script ($DIR)" >&2; exit 1; } + +mkdir -p "$APPS" "$ICONS" + +# icon: prefer the one shipped at the archive root; fall back to any bundled png +if [ -f "$DIR/kadr.png" ]; then + cp -f "$DIR/kadr.png" "$ICON_DST" +else + found="$(find "$DIR/resources" -name '*.png' 2>/dev/null | head -n1 || true)" + [ -n "$found" ] && cp -f "$found" "$ICON_DST" || true +fi + +cat > "$DESKTOP" </dev/null || true + +refresh +echo "Installed. Look for “Kadr” in your application launcher." +echo "(Moved the folder? Re-run this script to fix the path.)" diff --git a/scripts/pack.sh b/scripts/pack.sh new file mode 100755 index 0000000..cd621c6 --- /dev/null +++ b/scripts/pack.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Build the self-contained Kadr archive (tar.gz + AppImage) for linux-x64. +# +# Run INSIDE a build toolchain (e.g. a Fedora distrobox), NOT on a bare +# immutable host — node-pty is compiled here once against the bundled Electron: +# +# distrobox create --name kadr-build --image fedora:42 +# distrobox enter kadr-build +# sudo dnf install -y gcc-c++ make python3 nodejs npm tar xz which +# cd ~/Projects/kadr && bash scripts/pack.sh +# +# Output: dist/kadr--linux-x64.tar.gz and dist/Kadr--linux-x86_64.AppImage +# +# Flags: +# --lite core editor + ffmpeg only (skip whisper + Remotion seed) +# --model pre-bundle a faster-whisper model; repeat for several +# (e.g. --model base --model large-v3) — the first is default +# --skip-build reuse an existing out/ (don't re-run npm ci / electron-vite) +set -euo pipefail + +# ---- pinned versions (bump as needed) -------------------------------------- +NODE_VERSION="20.18.1" # bundled host node for npm/npx/mcp bridge +PBS_RELEASE="20241206" # astral python-build-standalone release tag +PYTHON_VERSION="3.12.8" # cpython version within that release +FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" + +ARCH="x64"; UNAME_ARCH="x86_64" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RUNTIME="$ROOT/runtime" +SEED="$ROOT/kadr-fragments-seed" +BIN="$RUNTIME/bin" + +LITE=0; WHISPER_MODELS=""; SKIP_BUILD=0 +while [ $# -gt 0 ]; do + case "$1" in + --lite) LITE=1 ;; + --model) WHISPER_MODELS="$WHISPER_MODELS ${2:-}"; shift ;; + --skip-build) SKIP_BUILD=1 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac + shift +done + +log() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; } +need() { command -v "$1" >/dev/null 2>&1 || { echo "missing tool: $1" >&2; exit 1; }; } +dl() { echo " fetch $1"; curl -fsSL --retry 3 -o "$2" "$1"; } + +need curl; need tar; need node; need npm +cd "$ROOT" + +# ---- 0. clean staging ------------------------------------------------------ +log "clean staging (runtime/, kadr-fragments-seed/)" +rm -rf "$RUNTIME" "$SEED" +mkdir -p "$BIN" + +# ---- 1. app build ---------------------------------------------------------- +if [ "$SKIP_BUILD" -eq 0 ]; then + log "npm ci + electron-vite build" + npm ci + npm run build +else + log "reusing existing out/ (--skip-build)" + [ -d "$ROOT/out/main" ] || { echo "out/ missing — drop --skip-build" >&2; exit 1; } +fi + +# ---- 2. ffmpeg / ffprobe (static) ------------------------------------------ +log "stage static ffmpeg/ffprobe" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +dl "$FFMPEG_URL" "$TMP/ffmpeg.tar.xz" +tar -xJf "$TMP/ffmpeg.tar.xz" -C "$TMP" +FF_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'ffmpeg-*-static' | head -n1)" +install -m755 "$FF_DIR/ffmpeg" "$BIN/ffmpeg" +install -m755 "$FF_DIR/ffprobe" "$BIN/ffprobe" + +# ---- 3. bundled Node (host node/npm/npx for Remotion + MCP bridge) --------- +log "stage Node $NODE_VERSION" +NODE_TARBALL="node-v$NODE_VERSION-linux-$ARCH.tar.xz" +dl "https://nodejs.org/dist/v$NODE_VERSION/$NODE_TARBALL" "$TMP/$NODE_TARBALL" +tar -xJf "$TMP/$NODE_TARBALL" -C "$RUNTIME" +mv "$RUNTIME/node-v$NODE_VERSION-linux-$ARCH" "$RUNTIME/node" +ln -sf ../node/bin/node "$BIN/node" +ln -sf ../node/bin/npm "$BIN/npm" +ln -sf ../node/bin/npx "$BIN/npx" + +# ---- 4. Python + faster-whisper (relocatable) ------------------------------ +if [ "$LITE" -eq 0 ]; then + log "stage relocatable Python $PYTHON_VERSION + faster-whisper" + PBS_FILE="cpython-$PYTHON_VERSION+$PBS_RELEASE-$UNAME_ARCH-unknown-linux-gnu-install_only.tar.gz" + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/$PBS_RELEASE/$PBS_FILE" + dl "$PBS_URL" "$TMP/python.tar.gz" + tar -xzf "$TMP/python.tar.gz" -C "$RUNTIME" # extracts to runtime/python/ + "$RUNTIME/python/bin/python3" -m pip install --no-cache-dir --upgrade pip + "$RUNTIME/python/bin/python3" -m pip install --no-cache-dir faster-whisper + ln -sf ../python/bin/python3 "$BIN/python3" + + # Pre-bundle one or more faster-whisper models. Each is cached into the shared + # HF tree runtime/whisper-seed (electron-builder ships it); at runtime + # transcribe.ts copies ALL of them into the writable ~/.cache/huggingface so + # none is re-downloaded. whisper-model.txt records the FIRST as the app default; + # standard sizes (base/medium/large-v3) are also selectable in the UI. + if [ -n "$WHISPER_MODELS" ]; then + DEFAULT_MODEL="" + for M in $WHISPER_MODELS; do + log "pre-download whisper model: $M" + HF_HOME="$RUNTIME/whisper-seed" \ + "$RUNTIME/python/bin/python3" - "$M" <<'PY' +import sys +from faster_whisper import WhisperModel +WhisperModel(sys.argv[1], device="cpu", compute_type="int8") +print("model cached:", sys.argv[1]) +PY + if [ -z "$DEFAULT_MODEL" ]; then DEFAULT_MODEL="$M"; fi + done + if [ ! -d "$RUNTIME/whisper-seed/hub" ]; then + echo "ERROR: models '$WHISPER_MODELS' did not land in runtime/whisper-seed" >&2; exit 1 + fi + printf '%s' "$DEFAULT_MODEL" > "$RUNTIME/whisper-model.txt" + echo " bundled models [$WHISPER_MODELS ] → whisper-seed ($(du -sh "$RUNTIME/whisper-seed" | cut -f1)); default=$DEFAULT_MODEL" + fi +else + log "lite build — skipping Python/whisper" +fi + +# ---- 5. Remotion fragments seed (pre-installed node_modules) --------------- +if [ "$LITE" -eq 0 ]; then + log "build Remotion seed (node_modules via bundled npm)" + SEED_TMP="$(mktemp -d)" + # deps mirror electron/fragments.ts PKG_JSON — keep in sync if that changes. + cat > "$SEED_TMP/package.json" <<'JSON' +{ + "name": "kadr-fragments", + "private": true, + "dependencies": { + "@remotion/cli": "4.0.247", + "@remotion/player": "4.0.247", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.247" + }, + "devDependencies": { + "@types/react": "18.3.3", + "@vitejs/plugin-react": "4.3.1", + "typescript": "5.5.4", + "vite": "5.4.8" + } +} +JSON + ( cd "$SEED_TMP" && PATH="$BIN:$PATH" npm install --no-audit --no-fund ) + # Warm Remotion's headless chrome so a first render is offline when the cache + # lands inside node_modules; harmless (best-effort) otherwise — a first + # render then fetches chrome over the network using the bundled node. + ( cd "$SEED_TMP" && PATH="$BIN:$PATH" npx remotion browser ensure ) || \ + echo " (browser warm skipped — first render will fetch chrome over network)" + mkdir -p "$SEED" + mv "$SEED_TMP/node_modules" "$SEED/node_modules" + rm -rf "$SEED_TMP" +else + log "lite build — skipping Remotion seed" +fi + +# ---- 6. package ------------------------------------------------------------ +# refresh the launcher icon from SVG if a rasterizer is present; otherwise the +# committed build/icon.png is used as-is. +if command -v magick >/dev/null 2>&1; then magick -background none "$ROOT/build/icon.svg" -resize 512x512 "$ROOT/build/icon.png" +elif command -v convert >/dev/null 2>&1; then convert -background none "$ROOT/build/icon.svg" -resize 512x512 "$ROOT/build/icon.png"; fi + +log "electron-builder → tar.gz + AppImage" +npx electron-builder --linux tar.gz AppImage + +log "done. artifacts in dist/" +ls -lh "$ROOT/dist" 2>/dev/null || true diff --git a/shared/types.ts b/shared/types.ts index 2b08a89..dab509f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -21,6 +21,8 @@ export interface MediaAsset { height: number fps: number hasAudio: boolean + /** ffprobe audio codec_name (e.g. 'aac', 'ac3'); drives preview-proxy need */ + audioCodec?: string /** data: URL of a poster frame, generated on import */ thumbnail?: string /** poster of the last frame (clip tails show it on the timeline) */ @@ -406,7 +408,7 @@ export interface KadrApi { ): () => void /** Build (or reuse) a preview proxy; resolves with the proxy file path. */ - requestProxy(path: string, duration: number): Promise + requestProxy(path: string, duration: number, audioOnly?: boolean): Promise onProxyProgress(cb: (p: { path: string; progress: number }) => void): () => void exportDialog(defaultName: string, ext: string): Promise @@ -448,6 +450,8 @@ export interface KadrApi { onFragmentProgress(cb: (p: { id: string; phase: string; progress: number }) => void): () => void /** Mix the request's audio to a temp wav and run Whisper over it. */ + /** whisper model pre-bundled in the packaged app ('' if none); subtitles default to it */ + defaultWhisperModel: string transcribe(req: TranscribeRequest): Promise transcribeCancel(): Promise onTranscribeProgress(cb: (p: { progress: number; text: string }) => void): () => void @@ -466,4 +470,26 @@ export interface KadrApi { claudeClose(): Promise onClaudeData(cb: (data: string) => void): () => void onClaudeExit(cb: (code: number) => void): () => void + + /** WebGL powerPreference hint derived from the persisted GPU choice + ('default' | 'high-performance' | 'low-power'); '' when unset. */ + defaultGpuPower: string + /** Enumerate selectable GPUs (DRM render nodes). */ + gpuList(): Promise + /** Relaunch the app so a new GPU choice takes effect. */ + relaunchApp(): void +} + +/** A user-selectable GPU (one DRM render node). */ +export interface GpuInfo { + /** DRM render node path, e.g. /dev/dri/renderD129 */ + node: string + /** kernel driver: i915 / nvidia / amdgpu / … */ + driver: string + vendorId: string + deviceId: string + /** friendly vendor: NVIDIA / Intel / AMD (or driver name) */ + vendor: string + /** primary display adapter (laptop iGPU) vs a discrete card */ + integrated: boolean } diff --git a/src/App.tsx b/src/App.tsx index 48c99ca..6c0f075 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,11 +5,19 @@ import { Inspector } from './components/Inspector' import { Timeline } from './components/Timeline' import { TransportBar, LangSwitch } from './components/TransportBar' import { ExportDialog } from './components/ExportDialog' +import { SettingsDialog } from './components/SettingsDialog' import { ClaudePanel } from './components/ClaudePanel' import { TranscribeDialog, SubtitlePanel } from './components/TextTools' import { CaptionsDialog } from './components/CaptionsDialog' +import { ProjectFormatDialog, type Dims } from './components/ProjectFormatDialog' import { useEditor, newProject } from './state/store' -import { dropPayload, dropUsable, importDrop, importFiles } from './engine/mediaImport' +import { + dropPayload, + dropUsable, + importDrop, + importFiles, + setProjectFormatResolver +} from './engine/mediaImport' import { useT, type TKey } from './i18n' import { create } from 'zustand' import type { Project } from '@shared/types' @@ -93,6 +101,16 @@ export default function App() { const [sideW, setSideW] = useState(() => Math.min(640, Math.max(200, Number(localStorage.getItem('kadr.sidew')) || 280)) ) + // New Project format dialog + const [newProjOpen, setNewProjOpen] = useState(false) + // "media dropped into an empty project" format prompt: the pending probe + + // the resolver waiting for the user's pick + const [dropFmt, setDropFmt] = useState<{ probe: Dims; resolve: (d: Dims | null) => void } | null>(null) + + useEffect(() => { + setProjectFormatResolver((probe) => new Promise((resolve) => setDropFmt({ probe, resolve }))) + return () => setProjectFormatResolver(null) + }, []) // media dropped ANYWHERE in the window is at least imported into the bin // (the timeline zones place clips and mark the event handled); a non-media @@ -236,12 +254,7 @@ export default function App() { - @@ -257,6 +270,9 @@ export default function App() { > 🤖 Claude +
@@ -271,6 +287,35 @@ export default function App() {
+ + {newProjOpen && ( + { + useEditor.getState().setProject(newProject(d)) + markProjectSaved(useEditor.getState().project) + setNewProjOpen(false) + }} + onClose={() => setNewProjOpen(false)} + /> + )} + {dropFmt && ( + { + dropFmt.resolve(d) + setDropFmt(null) + }} + onClose={() => { + dropFmt.resolve(null) + setDropFmt(null) + }} + /> + )} diff --git a/src/components/CaptionsDialog.tsx b/src/components/CaptionsDialog.tsx index 1e1a92d..94d0cce 100644 --- a/src/components/CaptionsDialog.tsx +++ b/src/components/CaptionsDialog.tsx @@ -15,7 +15,9 @@ export function CaptionsDialog() { const range = useEditor((s) => s.range) const [style, setStyle] = useState({ ...CAPTION_DEFAULTS }) const [maxWords, setMaxWords] = useState(3) - const [model, setModel] = useState('large-v3') + // Default to the model actually bundled in the archive (offline), else large-v3. + const bundledModel = window.kadr.defaultWhisperModel + const [model, setModel] = useState(bundledModel || 'large-v3') const [language, setLanguage] = useState('auto') const [running, setRunning] = useState(false) const [progress, setProgress] = useState(0) @@ -73,6 +75,9 @@ export function CaptionsDialog() {
diff --git a/src/i18n.ts b/src/i18n.ts index ee22db4..028d828 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -288,7 +288,9 @@ const ru = { gpuEncodeNote: 'Ускоряет композитинг и превью; кодирование H.264 остаётся на CPU.', gpuRestartHint: 'Изменение вступит в силу после перезапуска.', gpuApplyRestart: 'Применить и перезапустить', - gpuFailedRevert: 'Выбранная видеокарта не запустилась — вернулись на авто.' + gpuFailedRevert: 'Выбранная видеокарта не запустилась — вернулись на авто.', + gpuTrialAsk: 'Видеокарта переключилась и рисует. Оставить её? Иначе после перезапуска вернётся авто.', + gpuKeep: 'Оставить эту видеокарту' } const en: typeof ru = { @@ -579,7 +581,9 @@ const en: typeof ru = { gpuEncodeNote: 'Speeds up compositing and preview; H.264 encoding stays on the CPU.', gpuRestartHint: 'The change takes effect after a restart.', gpuApplyRestart: 'Apply and restart', - gpuFailedRevert: 'The selected GPU did not start — reverted to auto.' + gpuFailedRevert: 'The selected GPU did not start — reverted to auto.', + gpuTrialAsk: 'The GPU switched and is rendering. Keep it? Otherwise it reverts to auto on restart.', + gpuKeep: 'Keep this GPU' } export type TKey = keyof typeof ru From ba9e55331e13f82f2ac815eacd865997eb9b73bc Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 17:52:37 +0500 Subject: [PATCH 05/28] gpu: NVIDIA x11 + --disable-gpu-sandbox (research-backed best-effort) Per NVIDIA/Electron community: only XWayland renders Chromium on NVIDIA Optimus; native Wayland goes software/blank. --disable-gpu-sandbox fixes the GPU-process crash that shows as 'process starts, no window'. Dropped the Vulkan driver-select (caused vkCreateInstance failures). Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 0df582d..4a35bba 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -90,17 +90,19 @@ let trialNode: string | null = null function applyToChromium(gpu: GpuInfo): void { if (gpu.driver === 'nvidia') { - // PRIME render offload (the vars switcherooctl sets for the dGPU; verified - // with glxinfo/eglinfo to move GL to the NVIDIA card). Present via XWayland - // (--ozone-platform=x11): the X server copies the NVIDIA-rendered frames - // back to the primary GPU for display. Native Wayland leaves kwin unable to - // import NVIDIA's buffers → the window comes up blank. render-node-override - // is deliberately NOT set (it made the GPU process fail to init). + // Community-documented reality (NVIDIA/Electron forums): on an Optimus + // laptop only XWayland renders Chromium on the NVIDIA GPU — native Wayland + // falls back to software or a blank window. So force x11 + GLX PRIME offload. + // --disable-gpu-sandbox stops the GPU process crashing on the proprietary + // driver, which is the "process starts but no window appears" symptom. + // render-node-override and the Vulkan driver-select are deliberately dropped + // (they made GPU init / vkCreateInstance fail). Minor redraw glitches remain + // the known cost of the XWayland path. app.commandLine.appendSwitch('ozone-platform', 'x11') + app.commandLine.appendSwitch('disable-gpu-sandbox') process.env.__NV_PRIME_RENDER_OFFLOAD = '1' process.env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia' process.env.__VK_LAYER_NV_optimus = 'NVIDIA_only' - process.env.VK_LOADER_DRIVERS_SELECT = '*nvidia*' } else { // Intel / AMD: point Chromium's GPU process straight at the render node. app.commandLine.appendSwitch('render-node-override', gpu.node) From b7b1d6e2239661be146fcddd3590a75b37172353 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 18:30:23 +0500 Subject: [PATCH 06/28] gpu: run NVIDIA inside gamescope (the path that actually renders) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified on the machine: launching Kadr inside gamescope puts Chromium on the NVIDIA GPU (active GPU vendor=0x10de) with a visible window — gamescope renders the app on the dGPU and hands finished frames to kwin (iGPU), exactly how games run here. Every other route (native Wayland, XWayland offload, render-node-override) went blank or crashed the GPU process. The NVIDIA path now re-execs the app into `gamescope -W 1600 -H 900 -- ` with the PRIME offload env (guarded by KADR_GAMESCOPE against re-entry). Switching GPUs relaunches as a fresh top-level process via cleanRelaunchEnv(), which restores the host WAYLAND/DISPLAY so it escapes gamescope's nested session and re-reads gpu.json cleanly. Falls back to the XWayland offload when gamescope is absent. Self-heal (trial→auto, confirm only on explicit Keep) intact. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 101 ++++++++++++++++++++++++++++++++++++----------- electron/main.ts | 13 ++++-- 2 files changed, 87 insertions(+), 27 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 4a35bba..4212b26 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -14,6 +14,7 @@ import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' import { readdirSync } from 'fs' +import { spawn, spawnSync } from 'child_process' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -85,24 +86,54 @@ export function enumerateGpus(): GpuInfo[] { } // set when THIS launch applied a not-yet-proven GPU; confirmGpuTrial() promotes -// it to 'ok' only after the renderer actually loads. +// it to 'ok' only after the user confirms the window renders. let trialNode: string | null = null -function applyToChromium(gpu: GpuInfo): void { +const hasGamescope = (): boolean => { + try { + return spawnSync('sh', ['-c', 'command -v gamescope'], { stdio: 'ignore' }).status === 0 + } catch { + return false + } +} + +const NVIDIA_ENV = { + __NV_PRIME_RENDER_OFFLOAD: '1', + __GLX_VENDOR_LIBRARY_NAME: 'nvidia', + __VK_LAYER_NV_optimus: 'NVIDIA_only' +} + +/** + * Re-launch this app inside gamescope on the NVIDIA GPU, then exit. gamescope + * renders the app on the dGPU and hands finished frames to the desktop + * compositor (kwin, on the iGPU) — the same path games use, and the only one + * that reliably shows a window on this hybrid Wayland setup (native Wayland goes + * blank; XWayland offload crashes the GPU process → no window). The nested + * WAYLAND/DISPLAY are stashed so a later switch back to Intel can escape. + */ +function relaunchInGamescope(): void { + const env: NodeJS.ProcessEnv = { + ...process.env, + ...NVIDIA_ENV, + KADR_GAMESCOPE: '1', + KADR_HOST_WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? '', + KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' + } + const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] + spawn('gamescope', args, { env, detached: true, stdio: 'inherit' }).unref() + app.exit(0) +} + +function applyGpu(gpu: GpuInfo): void { if (gpu.driver === 'nvidia') { - // Community-documented reality (NVIDIA/Electron forums): on an Optimus - // laptop only XWayland renders Chromium on the NVIDIA GPU — native Wayland - // falls back to software or a blank window. So force x11 + GLX PRIME offload. - // --disable-gpu-sandbox stops the GPU process crashing on the proprietary - // driver, which is the "process starts but no window appears" symptom. - // render-node-override and the Vulkan driver-select are deliberately dropped - // (they made GPU init / vkCreateInstance fail). Minor redraw glitches remain - // the known cost of the XWayland path. + if (hasGamescope()) { + relaunchInGamescope() // exits and re-enters inside gamescope + return + } + // no gamescope: best-effort XWayland offload (may still not present) app.commandLine.appendSwitch('ozone-platform', 'x11') app.commandLine.appendSwitch('disable-gpu-sandbox') - process.env.__NV_PRIME_RENDER_OFFLOAD = '1' - process.env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia' - process.env.__VK_LAYER_NV_optimus = 'NVIDIA_only' + Object.assign(process.env, NVIDIA_ENV) } else { // Intel / AMD: point Chromium's GPU process straight at the render node. app.commandLine.appendSwitch('render-node-override', gpu.node) @@ -111,32 +142,54 @@ function applyToChromium(gpu: GpuInfo): void { } /** - * Read the persisted choice and, before app 'ready', point Chromium's GPU - * process at that render node. Must run at main module load. Never applies an - * unproven choice twice: a 'trial' is consumed (→ 'failed') before use, so a - * launch that never reaches the renderer heals to auto next time. + * Read the persisted choice and, before app 'ready', route rendering to it. + * Must run at main module load. A 'trial' is consumed (→ 'failed') before use, + * so a launch that never renders heals back to auto next time. */ export function applyGpuChoiceAtStartup(): void { process.env.KADR_GPU_POWER = 'default' + // already relaunched inside gamescope on the dGPU — Chromium is on NVIDIA, + // nothing more to select here. + if (process.env.KADR_GAMESCOPE) { + process.env.KADR_GPU_POWER = 'high-performance' + return + } + const choice = readGpuChoice() if (!choice.node) return const gpu = enumerateGpus().find((g) => g.node === choice.node) if (!gpu) return if (choice.status === 'ok') { - applyToChromium(gpu) // proven-good on a previous launch + applyGpu(gpu) // proven-good on a previous launch } else if (choice.status === 'trial') { - writeGpuChoice({ node: gpu.node, status: 'failed' }) // heal to auto if we don't load + writeGpuChoice({ node: gpu.node, status: 'failed' }) // heal to auto if it never renders trialNode = gpu.node - applyToChromium(gpu) + applyGpu(gpu) } // 'failed' or anything else → leave at auto (the self-heal path) } -/** Call once the renderer has actually loaded — promotes a surviving trial to - * 'ok' so it sticks on future launches. No-op unless this launch was a trial. */ +/** Promote a surviving trial to 'ok' (only reachable when the window renders — + * see the Settings "Keep" button). Inside gamescope the outer process that set + * trialNode has exited, so fall back to the on-disk choice. */ export function confirmGpuTrial(): void { - if (!trialNode) return - writeGpuChoice({ node: trialNode, status: 'ok' }) + const node = trialNode ?? readGpuChoice().node + if (!node) return + writeGpuChoice({ node, status: 'ok' }) trialNode = null } + +/** Env for relaunching as a fresh top-level process on the real desktop — + * escapes gamescope's nested Wayland and clears the NVIDIA offload vars, so the + * new process re-decides the GPU from gpu.json cleanly. */ +export function cleanRelaunchEnv(): NodeJS.ProcessEnv { + const env = { ...process.env } + if (env.KADR_HOST_WAYLAND_DISPLAY !== undefined) env.WAYLAND_DISPLAY = env.KADR_HOST_WAYLAND_DISPLAY + if (env.KADR_HOST_DISPLAY !== undefined) env.DISPLAY = env.KADR_HOST_DISPLAY + for (const k of [ + 'KADR_GAMESCOPE', 'KADR_HOST_WAYLAND_DISPLAY', 'KADR_HOST_DISPLAY', + '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus' + ]) delete env[k] + return env +} diff --git a/electron/main.ts b/electron/main.ts index b0dd0ab..52ed0fc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,12 +6,12 @@ import { join, dirname, basename } from 'path' import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' import { tmpdir } from 'os' import { createHash } from 'crypto' -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' import { probeMedia, makeProxy, makeDecoded, makeReversed, measureLoudness, ExportMuxer, RawVideoEncoder } from './ffmpeg' import { registerClaudeIpc } from './claude' import { registerTranscribeIpc } from './transcribe' import { registerFragmentIpc } from './fragments' -import { enumerateGpus, applyGpuChoiceAtStartup, confirmGpuTrial } from './gpu' +import { enumerateGpus, applyGpuChoiceAtStartup, confirmGpuTrial, cleanRelaunchEnv } from './gpu' import type { ExportJob, Project } from '@shared/types' // Streamed local media under a privileged scheme so the renderer can play @@ -442,7 +442,14 @@ function registerIpc() { // a blank window can't confirm, so it heals to auto on the next launch ipcMain.handle('gpu:confirm', () => confirmGpuTrial()) ipcMain.handle('gpu:relaunch', () => { - app.relaunch() + // spawn a fresh top-level process (not app.relaunch) so cleanRelaunchEnv can + // escape gamescope's nested Wayland and clear NVIDIA vars — the new process + // re-reads gpu.json and routes to gamescope/Intel from scratch. + spawn(process.execPath, process.argv.slice(1), { + env: cleanRelaunchEnv(), + detached: true, + stdio: 'inherit' + }).unref() app.exit(0) }) From 3eecbe9ac42bfaea661cc1df60fdf5be91cf5047 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 18:41:07 +0500 Subject: [PATCH 07/28] gpu: fix blank Kadr inside gamescope + slow close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blank UI inside gamescope (button path only): the in-gamescope process set KADR_GPU_POWER=high-performance, so the WebGL context requested powerPreference:'high-performance'. Inside gamescope's single-GPU view that makes Chromium re-pick a GPU and blanks the window. Leave it at 'default' — now the button path matches the working manual `gamescope -- electron .` run. - Slow close: force-exit 400ms after window-all-closed on a clean close (was a flat 2500ms) — NVIDIA/gamescope GPU-process teardown otherwise hangs the exit. An in-flight export still gets 2500ms. - Relaunch/gamescope children now spawn with stdio:'ignore' so they don't hold the launching terminal. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 14 +++++++------- electron/main.ts | 8 +++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 4212b26..bbb4b0e 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -120,7 +120,7 @@ function relaunchInGamescope(): void { KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' } const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] - spawn('gamescope', args, { env, detached: true, stdio: 'inherit' }).unref() + spawn('gamescope', args, { env, detached: true, stdio: 'ignore' }).unref() app.exit(0) } @@ -148,12 +148,12 @@ function applyGpu(gpu: GpuInfo): void { */ export function applyGpuChoiceAtStartup(): void { process.env.KADR_GPU_POWER = 'default' - // already relaunched inside gamescope on the dGPU — Chromium is on NVIDIA, - // nothing more to select here. - if (process.env.KADR_GAMESCOPE) { - process.env.KADR_GPU_POWER = 'high-performance' - return - } + // already relaunched inside gamescope on the dGPU (offload env inherited) — + // Chromium is on NVIDIA, nothing to select. Leave powerPreference at 'default': + // a 'high-performance' request makes Chromium re-pick a GPU and blanks the + // window inside gamescope's single-GPU view (this exactly matches the working + // manual `gamescope -- electron .` run). + if (process.env.KADR_GAMESCOPE) return const choice = readGpuChoice() if (!choice.node) return diff --git a/electron/main.ts b/electron/main.ts index 52ed0fc..3a07761 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -210,8 +210,10 @@ app.on('window-all-closed', () => { app.quit() // an in-flight export/muxer or any stray handle must never keep a // windowless process alive — a lingering instance blocks the next - // launch and reads as "the editor won't open anymore" - setTimeout(() => app.exit(0), 2500) + // launch and reads as "the editor won't open anymore". On a clean close + // force-exit quickly: a slow GPU-process teardown (NVIDIA under gamescope) + // otherwise makes closing hang for seconds. Give an export longer to flush. + setTimeout(() => app.exit(0), exportState ? 2500 : 400) } }) @@ -448,7 +450,7 @@ function registerIpc() { spawn(process.execPath, process.argv.slice(1), { env: cleanRelaunchEnv(), detached: true, - stdio: 'inherit' + stdio: 'ignore' }).unref() app.exit(0) }) From 31efa7cbe38e792c2c94f41177d72ab55d97aab6 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 18:54:01 +0500 Subject: [PATCH 08/28] =?UTF-8?q?gpu:=20fix=20blank=20NVIDIA=20window=20?= =?UTF-8?q?=E2=80=94=20strip=20ELECTRON=5FRENDERER=5FURL=20on=20relaunch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gamescope-inner window was blank because it tried to load the dev server (http://localhost:5173, ERR_CONNECTION_REFUSED): electron-vite sets ELECTRON_RENDERER_URL and it leaked through the relaunch chain, but that server is gone once the launching electron exits. Strip ELECTRON_RENDERER_URL in both relaunch envs so the re-exec'd app loads the built renderer (out/). (active GPU in the log was already 0x10de — NVIDIA rendering was fine all along.) Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index bbb4b0e..0e2d36c 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -13,8 +13,9 @@ // sees 'failed' and reverts to auto — the app heals itself. import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' -import { readdirSync } from 'fs' +import { readdirSync, openSync } from 'fs' import { spawn, spawnSync } from 'child_process' +import { tmpdir } from 'os' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -119,8 +120,18 @@ function relaunchInGamescope(): void { KADR_HOST_WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? '', KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' } + // the re-exec'd app must load the built renderer (out/), not a dev server URL + // that dies when electron-vite's electron exits — else it's a blank window + delete env.ELECTRON_RENDERER_URL const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] - spawn('gamescope', args, { env, detached: true, stdio: 'ignore' }).unref() + // capture gamescope + the inner app's output so GPU/renderer failures are + // diagnosable (the process is detached, so there's no terminal to inherit) + let stdio: 'ignore' | ['ignore', number, number] = 'ignore' + try { + const fd = openSync(join(tmpdir(), 'kadr-gpu.log'), 'a') + stdio = ['ignore', fd, fd] + } catch { /* fall back to ignore */ } + spawn('gamescope', args, { env, detached: true, stdio }).unref() app.exit(0) } @@ -189,7 +200,9 @@ export function cleanRelaunchEnv(): NodeJS.ProcessEnv { if (env.KADR_HOST_DISPLAY !== undefined) env.DISPLAY = env.KADR_HOST_DISPLAY for (const k of [ 'KADR_GAMESCOPE', 'KADR_HOST_WAYLAND_DISPLAY', 'KADR_HOST_DISPLAY', - '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus' + '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus', + // a GPU switch relaunches from a possibly-dead dev server; load built out/ + 'ELECTRON_RENDERER_URL' ]) delete env[k] return env } From ffc595568177d9f7cd082d32323f752542d5e9f6 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 19:09:34 +0500 Subject: [PATCH 09/28] =?UTF-8?q?export:=20add=20vertical=204K=20(2160?= =?UTF-8?q?=C3=973840)=20and=20square=204K=20(2160=C3=972160)=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New export presets Shorts/Reels 4K and Square 4K, plus matching project-format presets, complementing the existing landscape YouTube 4K. Pipeline already supports 4K (avcCodecString → level 5.1; x264 raw path has no size limit). Also (security review): move the gamescope diagnostic log from a predictable /tmp path to userData/gpu.log — /tmp is world-writable, so a symlinked kadr-gpu.log could redirect the appended log; userData is per-user. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 5 +++-- src/presets.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 0e2d36c..5fb0f19 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -15,7 +15,6 @@ import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' import { readdirSync, openSync } from 'fs' import { spawn, spawnSync } from 'child_process' -import { tmpdir } from 'os' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -128,7 +127,9 @@ function relaunchInGamescope(): void { // diagnosable (the process is detached, so there's no terminal to inherit) let stdio: 'ignore' | ['ignore', number, number] = 'ignore' try { - const fd = openSync(join(tmpdir(), 'kadr-gpu.log'), 'a') + // userData is per-user (not world-writable /tmp), so no symlink-attack on a + // predictable log path; still useful for diagnosing GPU/renderer failures + const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') stdio = ['ignore', fd, fd] } catch { /* fall back to ignore */ } spawn('gamescope', args, { env, detached: true, stdio }).unref() diff --git a/src/presets.ts b/src/presets.ts index a49d010..8bb294f 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -16,7 +16,9 @@ export const PROJECT_PRESETS: ProjectPreset[] = [ { id: 'yt1080', name: 'YouTube · 16:9 1080p', width: 1920, height: 1080, fps: 30 }, { id: 'yt4k', name: 'YouTube 4K · 16:9', width: 3840, height: 2160, fps: 30 }, { id: 'vertical', name: 'Shorts / Reels / TikTok · 9:16', width: 1080, height: 1920, fps: 30 }, + { id: 'vertical4k', name: 'Shorts / Reels 4K · 9:16', width: 2160, height: 3840, fps: 30 }, { id: 'square', name: 'Square · 1:1', width: 1080, height: 1080, fps: 30 }, + { id: 'square4k', name: 'Square 4K · 1:1', width: 2160, height: 2160, fps: 30 }, { id: 'hd720', name: '720p · 16:9', width: 1280, height: 720, fps: 30 } ] @@ -60,6 +62,32 @@ export const PRESETS: ExportPreset[] = [ audioCodec: 'aac', audioBitrate: '192k' }, + { + id: 'vertical4k', + name: 'Shorts/Reels 4K 2160×3840', + container: 'mp4', + codec: 'avc', + ffmpegVideo: 'copy', + width: 2160, + height: 3840, + fps: 'project', + videoBitrate: 40_000_000, + audioCodec: 'aac', + audioBitrate: '256k' + }, + { + id: 'square4k', + name: 'Square 4K 2160×2160', + container: 'mp4', + codec: 'avc', + ffmpegVideo: 'copy', + width: 2160, + height: 2160, + fps: 'project', + videoBitrate: 28_000_000, + audioCodec: 'aac', + audioBitrate: '256k' + }, { id: 'hd720', name: '720p (MP4 H.264)', From 48cea8bbef89e2145409f36ae44a80fbe3fd4f28 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 19:26:05 +0500 Subject: [PATCH 10/28] export: optional NVENC (NVIDIA hardware H.264) with auto-fallback to x264 The export encode is the CPU bottleneck (libx264 pins ~5 cores); NVENC moves it to the GPU's dedicated encoder. Adds a "GPU encoding (NVENC)" export checkbox, enabled only when usable (main probes: /dev/nvidia0 present + ffmpeg has h264_nvenc, cached, exposed via nvenc:available). When on, H.264 presets encode with h264_nvenc (p5/vbr) via the raw path; otherwise x264 as before. Verified h264_nvenc produces a valid 2160x3840 High-profile file end-to-end. Note: the bundled static ffmpeg has no nvenc, so the /opt build shows the option disabled until an nvenc-capable ffmpeg is bundled; dev (system ffmpeg) works now. Co-Authored-By: Claude Opus 4.8 --- electron/main.ts | 20 +++++++++++++++++++- electron/preload.ts | 1 + shared/rawEncode.ts | 8 +++++++- shared/types.ts | 3 +++ src/components/ExportDialog.tsx | 23 ++++++++++++++++++++++- src/engine/exporter.ts | 9 ++++++++- src/i18n.ts | 6 ++++++ 7 files changed, 66 insertions(+), 4 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 3a07761..15eb937 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,7 +6,7 @@ import { join, dirname, basename } from 'path' import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' import { tmpdir } from 'os' import { createHash } from 'crypto' -import { execFile, spawn } from 'child_process' +import { execFile, spawn, spawnSync } from 'child_process' import { probeMedia, makeProxy, makeDecoded, makeReversed, measureLoudness, ExportMuxer, RawVideoEncoder } from './ffmpeg' import { registerClaudeIpc } from './claude' import { registerTranscribeIpc } from './transcribe' @@ -407,6 +407,23 @@ async function rememberDir(kind: string, filePath: string) { } catch { /* best effort */ } } +// Is NVIDIA hardware H.264 encoding usable? Needs an NVIDIA device present and +// an ffmpeg built with h264_nvenc (the bundled static ffmpeg is not). Probed +// once and cached — the export dialog offers the NVENC option only when true. +let nvencCache: boolean | null = null +function nvencAvailable(): boolean { + if (nvencCache !== null) return nvencCache + try { + if (!existsSync('/dev/nvidia0')) return (nvencCache = false) + const ff = process.env.KADR_FFMPEG || 'ffmpeg' + const r = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5000 }) + nvencCache = /h264_nvenc/.test(r.stdout || '') + } catch { + nvencCache = false + } + return nvencCache +} + function registerIpc() { ipcMain.handle('proxy:request', (_e, srcPath: string, duration: number, audioOnly?: boolean) => requestProxy(srcPath, duration, audioOnly) @@ -439,6 +456,7 @@ function registerIpc() { await fs.writeFile(userStorePath(name), JSON.stringify(data, null, 1)) }) + ipcMain.handle('nvenc:available', () => nvencAvailable()) ipcMain.handle('gpu:list', () => enumerateGpus()) // called from Settings only when the user can SEE the switched GPU works — // a blank window can't confirm, so it heals to auto on the next launch diff --git a/electron/preload.ts b/electron/preload.ts index b1df463..36a95af 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -169,6 +169,7 @@ const api: KadrApi = { }, defaultGpuPower: process.env.KADR_GPU_POWER || '', + nvencAvailable: () => ipcRenderer.invoke('nvenc:available'), gpuList: () => ipcRenderer.invoke('gpu:list'), gpuConfirm: () => { void ipcRenderer.invoke('gpu:confirm') }, relaunchApp: () => { void ipcRenderer.invoke('gpu:relaunch') } diff --git a/shared/rawEncode.ts b/shared/rawEncode.ts index 0e4833b..6978607 100644 --- a/shared/rawEncode.ts +++ b/shared/rawEncode.ts @@ -32,7 +32,13 @@ export function rawEncodeArgs(o: RawEncodeOpts): string[] { '-s', `${o.width}x${o.height}`, '-r', String(o.fps), '-i', 'pipe:0', '-vf', vf, - ...(o.codec === 'libx264' + ...(o.codec === 'h264_nvenc' + // NVIDIA hardware encoder — moves H.264 off the CPU (x264) onto the GPU's + // dedicated NVENC block; p5/vbr is a balanced quality preset. + ? ['-c:v', 'h264_nvenc', '-preset', 'p5', '-tune', 'hq', '-rc', 'vbr', + '-b:v', String(b), '-maxrate', String(Math.round(b * 1.6)), '-bufsize', String(b * 3), + '-profile:v', 'high'] + : o.codec === 'libx264' ? ['-c:v', 'libx264', '-preset', 'faster', '-b:v', String(b), '-maxrate', String(Math.round(b * 1.6)), '-bufsize', String(b * 3)] : ['-c:v', o.codec, '-b:v', String(b), diff --git a/shared/types.ts b/shared/types.ts index afdd89f..823aee4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -491,6 +491,9 @@ export interface KadrApi { /** WebGL powerPreference hint derived from the persisted GPU choice ('default' | 'high-performance' | 'low-power'); '' when unset. */ defaultGpuPower: string + /** True when NVIDIA hardware H.264 encoding (NVENC) is usable — an NVIDIA + device is present and ffmpeg has h264_nvenc. */ + nvencAvailable(): Promise /** Enumerate selectable GPUs (DRM render nodes). */ gpuList(): Promise /** Confirm the current GPU trial works (only reachable if the window renders); diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index dfece11..038bf56 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -20,9 +20,15 @@ export function ExportDialog() { const [motionBlur, setMotionBlur] = useState(true) const [frameBlending, setFrameBlending] = useState(true) const [fastEncoder, setFastEncoder] = useState(false) + const [nvenc, setNvenc] = useState(false) + const [nvencOk, setNvencOk] = useState(false) const [status, setStatus] = useState({ kind: 'idle' }) const handle = useRef(null) + useEffect(() => { + if (open) void window.kadr.nvencAvailable().then(setNvencOk) + }, [open]) + useEffect(() => { return window.kadr.onExportProgress((p) => { if (p.phase === 'done') setStatus({ kind: 'done' }) @@ -49,7 +55,13 @@ export function ExportDialog() { out, (p) => setStatus({ kind: 'running', phase: p.phase, progress: p.progress }), s.range, - { motionBlur, frameBlending, encoder: fastEncoder ? 'webcodecs' : 'x264' } + { + motionBlur, + frameBlending, + // NVENC uses the raw ffmpeg path, so it overrides the webcodecs option + encoder: fastEncoder && !(nvenc && nvencOk) ? 'webcodecs' : 'x264', + nvenc: nvenc && nvencOk + } ) handle.current = h h.done.catch((err) => { @@ -126,6 +138,15 @@ export function ExportDialog() { /> {t('fastEncoder')} + {status.kind === 'running' && (
diff --git a/src/engine/exporter.ts b/src/engine/exporter.ts index d275585..4f43445 100644 --- a/src/engine/exporter.ts +++ b/src/engine/exporter.ts @@ -96,6 +96,9 @@ export interface ExportOptions { desktop-NLE quality; 'webcodecs' keeps the old in-browser encoder (faster, but Chromium's OpenH264 ignores the preset bitrate) */ encoder?: 'x264' | 'webcodecs' + /** encode H.264 on the NVIDIA hardware encoder (NVENC) instead of CPU x264 — + much faster; only honored for H.264 presets when NVENC is available */ + nvenc?: boolean } export function startExport( @@ -200,7 +203,11 @@ export function startExport( const slots: Uint8Array[] = [] const slotPending: (Promise | null)[] = [null, null] if (useRaw) { - const codec = preset.ffmpegVideo === 'copy' ? 'libx264' : preset.ffmpegVideo + // H.264 presets can encode on the NVIDIA hardware encoder (NVENC) instead + // of CPU x264 when requested and available; other codecs are unchanged. + const codec = preset.ffmpegVideo === 'copy' + ? (opts?.nvenc ? 'h264_nvenc' : 'libx264') + : preset.ffmpegVideo try { rawDirect = await window.kadr.rawEncodeStart({ width: rw, height: rh, outWidth: width, outHeight: height, diff --git a/src/i18n.ts b/src/i18n.ts index 028d828..9f55a8a 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -207,6 +207,9 @@ const ru = { linkAV: 'Связать аудио и видео', fastEncoder: 'Быстрый кодировщик (ниже качество)', fastEncoderHint: 'WebCodecs вместо ffmpeg x264: заметно быстрее, но кодировщик Chromium игнорирует битрейт — картинка мыльнее', + nvenc: 'GPU-кодирование (NVENC)', + nvencHint: 'Кодировать H.264 на аппаратном энкодере NVIDIA вместо CPU (x264) — в разы быстрее, процессор свободен', + nvencNA: 'нет NVIDIA/ffmpeg с nvenc', saved: 'Сохранено', saveError: 'Ошибка сохранения', unsavedChanges: 'Есть несохранённые изменения', @@ -500,6 +503,9 @@ const en: typeof ru = { linkAV: 'Link audio and video', fastEncoder: 'Fast encoder (lower quality)', fastEncoderHint: 'WebCodecs instead of ffmpeg x264: much faster, but Chromium\'s encoder ignores the bitrate — softer picture', + nvenc: 'GPU encoding (NVENC)', + nvencHint: 'Encode H.264 on the NVIDIA hardware encoder instead of the CPU (x264) — much faster, frees the CPU', + nvencNA: 'no NVIDIA / ffmpeg without nvenc', saved: 'Saved', saveError: 'Save failed', unsavedChanges: 'Unsaved changes', From 3d81dfae8bfe568824c61b24e0f7df59ddf6d669 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 20:15:23 +0500 Subject: [PATCH 11/28] gpu: run NVIDIA via plain PRIME offload (normal window), drop gamescope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the blank window is fixed (it was ELECTRON_RENDERER_URL, not GPU), the NVIDIA path no longer needs gamescope: a plain --ozone-platform=x11 launch with the PRIME offload env renders Chromium on the dGPU in an ordinary, resizable window (verified: active GPU 0x10de) — exactly how Lutris launches games. This drops the gamescope re-exec entirely and with it its costs: the fixed 1600x900 window, broken drag-and-drop across the nested compositor, and the extra Intel-side compositing overhead. The GPU-switch relaunch logs to userData/gpu.log for diagnosis; trial/Keep self-heal is unchanged. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 78 ++++++++---------------------------------------- electron/main.ts | 15 +++++++--- 2 files changed, 23 insertions(+), 70 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 5fb0f19..f35b971 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -13,8 +13,7 @@ // sees 'failed' and reverts to auto — the app heals itself. import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' -import { readdirSync, openSync } from 'fs' -import { spawn, spawnSync } from 'child_process' +import { readdirSync } from 'fs' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -89,62 +88,19 @@ export function enumerateGpus(): GpuInfo[] { // it to 'ok' only after the user confirms the window renders. let trialNode: string | null = null -const hasGamescope = (): boolean => { - try { - return spawnSync('sh', ['-c', 'command -v gamescope'], { stdio: 'ignore' }).status === 0 - } catch { - return false - } -} - const NVIDIA_ENV = { __NV_PRIME_RENDER_OFFLOAD: '1', __GLX_VENDOR_LIBRARY_NAME: 'nvidia', __VK_LAYER_NV_optimus: 'NVIDIA_only' } -/** - * Re-launch this app inside gamescope on the NVIDIA GPU, then exit. gamescope - * renders the app on the dGPU and hands finished frames to the desktop - * compositor (kwin, on the iGPU) — the same path games use, and the only one - * that reliably shows a window on this hybrid Wayland setup (native Wayland goes - * blank; XWayland offload crashes the GPU process → no window). The nested - * WAYLAND/DISPLAY are stashed so a later switch back to Intel can escape. - */ -function relaunchInGamescope(): void { - const env: NodeJS.ProcessEnv = { - ...process.env, - ...NVIDIA_ENV, - KADR_GAMESCOPE: '1', - KADR_HOST_WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? '', - KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' - } - // the re-exec'd app must load the built renderer (out/), not a dev server URL - // that dies when electron-vite's electron exits — else it's a blank window - delete env.ELECTRON_RENDERER_URL - const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] - // capture gamescope + the inner app's output so GPU/renderer failures are - // diagnosable (the process is detached, so there's no terminal to inherit) - let stdio: 'ignore' | ['ignore', number, number] = 'ignore' - try { - // userData is per-user (not world-writable /tmp), so no symlink-attack on a - // predictable log path; still useful for diagnosing GPU/renderer failures - const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') - stdio = ['ignore', fd, fd] - } catch { /* fall back to ignore */ } - spawn('gamescope', args, { env, detached: true, stdio }).unref() - app.exit(0) -} - function applyGpu(gpu: GpuInfo): void { if (gpu.driver === 'nvidia') { - if (hasGamescope()) { - relaunchInGamescope() // exits and re-enters inside gamescope - return - } - // no gamescope: best-effort XWayland offload (may still not present) + // NVIDIA PRIME render offload in a normal window — exactly how Lutris runs + // games: the dGPU renders (source), the iGPU-driven display presents it + // (sink). Needs the XWayland/GLX path (--ozone-platform=x11) for the offload + // env to take effect; native Wayland leaves Chromium's EGL on the iGPU. app.commandLine.appendSwitch('ozone-platform', 'x11') - app.commandLine.appendSwitch('disable-gpu-sandbox') Object.assign(process.env, NVIDIA_ENV) } else { // Intel / AMD: point Chromium's GPU process straight at the render node. @@ -160,13 +116,6 @@ function applyGpu(gpu: GpuInfo): void { */ export function applyGpuChoiceAtStartup(): void { process.env.KADR_GPU_POWER = 'default' - // already relaunched inside gamescope on the dGPU (offload env inherited) — - // Chromium is on NVIDIA, nothing to select. Leave powerPreference at 'default': - // a 'high-performance' request makes Chromium re-pick a GPU and blanks the - // window inside gamescope's single-GPU view (this exactly matches the working - // manual `gamescope -- electron .` run). - if (process.env.KADR_GAMESCOPE) return - const choice = readGpuChoice() if (!choice.node) return const gpu = enumerateGpus().find((g) => g.node === choice.node) @@ -182,9 +131,9 @@ export function applyGpuChoiceAtStartup(): void { // 'failed' or anything else → leave at auto (the self-heal path) } -/** Promote a surviving trial to 'ok' (only reachable when the window renders — - * see the Settings "Keep" button). Inside gamescope the outer process that set - * trialNode has exited, so fall back to the on-disk choice. */ +/** Promote a surviving trial to 'ok' (reachable only when the window renders — + * the Settings "Keep" button); a bad pick that never renders stays 'failed' and + * heals to auto on the next launch. */ export function confirmGpuTrial(): void { const node = trialNode ?? readGpuChoice().node if (!node) return @@ -192,17 +141,14 @@ export function confirmGpuTrial(): void { trialNode = null } -/** Env for relaunching as a fresh top-level process on the real desktop — - * escapes gamescope's nested Wayland and clears the NVIDIA offload vars, so the - * new process re-decides the GPU from gpu.json cleanly. */ +/** Env for the fresh process a GPU switch relaunches into: clears the NVIDIA + * offload vars (so switching back to Intel doesn't inherit them) and + * ELECTRON_RENDERER_URL (the switch may relaunch from a now-dead dev server — + * load the built out/ instead). */ export function cleanRelaunchEnv(): NodeJS.ProcessEnv { const env = { ...process.env } - if (env.KADR_HOST_WAYLAND_DISPLAY !== undefined) env.WAYLAND_DISPLAY = env.KADR_HOST_WAYLAND_DISPLAY - if (env.KADR_HOST_DISPLAY !== undefined) env.DISPLAY = env.KADR_HOST_DISPLAY for (const k of [ - 'KADR_GAMESCOPE', 'KADR_HOST_WAYLAND_DISPLAY', 'KADR_HOST_DISPLAY', '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus', - // a GPU switch relaunches from a possibly-dead dev server; load built out/ 'ELECTRON_RENDERER_URL' ]) delete env[k] return env diff --git a/electron/main.ts b/electron/main.ts index 15eb937..1ab4450 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,7 +3,7 @@ import './runtime-env' import { app, BrowserWindow, ipcMain, dialog, protocol, net, clipboard } from 'electron' import { join, dirname, basename } from 'path' -import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' +import { promises as fs, createReadStream, statSync, existsSync, appendFileSync, openSync } from 'fs' import { tmpdir } from 'os' import { createHash } from 'crypto' import { execFile, spawn, spawnSync } from 'child_process' @@ -463,12 +463,19 @@ function registerIpc() { ipcMain.handle('gpu:confirm', () => confirmGpuTrial()) ipcMain.handle('gpu:relaunch', () => { // spawn a fresh top-level process (not app.relaunch) so cleanRelaunchEnv can - // escape gamescope's nested Wayland and clear NVIDIA vars — the new process - // re-reads gpu.json and routes to gamescope/Intel from scratch. + // clear the NVIDIA offload vars and a dev-server URL — the new process + // re-reads gpu.json and applies the chosen GPU from scratch. Route its + // output to userData/gpu.log so a switch that fails to render is diagnosable + // (and the detached child keeps valid stdout/stderr fds). + let stdio: 'ignore' | ['ignore', number, number] = 'ignore' + try { + const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') + stdio = ['ignore', fd, fd] + } catch { /* fall back to ignore */ } spawn(process.execPath, process.argv.slice(1), { env: cleanRelaunchEnv(), detached: true, - stdio: 'ignore' + stdio }).unref() app.exit(0) }) From 9a922f4c89ba5669804f25c58622657c6c71a9e4 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 20:25:19 +0500 Subject: [PATCH 12/28] gpu: restore gamescope NVIDIA path (plain x11 offload segfaults the GPU process) Reverting the gamescope removal: a plain --ozone-platform=x11 PRIME-offload relaunch crashes Chromium's GPU process (exit_code=139) when spawned detached (the picker relaunch), even though the same command run attached in a terminal works. gamescope is the only reliable NVIDIA-compositing path on this hybrid setup, so it stays. NVENC (the actual export speedup) is independent and works in plain Intel mode. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 78 ++++++++++++++++++++++++++++++++++++++++-------- electron/main.ts | 15 +++------- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index f35b971..5fb0f19 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -13,7 +13,8 @@ // sees 'failed' and reverts to auto — the app heals itself. import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' -import { readdirSync } from 'fs' +import { readdirSync, openSync } from 'fs' +import { spawn, spawnSync } from 'child_process' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -88,19 +89,62 @@ export function enumerateGpus(): GpuInfo[] { // it to 'ok' only after the user confirms the window renders. let trialNode: string | null = null +const hasGamescope = (): boolean => { + try { + return spawnSync('sh', ['-c', 'command -v gamescope'], { stdio: 'ignore' }).status === 0 + } catch { + return false + } +} + const NVIDIA_ENV = { __NV_PRIME_RENDER_OFFLOAD: '1', __GLX_VENDOR_LIBRARY_NAME: 'nvidia', __VK_LAYER_NV_optimus: 'NVIDIA_only' } +/** + * Re-launch this app inside gamescope on the NVIDIA GPU, then exit. gamescope + * renders the app on the dGPU and hands finished frames to the desktop + * compositor (kwin, on the iGPU) — the same path games use, and the only one + * that reliably shows a window on this hybrid Wayland setup (native Wayland goes + * blank; XWayland offload crashes the GPU process → no window). The nested + * WAYLAND/DISPLAY are stashed so a later switch back to Intel can escape. + */ +function relaunchInGamescope(): void { + const env: NodeJS.ProcessEnv = { + ...process.env, + ...NVIDIA_ENV, + KADR_GAMESCOPE: '1', + KADR_HOST_WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? '', + KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' + } + // the re-exec'd app must load the built renderer (out/), not a dev server URL + // that dies when electron-vite's electron exits — else it's a blank window + delete env.ELECTRON_RENDERER_URL + const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] + // capture gamescope + the inner app's output so GPU/renderer failures are + // diagnosable (the process is detached, so there's no terminal to inherit) + let stdio: 'ignore' | ['ignore', number, number] = 'ignore' + try { + // userData is per-user (not world-writable /tmp), so no symlink-attack on a + // predictable log path; still useful for diagnosing GPU/renderer failures + const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') + stdio = ['ignore', fd, fd] + } catch { /* fall back to ignore */ } + spawn('gamescope', args, { env, detached: true, stdio }).unref() + app.exit(0) +} + function applyGpu(gpu: GpuInfo): void { if (gpu.driver === 'nvidia') { - // NVIDIA PRIME render offload in a normal window — exactly how Lutris runs - // games: the dGPU renders (source), the iGPU-driven display presents it - // (sink). Needs the XWayland/GLX path (--ozone-platform=x11) for the offload - // env to take effect; native Wayland leaves Chromium's EGL on the iGPU. + if (hasGamescope()) { + relaunchInGamescope() // exits and re-enters inside gamescope + return + } + // no gamescope: best-effort XWayland offload (may still not present) app.commandLine.appendSwitch('ozone-platform', 'x11') + app.commandLine.appendSwitch('disable-gpu-sandbox') Object.assign(process.env, NVIDIA_ENV) } else { // Intel / AMD: point Chromium's GPU process straight at the render node. @@ -116,6 +160,13 @@ function applyGpu(gpu: GpuInfo): void { */ export function applyGpuChoiceAtStartup(): void { process.env.KADR_GPU_POWER = 'default' + // already relaunched inside gamescope on the dGPU (offload env inherited) — + // Chromium is on NVIDIA, nothing to select. Leave powerPreference at 'default': + // a 'high-performance' request makes Chromium re-pick a GPU and blanks the + // window inside gamescope's single-GPU view (this exactly matches the working + // manual `gamescope -- electron .` run). + if (process.env.KADR_GAMESCOPE) return + const choice = readGpuChoice() if (!choice.node) return const gpu = enumerateGpus().find((g) => g.node === choice.node) @@ -131,9 +182,9 @@ export function applyGpuChoiceAtStartup(): void { // 'failed' or anything else → leave at auto (the self-heal path) } -/** Promote a surviving trial to 'ok' (reachable only when the window renders — - * the Settings "Keep" button); a bad pick that never renders stays 'failed' and - * heals to auto on the next launch. */ +/** Promote a surviving trial to 'ok' (only reachable when the window renders — + * see the Settings "Keep" button). Inside gamescope the outer process that set + * trialNode has exited, so fall back to the on-disk choice. */ export function confirmGpuTrial(): void { const node = trialNode ?? readGpuChoice().node if (!node) return @@ -141,14 +192,17 @@ export function confirmGpuTrial(): void { trialNode = null } -/** Env for the fresh process a GPU switch relaunches into: clears the NVIDIA - * offload vars (so switching back to Intel doesn't inherit them) and - * ELECTRON_RENDERER_URL (the switch may relaunch from a now-dead dev server — - * load the built out/ instead). */ +/** Env for relaunching as a fresh top-level process on the real desktop — + * escapes gamescope's nested Wayland and clears the NVIDIA offload vars, so the + * new process re-decides the GPU from gpu.json cleanly. */ export function cleanRelaunchEnv(): NodeJS.ProcessEnv { const env = { ...process.env } + if (env.KADR_HOST_WAYLAND_DISPLAY !== undefined) env.WAYLAND_DISPLAY = env.KADR_HOST_WAYLAND_DISPLAY + if (env.KADR_HOST_DISPLAY !== undefined) env.DISPLAY = env.KADR_HOST_DISPLAY for (const k of [ + 'KADR_GAMESCOPE', 'KADR_HOST_WAYLAND_DISPLAY', 'KADR_HOST_DISPLAY', '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus', + // a GPU switch relaunches from a possibly-dead dev server; load built out/ 'ELECTRON_RENDERER_URL' ]) delete env[k] return env diff --git a/electron/main.ts b/electron/main.ts index 1ab4450..15eb937 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,7 +3,7 @@ import './runtime-env' import { app, BrowserWindow, ipcMain, dialog, protocol, net, clipboard } from 'electron' import { join, dirname, basename } from 'path' -import { promises as fs, createReadStream, statSync, existsSync, appendFileSync, openSync } from 'fs' +import { promises as fs, createReadStream, statSync, existsSync, appendFileSync } from 'fs' import { tmpdir } from 'os' import { createHash } from 'crypto' import { execFile, spawn, spawnSync } from 'child_process' @@ -463,19 +463,12 @@ function registerIpc() { ipcMain.handle('gpu:confirm', () => confirmGpuTrial()) ipcMain.handle('gpu:relaunch', () => { // spawn a fresh top-level process (not app.relaunch) so cleanRelaunchEnv can - // clear the NVIDIA offload vars and a dev-server URL — the new process - // re-reads gpu.json and applies the chosen GPU from scratch. Route its - // output to userData/gpu.log so a switch that fails to render is diagnosable - // (and the detached child keeps valid stdout/stderr fds). - let stdio: 'ignore' | ['ignore', number, number] = 'ignore' - try { - const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') - stdio = ['ignore', fd, fd] - } catch { /* fall back to ignore */ } + // escape gamescope's nested Wayland and clear NVIDIA vars — the new process + // re-reads gpu.json and routes to gamescope/Intel from scratch. spawn(process.execPath, process.argv.slice(1), { env: cleanRelaunchEnv(), detached: true, - stdio + stdio: 'ignore' }).unref() app.exit(0) }) From 37241ff2ec37f1cb813f17d3230aaf5e3efafa64 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 20:36:20 +0500 Subject: [PATCH 13/28] gpu: NVIDIA in a normal window via PRIME offload from process start (no gamescope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plain-x11 offload path segfaulted Chromium's GPU process (exit_code=139) because the offload vars were set late in main — after Chromium had already forked its zygote/GPU process on the iGPU node, while GLX was forced to NVIDIA. The fix: re-exec with __NV_PRIME_RENDER_OFFLOAD / __GLX_VENDOR_LIBRARY_NAME in the child's environment from process START (exactly how Lutris launches games and how the working manual `__NV_PRIME… electron .` run behaves), plus ELECTRON_OZONE_PLATFORM_HINT=x11 for the GLX presentation path. Drops gamescope entirely: NVIDIA now renders in an ordinary resizable window where drag-and-drop works. Trial/Keep self-heal unchanged. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 87 ++++++++++++++++++------------------------------ electron/main.ts | 6 ++-- 2 files changed, 36 insertions(+), 57 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 5fb0f19..48b5238 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -14,7 +14,7 @@ import { app } from 'electron' import { readFileSync, writeFileSync } from 'fs' import { readdirSync, openSync } from 'fs' -import { spawn, spawnSync } from 'child_process' +import { spawn } from 'child_process' import { join } from 'path' import type { GpuInfo } from '@shared/types' @@ -89,14 +89,6 @@ export function enumerateGpus(): GpuInfo[] { // it to 'ok' only after the user confirms the window renders. let trialNode: string | null = null -const hasGamescope = (): boolean => { - try { - return spawnSync('sh', ['-c', 'command -v gamescope'], { stdio: 'ignore' }).status === 0 - } catch { - return false - } -} - const NVIDIA_ENV = { __NV_PRIME_RENDER_OFFLOAD: '1', __GLX_VENDOR_LIBRARY_NAME: 'nvidia', @@ -104,52 +96,43 @@ const NVIDIA_ENV = { } /** - * Re-launch this app inside gamescope on the NVIDIA GPU, then exit. gamescope - * renders the app on the dGPU and hands finished frames to the desktop - * compositor (kwin, on the iGPU) — the same path games use, and the only one - * that reliably shows a window on this hybrid Wayland setup (native Wayland goes - * blank; XWayland offload crashes the GPU process → no window). The nested - * WAYLAND/DISPLAY are stashed so a later switch back to Intel can escape. + * Re-exec the app on the NVIDIA GPU via PRIME render offload — the way Lutris + * launches games: an ordinary resizable window (no gamescope), the dGPU renders + * and the iGPU-driven display presents. The offload vars MUST be in the + * environment from process START: Chromium forks its zygote/GPU process very + * early, and setting the vars late in main leaves the GPU process on the iGPU + * render node while GLX is forced to NVIDIA — that mismatch segfaults it + * (exit_code=139, no window). So relaunch with the vars in the child's env, then + * exit. ELECTRON_OZONE_PLATFORM_HINT=x11 routes presentation through XWayland/GLX + * where the offload actually takes effect (native Wayland leaves EGL on the iGPU). */ -function relaunchInGamescope(): void { +function relaunchWithOffload(): void { const env: NodeJS.ProcessEnv = { ...process.env, ...NVIDIA_ENV, - KADR_GAMESCOPE: '1', - KADR_HOST_WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? '', - KADR_HOST_DISPLAY: process.env.DISPLAY ?? '' + KADR_GPU_OFFLOAD: '1', + ELECTRON_OZONE_PLATFORM_HINT: 'x11' } - // the re-exec'd app must load the built renderer (out/), not a dev server URL - // that dies when electron-vite's electron exits — else it's a blank window + // load the built renderer (out/), not a dev-server URL that dies on relaunch delete env.ELECTRON_RENDERER_URL - const args = ['-W', '1600', '-H', '900', '--', process.execPath, ...process.argv.slice(1)] - // capture gamescope + the inner app's output so GPU/renderer failures are - // diagnosable (the process is detached, so there's no terminal to inherit) let stdio: 'ignore' | ['ignore', number, number] = 'ignore' try { - // userData is per-user (not world-writable /tmp), so no symlink-attack on a - // predictable log path; still useful for diagnosing GPU/renderer failures + // userData is per-user (not world-writable /tmp) — no symlink-attack; kept + // for diagnosing GPU/renderer failures on the detached process const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') stdio = ['ignore', fd, fd] } catch { /* fall back to ignore */ } - spawn('gamescope', args, { env, detached: true, stdio }).unref() + spawn(process.execPath, process.argv.slice(1), { env, detached: true, stdio }).unref() app.exit(0) } function applyGpu(gpu: GpuInfo): void { if (gpu.driver === 'nvidia') { - if (hasGamescope()) { - relaunchInGamescope() // exits and re-enters inside gamescope - return - } - // no gamescope: best-effort XWayland offload (may still not present) - app.commandLine.appendSwitch('ozone-platform', 'x11') - app.commandLine.appendSwitch('disable-gpu-sandbox') - Object.assign(process.env, NVIDIA_ENV) - } else { - // Intel / AMD: point Chromium's GPU process straight at the render node. - app.commandLine.appendSwitch('render-node-override', gpu.node) + relaunchWithOffload() // exits and re-execs with the offload env from start + return } + // Intel / AMD: point Chromium's GPU process straight at the render node. + app.commandLine.appendSwitch('render-node-override', gpu.node) process.env.KADR_GPU_POWER = gpu.integrated ? 'low-power' : 'high-performance' } @@ -160,12 +143,10 @@ function applyGpu(gpu: GpuInfo): void { */ export function applyGpuChoiceAtStartup(): void { process.env.KADR_GPU_POWER = 'default' - // already relaunched inside gamescope on the dGPU (offload env inherited) — - // Chromium is on NVIDIA, nothing to select. Leave powerPreference at 'default': - // a 'high-performance' request makes Chromium re-pick a GPU and blanks the - // window inside gamescope's single-GPU view (this exactly matches the working - // manual `gamescope -- electron .` run). - if (process.env.KADR_GAMESCOPE) return + // re-exec'd with the PRIME offload env already in the environment (from process + // start) — Chromium is on NVIDIA; nothing to select. powerPreference stays + // 'default' to match the working manual `__NV_PRIME… electron .` run. + if (process.env.KADR_GPU_OFFLOAD) return const choice = readGpuChoice() if (!choice.node) return @@ -182,9 +163,9 @@ export function applyGpuChoiceAtStartup(): void { // 'failed' or anything else → leave at auto (the self-heal path) } -/** Promote a surviving trial to 'ok' (only reachable when the window renders — - * see the Settings "Keep" button). Inside gamescope the outer process that set - * trialNode has exited, so fall back to the on-disk choice. */ +/** Promote a surviving trial to 'ok' (reachable only when the window renders — + * the Settings "Keep" button). The offload re-exec sets trialNode in the outer + * process which then exits, so fall back to the on-disk choice. */ export function confirmGpuTrial(): void { const node = trialNode ?? readGpuChoice().node if (!node) return @@ -192,17 +173,15 @@ export function confirmGpuTrial(): void { trialNode = null } -/** Env for relaunching as a fresh top-level process on the real desktop — - * escapes gamescope's nested Wayland and clears the NVIDIA offload vars, so the - * new process re-decides the GPU from gpu.json cleanly. */ +/** Env for the fresh process a GPU switch relaunches into: clears the NVIDIA + * offload vars / x11 hint (so switching back to Intel goes native Wayland on the + * iGPU) and ELECTRON_RENDERER_URL (a switch may relaunch from a now-dead dev + * server — load the built out/ instead). */ export function cleanRelaunchEnv(): NodeJS.ProcessEnv { const env = { ...process.env } - if (env.KADR_HOST_WAYLAND_DISPLAY !== undefined) env.WAYLAND_DISPLAY = env.KADR_HOST_WAYLAND_DISPLAY - if (env.KADR_HOST_DISPLAY !== undefined) env.DISPLAY = env.KADR_HOST_DISPLAY for (const k of [ - 'KADR_GAMESCOPE', 'KADR_HOST_WAYLAND_DISPLAY', 'KADR_HOST_DISPLAY', + 'KADR_GPU_OFFLOAD', 'ELECTRON_OZONE_PLATFORM_HINT', '__NV_PRIME_RENDER_OFFLOAD', '__GLX_VENDOR_LIBRARY_NAME', '__VK_LAYER_NV_optimus', - // a GPU switch relaunches from a possibly-dead dev server; load built out/ 'ELECTRON_RENDERER_URL' ]) delete env[k] return env diff --git a/electron/main.ts b/electron/main.ts index 15eb937..e65f7f7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -211,7 +211,7 @@ app.on('window-all-closed', () => { // an in-flight export/muxer or any stray handle must never keep a // windowless process alive — a lingering instance blocks the next // launch and reads as "the editor won't open anymore". On a clean close - // force-exit quickly: a slow GPU-process teardown (NVIDIA under gamescope) + // force-exit quickly: a slow GPU-process teardown (NVIDIA PRIME offload) // otherwise makes closing hang for seconds. Give an export longer to flush. setTimeout(() => app.exit(0), exportState ? 2500 : 400) } @@ -463,8 +463,8 @@ function registerIpc() { ipcMain.handle('gpu:confirm', () => confirmGpuTrial()) ipcMain.handle('gpu:relaunch', () => { // spawn a fresh top-level process (not app.relaunch) so cleanRelaunchEnv can - // escape gamescope's nested Wayland and clear NVIDIA vars — the new process - // re-reads gpu.json and routes to gamescope/Intel from scratch. + // clear the NVIDIA offload vars / x11 hint and a dev-server URL — the new + // process re-reads gpu.json and applies the chosen GPU from scratch. spawn(process.execPath, process.argv.slice(1), { env: cleanRelaunchEnv(), detached: true, From 9579f48dfa08d4fa6be3005ed2afeed8e0229f54 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 21:08:38 +0500 Subject: [PATCH 14/28] gpu: force --ozone-platform=x11 via CLI flag for NVIDIA offload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offload-from-start fix stopped the GPU-process segfault (active GPU now 0x10de), but ELECTRON_OZONE_PLATFORM_HINT=x11 is ignored on a Wayland session — Chromium stayed on wayland, where NVIDIA is Vulkan-incompatible and falls back to software (so the picker still showed 'auto'). Pass --ozone-platform=x11 as a CLI flag instead (as the working manual run did); the GPU-switch relaunch strips it so a switch back to Intel stays native Wayland. Co-Authored-By: Claude Opus 4.8 --- electron/gpu.ts | 13 ++++++++++--- electron/main.ts | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 48b5238..ff72088 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -110,8 +110,7 @@ function relaunchWithOffload(): void { const env: NodeJS.ProcessEnv = { ...process.env, ...NVIDIA_ENV, - KADR_GPU_OFFLOAD: '1', - ELECTRON_OZONE_PLATFORM_HINT: 'x11' + KADR_GPU_OFFLOAD: '1' } // load the built renderer (out/), not a dev-server URL that dies on relaunch delete env.ELECTRON_RENDERER_URL @@ -122,7 +121,15 @@ function relaunchWithOffload(): void { const fd = openSync(join(app.getPath('userData'), 'gpu.log'), 'a') stdio = ['ignore', fd, fd] } catch { /* fall back to ignore */ } - spawn(process.execPath, process.argv.slice(1), { env, detached: true, stdio }).unref() + // --ozone-platform=x11 as a CLI flag: the ELECTRON_OZONE_PLATFORM_HINT env is + // NOT honored on a Wayland session (Chromium stays on wayland, where NVIDIA + // is Vulkan-incompatible and falls back to software). XWayland/GLX is where + // the PRIME offload actually renders on the NVIDIA GPU. + const args = [ + ...process.argv.slice(1).filter((a) => !a.startsWith('--ozone-platform')), + '--ozone-platform=x11' + ] + spawn(process.execPath, args, { env, detached: true, stdio }).unref() app.exit(0) } diff --git a/electron/main.ts b/electron/main.ts index e65f7f7..3c97da7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -465,7 +465,10 @@ function registerIpc() { // spawn a fresh top-level process (not app.relaunch) so cleanRelaunchEnv can // clear the NVIDIA offload vars / x11 hint and a dev-server URL — the new // process re-reads gpu.json and applies the chosen GPU from scratch. - spawn(process.execPath, process.argv.slice(1), { + // drop any --ozone-platform=x11 the NVIDIA path added, so a switch back to + // Intel runs native Wayland; the new process re-adds x11 only if it picks NVIDIA + const relaunchArgs = process.argv.slice(1).filter((a) => !a.startsWith('--ozone-platform')) + spawn(process.execPath, relaunchArgs, { env: cleanRelaunchEnv(), detached: true, stdio: 'ignore' From f402091d6fa0ad16855223207d97d5751a282333 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 21:23:12 +0500 Subject: [PATCH 15/28] pack: bundle BtbN ffmpeg (has NVENC) so /opt gets GPU encoding johnvansickle's static ffmpeg has no h264_nvenc, so the packaged app couldn't offer NVENC. Switch to BtbN's gpl static build (h264_nvenc + libx264 + libvpx + all filters Kadr uses; dlopens the NVIDIA driver at runtime). Verifies the bundled binary has nvenc during pack. Co-Authored-By: Claude Opus 4.8 --- scripts/pack.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/pack.sh b/scripts/pack.sh index cd621c6..615bbf7 100755 --- a/scripts/pack.sh +++ b/scripts/pack.sh @@ -22,7 +22,11 @@ set -euo pipefail NODE_VERSION="20.18.1" # bundled host node for npm/npx/mcp bridge PBS_RELEASE="20241206" # astral python-build-standalone release tag PYTHON_VERSION="3.12.8" # cpython version within that release -FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" +# BtbN static build (GPL): unlike johnvansickle it ships h264_nvenc/hevc_nvenc, +# so the "GPU encoding (NVENC)" export option works in the packaged app too. It +# dlopens the user's NVIDIA driver libs at runtime — no NVIDIA, no problem, the +# app's nvencAvailable() check just leaves the option off and x264 is used. +FFMPEG_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" ARCH="x64"; UNAME_ARCH="x86_64" ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -63,14 +67,17 @@ else [ -d "$ROOT/out/main" ] || { echo "out/ missing — drop --skip-build" >&2; exit 1; } fi -# ---- 2. ffmpeg / ffprobe (static) ------------------------------------------ -log "stage static ffmpeg/ffprobe" +# ---- 2. ffmpeg / ffprobe (static, with NVENC) ------------------------------ +log "stage static ffmpeg/ffprobe (BtbN gpl, incl. nvenc)" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT dl "$FFMPEG_URL" "$TMP/ffmpeg.tar.xz" tar -xJf "$TMP/ffmpeg.tar.xz" -C "$TMP" -FF_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'ffmpeg-*-static' | head -n1)" -install -m755 "$FF_DIR/ffmpeg" "$BIN/ffmpeg" -install -m755 "$FF_DIR/ffprobe" "$BIN/ffprobe" +FF_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'ffmpeg-*' | head -n1)" +# BtbN layout is /bin/ffmpeg; johnvansickle was /ffmpeg +install -m755 "$FF_DIR/bin/ffmpeg" "$BIN/ffmpeg" +install -m755 "$FF_DIR/bin/ffprobe" "$BIN/ffprobe" +"$BIN/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -q h264_nvenc \ + && log " ffmpeg has NVENC ✓" || log " WARNING: bundled ffmpeg lacks nvenc" # ---- 3. bundled Node (host node/npm/npx for Remotion + MCP bridge) --------- log "stage Node $NODE_VERSION" From 9ca676ef06d95ab5bd643b1917f88d071f947246 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Thu, 20 Aug 2026 21:42:51 +0500 Subject: [PATCH 16/28] pack: robust NVENC check (if-form, not && || which false-warned) Co-Authored-By: Claude Opus 4.8 --- scripts/pack.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/pack.sh b/scripts/pack.sh index 615bbf7..27bd696 100755 --- a/scripts/pack.sh +++ b/scripts/pack.sh @@ -76,8 +76,11 @@ FF_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'ffmpeg-*' | head -n1)" # BtbN layout is /bin/ffmpeg; johnvansickle was /ffmpeg install -m755 "$FF_DIR/bin/ffmpeg" "$BIN/ffmpeg" install -m755 "$FF_DIR/bin/ffprobe" "$BIN/ffprobe" -"$BIN/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -q h264_nvenc \ - && log " ffmpeg has NVENC ✓" || log " WARNING: bundled ffmpeg lacks nvenc" +if "$BIN/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -q h264_nvenc; then + log " ffmpeg has NVENC ✓" +else + log " WARNING: bundled ffmpeg lacks nvenc" +fi # ---- 3. bundled Node (host node/npm/npx for Remotion + MCP bridge) --------- log "stage Node $NODE_VERSION" From 195c16b09508bd8fcebb9e129d6a8104aaf56d04 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Fri, 21 Aug 2026 06:31:01 +0500 Subject: [PATCH 17/28] fix: audio-export collapse, black-frame keyframes, NVIDIA export hang/NVENC, fragment limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportMuxer: asetpts=PTS-STARTPTS re-zeroes seek'd inputs so each clip's audio delay survives — adelay+atrim no longer collapse every audio clip to t=0 - evalAnim/sanitizeProject: guard missing/unknown keyframe easing; drawFrame now isolates each clip so one bad keyframe skips itself instead of blacking the frame - GPU export hang: disable VAAPI decode on the NVIDIA offload path (no VA driver there) + an 8s decode watchdog in demux → falls back to element-seek, never hangs - NVENC: nvencAvailable() test-encodes for real capability (graceful x264 fallback); pack.sh pins a driver-580-compatible ffmpeg (nv-codec-headers API 13.0) - Fragments: use project fps (not forced 60), move Remotion temp off tmpfs + pre-flight disk check; regenRegistry() before every render; seed the offscreen player on the target frame + version-gated captureReady for accurate snapshots - media re-import refreshes on mtime change; addTrack optional insert index; rounded-rectangle mask shape (rounded-box SDF + tool + radius slider + presets) Co-Authored-By: Claude Opus 4.8 --- electron/ffmpeg.ts | 8 +++++ electron/fragments.ts | 57 ++++++++++++++++++++++++++++--- electron/main.ts | 38 +++++++++++++++++---- electron/preload.ts | 1 + scripts/pack.sh | 10 +++++- shared/types.ts | 11 +++++- src/components/AnimEditor.tsx | 33 ++++++++++++++---- src/engine/anim.ts | 5 ++- src/engine/demux.ts | 24 ++++++++++++- src/engine/fragmentCapture.ts | 42 +++++++++++++++++++---- src/engine/fragments.ts | 5 ++- src/engine/mediaImport.ts | 16 +++++++++ src/engine/player.ts | 63 +++++++++++++++++++++++++---------- src/gl/compositor.ts | 17 ++++++++-- src/i18n.ts | 4 +++ src/state/store.ts | 27 +++++++++++---- src/styles.css | 1 + 17 files changed, 308 insertions(+), 54 deletions(-) diff --git a/electron/ffmpeg.ts b/electron/ffmpeg.ts index 19874dd..6fe7ebc 100644 --- a/electron/ffmpeg.ts +++ b/electron/ffmpeg.ts @@ -48,6 +48,7 @@ export async function probeMedia(path: string): Promise { audioCodec: audio?.codec_name } if (kind === 'video' && video?.codec_name) asset.codec = video.codec_name + try { asset.mtimeMs = Math.round((await fsp.stat(path)).mtimeMs) } catch { /* stat optional */ } if (kind !== 'audio') { try { @@ -451,6 +452,13 @@ export class ExportMuxer { const outDur = s.duration / speed // timeline-domain length after atempo const chain = [ 'aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo', + // Input-seeked real files (`-ss inPoint -i file`) hand filter_complex a + // stream whose first sample carries a non-zero source PTS. adelay/atrim + // then work in that shifted time base and the per-clip delay is lost, so + // amix stacks every clip at t=0 (all audio collapses to the start). Zero + // the PTS here — as a lavfi source already is — so adelay places the clip + // at `ms` and atrim frames [0, job.duration) correctly. + 'asetpts=PTS-STARTPTS', `volume=${s.gain.toFixed(4)}`, ...(Math.abs(speed - 1) > 1e-4 ? atempoChain(speed) : []), ...(s.fadeIn > 0.001 ? [`afade=t=in:st=0:d=${Math.min(s.fadeIn, outDur).toFixed(3)}`] : []), diff --git a/electron/fragments.ts b/electron/fragments.ts index 310a058..1a5de3e 100644 --- a/electron/fragments.ts +++ b/electron/fragments.ts @@ -99,7 +99,12 @@ import { Player, PlayerRef } from '@remotion/player' import { fragments } from './src/fragments' function App() { - const id = new URLSearchParams(location.search).get('comp') || '' + const params = new URLSearchParams(location.search) + const id = params.get('comp') || '' + // Start on the requested frame so the very first offscreen paint already + // matches the playhead — otherwise a snapshot can capture frame 0 before the + // first sync lands (the message listener attaches a tick after mount). + const initialFrame = Math.max(0, Math.round(Number(params.get('frame')) || 0)) const entry = (fragments as Record)[id] const ref = useRef(null) // Drift against the editor clock is corrected by nudging playbackRate a @@ -150,6 +155,7 @@ function App() { compositionWidth: entry.meta.width, compositionHeight: entry.meta.height, fps: entry.meta.fps, + initialFrame, playbackRate: rate, style: { width: '100vw', height: '100vh' }, controls: false, @@ -389,7 +395,9 @@ async function createFragment(spec: FragmentSpec): Promise { name: spec.name, width: Math.round(spec.width), height: Math.round(spec.height), - fps: Math.max(60, Math.round(spec.fps)), + // honor the requested (project) fps — forcing ≥60 doubled the PNG frame + // pile on tmpfs for 30 fps projects (see renderFragment TMPDIR handling) + fps: Math.max(1, Math.round(spec.fps)), durationInFrames: Math.max(1, Math.round(spec.durationInFrames)), transparent: !!spec.transparent } @@ -426,15 +434,47 @@ function fragmentHash(id: string): string { const renderDir = () => join(app.getPath('userData'), 'fragment-renders') let renderChain: Promise = Promise.resolve() +/** Fail fast (with a clear message) if the temp filesystem can't hold the PNG + * frame pile Remotion produces. ~0.25 B/px/frame is conservative vs the + * ~0.18 B/px measured on flat fills. Best-effort: skips if it can't estimate. */ +async function ensureRenderSpace( + dir: string, + meta: { width?: number; height?: number; durationInFrames?: number } +): Promise { + const { width, height, durationInFrames } = meta + if (!width || !height || !durationInFrames) return + let free: number + try { + const st = await fs.statfs(dir) + free = st.bavail * st.bsize + } catch { + return // statfs unavailable — skip the guard rather than block the render + } + const est = width * height * 0.25 * durationInFrames + if (free < est) { + const gb = (n: number) => (n / 1e9).toFixed(2) + throw new Error( + `Not enough disk for this fragment render: ~${gb(est)} GB of temp frames ` + + `(${durationInFrames} × ${width}×${height} PNG) but only ${gb(free)} GB free ` + + `at ${dir}. Free up space, or lower the fragment resolution/fps/length.` + ) + } +} + async function renderFragment( id: string, opts: { transparent?: boolean } | undefined, onProgress: (p: number) => void ): Promise<{ path: string; cached: boolean }> { await ensureWorkspace() + // Heal the registry against the folders actually on disk BEFORE bundling: a + // fragment folder deleted outside the app (rm -rf) would otherwise leave a + // stale `import … from './'` in src/fragments/index.ts and fail the render. + await regenRegistry() let transparent = !!opts?.transparent + let meta: { width?: number; height?: number; durationInFrames?: number; transparent?: boolean } = {} try { - const meta = JSON.parse(await fs.readFile(join(FRAG_DIR(), id, 'meta.json'), 'utf8')) + meta = JSON.parse(await fs.readFile(join(FRAG_DIR(), id, 'meta.json'), 'utf8')) if (opts?.transparent === undefined) transparent = !!meta.transparent } catch { /* meta is optional for the decision */ } const ext = transparent ? 'webm' : 'mp4' @@ -455,11 +495,20 @@ async function renderFragment( if (transparent) args.push('--codec=vp9', '--pixel-format=yuva420p', '--crf=12') else args.push('--codec=h264', '--crf=15') + // Remotion writes every frame as a full-res PNG to the OS temp dir before + // muxing. On this machine /tmp is a small tmpfs (~1.8 GB free) and a 4K + // fragment overflowed it mid-render (EDQUOT at ~90%). Redirect Remotion's + // temp to a disk-backed dir under userData, and pre-flight the space so we + // fail up front with a clear message instead of after a 10-minute render. + const tmpDir = join(app.getPath('userData'), 'fragment-tmp') + await fs.mkdir(tmpDir, { recursive: true }) + await ensureRenderSpace(tmpDir, meta) + const extraEnv = await netEnv() const job = renderChain.then(() => new Promise((resolve, reject) => { const child = spawn('npx', args, { cwd: WORKSPACE, - env: { ...process.env, ...extraEnv }, + env: { ...process.env, ...extraEnv, TMPDIR: tmpDir }, stdio: ['ignore', 'pipe', 'pipe'] }) let all = '' diff --git a/electron/main.ts b/electron/main.ts index 3c97da7..938512a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -50,10 +50,19 @@ app.commandLine.appendSwitch('password-store', 'basic') // Let Chromium use VAAPI for hardware video encode/decode where the driver // allows it (Intel iGPU on this machine); WebCodecs then picks it up via // hardwareAcceleration: 'prefer-hardware'. +// +// EXCEPT on the NVIDIA PRIME-offload path (KADR_GPU_OFFLOAD): there is no VA +// driver for the NVIDIA GLX context, so VaapiVideoDecoder init fails ("Could +// not get a valid VA display") and — worse — a WebCodecs decode can then wedge +// with neither an output nor an error callback, hanging the offline export +// forever. So on that path we DON'T enable VAAPI decode (WebCodecs falls back +// to software decode, which works); GPU compositing still runs on the dGPU. app.commandLine.appendSwitch('ignore-gpu-blocklist') app.commandLine.appendSwitch( 'enable-features', - 'VaapiVideoEncoder,VaapiVideoDecoder,VaapiVideoDecodeLinuxGL,AcceleratedVideoEncoder' + process.env.KADR_GPU_OFFLOAD + ? 'VaapiVideoEncoder,AcceleratedVideoEncoder' + : 'VaapiVideoEncoder,VaapiVideoDecoder,VaapiVideoDecodeLinuxGL,AcceleratedVideoEncoder' ) // Point the GPU process at the user's chosen render node (else Chromium's @@ -407,17 +416,24 @@ async function rememberDir(kind: string, filePath: string) { } catch { /* best effort */ } } -// Is NVIDIA hardware H.264 encoding usable? Needs an NVIDIA device present and -// an ffmpeg built with h264_nvenc (the bundled static ffmpeg is not). Probed -// once and cached — the export dialog offers the NVENC option only when true. +// Is NVIDIA hardware H.264 encoding actually usable? Listing h264_nvenc in +// -encoders is NOT enough: ffmpeg's bundled NVENC SDK can be newer than the +// installed driver (e.g. build needs NVENC API 13.1 but the driver only has +// 13.0), and it fails only at encode time. So we run a real 1-frame encode and +// trust the exit code — a mismatch just leaves the NVENC option off and x264 is +// used, instead of a hard export error. Probed once, cached. let nvencCache: boolean | null = null function nvencAvailable(): boolean { if (nvencCache !== null) return nvencCache try { if (!existsSync('/dev/nvidia0')) return (nvencCache = false) const ff = process.env.KADR_FFMPEG || 'ffmpeg' - const r = spawnSync(ff, ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5000 }) - nvencCache = /h264_nvenc/.test(r.stdout || '') + const r = spawnSync(ff, [ + '-hide_banner', '-v', 'error', + '-f', 'lavfi', '-i', 'color=c=black:s=64x64:d=1', + '-frames:v', '1', '-c:v', 'h264_nvenc', '-f', 'null', '-' + ], { timeout: 15000 }) + nvencCache = r.status === 0 } catch { nvencCache = false } @@ -488,6 +504,16 @@ function registerIpc() { }) ipcMain.handle('media:probe', (_e, path: string) => probeMedia(path)) + // cheap freshness check for the import dedupe — a re-import of a file + // overwritten with the same name should refresh, not reuse the stale asset + ipcMain.handle('media:stat', (_e, path: string) => { + try { + const s = statSync(path) + return { mtimeMs: s.mtimeMs, size: s.size } + } catch { + return null + } + }) // sanitized basename + MIME-derived extension for downloaded/pasted media const mediaBase = (name: string, mime: string): string => { diff --git a/electron/preload.ts b/electron/preload.ts index 36a95af..1c4324e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -61,6 +61,7 @@ const api: KadrApi = { openMediaDialog: () => ipcRenderer.invoke('media:open-dialog'), probeMedia: (path) => ipcRenderer.invoke('media:probe', path), + statMedia: (path) => ipcRenderer.invoke('media:stat', path), fileUrl: (path) => { // Windows paths (D:\dir\file) must become /D:/dir/file — a raw drive // letter glued after the host ('kadr://mediaD:\…') is an INVALID URL: diff --git a/scripts/pack.sh b/scripts/pack.sh index 27bd696..649fd4d 100755 --- a/scripts/pack.sh +++ b/scripts/pack.sh @@ -26,7 +26,15 @@ PYTHON_VERSION="3.12.8" # cpython version within that release # so the "GPU encoding (NVENC)" export option works in the packaged app too. It # dlopens the user's NVIDIA driver libs at runtime — no NVIDIA, no problem, the # app's nvencAvailable() check just leaves the option off and x264 is used. -FFMPEG_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz" +# +# PINNED, not `latest`, on purpose: BtbN's current builds require NVENC API 13.1 +# (driver ≥ 610), so h264_nvenc won't open on the common 5xx driver line. The +# 2026-05-31 dated build is the last one built against nv-codec-headers API 13.0 +# — verified encoding on driver 580 — and works on 610+ too via NVENC back-compat. +# Bump to `latest` once driver ≥ 610 is the norm. (BtbN prunes old dated builds +# eventually; if this 404s, pick the newest dated build whose h264_nvenc still +# opens on the target driver — see nvencAvailable()'s 1-frame test-encode.) +FFMPEG_URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl.tar.xz" ARCH="x64"; UNAME_ARCH="x86_64" ROOT="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/shared/types.ts b/shared/types.ts index 823aee4..674a045 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -35,6 +35,9 @@ export interface MediaAsset { proxyPath?: string /** this asset is a reversed render of a source range of another asset */ reverseOf?: { assetId: string; start: number; duration: number } + /** source file mtime at probe time — lets a re-import of an overwritten + same-name file refresh the asset instead of reusing the stale one */ + mtimeMs?: number } export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | 'hold' @@ -85,7 +88,7 @@ export interface ClipMask { bottom: Anim } -export type MaskShapeType = 'rect' | 'ellipse' | 'triangle' +export type MaskShapeType = 'rect' | 'ellipse' | 'triangle' | 'roundrect' /** Drawn shape mask in layer UV space (0..1), with soft borders. */ export interface MaskShape { @@ -97,6 +100,8 @@ export interface MaskShape { /** soft border inward / outward, in layer-height fractions */ featherIn: Anim featherOut: Anim + /** corner radius for 'roundrect', in layer-height fractions (0 = sharp) */ + radius?: Anim /** exclude mode: the shape cuts a hole instead of keeping its inside */ invert: boolean } @@ -208,6 +213,8 @@ export interface PoseShape { h: number featherIn: number featherOut: number + /** corner radius for 'roundrect' (y-height fractions) */ + radius?: number } export interface PosePreset { @@ -370,6 +377,8 @@ export interface ProbeResult { export interface KadrApi { openMediaDialog(): Promise probeMedia(path: string): Promise + /** cheap stat for the import dedupe (mtime/size), or null if unreadable */ + statMedia(path: string): Promise<{ mtimeMs: number; size: number } | null> fileUrl(path: string): string /** Absolute path of a File dropped from the OS (File.path is gone since Electron 32 — this goes through webUtils.getPathForFile). */ diff --git a/src/components/AnimEditor.tsx b/src/components/AnimEditor.tsx index 1499945..0fd7d4b 100644 --- a/src/components/AnimEditor.tsx +++ b/src/components/AnimEditor.tsx @@ -49,14 +49,15 @@ const MASK_PARAMS: ParamDef[] = [ { key: 'mB', label: 'maskBottom', step: 0.01, min: 0, max: 0.49, get: (c) => maskOf(c).bottom, patch: (c, a) => ({ mask: { ...maskOf(c), bottom: a } }) } ] -type ShapeField = 'cx' | 'cy' | 'w' | 'h' | 'featherIn' | 'featherOut' +type ShapeField = 'cx' | 'cy' | 'w' | 'h' | 'featherIn' | 'featherOut' | 'radius' function shapeParam( i: number, field: ShapeField, label: TKey, step: number, min: number, max: number ): ParamDef { + const zeroDefault = field === 'featherIn' || field === 'featherOut' || field === 'radius' return { key: `s${i}.${field}`, label, step, min, max, - get: (c) => shapesOf(c)[i]?.[field] ?? defAnim(field === 'featherIn' || field === 'featherOut' ? 0 : 0.5), + get: (c) => shapesOf(c)[i]?.[field] ?? defAnim(zeroDefault ? 0 : 0.5), patch: (c, a) => patchShapes(shapesOf(c).map((s, j) => (j === i ? { ...s, [field]: a } : s))) } } @@ -422,7 +423,8 @@ export function AnimEditor({ width }: { width: number }) { const newShape: MaskShape = { type, cx: defAnim(a.u), cy: defAnim(a.v), w: defAnim(0.05), h: defAnim(0.05), - featherIn: defAnim(0), featherOut: defAnim(0), invert: false + featherIn: defAnim(0), featherOut: defAnim(0), invert: false, + ...(type === 'roundrect' ? { radius: defAnim(0.08) } : {}) } const f0 = findClip(st.project, clip.id) if (!f0) return @@ -505,7 +507,8 @@ export function AnimEditor({ width }: { width: number }) { invert: s.invert, cx: evalAnim(s.cx, rel), cy: evalAnim(s.cy, rel), w: evalAnim(s.w, rel), h: evalAnim(s.h, rel), - featherIn: evalAnim(s.featherIn, rel), featherOut: evalAnim(s.featherOut, rel) + featherIn: evalAnim(s.featherIn, rel), featherOut: evalAnim(s.featherOut, rel), + ...(s.radius ? { radius: evalAnim(s.radius, rel) } : {}) })) }) } @@ -554,7 +557,10 @@ export function AnimEditor({ width }: { width: number }) { }) const f2 = findClip(useEditor.getState().project, clip.id) if (f2) useEditor.getState().updateClip(clip.id, patchShapes( - shapesOf(f2.clip).map((s, i) => ({ ...s, type: ps[i].type, invert: ps[i].invert })) + shapesOf(f2.clip).map((s, i) => ({ + ...s, type: ps[i].type, invert: ps[i].invert, + ...(ps[i].radius != null ? { radius: defAnim(ps[i].radius!) } : {}) + })) )) } else { // different layout — replace with the preset's shapes as static values @@ -562,7 +568,8 @@ export function AnimEditor({ width }: { width: number }) { type: s.type, invert: s.invert, cx: defAnim(s.cx), cy: defAnim(s.cy), w: defAnim(s.w), h: defAnim(s.h), - featherIn: defAnim(s.featherIn), featherOut: defAnim(s.featherOut) + featherIn: defAnim(s.featherIn), featherOut: defAnim(s.featherOut), + ...(s.radius != null ? { radius: defAnim(s.radius) } : {}) })))) } } @@ -777,6 +784,7 @@ export function AnimEditor({ width }: { width: number }) { const shapeTools: { id: Tool; icon: string; title: TKey }[] = [ { id: 'edges', icon: '⊞', title: 'toolEdges' }, { id: 'rect', icon: '▭', title: 'toolRect' }, + { id: 'roundrect', icon: '▢', title: 'toolRoundrect' }, { id: 'ellipse', icon: '◯', title: 'toolEllipse' }, { id: 'triangle', icon: '△', title: 'toolTriangle' } ] @@ -914,6 +922,19 @@ export function AnimEditor({ width }: { width: number }) { onChange={(e) => write(selShapeParams[5], Number(e.target.value))} /> + {sel.type === 'roundrect' && ( + + )}
)} diff --git a/src/engine/anim.ts b/src/engine/anim.ts index 0c1eef3..5a7029d 100644 --- a/src/engine/anim.ts +++ b/src/engine/anim.ts @@ -49,7 +49,10 @@ export function evalAnim(a: Anim | number | undefined, t: number): number { } return k0.value + (k1.value - k0.value) * f } - return k0.value + (k1.value - k0.value) * ease[k0.easing](f) + // easing may be missing/unknown on script-written or foreign keyframes; + // fall back to linear rather than throwing (an unguarded ease[…] would + // blow up drawFrame and black out the whole frame). + return k0.value + (k1.value - k0.value) * (ease[k0.easing] ?? ease.linear)(f) } } return last.value diff --git a/src/engine/demux.ts b/src/engine/demux.ts index 8a28a69..f4f2907 100644 --- a/src/engine/demux.ts +++ b/src/engine/demux.ts @@ -10,6 +10,10 @@ import type { MediaAsset } from '@shared/types' /** non-faststart files keep moov at the end — give up early and fall back */ const HEAD_LIMIT = 16 * 1024 * 1024 +/** watchdog for a decode that emits neither an output frame nor an error + * (wedged hardware decoder) — after this, give up and fall back to element-seek */ +const DECODE_STALL_MS = 8000 + type MP4File = ReturnType interface Sample { @@ -72,8 +76,26 @@ export class Mp4FrameSource { for (const w of ws) w() } + // Resolved by kick() (a decoder output or error). Bounded by a watchdog: + // if neither fires within DECODE_STALL_MS the decoder is wedged — e.g. VAAPI + // init deadlocks on a driver/GPU mismatch and emits no output AND no error. + // On timeout we flag the source fatal and resolve, so the frameAt guard-loop + // throws and the exporter falls back to element-seek. Prevents an infinite + // export hang regardless of GPU/driver. private wait(): Promise { - return new Promise((r) => this.waiters.push(r)) + return new Promise((r) => { + const w = () => { + clearTimeout(timer) + r() + } + const timer = setTimeout(() => { + this.fatal = true + const i = this.waiters.indexOf(w) + if (i >= 0) this.waiters.splice(i, 1) + r() + }, DECODE_STALL_MS) + this.waiters.push(w) + }) } /** diff --git a/src/engine/fragmentCapture.ts b/src/engine/fragmentCapture.ts index e48b0e2..88cdd70 100644 --- a/src/engine/fragmentCapture.ts +++ b/src/engine/fragmentCapture.ts @@ -18,6 +18,21 @@ export interface CaptureFrame { const frames = new Map() const active = new Map() +// Per fragment: the last frame number we asked the offscreen player to show, +// and the capture `version` at the moment we asked. captureReady() then waits +// for a paint that landed AFTER the request, so a snapshot reflects the target +// frame rather than whatever the offscreen happened to be showing. +const syncedFrame = new Map() +const versionAtSync = new Map() + +/** Offscreen player frame for a fragment clip at editor time `playhead`. */ +function frameOf(clip: Clip, playhead: number): number { + const fps = clip.fragmentMeta?.fps ?? 60 + const rel = playhead - clip.start + return Math.max(0, Math.round( + (Math.max(0, Math.min(clip.duration, rel)) * (clip.speed || 1) + clip.inPoint) * fps + )) +} export function getCaptureFrame(fragmentId: string): CaptureFrame | null { return frames.get(fragmentId) ?? null @@ -35,10 +50,15 @@ export function setForceCaptureAll(on: boolean) { reconcileNow?.() } -/** Every fragment wanted at time t is captured and has frames on hand. */ +/** Every fragment wanted at time t is captured AND has painted a frame for the + * current sync target (not just any leftover frame). */ export function captureReady(project: Project, t: number): boolean { for (const id of wanted(project, t).keys()) { - if (!frames.has(id)) return false + const f = frames.get(id) + if (!f) return false + // require a paint newer than the last frame request, so we don't read a + // stale frame (e.g. the offscreen's initial frame before it seeked) + if (f.version <= (versionAtSync.get(id) ?? -1)) return false } return true } @@ -106,17 +126,21 @@ export function wireFragmentCapture() { const s = useEditor.getState() const rel = s.playhead - clip.start const inside = rel >= 0 && rel < clip.duration - const fps = clip.fragmentMeta?.fps ?? 60 const vol = clip.muted || track.muted || !inside ? 0 : Math.min(1, evalAnim(clip.gain, Math.max(0, rel)) * track.gain * fadeFactor(clip, Math.max(0, rel), overlapFades(track, clip))) + const frame = frameOf(clip, s.playhead) + // when the target frame changes, remember the current paint version so + // captureReady() waits for the NEXT paint (the one that shows this frame) + if (syncedFrame.get(fragmentId) !== frame) { + syncedFrame.set(fragmentId, frame) + versionAtSync.set(fragmentId, frames.get(fragmentId)?.version ?? 0) + } window.kadr.fragmentCaptureSync(fragmentId, { kadr: true, type: 'sync', - frame: Math.max(0, Math.round( - (Math.max(0, Math.min(clip.duration, rel)) * (clip.speed || 1) + clip.inPoint) * fps - )), + frame, playing: s.playing && inside, volume: vol }) @@ -129,6 +153,8 @@ export function wireFragmentCapture() { if (!want.has(id)) { active.delete(id) frames.delete(id) + syncedFrame.delete(id) + versionAtSync.delete(id) void window.kadr.fragmentCaptureStop(id) } } @@ -140,8 +166,10 @@ export function wireFragmentCapture() { const meta = clip.fragmentMeta const cw = Math.min(CAPTURE_MAX_W, meta?.width ?? s.project.width) const ch = Math.round(cw * ((meta?.height ?? s.project.height) / (meta?.width ?? s.project.width))) + // seed the player on the target frame so its first paint is correct + const frame = frameOf(clip, s.playhead) await window.kadr.fragmentCaptureStart( - id, `${url}/?comp=${encodeURIComponent(id)}`, cw, ch, meta?.fps ?? 60 + id, `${url}/?comp=${encodeURIComponent(id)}&frame=${frame}`, cw, ch, meta?.fps ?? 60 ) } catch { active.delete(id) diff --git a/src/engine/fragments.ts b/src/engine/fragments.ts index bd0396e..6613054 100644 --- a/src/engine/fragments.ts +++ b/src/engine/fragments.ts @@ -52,7 +52,10 @@ export async function createFragment( if (!(opts.end > opts.start)) throw new Error('empty fragment range') const p = useEditor.getState().project await window.kadr.fragmentEnsure() - const fps = Math.max(60, p.fps) + // Match the project fps. Forcing ≥60 fps doubled the rendered frame count for + // 30 fps projects — and every frame is a full-res PNG on tmpfs, so a 4K + // fragment overflowed /tmp. The project fps is what the timeline samples anyway. + const fps = Math.max(1, p.fps) const info = await window.kadr.fragmentCreate({ name: opts.name, width: p.width, diff --git a/src/engine/mediaImport.ts b/src/engine/mediaImport.ts index be82d6f..722c031 100644 --- a/src/engine/mediaImport.ts +++ b/src/engine/mediaImport.ts @@ -62,6 +62,22 @@ async function importFilesInner( } const existing = st().project.assets.find((a) => a.path === path) if (existing) { + // reuse only if the file on disk hasn't changed; a same-name overwrite + // (new mtime) re-probes and refreshes the asset in place, keeping its id + // so every clip that references it picks up the new pixels/waveform/dims. + const stat = await window.kadr.statMedia(path).catch(() => null) + const unchanged = + !stat || (existing.mtimeMs != null && Math.round(stat.mtimeMs) === Math.round(existing.mtimeMs)) + if (unchanged) { + assetIds.push(existing.id) + continue + } + try { + const { asset } = await window.kadr.probeMedia(path) + st().updateAsset(existing.id, asset) + } catch (err) { + console.error('re-probe failed', path, err) + } assetIds.push(existing.id) continue } diff --git a/src/engine/player.ts b/src/engine/player.ts index 7ef5131..3289081 100644 --- a/src/engine/player.ts +++ b/src/engine/player.ts @@ -225,6 +225,17 @@ export interface BlendFrame { * `blends` adds frame blending: the successor source frame is composited * over the main one with its weight, smoothing fps-mismatch cadence. */ +// Throttled so one persistently-broken clip can't flood the console every frame +// (preview redraws ~continuously; export runs thousands of frames). +let lastLayerErrLog = 0 +function logLayerError(clip: Clip, err: unknown) { + const now = Date.now() + if (now - lastLayerErrLog > 2000) { + lastLayerErrLog = now + console.error(`[kadr] clip ${clip.id} failed to draw (skipped this frame):`, err) + } +} + export function drawFrame( comp: Compositor, project: Project, @@ -246,16 +257,23 @@ export function drawFrame( .sort((a, b) => a.start - b.start) const assetOf = (c: Clip) => c.assetId ? project.assets.find((a) => a.id === c.assetId) : undefined - // clip tails/heads with edge effects render through an offscreen pass + // clip tails/heads with edge effects render through an offscreen pass. + // Guarded per clip: a bad clip (e.g. a keyframe with no easing) must skip + // only itself, not throw out of the layer loop and black out the whole frame + // — comp.begin() already cleared the framebuffer above. const drawWithEdge = (c: Clip) => { - const eff = edgeAt(c, t - c.start) - if (eff && eff.g > 0.003 && eff.g < 0.997) { - comp.beginOverlay(0) - drawClipLayer(comp, project, t, pool, c, track, assetOf(c), 1, frames, blends) - comp.endOverlay() - comp.drawEdgeEffect(eff.type, eff.g, trackOpacity) - } else { - drawClipLayer(comp, project, t, pool, c, track, assetOf(c), trackOpacity, frames, blends) + try { + const eff = edgeAt(c, t - c.start) + if (eff && eff.g > 0.003 && eff.g < 0.997) { + comp.beginOverlay(0) + drawClipLayer(comp, project, t, pool, c, track, assetOf(c), 1, frames, blends) + comp.endOverlay() + comp.drawEdgeEffect(eff.type, eff.g, trackOpacity) + } else { + drawClipLayer(comp, project, t, pool, c, track, assetOf(c), trackOpacity, frames, blends) + } + } catch (err) { + logLayerError(c, err) } } @@ -278,14 +296,22 @@ export function drawFrame( if (c !== pair[0] && c !== pair[1]) drawWithEdge(c) } const [A, B] = pair - const overlapEnd = Math.min(A.start + A.duration, B.start + B.duration) - const p = Math.min(1, Math.max(0, (t - B.start) / Math.max(0.001, overlapEnd - B.start))) - comp.beginOverlay(0) - drawClipLayer(comp, project, t, pool, A, track, assetOf(A), 1, frames, blends) - comp.beginOverlay(1) - drawClipLayer(comp, project, t, pool, B, track, assetOf(B), 1, frames, blends) - comp.endOverlay() - comp.drawTransition(pairType, p, trackOpacity) + try { + const overlapEnd = Math.min(A.start + A.duration, B.start + B.duration) + const p = Math.min(1, Math.max(0, (t - B.start) / Math.max(0.001, overlapEnd - B.start))) + comp.beginOverlay(0) + drawClipLayer(comp, project, t, pool, A, track, assetOf(A), 1, frames, blends) + comp.beginOverlay(1) + drawClipLayer(comp, project, t, pool, B, track, assetOf(B), 1, frames, blends) + comp.endOverlay() + comp.drawTransition(pairType, p, trackOpacity) + } catch (err) { + // a throw mid-transition would leave the frame black; draw the pair + // flat (each clip guarded) instead of losing the whole frame + logLayerError(B, err) + drawWithEdge(A) + drawWithEdge(B) + } } else { for (const c of active) drawWithEdge(c) } @@ -338,13 +364,14 @@ function drawClipLayer( : undefined, shapes: shapes.length ? shapes.map((ms) => ({ - type: (ms.type === 'rect' ? 1 : ms.type === 'ellipse' ? 2 : 3) as 1 | 2 | 3, + type: (ms.type === 'rect' ? 1 : ms.type === 'ellipse' ? 2 : ms.type === 'roundrect' ? 4 : 3) as 1 | 2 | 3 | 4, cx: evalAnim(ms.cx, rel), cy: evalAnim(ms.cy, rel), halfW: Math.max(0, evalAnim(ms.w, rel)) / 2, halfH: Math.max(0, evalAnim(ms.h, rel)) / 2, featherIn: Math.max(0, evalAnim(ms.featherIn, rel)), featherOut: Math.max(0, evalAnim(ms.featherOut, rel)), + radius: ms.radius ? Math.max(0, evalAnim(ms.radius, rel)) : 0, invert: ms.invert })) : undefined diff --git a/src/gl/compositor.ts b/src/gl/compositor.ts index 6d274b2..4907113 100644 --- a/src/gl/compositor.ts +++ b/src/gl/compositor.ts @@ -23,9 +23,10 @@ uniform int uRawBGRA; // 1 = premultiplied BGRA capture frame uniform float uOpacity; uniform vec4 uCrop; // left, top, right, bottom cut fractions uniform int uShapeCount; -uniform int uShapeType[MAX_SHAPES]; // 1 rect, 2 ellipse, 3 triangle +uniform int uShapeType[MAX_SHAPES]; // 1 rect, 2 ellipse, 3 triangle, 4 roundrect uniform vec4 uShapeRect[MAX_SHAPES]; // cx, cy, halfW, halfH in UV uniform vec3 uShapeFeather[MAX_SHAPES];// featherIn, featherOut, invert flag +uniform float uShapeRadius[MAX_SHAPES];// corner radius (y-height units), roundrect only uniform float uAspect; // layer width/height (aspect-correct distances) out vec4 outColor; @@ -61,6 +62,11 @@ void main() { d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0); } else if (uShapeType[i] == 2) { d = (length(p / b) - 1.0) * min(b.x, b.y); + } else if (uShapeType[i] == 4) { + // rounded box: sharp box shrunk by rr then re-expanded (rr=0 ⇒ rect) + float rr = clamp(uShapeRadius[i], 0.0, min(b.x, b.y)); + vec2 q = abs(p) - b + rr; + d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; } else { vec2 v0 = vec2(0.0, -b.y); vec2 v1 = vec2(b.x, b.y); @@ -210,13 +216,15 @@ export interface LayerDraw { } export interface ShapeUniform { - type: 1 | 2 | 3 // rect | ellipse | triangle + type: 1 | 2 | 3 | 4 // rect | ellipse | triangle | roundrect cx: number cy: number halfW: number halfH: number featherIn: number featherOut: number + /** corner radius for roundrect (y-height fractions); ignored by other types */ + radius?: number invert: boolean } @@ -275,6 +283,7 @@ export class Compositor { private uShapeType: WebGLUniformLocation private uShapeRect: WebGLUniformLocation private uShapeFeather: WebGLUniformLocation + private uShapeRadius: WebGLUniformLocation private uAspect: WebGLUniformLocation private textures = new Map() private frame = 0 @@ -307,6 +316,7 @@ export class Compositor { this.uShapeType = gl.getUniformLocation(prog, 'uShapeType[0]')! this.uShapeRect = gl.getUniformLocation(prog, 'uShapeRect[0]')! this.uShapeFeather = gl.getUniformLocation(prog, 'uShapeFeather[0]')! + this.uShapeRadius = gl.getUniformLocation(prog, 'uShapeRadius[0]')! this.uAspect = gl.getUniformLocation(prog, 'uAspect')! this.vbo = gl.createBuffer()! @@ -452,14 +462,17 @@ export class Compositor { const types = new Int32Array(MAX_SHAPES) const rects = new Float32Array(MAX_SHAPES * 4) const feathers = new Float32Array(MAX_SHAPES * 3) + const radii = new Float32Array(MAX_SHAPES) shapes.forEach((s, i) => { types[i] = s.type rects.set([s.cx, s.cy, s.halfW, s.halfH], i * 4) feathers.set([s.featherIn, s.featherOut, s.invert ? 1 : 0], i * 3) + radii[i] = s.radius ?? 0 }) gl.uniform1iv(this.uShapeType, types) gl.uniform4fv(this.uShapeRect, rects) gl.uniform3fv(this.uShapeFeather, feathers) + gl.uniform1fv(this.uShapeRadius, radii) gl.uniform1f(this.uAspect, l.srcWidth / Math.max(1, l.srcHeight)) } gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) diff --git a/src/i18n.ts b/src/i18n.ts index 9f55a8a..8cfd337 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -188,8 +188,10 @@ const ru = { featherOut: 'Растушёвка наружу', toolEdges: 'Края (обрезка сторон)', toolRect: 'Прямоугольник', + toolRoundrect: 'Скруглённый прямоугольник', toolEllipse: 'Круг / эллипс', toolTriangle: 'Треугольник', + shapeRadius: 'Скругление углов', invertMask: 'Исключающий режим', invertHint: 'Фигура вырезает дыру вместо того, чтобы оставлять содержимое', deleteShape: 'Удалить фигуру', @@ -484,8 +486,10 @@ const en: typeof ru = { featherOut: 'Feather outward', toolEdges: 'Edges (side crop)', toolRect: 'Rectangle', + toolRoundrect: 'Rounded rectangle', toolEllipse: 'Circle / ellipse', toolTriangle: 'Triangle', + shapeRadius: 'Corner radius', invertMask: 'Exclude mode', invertHint: 'The shape cuts a hole instead of keeping its inside', deleteShape: 'Delete shape', diff --git a/src/state/store.ts b/src/state/store.ts index 9b604b6..83b393b 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -80,13 +80,21 @@ export function findClip(p: Project, clipId: string): { track: Track; clip: Clip * non-finite values and broken keyframes are dropped — a single NaN reaching * WebAudio used to kill playback for the rest of the session. */ +// The easings evalAnim can interpolate — mirror of `ease` in engine/anim.ts. +// A keyframe carrying anything else (or nothing) is coerced to 'linear' on load. +const KNOWN_EASINGS = new Set(['linear', 'easeIn', 'easeOut', 'easeInOut', 'hold']) + export function sanitizeProject(p: Project): Project { const anim = (a: unknown, fallback: number): Anim => { if (typeof a === 'number') return { value: Number.isFinite(a) ? a : fallback } if (!a || typeof a !== 'object') return { value: fallback } const o = a as Anim if (o.keyframes) { - o.keyframes = o.keyframes.filter((k) => Number.isFinite(k?.time) && Number.isFinite(k?.value)) + o.keyframes = o.keyframes + .filter((k) => Number.isFinite(k?.time) && Number.isFinite(k?.value)) + // heal script-written / foreign keyframes: a missing or unknown `easing` + // makes evalAnim index ease[undefined] and throw, blacking out the frame. + .map((k) => (KNOWN_EASINGS.has(k.easing) ? k : { ...k, easing: 'linear' as const })) if (!o.keyframes.length) delete o.keyframes } if (!Number.isFinite(o.value)) o.value = o.keyframes?.[0]?.value ?? fallback @@ -134,7 +142,8 @@ export function forEachAnim(c: Clip, fn: (a: Anim) => Anim) { ...c.maskShape, cx: fn(c.maskShape.cx), cy: fn(c.maskShape.cy), w: fn(c.maskShape.w), h: fn(c.maskShape.h), - featherIn: fn(c.maskShape.featherIn), featherOut: fn(c.maskShape.featherOut) + featherIn: fn(c.maskShape.featherIn), featherOut: fn(c.maskShape.featherOut), + ...(c.maskShape.radius ? { radius: fn(c.maskShape.radius) } : {}) } } if (c.maskShapes) { @@ -142,7 +151,8 @@ export function forEachAnim(c: Clip, fn: (a: Anim) => Anim) { ...s, cx: fn(s.cx), cy: fn(s.cy), w: fn(s.w), h: fn(s.h), - featherIn: fn(s.featherIn), featherOut: fn(s.featherOut) + featherIn: fn(s.featherIn), featherOut: fn(s.featherOut), + ...(s.radius ? { radius: fn(s.radius) } : {}) })) } } @@ -410,7 +420,9 @@ interface EditorState { insertFragmentClip(fragmentId: string, meta: FragmentSpec, start: number, duration: number): string /** Patch asset metadata (e.g. a freshly built proxy path); no history. */ updateAsset(assetId: string, patch: Partial): void - addTrack(kind: TrackKind): void + /** Add a track. `at` (0 = topmost) overrides the default placement — + video defaults to the top, audio to the bottom. */ + addTrack(kind: TrackKind, at?: number): void addTrackNear(refTrackId: string): void removeTrack(trackId: string): void moveTrack(trackId: string, toIndex: number): void @@ -602,11 +614,14 @@ export const useEditor = create((set, get) => ({ return { project: p } }), - addTrack: (kind) => { + addTrack: (kind, at) => { get().pushHistory('hTrack') set((s) => { const p = clone(s.project) - p.tracks.splice(kind === 'video' ? 0 : p.tracks.length, 0, makeTrack(p, kind)) + const idx = at == null + ? (kind === 'video' ? 0 : p.tracks.length) // default: video top, audio bottom + : Math.max(0, Math.min(p.tracks.length, Math.round(at))) + p.tracks.splice(idx, 0, makeTrack(p, kind)) return { project: p } }) }, diff --git a/src/styles.css b/src/styles.css index d0b5c2b..15adcf6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -939,6 +939,7 @@ button.danger:hover { background: #944; } touch-action: none; } .mask-shape.ellipse { border-radius: 50%; } +.mask-shape.roundrect { border-radius: 14px; } .mask-shape.triangle { clip-path: polygon(50% 0, 100% 100%, 0 100%); border: none; From f15cf55063d8a7cbb941b25244281fbda121e060 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Fri, 21 Aug 2026 11:49:30 +0500 Subject: [PATCH 18/28] fix: write export intermediates to a disk-backed dir, not the /tmp tmpfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw-video encode (preload) and the mux temp (main) went to os.tmpdir(), which is a small RAM-backed tmpfs on many Linux setups (here /tmp is a 7.7 GB tmpfs). A large export overflows it mid-run and ffmpeg dies with EDQUOT/ENOSPC ("Disk quota exceeded", errno -122) — which surfaced as an export that stalled right after the start, or at ~67% once the intermediate had grown. runtime-env.ts now points KADR_TMPDIR at /tmp (disk-backed); preload.ts and main.ts use it with an os.tmpdir() fallback, and the renderer inherits the env like KADR_FFMPEG. Co-Authored-By: Claude Opus 4.8 --- electron/main.ts | 4 +++- electron/preload.ts | 4 +++- electron/runtime-env.ts | 15 ++++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 938512a..99cc434 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -729,7 +729,9 @@ function registerIpc() { ipcMain.handle('export:begin', async (_e, job: ExportJob) => { await cleanupExport() - const videoTemp = join(tmpdir(), `kadr-export-${Date.now()}.mp4`) + // disk-backed temp (KADR_TMPDIR) — os.tmpdir() is often a small tmpfs (see + // runtime-env.ts); a multi-GB export intermediate overflows it otherwise + const videoTemp = join(process.env.KADR_TMPDIR || tmpdir(), `kadr-export-${Date.now()}.mp4`) const fh = job.preset.audioOnly ? null : await fs.open(videoTemp, 'w') exportState = { job, videoTemp, fh, muxer: null, raw: null, rawEncoded: false, diff --git a/electron/preload.ts b/electron/preload.ts index 1c4324e..461fcb9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -16,7 +16,9 @@ let rawEncExit: Promise | null = null const api: KadrApi = { rawEncodeStart: (o) => { - const out = join(tmpdir(), `kadr-export-raw-${Date.now()}.mp4`) + // disk-backed temp (KADR_TMPDIR) — os.tmpdir() is often a small tmpfs that a + // big export overflows mid-encode (see runtime-env.ts) + const out = join(process.env.KADR_TMPDIR || tmpdir(), `kadr-export-raw-${Date.now()}.mp4`) const child = spawn(process.env.KADR_FFMPEG || 'ffmpeg', rawEncodeArgs({ ...o, out }), { stdio: ['pipe', 'ignore', 'pipe'] }) diff --git a/electron/runtime-env.ts b/electron/runtime-env.ts index 812983e..0a09047 100644 --- a/electron/runtime-env.ts +++ b/electron/runtime-env.ts @@ -11,7 +11,7 @@ // (the renderer inherits this env when its process is spawned). In dev // (`app.isPackaged` false) nothing changes — the host toolchain is used as before. import { app } from 'electron' -import { readFileSync } from 'fs' +import { readFileSync, mkdirSync } from 'fs' import { homedir } from 'os' import { join } from 'path' @@ -30,6 +30,19 @@ import { join } from 'path' process.env.PULSE_PROP_OVERRIDE ??= 'application.name=Kadr' process.env.PULSE_PROP ??= 'application.name=Kadr' +// Export/render intermediates (the raw-video encode and the mux temp) can be +// multi-GB. os.tmpdir() is a small tmpfs on many Linux setups — here /tmp is a +// 7.7 GB RAM-backed tmpfs — so a big export fills it mid-run and ffmpeg dies with +// EDQUOT/ENOSPC ("Disk quota exceeded", errno -122). Route the temps to a +// disk-backed dir under userData instead; preload.ts and main.ts read +// KADR_TMPDIR (falling back to os.tmpdir()), and the renderer inherits this env +// when its process is spawned. Set for dev too — same tmpfs trap there. +try { + const t = join(app.getPath('userData'), 'tmp') + mkdirSync(t, { recursive: true }) + process.env.KADR_TMPDIR ??= t +} catch { /* fall back to os.tmpdir() at the call sites */ } + if (app.isPackaged) { const runtime = join(process.resourcesPath, 'runtime') const bin = join(runtime, 'bin') From 55cd8475f1e0c70d4544d088530b2ecd182ace57 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Fri, 21 Aug 2026 12:05:22 +0500 Subject: [PATCH 19/28] fix: never hang on export errors; sweep crashed export temps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - demux Mp4FrameSource.open() gets a 10s watchdog — a wedged decoder init could hang here (the only decode path not already bounded), freezing the export with no error. On timeout it falls back to element-seek, so the export always makes progress or surfaces an error instead of stalling on the last rendered frame. - main sweeps orphaned kadr-export[-raw]-.mp4 intermediates (multi-GB) at startup, older than 60s so a concurrent live export is never touched. Covers KADR_TMPDIR and os.tmpdir(). Co-Authored-By: Claude Opus 4.8 --- electron/main.ts | 24 ++++++++++++++++++++++++ src/engine/demux.ts | 24 +++++++++++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 99cc434..81ede43 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -200,6 +200,7 @@ app.whenReady().then(() => { registerClaudeIpc(() => win) registerTranscribeIpc(() => win) registerFragmentIpc(() => win) + void sweepExportTemps() // reclaim multi-GB export intermediates left by a crash createWindow() // log which GPU Chromium actually settled on — the diagnostic for the GPU // picker (0x8086 = Intel, 0x10de = NVIDIA, 0x1002 = AMD) @@ -861,3 +862,26 @@ async function cleanupExport() { try { await st.fh?.close() } catch { /* already closed */ } try { await fs.unlink(st.videoTemp) } catch { /* never created */ } } + +// Delete raw-export / mux temp files (kadr-export[-raw]-.mp4) orphaned by a +// previous crash or hard kill — these intermediates can be multi-GB. Only files +// older than a minute are touched, so a concurrent instance's live export (temps +// named by Date.now()) is never removed. Covers KADR_TMPDIR and os.tmpdir() +// (leftovers from before temps moved off the tmpfs). +async function sweepExportTemps() { + const cutoff = Date.now() - 60_000 + const dirs = new Set([process.env.KADR_TMPDIR, tmpdir()].filter(Boolean) as string[]) + for (const dir of dirs) { + let names: string[] + try { + names = await fs.readdir(dir) + } catch { continue } // dir missing — nothing to sweep + for (const name of names) { + if (!/^kadr-export(-raw)?-\d+\.mp4$/.test(name)) continue + const p = join(dir, name) + try { + if ((await fs.stat(p)).mtimeMs < cutoff) await fs.unlink(p) + } catch { /* raced with another cleanup */ } + } + } +} diff --git a/src/engine/demux.ts b/src/engine/demux.ts index f4f2907..46c670c 100644 --- a/src/engine/demux.ts +++ b/src/engine/demux.ts @@ -14,6 +14,10 @@ const HEAD_LIMIT = 16 * 1024 * 1024 * (wedged hardware decoder) — after this, give up and fall back to element-seek */ const DECODE_STALL_MS = 8000 +/** watchdog for demux/decoder init (moov fetch + configure) — a wedged decoder + * can hang here with no output and no error; bound it so open() falls back */ +const OPEN_TIMEOUT_MS = 10000 + type MP4File = ReturnType interface Sample { @@ -57,14 +61,20 @@ export class Mp4FrameSource { private scratch: Uint8Array | null = null static async open(asset: MediaAsset): Promise { + const src = new Mp4FrameSource(asset) try { - const src = new Mp4FrameSource(asset) - if (await src.init()) return src - src.close() - return null - } catch { - return null - } + // init pulls the moov and configures the decoder; on a wedged GPU/decoder + // it can hang with neither an output nor an error. Bound it so the export + // never stalls here — a timeout just falls back to the element-seek path. + const ok = await Promise.race([ + src.init(), + new Promise((_, rej) => + setTimeout(() => rej(new Error('demux init timeout')), OPEN_TIMEOUT_MS)) + ]) + if (ok) return src + } catch { /* fall through to cleanup + element-seek fallback */ } + src.close() + return null } private constructor(asset: MediaAsset) { From ec88fc428e44053fe39f2bec18a718681cfcabea Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Sat, 5 Sep 2026 03:48:54 +0500 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20audio=20clip=20with=20inPoint>0=20?= =?UTF-8?q?jumped=20to=20t=3D0=20in=20export=20=E2=80=94=20bound=20the=20p?= =?UTF-8?q?ad,=20drop=20trailing=20atrim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real root cause of the audio-collapse bug (reproduced on the user's actual sources, not synthetic ones): the per-segment chain ended in an unbounded `apad` followed by `atrim=0:job.duration`. On an INPUT-SEEKED segment (`-ss inPoint -i file`, i.e. any clip with inPoint > 0) that trailing atrim threw the delayed audio back to t=0 and left its real slot silent. It was not a PTS problem: re-zeroing PTS (asetpts=PTS-STARTPTS, even asetpts=N/SR/TB) with the atrim still present still collapsed; every chain WITHOUT the trailing atrim placed the clip correctly. Replace `apad` + `atrim` with `apad=whole_dur=`: each stream is padded to exactly the export length and EOFs, so amix (duration=longest) still terminates (verified 2-input mix completes in 50 ms), and nothing trims after adelay. asetpts stays as a harmless start-PTS normalization. Verified on the reporting project: clips at 25.667 s (inPoint 4.219) and 28.55 s (inPoint 3.033) now land in place instead of at 0. Co-Authored-By: Claude Fable 5.1 --- electron/ffmpeg.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/electron/ffmpeg.ts b/electron/ffmpeg.ts index 6fe7ebc..6d54a8d 100644 --- a/electron/ffmpeg.ts +++ b/electron/ffmpeg.ts @@ -452,12 +452,10 @@ export class ExportMuxer { const outDur = s.duration / speed // timeline-domain length after atempo const chain = [ 'aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo', - // Input-seeked real files (`-ss inPoint -i file`) hand filter_complex a - // stream whose first sample carries a non-zero source PTS. adelay/atrim - // then work in that shifted time base and the per-clip delay is lost, so - // amix stacks every clip at t=0 (all audio collapses to the start). Zero - // the PTS here — as a lavfi source already is — so adelay places the clip - // at `ms` and atrim frames [0, job.duration) correctly. + // Normalize the start PTS to 0 (a lavfi source already is). Defensive: + // some containers hand filter_complex a non-zero first PTS after an + // input seek. NOTE this alone does NOT fix the "clip jumps to t=0" bug — + // see the pad below for the actual cause. 'asetpts=PTS-STARTPTS', `volume=${s.gain.toFixed(4)}`, ...(Math.abs(speed - 1) > 1e-4 ? atempoChain(speed) : []), @@ -466,8 +464,16 @@ export class ExportMuxer { ? [`afade=t=out:st=${Math.max(0, outDur - s.fadeOut).toFixed(3)}:d=${Math.min(s.fadeOut, outDur).toFixed(3)}`] : []), `adelay=${ms}|${ms}`, - 'apad', - `atrim=0:${job.duration.toFixed(3)}` + // Pad each stream to exactly job.duration with a BOUNDED pad so amix + // (duration=longest) reaches EOF — instead of an unbounded `apad` + // followed by `atrim=0:job.duration`. That trailing atrim is the real + // cause of the audio-collapse bug: on an input-seeked segment + // (`-ss inPoint -i file`, i.e. inPoint > 0) it throws the delayed audio + // back to t=0 and leaves its slot silent — even with PTS re-zeroed + // (verified: asetpts=N/SR/TB + atrim still collapses; any chain without + // the trailing atrim places the clip correctly). Segments are already + // range-bounded, so nothing here can exceed job.duration. + `apad=whole_dur=${job.duration.toFixed(3)}` ] filters.push(`[${idx}:a]${chain.join(',')}[a${i}]`) labels.push(`[a${i}]`) From f90f59559db43f79a91bfec5e928005ed1159fba Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 08:30:58 +0500 Subject: [PATCH 21/28] test(e2e24): expect fragments at the project fps, not a forced 60 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fragments are created at the project fps (a 30 fps project no longer renders its fragments at 60 — that doubled the PNG frame pile with no visible gain in a 30 fps export). The suite asserted the old forced-60 policy in two checks; both now compare against the project's own fps. Co-Authored-By: Claude Fable 5.1 --- scripts/e2e24.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/e2e24.mjs b/scripts/e2e24.mjs index fc1495f..8e74e8e 100644 --- a/scripts/e2e24.mjs +++ b/scripts/e2e24.mjs @@ -1,5 +1,5 @@ // Test: Remotion fragments — create over a range (clip on a fresh top -// track, ≥60 fps meta), live preview overlay iframe, hot file edits keep it +// track, meta at the project fps), live preview overlay iframe, hot file edits keep it // alive, one-shot final render (vp9 alpha webm), export materialization // composites the fragment over a video, MCP exposes kadr_fragment_create. // Requires the workspace to be installed (first fragmentEnsure run). @@ -114,11 +114,13 @@ try { const overlapping = track?.clips.filter(c => c.id !== r.clipId && c.start < 2 && c.start + c.duration > 1).length return { id: r.id, entry: r.entry, kind: clip?.kind, fps: clip?.fragmentMeta?.fps, - trackKind: track?.kind, overlapping } + projectFps: st().project.fps, trackKind: track?.kind, overlapping } })()`) fragId = created.id + // the fragment samples at the project fps: forcing ≥60 doubled the PNG frame + // pile for 30 fps projects with no visible gain in a 30 fps export check('fragment created as a remotion clip on a free video track', - created.kind === 'remotion' && created.fps >= 60 && + created.kind === 'remotion' && created.fps === created.projectFps && created.trackKind === 'video' && created.overlapping === 0, JSON.stringify(created)) @@ -189,8 +191,8 @@ try { partsOf().length === 0 && existsSync(rendered.path), partsOf().join(',')) const probe = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'stream=codec_name,width,r_frame_rate', '-of', 'csv', rendered.path]).toString() - check('render is vp9 at 60 fps, project width', - probe.includes('vp9') && probe.includes('60/1'), probe.trim().split('\n')[0]) + check('render is vp9 at the project fps, project width', + probe.includes('vp9') && probe.includes(`${created.fps}/1`), probe.trim().split('\n')[0]) // 5) export composites the fragment over an underlying video await evalJs(`(async () => { From 46ed51ef738511d342b78ce55599b301c755578b Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 08:30:58 +0500 Subject: [PATCH 22/28] fix(export): alpha matte was range-compressed twice in the packed intermediate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed colour-over-matte intermediate built its matte with `alphaextract,format=yuv420p,scale=in_range=pc:out_range=tv`. On current swscale (system ffmpeg 8.1 and the bundled BtbN build alike) format=yuv420p already maps the gray alpha into limited range, so the explicit pc→tv scale after it compressed the matte a second time: α 255 landed at Y=218 instead of 235, i.e. every opaque area of an alpha source composited at ~92 % (a 255 red came out at 229 — e2e32's "packed vs element" checks). Older swscale copied gray→yuv420p verbatim, which is why the suite passed where it was written. Put the range compression BEFORE format=. New swscale then sees a tv-tagged input and leaves it alone; old swscale still gets its single compression — 235/127/71 for α 255/129/64 on both ffmpeg builds here. The packed cache suffix moves ':p' → ':p2' so intermediates packed with the double compression miss the cache and re-pack. Measured (e2e32 on this machine): packed opaque red 229 → 250 (element 251), PSNR 37.3 → 44.3 dB (threshold 40). The remaining bitwise-equality check still differs by 1–5 levels on saturated primaries — rounding of the packed path's bt601→bt709→RGB double conversion vs the element path's single one on newer swscale; the PSNR gate is upstream's own quality criterion and passes. Co-Authored-By: Claude Fable 5.1 --- electron/cacheKeys.ts | 4 +++- electron/ffmpeg.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/electron/cacheKeys.ts b/electron/cacheKeys.ts index 300f917..eee8a97 100644 --- a/electron/cacheKeys.ts +++ b/electron/cacheKeys.ts @@ -30,8 +30,10 @@ export function mediaCacheKey( export const proxySuffix = (alpha?: boolean) => (alpha ? ':a' : '') /** decoded: packed colour+matte, plain alpha, or neither */ +// ':p2' — packed-matte generation: intermediates packed before the matte +// range fix (double pc→tv compression) must miss the cache and re-pack. export const decodedSuffix = (opts?: { alpha?: boolean; packed?: boolean }) => - opts?.packed ? ':p' : opts?.alpha ? ':a' : '' + opts?.packed ? ':p2' : opts?.alpha ? ':a' : '' /** reversed: the same source cut differently is a different render */ export const reverseSuffix = (start: number, duration: number) => diff --git a/electron/ffmpeg.ts b/electron/ffmpeg.ts index e9f6692..dc30353 100644 --- a/electron/ffmpeg.ts +++ b/electron/ffmpeg.ts @@ -346,7 +346,14 @@ export async function makeDecoded( `[c]format=yuv420p,scale=in_color_matrix=${opts.matrix || 'bt601'}:out_color_matrix=bt709[col];` + // the matte rides in the luma plane, mapped into the same limited range // the decoder will expand back — verified to round-trip bit-exactly - '[a]alphaextract,format=yuv420p,scale=in_range=pc:out_range=tv[m];' + + // Range-compress the matte BEFORE format=yuv420p. Newer swscale already + // maps gray→yuv420p into limited range, so a scale=pc→tv placed after it + // compressed the matte TWICE (255 → 235 → 218): every opaque area of an + // alpha source composited at ~92 % (a 255 red came out at 229). With the + // explicit scale first, format= sees a tv-tagged input and leaves it alone + // on new swscale, while old swscale (a plain copy) still gets the one + // compression it needs — 235/127/71 for α 255/129/64 on both. + '[a]alphaextract,scale=in_range=pc:out_range=tv,format=yuv420p[m];' + '[col][m]vstack=inputs=2[v]', '-map', '[v]', '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-qp', '0', '-pix_fmt', 'yuv420p', From f9d62695e5d1d64ff87a7d1d4e9d245d092924a6 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 09:48:22 +0500 Subject: [PATCH 23/28] ui(settings): the GPU picker joins the design system, with a video-card icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings dialog was the last one still drawing its own .modal-back / .modal-actions and an inline marginTop, so it missed everything the shared Modal brought in v0.5.0: role/aria-modal, a labelled head, Escape, a focus trap, focus restored, and a footer that holds still while the body scrolls. It now uses like the other eight, and its notes use the same `dim hint-inline` the other dialogs do instead of local spacing. The toolbar button was a bare ⚙ emoji — the one control in that row that did not come from the icon set, and a gear says "settings" without saying which. It is now icon-only with a new `gpu` glyph, and carries an aria-label like its neighbours (it only had a title). The glyph is not in lucide: a board with its two fans. A first draft (one fan plus two port lines) read as a contact card at 17px; the dual-fan silhouette is what makes it a video card at a glance. Verified live over CDP: role=dialog aria-modal=true, labelled title with the icon, 3 trapped focusables, Escape closes and focus returns to the button, and the picker still lists auto/Intel/NVIDIA with the active renderer. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 10 ++++- src/components/SettingsDialog.tsx | 64 ++++++++++++++++--------------- src/components/icons.tsx | 8 ++++ 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5c3b5fa..3319e44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -362,8 +362,14 @@ export default function App() { > Claude -
diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 3c234d9..d456b80 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import type { GpuInfo } from '@shared/types' import { useEditor } from '@/state/store' import { useT } from '@/i18n' +import { Modal } from './Modal' +import { Icon } from './icons' /** Read the currently-active WebGL renderer string (e.g. "Mesa Intel(R) UHD * Graphics 630 …" or an NVIDIA string) so the user can confirm a GPU switch @@ -78,41 +80,41 @@ export function SettingsDialog() { } return ( -
-
e.stopPropagation()}> -

{t('settings')}

- - - - {renderer &&
{t('gpuActive')}: {renderer}
} - {reverted &&
{t('gpuFailedRevert')}
} -
{t('gpuEncodeNote')}
- - {trialPending && !changed && ( -
{t('gpuTrialAsk')}
- )} - {changed && ( -
{t('gpuRestartHint')}
- )} - -
+ } + wide + actions={ + <> + {trialPending && !changed && ( )} {changed && ( - + )} - -
-
-
+ + } + > + + + {renderer &&
{t('gpuActive')}: {renderer}
} + {reverted &&
{t('gpuFailedRevert')}
} +
{t('gpuEncodeNote')}
+ + {trialPending && !changed &&
{t('gpuTrialAsk')}
} + {changed &&
{t('gpuRestartHint')}
} + ) } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index d4cdce0..a318d94 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -100,6 +100,14 @@ const GLYPHS: Record = { glow: ( <> ), + // not in lucide: a graphics card — a board with its two fans. The GPU picker + // is the only place in the UI that means "this physical device", and + // `settings` (the gear) said nothing about which setting. One fan plus two + // port lines read as a contact card at 17px; the dual-fan silhouette is what + // makes it a video card at a glance. + gpu: ( + <> + ), grip: ( <> ), From 6021ef243f1f2b9a1e6f84cb5b873d1eab3851e7 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 10:15:34 +0500 Subject: [PATCH 24/28] fix(gpu): wait for the old process to die before the relaunched one starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both relaunch paths — the GPU switch (gpu:relaunch) and the PRIME re-exec that moves Chromium onto the discrete card — spawned the successor and only then called app.exit(0), so for a moment two processes were alive and the newcomer inherited whatever singleton the old one still held. The visible casualty was --remote-debugging-port. The new process bound the socket but could never accept on it: ss showed LISTEN with Recv-Q at the backlog (11/10) while every CDP client hung on connect, and DevToolsActivePort still named a port nothing would answer on. That silently kills the e2e suites and any automation against a relaunched app — reproduced here by switching NVIDIA → Intel from the settings dialog. The successor now carries KADR_WAIT_PID and blocks at the top of runtime-env — the first import, before Chromium initialises anything — until that pid is gone (signal 0 as the liveness probe, 50 ms steps). Capped at 10 s so a wedged predecessor cannot hold the app hostage; the cap degrades to exactly the race we had before, never worse. Found by the user watching a switch, not by a test. Co-Authored-By: Claude Opus 5 --- electron/gpu.ts | 4 +++- electron/main.ts | 5 ++++- electron/runtime-env.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index ff72088..1cacd9d 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -110,7 +110,9 @@ function relaunchWithOffload(): void { const env: NodeJS.ProcessEnv = { ...process.env, ...NVIDIA_ENV, - KADR_GPU_OFFLOAD: '1' + KADR_GPU_OFFLOAD: '1', + // hold the successor at the top of runtime-env until we are gone + KADR_WAIT_PID: String(process.pid) } // load the built renderer (out/), not a dev-server URL that dies on relaunch delete env.ELECTRON_RENDERER_URL diff --git a/electron/main.ts b/electron/main.ts index b86899e..94fd70e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -651,7 +651,10 @@ function registerIpc() { // Intel runs native Wayland; the new process re-adds x11 only if it picks NVIDIA const relaunchArgs = process.argv.slice(1).filter((a) => !a.startsWith('--ozone-platform')) spawn(process.execPath, relaunchArgs, { - env: cleanRelaunchEnv(), + // KADR_WAIT_PID holds the successor at the top of runtime-env until this + // process is gone — otherwise the two overlap and the new one inherits a + // half-released --remote-debugging-port it can bind but never accept on + env: { ...cleanRelaunchEnv(), KADR_WAIT_PID: String(process.pid) }, detached: true, stdio: 'ignore' }).unref() diff --git a/electron/runtime-env.ts b/electron/runtime-env.ts index 0a09047..063a76a 100644 --- a/electron/runtime-env.ts +++ b/electron/runtime-env.ts @@ -27,6 +27,33 @@ import { join } from 'path' // application.icon_name is stamped by Chromium on the stream itself and can't be // overridden via env, so the mixer icon stays Chromium's; the Kadr logo still // shows in the app menu, task bar and window from the packaged icon.) +// A relaunch — the GPU switch, or the PRIME re-exec that puts Chromium on the +// discrete card — spawns its successor and only then exits, so for a moment +// both processes are alive and the newcomer loses whatever singleton the old +// one still holds. The visible casualty is --remote-debugging-port: the new +// process binds the socket but can never accept on it (Recv-Q fills to the +// backlog and every CDP client hangs), which silently kills the e2e suites and +// any automation. Block here — before Chromium initialises anything — until the +// predecessor is really gone. Capped, so a stuck old process can't hold the app +// hostage; the worst case is the race we already had. +{ + const waitPid = Number(process.env.KADR_WAIT_PID) + delete process.env.KADR_WAIT_PID // must not be inherited further down the chain + if (waitPid > 0) { + const sleep = (ms: number) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + try { + process.kill(waitPid, 0) // signal 0: liveness probe, delivers nothing + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ESRCH') break // gone + } + sleep(50) + } + } +} + process.env.PULSE_PROP_OVERRIDE ??= 'application.name=Kadr' process.env.PULSE_PROP ??= 'application.name=Kadr' From c00f4927ac3d2ca69def1685edec278d127abe93 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 10:30:59 +0500 Subject: [PATCH 25/28] fix(export): cancelling an export no longer files three failures in the log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cancelled export put three "сбой" rows in the session log and bumped the unseen badge, on a run that ended exactly as asked: Uncaught Error: write EPIPE необработанный отказ промиса (x2) Neither is a real failure, and together they teach people to ignore the panel v0.5.0 added to surface real ones. - The two rejections are the frames still in flight. The export keeps two raw buffers in the air (slotPending) and awaits a slot only before reusing it, so when the encoder is killed both pending rawEncodeFrame promises reject with nothing left to await them. The finally block now claims them; the run's outcome is already decided by then. - The EPIPE is ffmpeg's stdin breaking under SIGKILL. The stream had no 'error' listener, so Node escalated it to an uncaught exception — while the same failure was already being delivered through the write callback and rawEncExit, where it can actually be acted on. Verified live over CDP against a running editor: export a clip, cancel it mid-flight, count new log rows. Three before, one after the exporter fix (the rejections gone), and the last one needs a restart to pick up the preload side. The export itself still reports 'cancelled' throughout. Same three rows also appeared in e2e35, which kills the GL context mid-export — that is what put them in front of us. Co-Authored-By: Claude Opus 5 --- electron/preload.ts | 6 ++++++ src/engine/exporter.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/electron/preload.ts b/electron/preload.ts index 2eb8473..4517a7d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,6 +29,12 @@ const api: KadrApi = { rawEnc = child rawEncErr = '' child.stderr!.on('data', (c) => { rawEncErr += c }) + // Killing the encoder (a cancelled or aborted export) breaks this pipe + // mid-write, and a stdin stream with no 'error' listener turns that EPIPE + // into an uncaught exception — three "сбой" rows in the session log every + // time someone cancels an export. The failure is already delivered where it + // can be acted on: the write callback below, and rawEncExit. + child.stdin!.on('error', () => { /* see rawEncodeFrame / rawEncExit */ }) rawEncExit = new Promise((resolve, reject) => { child.on('close', (code) => { rawEnc = null diff --git a/src/engine/exporter.ts b/src/engine/exporter.ts index b14a223..922bda4 100644 --- a/src/engine/exporter.ts +++ b/src/engine/exporter.ts @@ -472,6 +472,12 @@ export function startExport( await window.kadr.exportCancel().catch(() => { /* already gone */ }) throw err } finally { + // Frames still in flight when the run ended (cancelled, or aborted on a + // lost context) reject once the encoder is killed, and nothing awaits + // them any more — each became an "unhandled promise rejection" in the + // session log. The run's outcome is already decided; claim them so they + // die quietly. + for (const p of slotPending) p?.catch(() => { /* run already over */ }) try { rawWs?.close() } catch { /* already closed */ } try { encoder?.close() } catch { /* already closed */ } for (const src of sources.values()) src?.close() From aa79eaf62c30830fce7e17dc0162b4efbf599d7e Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 10:39:56 +0500 Subject: [PATCH 26/28] ui: project format dialog joins the design system, and stops being a second New Project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The format picker was still drawing its own .modal-back / .modal-actions, so it missed what the shared Modal brought in v0.5.0: role/aria-modal, a labelled head, Escape, a focus trap, focus restored, and a footer that holds still. It now uses like every other dialog — that was the last hand-rolled one. It also fixes a duplicate the merge left behind. Upstream's file toolbar has its own New Project button that builds a project immediately at the default size; ours sat further along the same bar under the SAME label, opening the format picker. Two identically-labelled buttons doing different things, and the first one silently discarded the choice the feature exists to offer. The toolbar button now opens the picker and the stray duplicate is gone — one entry point, keeping upstream's placement and icon. Found while screenshotting the dialog over CDP: the click landed on the other button and no dialog appeared. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 11 +----- src/components/ProjectFormatDialog.tsx | 54 +++++++++++++++----------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3319e44..00363a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -311,13 +311,7 @@ export default function App() { - - + } + wide + actions={ + <> - - - + + + } + > + + ) } From 0887094054aac2903df4edd0cdf58cd1ff12b2ed Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 11:05:29 +0500 Subject: [PATCH 27/28] Revert the relaunch wait: it fixed a race that was not the cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6021ef2 held the successor until its predecessor exited, on the theory that the two overlapping was what cost a relaunched app its --remote-debugging-port. Measurement says otherwise, so the wait comes out and the real cause is recorded where the spawn happens. What the port actually loses: Chromium's DevTools listening socket has no CLOEXEC, so spawn() hands the child a duplicate fd. The child's own bind then fails with EADDRINUSE against itself — no DevTools server starts, the port file is never rewritten (its mtime predates the process by 31 s), and the socket sits in LISTEN with nobody accepting: Recv-Q climbs to the backlog and every CDP client hangs. Proof it is the same socket rather than a failed re-bind: the inode is IDENTICAL either side of a switch (10441318 → 10441318), where a fresh bind would allocate a new one. The fd is duplicated at spawn, before any wait could run, so waiting cannot help. The wait itself did work as written — with KADR_WAIT_PID pinned to pid 1 the port went LISTEN at 10.38 s against a 10 s block, so it does gate startup. It just gates the wrong thing, and costs up to 10 s on every relaunch to do it. Impact is dev-only: nothing binds that port unless the run asked for one, so users never meet this. Relaunching from a terminal gives automation its port back. Co-Authored-By: Claude Opus 5 --- electron/gpu.ts | 4 +--- electron/main.ts | 15 +++++++++++---- electron/runtime-env.ts | 27 --------------------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/electron/gpu.ts b/electron/gpu.ts index 1cacd9d..ff72088 100644 --- a/electron/gpu.ts +++ b/electron/gpu.ts @@ -110,9 +110,7 @@ function relaunchWithOffload(): void { const env: NodeJS.ProcessEnv = { ...process.env, ...NVIDIA_ENV, - KADR_GPU_OFFLOAD: '1', - // hold the successor at the top of runtime-env until we are gone - KADR_WAIT_PID: String(process.pid) + KADR_GPU_OFFLOAD: '1' } // load the built renderer (out/), not a dev-server URL that dies on relaunch delete env.ELECTRON_RENDERER_URL diff --git a/electron/main.ts b/electron/main.ts index 94fd70e..8719437 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -650,11 +650,18 @@ function registerIpc() { // drop any --ozone-platform=x11 the NVIDIA path added, so a switch back to // Intel runs native Wayland; the new process re-adds x11 only if it picks NVIDIA const relaunchArgs = process.argv.slice(1).filter((a) => !a.startsWith('--ozone-platform')) + // KNOWN, dev-only: a relaunch loses --remote-debugging-port. Chromium's + // DevTools listening socket has no CLOEXEC, so the child inherits the fd; + // its own bind then fails with EADDRINUSE against itself, no DevTools + // server starts (DevToolsActivePort is never rewritten), and the socket + // sits in LISTEN with nobody accepting — Recv-Q climbs to the backlog and + // every CDP client hangs. Measured: the socket inode is IDENTICAL either + // side of a switch, so it is the same socket, not a re-bind. Waiting for + // this process to exit first does NOT help — the fd is duplicated at spawn, + // long before any wait. Only affects runs started with a debugging port + // (e2e, automation); relaunch from the terminal to get one back. spawn(process.execPath, relaunchArgs, { - // KADR_WAIT_PID holds the successor at the top of runtime-env until this - // process is gone — otherwise the two overlap and the new one inherits a - // half-released --remote-debugging-port it can bind but never accept on - env: { ...cleanRelaunchEnv(), KADR_WAIT_PID: String(process.pid) }, + env: cleanRelaunchEnv(), detached: true, stdio: 'ignore' }).unref() diff --git a/electron/runtime-env.ts b/electron/runtime-env.ts index 063a76a..0a09047 100644 --- a/electron/runtime-env.ts +++ b/electron/runtime-env.ts @@ -27,33 +27,6 @@ import { join } from 'path' // application.icon_name is stamped by Chromium on the stream itself and can't be // overridden via env, so the mixer icon stays Chromium's; the Kadr logo still // shows in the app menu, task bar and window from the packaged icon.) -// A relaunch — the GPU switch, or the PRIME re-exec that puts Chromium on the -// discrete card — spawns its successor and only then exits, so for a moment -// both processes are alive and the newcomer loses whatever singleton the old -// one still holds. The visible casualty is --remote-debugging-port: the new -// process binds the socket but can never accept on it (Recv-Q fills to the -// backlog and every CDP client hangs), which silently kills the e2e suites and -// any automation. Block here — before Chromium initialises anything — until the -// predecessor is really gone. Capped, so a stuck old process can't hold the app -// hostage; the worst case is the race we already had. -{ - const waitPid = Number(process.env.KADR_WAIT_PID) - delete process.env.KADR_WAIT_PID // must not be inherited further down the chain - if (waitPid > 0) { - const sleep = (ms: number) => - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) - const deadline = Date.now() + 10_000 - while (Date.now() < deadline) { - try { - process.kill(waitPid, 0) // signal 0: liveness probe, delivers nothing - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ESRCH') break // gone - } - sleep(50) - } - } -} - process.env.PULSE_PROP_OVERRIDE ??= 'application.name=Kadr' process.env.PULSE_PROP ??= 'application.name=Kadr' From 7e40201a41e3c3f8215fb42e4072213fc73ef928 Mon Sep 17 00:00:00 2001 From: Ashurov Abdulaziz Date: Tue, 8 Sep 2026 11:49:51 +0500 Subject: [PATCH 28/28] pack: ship the runtime files the app reads from disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder's whitelist listed only out/, mcp-bridge.cjs and transcribe.py, but four more paths are read at runtime through app.getAppPath(). A packaged build therefore started fine and failed the feature instead: electron/kadr-skill.md claude.ts → "skill sync failed: ENOENT" scripts/ttsqc_run.py voice.ts → no driver for defect detection python/** voice.ts → KADR_TTSQC_HOME points at nothing python/models/scorer.pkl voice.ts → no model kadr-skill.md has been missing since v0.3.0 added it; the rest arrived with the v0.5.1 merge. Caught by the ENOENT in a packaged run, not by a test — nothing exercises the packaged layout. 2.2 MB in total, 1.9 MB of it the model. python3 is already in the bundled runtime, so the detector has an interpreter to run under. Co-Authored-By: Claude Opus 5 --- electron-builder.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index effd879..a0cd63a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,7 +19,15 @@ asar: false files: - out/**/* - electron/mcp-bridge.cjs + # every one of these is read from disk at runtime by app.getAppPath(), so a + # build without them starts fine and then fails the feature: the Claude panel + # cannot sync its skill ("skill sync failed: ENOENT"), and voice-defect + # detection has no driver, no package and no model. 2.2 MB in total, of which + # 1.9 MB is python/models. + - electron/kadr-skill.md - scripts/transcribe.py + - scripts/ttsqc_run.py + - python/**/* - package.json # Bundled runtime + Remotion seed land under resources/ (process.resourcesPath).