Compare commits

...
Author SHA1 Message Date
SiddharthandClaude Opus 4.7 4a0878c3d0 add homebrew cask bump workflow
Auto-updates the openscreen Homebrew tap on each published release:
finds the macOS DMGs, computes sha256, and rewrites Casks/openscreen.rb
in siddharthvaddem/homebrew-openscreen.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on the tap repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:03:50 -07:00
Siddharth 68c35ff01c zoom precision position 2026-05-09 14:32:50 -07:00
Siddharth c1f6cf67b2 loc first and then export processing 2026-05-09 11:59:52 -07:00
Siddharth 5bd17f4346 fix layout 2026-05-09 11:46:09 -07:00
Sid d3e397e249 Merge pull request #399 from muratclk/fix/trim-handle-boundary-clamp
fix: clamp trim handle end position to timeline boundary
2026-05-09 10:19:12 -07:00
Murat Çelik c771bf8bb9 fix: clamp trim handle end position to timeline boundary
The right-side trim handle could be dragged past the end of the
timeline because clampSpanToBounds did not cap the computed end
value at totalMs. This adds Math.min(…, totalMs) so the handle
snaps to the timeline edge.

Fixes #393
2026-05-09 10:07:01 -07:00
Sid 38f2044967 Merge pull request #549 from Ayusman-Singhal/feat/no-webcam-layout-preset
feat: add 'No Webcam' layout preset to hide webcam in final recording
2026-05-09 10:03:45 -07:00
Sid b4f7b4c182 Merge pull request #518 from makaradam/feature/custom-zoom-slider-clean
feat: add custom zoom slider with continuous scale control (#513)
2026-05-09 09:14:37 -07:00
Sid e880f05866 Merge pull request #504 from 0david0mp/fix/package.nix
fix: bumped npmDepsHash on package.nix
2026-05-09 09:02:22 -07:00
Sid b7c85a9b4e Merge pull request #546 from psychosomat/feature/add-russian-localization
Add Russian localization
2026-05-09 08:57:12 -07:00
Sid bc7c51ecdf Merge branch 'main' into feature/add-russian-localization 2026-05-09 08:55:16 -07:00
makaradam 42127e647f fix: add NaN guard in handleZoomCustomScaleChange before state update 2026-05-09 11:23:37 +02:00
makaradam f3dcbf2867 fix: address code review feedback on custom zoom slider
- Clamp and NaN-guard customScale in getZoomScale (defensive sanitization)
- Set customScale on preset button click so slider stays green
- Set customScale on new zoom region creation so slider lights up immediately
2026-05-09 11:23:37 +02:00
makaradam f30090bf88 fix: sanitize customScale in getZoomScale and fix isCustomActive styling 2026-05-09 11:23:36 +02:00
makaradamandClaude Sonnet 4.6 37215531c2 feat: add custom zoom slider with continuous scale control (#513)
Adds a Radix UI slider below the zoom preset buttons allowing any scale
between 1.0x and 5.0x. When the slider value matches a preset exactly,
that preset button also shows as active.

- Add `customScale?: number` to `ZoomRegion` and `getZoomScale()` helper
  that returns customScale when set, falling back to ZOOM_DEPTH_SCALES[depth]
- Overlay indicator, playback renderer, and frame exporter all use
  getZoomScale() so preview, playback, and export are consistent
- Fix focus clamping in zoomRegionUtils and frameRenderer to use actual
  scale instead of depth-based preset scale, preventing zoom drift with
  custom values
- Fix drag boundary in VideoPlayback to use clampFocusToScale with the
  actual scale so the full canvas is clickable at high custom zoom levels
- Timeline item label shows custom scale value when set
- Slider styled dark with green thumb/fill when a custom (non-preset) value is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:23:36 +02:00
Sid 770a872861 Merge pull request #521 from makaradam/feature/save-dialog-redesign
feat: replace native OS close dialog with custom in-app dialog
2026-05-08 20:14:43 -07:00
Sid e170fe1a83 Merge pull request #554 from creazyfrog/fix/missing-NSScreenCaptureUsageDescription
fix(macOS): add NSScreenCaptureUsageDescription and screen-capture entitlement
2026-05-08 20:07:32 -07:00
Sid 9af318561f Merge pull request #512 from AbhinRustagi/feature/remember-last-export-folder
feat: Add exportFolder to user preferences
2026-05-08 19:30:56 -07:00
Sid b525571f71 Merge pull request #552 from marcgabe15/feature/diagnostics
add diagnostics report
2026-05-08 08:08:29 -07:00
Sid fe93050089 Merge pull request #551 from marcgabe15/fix/tests
fix: tests + how to write them
2026-05-08 08:04:54 -07:00
Trivenzaa-Admin f47fa6bdca fix(macos): add NSScreenCaptureUsageDescription and screen-capture entitlement
Without NSScreenCaptureUsageDescription in Info.plist, macOS silently
blocks desktopCapturer.getSources(), breaking window detection on macOS
10.15+. Also adds the com.apple.security.device.screen-capture entitlement
to macos.entitlements alongside the existing camera and audio-input entries.

Fixes #548
2026-05-08 01:48:52 -07:00
Marc Diaz a0c423de67 add diagnostics report 2026-05-08 00:00:30 -04:00
Marc Diaz c9980b0dca fix: tests + how to write them 2026-05-07 23:22:32 -04:00
AbhinRustagi 1aac6eddb0 Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/remember-last-export-folder 2026-05-08 05:29:14 +05:30
AbhinRustagi 25cfd2777f fix: resolve comments 2026-05-08 05:24:40 +05:30
Ayusman Singhal ada1f434f7 feat: add 'No Webcam' layout preset to hide webcam in final recording
Adds a new 'No Webcam' option to the webcam layout preset dropdown in the editor. When selected, the webcam feed is completely hidden from both the preview and the exported video, allowing users who recorded with a webcam to exclude it from the final output.

- Add 'no-webcam' to WebcamLayoutPreset type union and preset map

- Handle 'no-webcam' in computeCompositeLayout (returns webcamRect: null)

- Add 'no-webcam' case in project persistence normalization

- Add 'No Webcam' option to the layout preset dropdown in SettingsPanel

- Add 'noWebcam' i18n translation key (en)
2026-05-07 12:19:48 +05:30
psychosomat 9336e3d3c6 Fix Russian translation typo and reorder imports 2026-05-06 13:16:21 +03:00
psychosomat 6130c66be6 Add Russian localization 2026-05-06 12:55:01 +03:00
makaradam e2bdfee653 fix: scope IPC close-confirm responses to the originating window
Both ipcMain.once handlers now check event.sender.id against
windowToClose.webContents.id and ignore messages from any other
renderer, preventing cross-window response mix-ups if multiple editor
windows are ever open simultaneously.
2026-05-02 14:36:59 +02:00
makaradam e7e493294b fix: use relative path for logo so it resolves in packaged app
./openscreen.png resolves correctly both in dev (Vite serves public/)
and in production (loadFile sets base to dist/, where public assets land
inside the asar). getAssetPath points to extraResources, which is the
wrong location for bundled dist assets.
2026-05-02 14:33:14 +02:00
makaradam b2cc722613 fix: use getAssetPath for logo so it resolves correctly in packaged app 2026-05-02 13:43:20 +02:00
makaradam 36076aaf2a fix: address code review feedback on custom close dialog 2026-05-02 13:08:52 +02:00
makaradam b3469c469b feat: replace native OS close dialog with custom in-app dialog 2026-05-02 12:28:04 +02:00
AbhinRustagi b801c1ccea fix: resolve comments 2026-05-02 01:19:44 +05:30
AbhinRustagi c40727672f feat: implement handlers to store last export location 2026-05-02 01:05:17 +05:30
AbhinRustagi a38454a7fb feat: update saveExportedVideo fn signature 2026-05-02 01:02:42 +05:30
david dc7259ba09 fix: bumped npmDepsHash on package.nix 2026-04-29 10:31:08 +02:00
39 changed files with 1605 additions and 176 deletions
+1
View File
@@ -41,6 +41,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test
- run: npm run test:browser:install
- run: npm run test:browser
+162
View File
@@ -0,0 +1,162 @@
name: Update Homebrew Cask
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to the tap (e.g. v1.4.0)"
required: true
type: string
permissions:
contents: read
jobs:
update-cask:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
env:
TAP_OWNER: siddharthvaddem
TAP_REPO: homebrew-openscreen
CASK_NAME: openscreen
steps:
- name: Resolve tag and version
id: meta
env:
GH_EVENT_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
TAG="${GH_EVENT_TAG:-$INPUT_TAG}"
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved from release event or workflow input"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Find macOS DMG assets
id: assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
NAMES=$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')
# arm64 DMG: explicit "arm64" / "apple silicon" / fallback to any .dmg
# whose name does NOT contain "x64" or non-mac platform markers.
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(arm64|apple[-_. ]?silicon)' | head -n1 || true)
if [[ -z "$ARM_NAME" ]]; then
ARM_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iv 'x64' | grep -iv 'linux' | grep -iv 'win' | head -n1 || true)
fi
# x64 DMG
X64_NAME=$(echo "$NAMES" | grep -iE '\.dmg$' \
| grep -iE '(x64|x86[-_]?64|intel)' | head -n1 || true)
if [[ -z "$ARM_NAME" || -z "$X64_NAME" ]]; then
echo "::error::Could not locate both arm64 and x64 DMGs in release assets"
echo "Available assets:"
echo "$NAMES"
exit 1
fi
echo "arm_name=$ARM_NAME" >> "$GITHUB_OUTPUT"
echo "x64_name=$X64_NAME" >> "$GITHUB_OUTPUT"
echo "Found arm64 asset: $ARM_NAME"
echo "Found x64 asset: $X64_NAME"
- name: Download DMGs and compute sha256
id: shas
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
run: |
set -euo pipefail
BASE="https://github.com/${REPO}/releases/download/${TAG}"
curl -fsSL --retry 3 -o /tmp/arm.dmg "${BASE}/${ARM_NAME}"
curl -fsSL --retry 3 -o /tmp/x64.dmg "${BASE}/${X64_NAME}"
ARM_SHA=$(sha256sum /tmp/arm.dmg | awk '{print $1}')
X64_SHA=$(sha256sum /tmp/x64.dmg | awk '{print $1}')
echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT"
echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT"
- name: Checkout tap
uses: actions/checkout@v4
with:
repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPO }}
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
- name: Write cask file
env:
REPO: ${{ github.repository }}
TAG: ${{ steps.meta.outputs.tag }}
VERSION: ${{ steps.meta.outputs.version }}
ARM_NAME: ${{ steps.assets.outputs.arm_name }}
X64_NAME: ${{ steps.assets.outputs.x64_name }}
ARM_SHA: ${{ steps.shas.outputs.arm_sha }}
X64_SHA: ${{ steps.shas.outputs.x64_sha }}
run: |
set -euo pipefail
mkdir -p tap/Casks
BASE="https://github.com/${REPO}/releases/download/${TAG}"
cat > "tap/Casks/${CASK_NAME}.rb" <<EOF
cask "${CASK_NAME}" do
version "${VERSION}"
on_arm do
sha256 "${ARM_SHA}"
url "${BASE}/${ARM_NAME}"
end
on_intel do
sha256 "${X64_SHA}"
url "${BASE}/${X64_NAME}"
end
name "Openscreen"
desc "Screen recorder and video editor"
homepage "https://github.com/${REPO}"
auto_updates false
depends_on macos: ">= :big_sur"
app "Openscreen.app"
zap trash: [
"~/Library/Application Support/Openscreen",
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
"~/Library/Logs/Openscreen",
"~/Library/Caches/com.siddharthvaddem.openscreen",
"~/Library/Saved Application State/com.siddharthvaddem.openscreen.savedState",
]
end
EOF
- name: Commit and push to tap
working-directory: tap
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "Casks/${CASK_NAME}.rb"
if git diff --cached --quiet; then
echo "Cask already up to date for ${VERSION} — nothing to commit."
exit 0
fi
git commit -m "Bump ${CASK_NAME} to ${VERSION}"
git push
+149
View File
@@ -0,0 +1,149 @@
# Writing Tests
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
## Unit tests
**Config:** `vitest.config.ts`
**Runs in:** jsdom (simulated DOM, no real browser)
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
**CI command:** `npm run test`
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
### File placement
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
```
src/lib/compositeLayout.ts
src/lib/compositeLayout.test.ts # co-located
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
```
### Example
```ts
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
});
```
### Path aliases
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
```ts
import { SUPPORTED_LOCALES } from "@/i18n/config";
```
### Running locally
```bash
npm run test # run once
npm run test:watch # watch mode
```
---
## Browser tests
**Config:** `vitest.browser.config.ts`
**Runs in:** real Chromium via Playwright (headless)
**File pattern:** `src/**/*.browser.test.ts`
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
### File placement
Name the file `<subject>.browser.test.ts` and place it next to the source file.
```
src/lib/exporter/videoExporter.ts
src/lib/exporter/videoExporter.browser.test.ts
```
### Loading fixture assets
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
```ts
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
```
### Example
```ts
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
});
});
```
### Timeouts
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
### Running locally
First install the browser (one-time):
```bash
npm run test:browser:install
```
Then run the tests:
```bash
npm run test:browser
```
---
## Choosing the right type
| Situation | Use |
|---|---|
| Pure function / data transformation | Unit test |
| i18n key coverage | Unit test |
| React hook logic (no real browser APIs) | Unit test |
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
| File export producing a real `Blob` | Browser test |
+1
View File
@@ -51,6 +51,7 @@
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
+26 -3
View File
@@ -79,10 +79,25 @@ interface Window {
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
pickExportSavePath: (
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
exportFolder?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
writeExportToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
@@ -149,7 +164,15 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
onRequestCloseConfirm: (callback: () => void) => () => void;
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
setLocale: (locale: string) => Promise<void>;
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
};
}
+86 -14
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -985,20 +986,36 @@ export function registerIpcHandlers(
* @returns Object with success status, optional file path, and error details.
*/
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
// Prefer the user's last export folder if it still exists, otherwise fall
// back to ~/Downloads. Validation must happen here because the renderer
// can't stat the filesystem.
let defaultDir = app.getPath("downloads");
if (exportFolder) {
try {
const stats = await fs.stat(exportFolder);
if (stats.isDirectory()) {
defaultDir = exportFolder;
}
} catch (err) {
console.warn(
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
err,
);
}
}
const dialogOptions = buildDialogOptions(
{
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
defaultPath: path.join(defaultDir, fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
},
@@ -1007,20 +1024,34 @@ export function registerIpcHandlers(
const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
return { success: false, canceled: true, message: "Export canceled" };
}
// --- FIX: Normalize the path for Windows compatibility ---
const normalizedPath = path.normalize(result.filePath);
return { success: true, path: path.normalize(result.filePath) };
} catch (error) {
console.error("Failed to show save dialog:", error);
return {
success: false,
message: "Failed to show save dialog",
error: String(error),
};
}
});
// Ensure the parent directory exists (Windows may fail if the folder is missing)
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
try {
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
// but a stale state bug shouldn't be able to clobber arbitrary files.
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
return { success: false, message: "Invalid path" };
}
const lower = filePath.toLowerCase();
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
return { success: false, message: "Invalid file type" };
}
const normalizedPath = path.normalize(filePath);
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
// --- END FIX ---
await fs.writeFile(normalizedPath, Buffer.from(videoData));
return {
@@ -1029,7 +1060,7 @@ export function registerIpcHandlers(
message: "Video exported successfully",
};
} catch (error) {
console.error("Failed to save exported video:", error);
console.error("Failed to write exported video:", error);
return {
success: false,
message: "Failed to save exported video",
@@ -1317,4 +1348,45 @@ export function registerIpcHandlers(
return { success: false, error: String(error) };
}
});
ipcMain.handle(
"save-diagnostic",
async (
_,
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
) => {
const { filePath, canceled } = await dialog.showSaveDialog({
title: "Save Diagnostic File",
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (canceled || !filePath) return { success: false, canceled: true };
const diagnostic = {
timestamp: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
osVersion: os.version(),
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
error: payload.error,
stack: payload.stack,
projectState: payload.projectState,
recentLogs: payload.logs,
};
try {
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
return { success: true, path: filePath };
} catch (error) {
console.error("Failed to write diagnostic file:", error);
return { success: false, error: String(error) };
}
},
);
}
+23 -27
View File
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
@@ -333,6 +332,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
let isCloseConfirmInFlight = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -364,39 +364,35 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
isCloseConfirmInFlight = true;
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
// Ask renderer to show the custom in-app dialog
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
if (event.sender.id !== windowToClose?.webContents.id) return;
isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// Tell renderer to save the project, then close when done
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
if (event.sender.id !== windowToClose?.webContents.id) return;
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
}
// "cancel": flag reset, window stays open
});
});
}
+21 -2
View File
@@ -71,8 +71,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
pickExportSavePath: (fileName: string, exportFolder?: string) => {
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
},
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
@@ -134,6 +137,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => {
return ipcRenderer.invoke("save-diagnostic", payload);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
@@ -166,4 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
},
onRequestCloseConfirm: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("request-close-confirm", listener);
return () => ipcRenderer.removeListener("request-close-confirm", listener);
},
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
ipcRenderer.send("close-confirm-response", choice);
},
});
+4
View File
@@ -21,5 +21,9 @@
<!-- Camera (webcam capture) -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
<key>com.apple.security.device.screen-capture</key>
<true/>
</dict>
</plist>
+1 -1
View File
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
+36 -55
View File
@@ -1,12 +1,13 @@
{
"name": "openscreen",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.3.0",
"version": "1.4.0",
"hasInstallScript": true,
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -187,6 +188,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -395,6 +397,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -718,6 +721,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -766,6 +770,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1197,7 +1202,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1219,7 +1223,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1236,7 +1239,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1251,7 +1253,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -1961,7 +1962,6 @@
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6"
}
@@ -1976,8 +1976,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@pixi/core": {
"version": "7.4.3",
@@ -2004,8 +2003,7 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@pixi/filter-drop-shadow": {
"version": "5.2.0",
@@ -2032,22 +2030,19 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@pixi/runner": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@pixi/settings": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/constants": "7.4.3",
"@types/css-font-loading-module": "^0.0.12",
@@ -2059,7 +2054,6 @@
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/extensions": "7.4.3",
"@pixi/settings": "7.4.3",
@@ -2071,7 +2065,6 @@
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/color": "7.4.3",
"@pixi/constants": "7.4.3",
@@ -3650,8 +3643,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3726,8 +3718,7 @@
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
@@ -3765,8 +3756,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -3865,6 +3855,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -3876,6 +3867,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4157,6 +4149,7 @@
"integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/browser": "4.1.5",
"@vitest/mocker": "4.1.5",
@@ -4349,6 +4342,7 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4874,6 +4868,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -5055,7 +5050,6 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -5406,8 +5400,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -5697,6 +5690,7 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -5789,8 +5783,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
@@ -5839,8 +5832,7 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ejs": {
"version": "3.1.10",
@@ -6023,7 +6015,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -6044,7 +6035,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -6287,8 +6277,7 @@
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
@@ -7700,7 +7689,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -7920,7 +7908,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -8234,7 +8221,6 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -8443,6 +8429,7 @@
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz",
"integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==",
"license": "MIT",
"peer": true,
"workspaces": [
"examples",
"playground"
@@ -8488,6 +8475,7 @@
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.59.1"
},
@@ -8558,6 +8546,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8702,7 +8691,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -8720,7 +8708,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -8731,7 +8718,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -8747,7 +8733,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -8861,7 +8846,6 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -8920,6 +8904,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8932,6 +8917,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8968,8 +8954,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -9290,7 +9275,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -9497,7 +9481,6 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -9517,7 +9500,6 @@
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
@@ -9534,7 +9516,6 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -9553,7 +9534,6 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -9894,6 +9874,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -9977,7 +9958,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -10378,7 +10358,6 @@
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
"license": "MIT",
"peer": true,
"dependencies": {
"punycode": "^1.4.1",
"qs": "^6.12.3"
@@ -10391,8 +10370,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
@@ -10485,6 +10463,7 @@
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10574,7 +10553,8 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
@@ -10597,6 +10577,7 @@
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
+217 -3
View File
@@ -1,8 +1,10 @@
import * as SliderPrimitive from "@radix-ui/react-slider";
import {
Bug,
ChevronDown,
Crop,
Download,
FileDown,
Film,
Image,
Lock,
@@ -59,14 +61,19 @@ import type {
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocus,
ZoomFocusMode,
} from "./types";
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER,
SPEED_OPTIONS,
ZOOM_DEPTH_SCALES,
} from "./types";
import { getFocusBoundsForScale } from "./videoPlayback/focusUtils";
function CustomSpeedInput({
value,
@@ -131,6 +138,58 @@ function CustomSpeedInput({
);
}
function ZoomFocusCoordInput({
percent,
onChange,
onCommit,
disabled,
ariaLabel,
}: {
percent: number;
onChange: (nextPercent: number) => void;
onCommit?: () => void;
disabled?: boolean;
ariaLabel: string;
}) {
// While the input is focused (user is editing), show their draft text
// so partial entries like "5" or "" don't get overwritten by re-renders.
// When not focused, mirror the live prop value so external changes
// (dragging the overlay on the preview) update the displayed number in real time.
const [draft, setDraft] = useState<string | null>(null);
const display = percent.toFixed(1);
return (
<input
type="number"
inputMode="decimal"
min={0}
max={100}
step={0.1}
value={draft ?? display}
disabled={disabled}
aria-label={ariaLabel}
onFocus={() => setDraft(display)}
onChange={(e) => {
const next = e.target.value;
setDraft(next);
const parsed = Number(next);
if (next !== "" && Number.isFinite(parsed)) {
const clamped = Math.min(100, Math.max(0, parsed));
onChange(clamped);
}
}}
onBlur={() => {
setDraft(null);
onCommit?.();
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed"
/>
);
}
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -169,8 +228,14 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomCustomScale?: number | null;
onZoomCustomScaleChange?: (scale: number) => void;
onZoomCustomScaleCommit?: () => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
selectedZoomFocus?: ZoomFocus | null;
onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void;
onZoomFocusCoordinateCommit?: () => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
@@ -240,6 +305,7 @@ interface SettingsPanelProps {
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
onSaveDiagnostic?: () => Promise<void>;
}
export default SettingsPanel;
@@ -261,8 +327,14 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomCustomScale,
onZoomCustomScaleChange,
onZoomCustomScaleCommit,
selectedZoomFocusMode,
onZoomFocusModeChange,
selectedZoomFocus,
onZoomFocusCoordinateChange,
onZoomFocusCoordinateCommit,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
@@ -327,6 +399,7 @@ export function SettingsPanel({
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
onSaveDiagnostic,
}: SettingsPanelProps) {
const t = useScopedT("settings");
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
@@ -590,7 +663,9 @@ export function SettingsPanel({
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
{ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
{selectedZoomCustomScale != null
? `${selectedZoomCustomScale.toFixed(2)}×`
: ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
</span>
)}
<KeyboardShortcutsHelp />
@@ -598,7 +673,10 @@ export function SettingsPanel({
</div>
<div className="grid grid-cols-6 gap-1.5">
{ZOOM_DEPTH_OPTIONS.map((option) => {
const isActive = selectedZoomDepth === option.depth;
const effectiveScale =
selectedZoomCustomScale ??
(selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null);
const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth];
return (
<Button
key={option.depth}
@@ -619,6 +697,65 @@ export function SettingsPanel({
);
})}
</div>
{zoomEnabled && (
<div className="mt-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-400">{t("zoom.customScale")}</span>
<span
className={cn(
"text-xs font-mono font-semibold tabular-nums",
selectedZoomCustomScale != null ? "text-[#34B27B]" : "text-slate-400",
)}
>
{(
selectedZoomCustomScale ??
(selectedZoomDepth != null
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
: MIN_ZOOM_SCALE)
).toFixed(2)}
×
</span>
</div>
<SliderPrimitive.Root
min={MIN_ZOOM_SCALE}
max={MAX_ZOOM_SCALE}
step={0.01}
value={[
selectedZoomCustomScale ??
(selectedZoomDepth != null
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
: MIN_ZOOM_SCALE),
]}
onValueChange={(values) => onZoomCustomScaleChange?.(values[0])}
onValueCommit={() => onZoomCustomScaleCommit?.()}
disabled={!zoomEnabled}
className="relative flex w-full touch-none select-none items-center py-1"
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full border border-white/10 bg-white/5">
<SliderPrimitive.Range
className={cn(
"absolute h-full transition-colors duration-150",
selectedZoomCustomScale != null ? "bg-[#34B27B]" : "bg-white/20",
)}
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className={cn(
"block h-3.5 w-3.5 rounded-full border-2 shadow transition-all duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50",
"disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing",
selectedZoomCustomScale != null
? "border-[#34B27B] bg-[#34B27B] shadow-[0_0_6px_rgba(52,178,123,0.4)]"
: "border-white/20 bg-[#2a2a30] hover:border-white/40",
)}
/>
</SliderPrimitive.Root>
<div className="flex justify-between text-[10px] text-slate-600 mt-0.5">
<span>{MIN_ZOOM_SCALE.toFixed(1)}×</span>
<span>{MAX_ZOOM_SCALE.toFixed(1)}×</span>
</div>
</div>
)}
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
@@ -657,6 +794,70 @@ export function SettingsPanel({
)}
</div>
)}
{zoomEnabled &&
selectedZoomFocusMode !== "auto" &&
selectedZoomFocus &&
onZoomFocusCoordinateChange &&
(() => {
const effectiveZoomScale =
selectedZoomCustomScale ??
(selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE);
const bounds = getFocusBoundsForScale(effectiveZoomScale);
const xRange = bounds.maxX - bounds.minX;
const yRange = bounds.maxY - bounds.minY;
const focusToPercentX = (cx: number) =>
xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100));
const focusToPercentY = (cy: number) =>
yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100));
const percentToFocusX = (p: number) =>
xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange;
const percentToFocusY = (p: number) =>
yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange;
return (
<div className="mt-4">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.position.title")}
</span>
<div className="flex items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
{t("zoom.position.x")}
</label>
<ZoomFocusCoordInput
ariaLabel={t("zoom.position.x")}
percent={focusToPercentX(selectedZoomFocus.cx)}
onChange={(p) =>
onZoomFocusCoordinateChange({
cx: percentToFocusX(p),
cy: selectedZoomFocus.cy,
})
}
onCommit={onZoomFocusCoordinateCommit}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
{t("zoom.position.y")}
</label>
<ZoomFocusCoordInput
ariaLabel={t("zoom.position.y")}
percent={focusToPercentY(selectedZoomFocus.cy)}
onChange={(p) =>
onZoomFocusCoordinateChange({
cx: selectedZoomFocus.cx,
cy: percentToFocusY(p),
})
}
onCommit={onZoomFocusCoordinateCommit}
/>
</div>
<span className="text-[10px] text-slate-500 pb-2">
{t("zoom.position.hint")}
</span>
</div>
</div>
);
})()}
{zoomEnabled && (
<div className="mt-4">
<span className="text-sm font-medium text-slate-200 mb-2 block">
@@ -821,6 +1022,7 @@ export function SettingsPanel({
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
if (preset.value === "picture-in-picture") return true;
if (preset.value === "no-webcam") return true;
if (preset.value === "vertical-stack") return isPortraitCanvas;
return !isPortraitCanvas;
}).map((preset) => (
@@ -829,7 +1031,9 @@ export function SettingsPanel({
? t("layout.pictureInPicture")
: preset.value === "vertical-stack"
? t("layout.verticalStack")
: t("layout.dualFrame")}
: preset.value === "no-webcam"
? t("layout.noWebcam")
: t("layout.dualFrame")}
</SelectItem>
))}
</SelectContent>
@@ -1682,6 +1886,16 @@ export function SettingsPanel({
<Bug className="w-3 h-3 text-[#34B27B]" />
{t("links.reportBug")}
</button>
{onSaveDiagnostic && (
<button
type="button"
onClick={onSaveDiagnostic}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<FileDown className="w-3 h-3 text-slate-400" />
Save Diagnostics
</button>
)}
<button
type="button"
onClick={() => {
@@ -0,0 +1,77 @@
import { Save, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
interface UnsavedChangesDialogProps {
isOpen: boolean;
onSaveAndClose: () => void;
onDiscardAndClose: () => void;
onCancel: () => void;
}
export function UnsavedChangesDialog({
isOpen,
onSaveAndClose,
onDiscardAndClose,
onCancel,
}: UnsavedChangesDialogProps) {
const td = useScopedT("dialogs");
const tc = useScopedT("common");
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
<DialogHeader className="mb-5">
<div className="flex items-center gap-3">
<img
src="./openscreen.png"
alt=""
aria-hidden="true"
className="w-9 h-9 rounded-xl flex-shrink-0"
/>
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
{td("unsavedChanges.title")}
</DialogTitle>
</div>
</DialogHeader>
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
<DialogDescription className="text-sm text-slate-500 mb-6">
{td("unsavedChanges.detail")}
</DialogDescription>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onSaveAndClose}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Save className="w-4 h-4" />
{td("unsavedChanges.saveAndClose")}
</button>
<button
type="button"
onClick={onDiscardAndClose}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
<Trash2 className="w-4 h-4" />
{td("unsavedChanges.discardAndClose")}
</button>
<button
type="button"
onClick={onCancel}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
>
{tc("actions.cancel")}
</button>
</div>
</DialogContent>
</Dialog>
);
}
+140 -32
View File
@@ -31,7 +31,12 @@ import {
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import {
getExportFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
@@ -70,11 +75,13 @@ import {
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
export default function VideoEditor() {
@@ -147,6 +154,7 @@ export default function VideoEditor() {
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
const playerContainerRef = useRef<HTMLDivElement>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
@@ -538,6 +546,28 @@ export default function VideoEditor() {
return () => cleanup();
}, [saveProject]);
useEffect(() => {
const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
setShowCloseConfirmDialog(true);
});
return () => cleanup();
}, []);
const handleCloseConfirmSave = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("save");
}, []);
const handleCloseConfirmDiscard = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("discard");
}, []);
const handleCloseConfirmCancel = useCallback(() => {
setShowCloseConfirmDialog(false);
window.electronAPI.sendCloseConfirmResponse("cancel");
}, []);
const handleSaveProject = useCallback(async () => {
await saveProject(false);
}, [saveProject]);
@@ -703,6 +733,7 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: { cx: 0.5, cy: 0.5 },
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
@@ -722,6 +753,7 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
@@ -805,6 +837,7 @@ export default function VideoEditor() {
? {
...region,
depth,
customScale: ZOOM_DEPTH_SCALES[depth],
focus: clampFocusToDepth(region.focus, depth),
}
: region,
@@ -814,6 +847,24 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomCustomScaleChange = useCallback(
(scale: number) => {
if (!selectedZoomId) return;
const rounded = Math.round(scale * 100) / 100;
if (!Number.isFinite(rounded)) return;
updateState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, customScale: rounded } : region,
),
}));
},
[selectedZoomId, updateState],
);
const handleZoomCustomScaleCommit = useCallback(() => {
commitState();
}, [commitState]);
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
@@ -1319,6 +1370,10 @@ export default function VideoEditor() {
const handleExportSaved = useCallback(
(formatLabel: "GIF" | "Video", filePath: string) => {
setExportedFilePath(filePath);
const folder = parentDirectoryOf(filePath);
if (folder) {
saveUserPreferences({ exportFolder: folder });
}
toast.success(
t("export.exportedSuccessfully", {
format: formatLabel,
@@ -1340,13 +1395,19 @@ export default function VideoEditor() {
const handleSaveUnsavedExport = useCallback(async () => {
if (!unsavedExport) return;
try {
const saveResult = await window.electronAPI.saveExportedVideo(
unsavedExport.arrayBuffer,
const pickResult = await window.electronAPI.pickExportSavePath(
unsavedExport.fileName,
getExportFolder(),
);
if (saveResult.canceled) {
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
return;
}
const saveResult = await window.electronAPI.writeExportToPath(
unsavedExport.arrayBuffer,
pickResult.path,
);
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
@@ -1371,6 +1432,21 @@ export default function VideoEditor() {
return;
}
// Ask the user where to save BEFORE starting the export. This avoids the
// post-export save dialog getting hidden behind other windows after a
// long-running export.
const isGifFormat = settings.format === "gif";
const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`;
const pickResult = await window.electronAPI.pickExportSavePath(
targetFileName,
getExportFolder(),
);
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
setShowExportDialog(false);
return;
}
const targetPath = pickResult.path;
setIsExporting(true);
setExportProgress(null);
setExportError(null);
@@ -1437,8 +1513,6 @@ export default function VideoEditor() {
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
if (result.warnings) {
for (const warning of result.warnings) {
@@ -1446,15 +1520,13 @@ export default function VideoEditor() {
}
}
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("GIF", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
setExportError(saveResult.message || "Failed to save GIF");
toast.error(saveResult.message || "Failed to save GIF");
}
@@ -1470,18 +1542,19 @@ export default function VideoEditor() {
let bitrate: number;
if (quality === "source") {
// Use source resolution
exportWidth = sourceWidth;
exportHeight = sourceHeight;
// Use the source's longer dimension as the long axis of the export so
// a landscape recording can still fill a portrait target (and vice versa).
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
if (aspectRatioValue === 1) {
// Square (1:1): use smaller dimension to avoid codec limits
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
// Landscape: find largest even dimensions that exactly match aspect ratio
const baseWidth = Math.floor(sourceWidth / 2) * 2;
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
@@ -1496,8 +1569,7 @@ export default function VideoEditor() {
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
}
} else {
// Portrait: find largest even dimensions that exactly match aspect ratio
const baseHeight = Math.floor(sourceHeight / 2) * 2;
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
@@ -1513,7 +1585,6 @@ export default function VideoEditor() {
}
}
// Calculate visually lossless bitrate matching screen recording optimization
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
@@ -1522,14 +1593,18 @@ export default function VideoEditor() {
bitrate = 80_000_000;
}
} else {
// Use quality-based target resolution
const targetHeight = quality === "medium" ? 720 : 1080;
// Quality presets target the SHORT side; the long side derives from the
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
const targetShortDim = quality === "medium" ? 720 : 1080;
// Calculate dimensions maintaining aspect ratio
exportHeight = Math.floor(targetHeight / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
if (aspectRatioValue >= 1) {
exportHeight = Math.floor(targetShortDim / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
} else {
exportWidth = Math.floor(targetShortDim / 2) * 2;
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
}
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
@@ -1579,8 +1654,6 @@ export default function VideoEditor() {
if (result.success && result.blob) {
const arrayBuffer = await result.blob.arrayBuffer();
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
if (result.warnings) {
for (const warning of result.warnings) {
@@ -1588,15 +1661,13 @@ export default function VideoEditor() {
}
}
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("Video", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
setExportError(saveResult.message || "Failed to save video");
toast.error(saveResult.message || "Failed to save video");
}
@@ -1730,6 +1801,19 @@ export default function VideoEditor() {
}
}, []);
const handleSaveDiagnostic = useCallback(async () => {
const result = await window.electronAPI.saveDiagnostic({
error: exportError ?? "Manual diagnostic export",
projectState: editorState,
logs: [],
});
if (result.success) {
toast.success("Diagnostic file saved");
} else if (!result.canceled) {
toast.error("Failed to save diagnostic file");
}
}, [exportError, editorState]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
@@ -2005,12 +2089,28 @@ export default function VideoEditor() {
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomCustomScale={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
: null
}
onZoomCustomScaleChange={handleZoomCustomScaleChange}
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
selectedZoomFocus={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
: null
}
onZoomFocusCoordinateChange={(focus) =>
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
}
onZoomFocusCoordinateCommit={commitState}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
@@ -2100,6 +2200,7 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
onSaveDiagnostic={handleSaveDiagnostic}
/>
</div>
</div>
@@ -2117,6 +2218,13 @@ export default function VideoEditor() {
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
}
/>
<UnsavedChangesDialog
isOpen={showCloseConfirmDialog}
onSaveAndClose={handleCloseConfirmSave}
onDiscardAndClose={handleCloseConfirmDiscard}
onCancel={handleCloseConfirmCancel}
/>
</div>
);
}
@@ -38,13 +38,12 @@ import {
type BlurData,
computeRotation3DContainScale,
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
@@ -67,7 +66,7 @@ import {
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -258,10 +257,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
}, []);
const updateOverlayForRegion = useCallback(
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
const overlayEl = overlayRef.current;
@@ -442,7 +437,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cx: clamp01(localX / stageWidth),
cy: clamp01(localY / stageHeight),
};
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
onZoomFocusChange(region.id, clampedFocus);
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
@@ -951,7 +946,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = region.focus;
targetScaleFactor = zoomScale;
@@ -100,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
case "no-webcam":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
@@ -14,6 +14,7 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -46,6 +47,7 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
zoomCustomScale,
speedValue,
isAutoFocus = false,
variant = "zoom",
@@ -134,7 +136,9 @@ export default function Item({
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
{zoomCustomScale != null
? `${zoomCustomScale.toFixed(2)}×`
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
@@ -102,6 +102,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -683,6 +684,7 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomCustomScale={item.zoomCustomScale}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
@@ -1339,6 +1341,7 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
zoomCustomScale: region.customScale,
isAutoFocus: region.focusMode === "auto",
variant: "zoom",
}));
@@ -57,7 +57,7 @@ export default function TimelineWrapper({
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
const end = start + duration;
const end = Math.min(start + duration, totalMs);
return { start, end };
},
+14
View File
@@ -65,6 +65,8 @@ export interface ZoomRegion {
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
/** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
customScale?: number;
}
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
@@ -356,8 +358,20 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
6: 5.0,
};
export const MIN_ZOOM_SCALE = 1.0;
export const MAX_ZOOM_SCALE = 5.0;
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
export function getZoomScale(region: ZoomRegion): number {
if (region.customScale != null) {
const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
if (Number.isFinite(clamped)) return clamped;
}
return ZOOM_DEPTH_SCALES[region.depth];
}
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
@@ -44,7 +44,7 @@ interface ViewportRatio {
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
export function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
@@ -1,5 +1,5 @@
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
import { clampFocusToStage } from "./focusUtils";
import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
import { clampFocusToScale } from "./focusUtils";
interface OverlayUpdateParams {
overlayEl: HTMLDivElement;
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
return;
}
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
width: stageWidth,
height: stageHeight,
});
const zoomScale = getZoomScale(region);
const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
const indicatorWidth = stageWidth / zoomScale;
@@ -1,5 +1,5 @@
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
@@ -155,7 +155,7 @@ function getActiveRegion(
}
const activeRegion = activeRegions[0].region;
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
const activeScale = getZoomScale(activeRegion);
return {
region: {
@@ -176,7 +176,7 @@ function getConnectedRegionHold(
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
const nextScale = getZoomScale(pair.nextRegion);
return {
region: {
...pair.nextRegion,
@@ -214,8 +214,8 @@ function getConnectedRegionTransition(
const transitionProgress = easeConnectedPan(
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
);
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const currentScale = getZoomScale(currentRegion);
const nextScale = getZoomScale(nextRegion);
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
@@ -1,11 +1,15 @@
import { describe, expect, it } from "vitest";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import arDialogs from "@/i18n/locales/ar/dialogs.json";
import enDialogs from "@/i18n/locales/en/dialogs.json";
import esDialogs from "@/i18n/locales/es/dialogs.json";
import frDialogs from "@/i18n/locales/fr/dialogs.json";
import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
@@ -35,10 +39,14 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
const dialogsByLocale = {
en: enDialogs,
"zh-CN": zhCNDialogs,
"zh-TW": zhTWDialogs,
es: esDialogs,
fr: frDialogs,
tr: trDialogs,
"ko-KR": koKRDialogs,
ru: ruDialogs,
"ja-JP": jaJPDialogs,
ar: arDialogs,
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
describe("TutorialHelp translations", () => {
+1
View File
@@ -9,6 +9,7 @@ export const SUPPORTED_LOCALES = [
"ko-KR",
"ja-JP",
"ar",
"ru",
] as const;
export const I18N_NAMESPACES = [
"common",
+8
View File
@@ -1,6 +1,7 @@
{
"zoom": {
"level": "Zoom Level",
"customScale": "Custom Zoom",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
@@ -16,6 +17,12 @@
"left": "Left",
"right": "Right"
}
},
"position": {
"title": "Focus Position",
"x": "X (%)",
"y": "Y (%)",
"hint": "0 = leftmost / topmost, 100 = rightmost / bottommost"
}
},
"speed": {
@@ -35,6 +42,7 @@
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"dualFrame": "Dual Frame",
"noWebcam": "No Webcam",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
+50
View File
@@ -0,0 +1,50 @@
{
"actions": {
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"close": "Закрыть",
"share": "Поделиться",
"done": "Готово",
"open": "Открыть",
"upload": "Загрузить",
"export": "Экспорт",
"showInFolder": "Показать в папке",
"file": "Файл",
"edit": "Редактировать",
"view": "Вид",
"window": "Окно",
"quit": "Выход",
"stopRecording": "Остановить запись",
"undo": "Отменить",
"redo": "Повторить",
"cut": "Вырезать",
"copy": "Копировать",
"paste": "Вставить",
"selectAll": "Выделить всё",
"minimize": "Свернуть",
"reload": "Перезагрузить",
"forceReload": "Принудительная перезагрузка",
"toggleDevTools": "Переключить инструменты разработчика",
"actualSize": "Реальный размер",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"toggleFullScreen": "Полноэкранный режим",
"recordingStatus": "Запись: {{source}}",
"about": "О OpenScreen",
"services": "Сервисы",
"hide": "Скрыть OpenScreen",
"hideOthers": "Скрыть остальные",
"unhide": "Показать все"
},
"playback": {
"play": "Воспроизвести",
"pause": "Пауза",
"fullscreen": "Полный экран",
"exitFullscreen": "Выход из полного экрана"
},
"locale": {
"name": "Русский",
"short": "RU"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "Экспорт завершён",
"yourFormatReady": "Ваш {{format}} готов",
"showInFolder": "Показать в папке",
"finalizingVideo": "Завершение экспорта видео...",
"compilingGifProgress": "Сборка GIF... {{progress}}%",
"compilingGifWait": "Сборка GIF... Это может занять некоторое время",
"takeMoment": "Это может занять некоторое время...",
"failed": "Экспорт не удался",
"tryAgain": "Пожалуйста, попробуйте снова",
"finalizingVideoTitle": "Завершение видео",
"compilingGif": "Сборка GIF",
"exportingFormat": "Экспорт {{format}}",
"compiling": "Сборка",
"renderingFrames": "Рендеринг кадров",
"processing": "Обработка...",
"finalizing": "Завершение...",
"compilingStatus": "Сборка...",
"status": "Статус",
"format": "Формат",
"frames": "Кадры",
"cancelExport": "Отменить экспорт",
"savedSuccessfully": "{{format}} успешно сохранён!"
},
"tutorial": {
"triggerLabel": "Как работает обрезка",
"title": "Как работает обрезка",
"description": "Как вырезать ненужные части видео.",
"explanationBefore": "Инструмент обрезки работает путём определения сегментов, которые вы хотите",
"remove": "удалить",
"explanationMiddle": " — всё, что",
"covered": "покрыто",
"explanationAfter": "красным сегментом обрезки, будет вырезано при экспорте.",
"visualExample": "Визуальный пример",
"removed": "УДАЛЕНО",
"kept": "Сохранено",
"part1": "Часть 1",
"part2": "Часть 2",
"part3": "Часть 3",
"finalVideo": "Итоговое видео",
"step1Title": "1. Добавить обрезку",
"step1DescriptionBefore": "Нажмите ",
"step1DescriptionAfter": " или нажмите на значок ножниц, чтобы отметить секцию для удаления.",
"step2Title": "2. Настроить",
"step2Description": "Перетащите края красной области, чтобы точно покрыть то, что вы хотите вырезать."
},
"unsavedChanges": {
"title": "Несохранённые изменения",
"message": "У вас есть несохранённые изменения.",
"detail": "Хотите сохранить проект перед закрытием?",
"saveAndClose": "Сохранить и закрыть",
"discardAndClose": "Отменить и закрыть",
"loadProject": "Загрузить проект…",
"saveProject": "Сохранить проект…",
"saveProjectAs": "Сохранить проект как…"
},
"fileDialogs": {
"saveGif": "Сохранить экспортированный GIF",
"saveVideo": "Сохранить экспортированное видео",
"selectVideo": "Выбрать видеофайл",
"saveProject": "Сохранить проект OpenScreen",
"openProject": "Открыть проект OpenScreen",
"gifImage": "GIF изображение",
"mp4Video": "MP4 видео",
"videoFiles": "Видеофайлы",
"openscreenProject": "Проект OpenScreen",
"allFiles": "Все файлы"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"newRecording": {
"title": "Вернуться к записи",
"description": "Ваша текущая сессия была сохранена.",
"cancel": "Отмена",
"confirm": "Подтвердить"
},
"loadingVideo": "Загрузка видео...",
"errors": {
"noVideoLoaded": "Видео не загружено",
"videoNotReady": "Видео не готово",
"unableToDetermineSourcePath": "Не удалось определить путь к исходному видео",
"failedToSaveGif": "Не удалось сохранить GIF",
"gifExportFailed": "Экспорт GIF не удался",
"failedToSaveVideo": "Не удалось сохранить видео",
"exportFailed": "Экспорт не удался",
"exportFailedWithError": "Экспорт не удался: {{error}}",
"exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})",
"failedToSaveExport": "Не удалось сохранить экспорт",
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
"failedToRevealInFolder": "Ошибка при показе в папке: {{error}}"
},
"export": {
"canceled": "Экспорт отменён",
"exportedSuccessfully": "{{format}} успешно экспортирован"
},
"project": {
"saveCanceled": "Сохранение проекта отменено",
"failedToSave": "Не удалось сохранить проект",
"savedTo": "Проект сохранён в {{path}}",
"failedToLoad": "Не удалось загрузить проект",
"invalidFormat": "Неверный формат файла проекта",
"loadedFrom": "Проект загружен из {{path}}"
},
"recording": {
"failedCameraAccess": "Не удалось запросить доступ к камере.",
"cameraBlocked": "Доступ к камере заблокирован. Включите его в системных настройках для использования веб-камеры.",
"systemAudioUnavailable": "Системное аудио недоступно. Запись без системного аудио.",
"microphoneDenied": "Доступ к микрофону запрещён. Запись продолжится без аудио.",
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
"cameraDisconnected": "Веб-камера отключена.",
"cameraNotFound": "Камера не найдена.",
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"tooltips": {
"hideHUD": "Скрыть HUD",
"closeApp": "Закрыть приложение",
"restartRecording": "Перезапустить запись",
"cancelRecording": "Отменить запись",
"pauseRecording": "Приостановить запись",
"resumeRecording": "Возобновить запись",
"openVideoFile": "Открыть видеофайл",
"openProject": "Открыть проект"
},
"audio": {
"enableSystemAudio": "Включить системное аудио",
"disableSystemAudio": "Отключить системное аудио",
"enableMicrophone": "Включить микрофон",
"disableMicrophone": "Отключить микрофон",
"defaultMicrophone": "Микрофон по умолчанию"
},
"webcam": {
"enableWebcam": "Включить веб-камеру",
"disableWebcam": "Отключить веб-камеру",
"defaultCamera": "Камера по умолчанию",
"searching": "Поиск...",
"noneFound": "Камера не найдена",
"unavailable": "Камера недоступна"
},
"sourceSelector": {
"loading": "Загрузка источников...",
"screens": "Экраны ({{count}})",
"windows": "Окна ({{count}})",
"defaultSourceName": "Экран"
},
"recording": {
"selectSource": "Пожалуйста, выберите источник для записи"
},
"language": "Язык",
"systemLanguagePrompt": {
"title": "Использовать системный язык?",
"description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?",
"switch": "Переключить на {{language}}",
"keepDefault": "Оставить текущий язык"
}
}
+202
View File
@@ -0,0 +1,202 @@
{
"zoom": {
"level": "Уровень масштабирования",
"selectRegion": "Выберите область масштабирования для настройки",
"deleteZoom": "Удалить масштабирование",
"focusMode": {
"title": "Режим фокуса",
"manual": "Ручной",
"auto": "Авто",
"autoDescription": "Камера следует за записанной позицией курсора"
},
"threeD": {
"title": "3D вращение",
"preset": {
"iso": "Изометрия",
"left": "Слева",
"right": "Справа"
}
}
},
"speed": {
"playbackSpeed": "Скорость воспроизведения",
"selectRegion": "Выберите область скорости для настройки",
"deleteRegion": "Удалить область скорости",
"customPlaybackSpeed": "Пользовательская скорость воспроизведения",
"maxSpeedError": "Скорость не может быть выше 16×"
},
"trim": {
"deleteRegion": "Удалить область обрезки"
},
"layout": {
"title": "Макет",
"preset": "Пресет",
"selectPreset": "Выбрать пресет",
"pictureInPicture": "Картинка в картинке",
"verticalStack": "Вертикальный стек",
"dualFrame": "Двойной кадр",
"webcamShape": "Форма камеры",
"webcamSize": "Размер веб-камеры"
},
"effects": {
"title": "Видеоэффекты",
"blurBg": "Размытие фона",
"motionBlur": "Размытие движения",
"off": "выкл",
"on": "вкл",
"shadow": "Тень",
"roundness": "Скругление",
"padding": "Отступ",
"cursorHighlight": {
"title": "Подсветка курсора",
"style": "Стиль",
"dot": "Точка",
"ring": "Кольцо",
"size": "Размер",
"onlyOnClicks": "Только при кликах",
"color": "Цвет",
"offsetX": "Смещение X (записи окон)",
"offsetY": "Смещение Y",
"accessibilityPermissionTitle": "Требуется разрешение на доступность",
"accessibilityPermissionDescription": "Откройте Системные настройки → Конфиденциальность и безопасность → Универсальный доступ, включите Openscreen, затем перезапустите приложение."
}
},
"background": {
"title": "Фон",
"image": "Изображение",
"color": "Цвет",
"gradient": "Градиент",
"uploadCustom": "Загрузить свой",
"gradientLabel": "Градиент {{index}}",
"colorWheel": "Цветовой круг",
"colorPalette": "Палитра цветов"
},
"crop": {
"title": "Обрезка",
"cropVideo": "Обрезать видео",
"dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
"ratio": "Соотношение сторон",
"free": "Свободно",
"done": "Готово",
"lockAspectRatio": "Заблокировать соотношение сторон",
"unlockAspectRatio": "Разблокировать соотношение сторон"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 видео",
"mp4Description": "Видеофайл высокого качества",
"gifAnimation": "GIF анимация",
"gifDescription": "Анимированное изображение для обмена"
},
"exportQuality": {
"title": "Качество экспорта",
"low": "Низкое",
"medium": "Среднее",
"high": "Высокое"
},
"gifSettings": {
"frameRate": "Частота кадров GIF",
"size": "Размер GIF",
"loop": "Зациклить GIF"
},
"project": {
"save": "Сохранить проект",
"load": "Загрузить проект"
},
"export": {
"videoButton": "Экспорт видео",
"gifButton": "Экспорт GIF",
"chooseSaveLocation": "Выбрать место сохранения"
},
"links": {
"reportBug": "Сообщить об ошибке",
"starOnGithub": "Звезда на GitHub"
},
"imageUpload": {
"invalidFileType": "Неверный тип файла",
"jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
"uploadSuccess": "Пользовательское изображение успешно загружено!",
"failedToUpload": "Не удалось загрузить изображение",
"errorReading": "Произошла ошибка при чтении файла."
},
"annotation": {
"title": "Настройки аннотаций",
"active": "Активно",
"typeText": "Текст",
"typeImage": "Изображение",
"typeArrow": "Стрелка",
"typeBlur": "Размытие",
"textContent": "Содержание текста",
"textPlaceholder": "Введите ваш текст...",
"fontStyle": "Стиль шрифта",
"selectStyle": "Выбрать стиль",
"size": "Размер",
"customFonts": "Пользовательские шрифты",
"textColor": "Цвет текста",
"background": "Фон",
"none": "Нет",
"color": "Цвет",
"colorWheel": "Цветовой круг",
"colorPalette": "Палитра цветов",
"clearBackground": "Очистить фон",
"uploadImage": "Загрузить изображение",
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
"arrowDirection": "Направление стрелки",
"strokeWidth": "Толщина линии: {{width}}px",
"arrowColor": "Цвет стрелки",
"blurType": "Тип размытия",
"blurTypeBlur": "Размытие",
"blurTypeMosaic": "Мозаичное размытие",
"blurColor": "Цвет размытия",
"blurColorWhite": "Белый",
"blurColorBlack": "Чёрный",
"blurShape": "Форма размытия",
"blurIntensity": "Интенсивность размытия",
"mosaicBlockSize": "Размер блока мозаики",
"blurShapeRectangle": "Прямоугольник",
"blurShapeOval": "Овал",
"blurShapeFreehand": "От руки",
"deleteAnnotation": "Удалить аннотацию",
"shortcutsAndTips": "Горячие клавиши и советы",
"tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
"tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
"tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
"invalidImageType": "Неверный тип файла",
"imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
"imageUploadSuccess": "Изображение успешно загружено!",
"failedImageUpload": "Не удалось загрузить изображение"
},
"fontStyles": {
"classic": "Классический",
"editor": "Редактор",
"strong": "Жирный",
"typewriter": "Пишущая машинка",
"deco": "Декоративный",
"simple": "Простой",
"modern": "Современный",
"clean": "Чистый"
},
"customFont": {
"dialogTitle": "Добавить шрифт Google",
"urlLabel": "URL импорта Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
"nameLabel": "Отображаемое имя",
"namePlaceholder": "Мой пользовательский шрифт",
"nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
"addButton": "Добавить шрифт",
"addingButton": "Добавление...",
"errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
"errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
"errorEmptyName": "Пожалуйста, введите имя шрифта",
"errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
"successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
"failedToAdd": "Не удалось добавить шрифт",
"errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
"errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
},
"language": {
"title": "Язык"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Горячие клавиши",
"customize": "Настроить",
"configurable": "Настраиваемые",
"fixed": "Фиксированные",
"pressKey": "Нажмите клавишу…",
"clickToChange": "Нажмите для изменения",
"pressEscToCancel": "Нажмите Esc для отмены",
"helpText": "Нажмите на горячую клавишу, затем нажмите новую комбинацию клавиш. Нажмите Esc для отмены.",
"resetToDefaults": "Сбросить по умолчанию",
"alreadyUsedBy": "Уже используется для {{action}}",
"swap": "Поменять",
"reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.",
"savedToast": "Горячие клавиши сохранены",
"resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения",
"actions": {
"addZoom": "Добавить масштабирование",
"addTrim": "Добавить обрезку",
"addSpeed": "Изменить скорость",
"addAnnotation": "Добавить аннотацию",
"addBlur": "Добавить размытие",
"addKeyframe": "Добавить ключевой кадр",
"deleteSelected": "Удалить выбранное",
"playPause": "Воспроизведение / Пауза"
},
"fixedActions": {
"undo": "Отменить",
"redo": "Повторить",
"cycleAnnotationsForward": "Циклически переключить аннотации вперёд",
"cycleAnnotationsBackward": "Циклически переключить аннотации назад",
"deleteSelectedAlt": "Удалить выбранное (альт)",
"panTimeline": "Панорамирование таймлайна",
"zoomTimeline": "Масштабирование таймлайна",
"frameBack": "Кадр назад",
"frameForward": "Кадр вперёд"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"buttons": {
"addZoom": "Добавить масштабирование (Z)",
"suggestZooms": "Предложить масштабирование на основе курсора",
"addTrim": "Добавить обрезку (T)",
"addAnnotation": "Добавить аннотацию (A)",
"addBlur": "Добавить размытие (B)",
"addSpeed": "Изменить скорость (S)"
},
"hints": {
"pressZoom": "Нажмите Z для добавления масштабирования",
"pressTrim": "Нажмите T для добавления обрезки",
"pressAnnotation": "Нажмите A для добавления аннотации",
"pressBlur": "Нажмите B для добавления области размытия",
"pressSpeed": "Нажмите S для изменения скорости"
},
"labels": {
"pan": "Панорамирование",
"zoom": "Масштабирование",
"trim": "Обрезка",
"speed": "Скорость воспроизведения",
"zoomItem": "Масштабирование {{index}}",
"trimItem": "Обрезка {{index}}",
"speedItem": "Скорость воспроизведения {{index}}",
"annotationItem": "Аннотация",
"blurItem": "Размытие {{index}}",
"imageItem": "Изображение",
"emptyText": "Пустой текст"
},
"emptyState": {
"noVideo": "Видео не загружено",
"dragAndDrop": "Перетащите видео для начала редактирования"
},
"errors": {
"cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
"zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
"zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
"noCursorTelemetry": "Нет данных телеметрии курсора",
"noCursorTelemetryDescription": "Сначала запишите screencast для генерации предложений на основе курсора.",
"noUsableTelemetry": "Нет пригодной телеметрии курсора",
"noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
"noDwellMoments": "Не найдено чётких моментов задержки курсора",
"noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
"noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
"noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
"cannotPlaceTrim": "Невозможно разместить обрезку здесь",
"trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
"cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
"speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места."
},
"success": {
"addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
"addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
}
}
+1 -1
View File
@@ -75,6 +75,6 @@ describe("blur color helpers", () => {
intensity: 12,
blockSize: 12,
}),
).toBe("rgba(0, 0, 0, 0.18)");
).toBe("rgba(0, 0, 0, 0.56)");
});
});
+31 -1
View File
@@ -15,7 +15,11 @@ export interface Size {
height: number;
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
export type WebcamLayoutPreset =
| "picture-in-picture"
| "vertical-stack"
| "dual-frame"
| "no-webcam";
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
@@ -126,6 +130,21 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
},
shadow: null,
},
"no-webcam": {
label: "No Webcam",
transform: {
type: "overlay",
marginFraction: 0,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: 0,
min: 0,
fraction: 0,
},
shadow: null,
},
};
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
@@ -172,6 +191,17 @@ export function computeCompositeLayout(params: {
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: screenWidth, height: screenHeight } = screenSize;
// "no-webcam" preset: hide the webcam entirely, screen fills the canvas normally
if (layoutPreset === "no-webcam") {
const screenRect = centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
});
return { screenRect, webcamRect: null };
}
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
+4 -13
View File
@@ -15,14 +15,13 @@ import type {
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import {
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
ZOOM_DEPTH_SCALES,
} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
@@ -42,7 +41,7 @@ import {
clickEmphasisAlpha,
drawCursorHighlightCanvas,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -645,14 +644,6 @@ export class FrameRenderer {
};
}
private clampFocusToStage(
focus: { cx: number; cy: number },
depth: ZoomDepth,
): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
}
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
@@ -673,8 +664,8 @@ export class FrameRenderer {
: { ...DEFAULT_ROTATION_3D };
if (region && strength > 0) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = clampFocusToScale(region.focus, zoomScale);
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parentDirectoryOf } from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
});
it("returns the directory for a Windows path", () => {
expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
});
it("preserves the POSIX root when the file is at /", () => {
expect(parentDirectoryOf("/video.mp4")).toBe("/");
});
it("preserves the Windows drive root with its trailing separator", () => {
expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
});
it("returns null when no separator is present", () => {
expect(parentDirectoryOf("video.mp4")).toBeNull();
expect(parentDirectoryOf("")).toBeNull();
});
});
+41
View File
@@ -23,6 +23,8 @@ export interface UserPreferences {
exportQuality: ExportQuality;
/** Default export format */
exportFormat: ExportFormat;
/** Folder used for the most recent successful export, if any */
exportFolder: string | null;
}
const DEFAULT_PREFS: UserPreferences = {
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
exportFolder: null,
};
function safeJsonParse(text: string | null): Record<string, unknown> | null {
@@ -76,9 +79,47 @@ export function loadUserPreferences(): UserPreferences {
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
? (raw.exportFormat as ExportFormat)
: DEFAULT_PREFS.exportFormat,
exportFolder:
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
? raw.exportFolder
: DEFAULT_PREFS.exportFolder,
};
}
/**
* Extracts the parent directory from a saved file path. Handles both POSIX
* and Windows separators since the path comes from the OS save dialog.
*
* Root directories are preserved with their trailing separator so that the
* value is still a valid directory path:
* "/video.mp4" -> "/"
* "C:\\video.mp4" -> "C:\\"
*
* Returns null if no separator is found.
*/
export function parentDirectoryOf(filePath: string): string | null {
const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
if (lastSep < 0) return null;
// POSIX root, e.g. "/video.mp4" -> "/"
if (lastSep === 0) return filePath[0];
// Windows drive root, e.g. "C:\\video.mp4" -> "C:\\"
if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) {
return filePath.slice(0, lastSep + 1);
}
return filePath.slice(0, lastSep);
}
/**
* Returns the remembered export folder as `string | undefined`, suitable for
* passing directly to IPC handlers that treat absence as "use the default".
*/
export function getExportFolder(): string | undefined {
return loadUserPreferences().exportFolder ?? undefined;
}
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
globals: true,
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
resolve: {
alias: {