mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-02 21:55:16 +08:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7feb05cca7 | ||
|
|
9ac4e778f8 | ||
|
|
ed825d8b37 | ||
|
|
b48370e3d0 | ||
|
|
8c0c555640 | ||
|
|
24be97bae7 | ||
|
|
c820b416f9 | ||
|
|
f42c478725 | ||
|
|
64c2a43006 | ||
|
|
4a0878c3d0 | ||
|
|
d8da26a41a | ||
|
|
f0699cc8b0 | ||
|
|
3ad3e22a16 | ||
|
|
2381e48a46 | ||
|
|
68c35ff01c | ||
|
|
3dd7b85ebb | ||
|
|
be4e2d0c94 | ||
|
|
c9b6074626 | ||
|
|
c1f6cf67b2 | ||
|
|
5bd17f4346 | ||
|
|
d3e397e249 | ||
|
|
c771bf8bb9 | ||
|
|
38f2044967 | ||
|
|
b4f7b4c182 | ||
|
|
e880f05866 | ||
|
|
b7c85a9b4e | ||
|
|
bc7c51ecdf | ||
|
|
42127e647f | ||
|
|
f3dcbf2867 | ||
|
|
f30090bf88 | ||
|
|
37215531c2 | ||
|
|
770a872861 | ||
|
|
e170fe1a83 | ||
|
|
9af318561f | ||
|
|
b525571f71 | ||
|
|
fe93050089 | ||
|
|
f47fa6bdca | ||
|
|
a0c423de67 | ||
|
|
c9980b0dca | ||
|
|
1aac6eddb0 | ||
|
|
25cfd2777f | ||
|
|
ada1f434f7 | ||
|
|
9336e3d3c6 | ||
|
|
6130c66be6 | ||
|
|
e2bdfee653 | ||
|
|
e7e493294b | ||
|
|
b2cc722613 | ||
|
|
36076aaf2a | ||
|
|
b3469c469b | ||
|
|
b801c1ccea | ||
|
|
c40727672f | ||
|
|
a38454a7fb | ||
|
|
37c1ea5984 | ||
|
|
dc7259ba09 |
@@ -0,0 +1,118 @@
|
||||
name: Bump Nix package on release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to bump (e.g. v1.5.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
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}"
|
||||
BRANCH="chore/bump-nix-${VERSION}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: Compute npmDepsHash
|
||||
id: hash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HASH=$(nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
|
||||
if [[ -z "$HASH" ]]; then
|
||||
echo "::error::prefetch-npm-deps returned an empty hash"
|
||||
exit 1
|
||||
fi
|
||||
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed npmDepsHash: $HASH"
|
||||
|
||||
- name: Update nix/package.nix
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
HASH: ${{ steps.hash.outputs.hash }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Update version line: ` version = "<anything>";`
|
||||
sed -i -E "s|^([[:space:]]*version[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${VERSION}\";|" nix/package.nix
|
||||
# Update npmDepsHash line: ` npmDepsHash = "<anything>";`
|
||||
sed -i -E "s|^([[:space:]]*npmDepsHash[[:space:]]*=[[:space:]]*)\"[^\"]*\";|\1\"${HASH}\";|" nix/package.nix
|
||||
|
||||
echo "=== diff ==="
|
||||
git --no-pager diff nix/package.nix || true
|
||||
|
||||
- name: Create PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
HASH: ${{ steps.hash.outputs.hash }}
|
||||
BRANCH: ${{ steps.meta.outputs.branch }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --quiet -- nix/package.nix; then
|
||||
echo "nix/package.nix already at v${VERSION} with this hash — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Replace any prior bump branch to keep the workflow idempotent.
|
||||
git push origin --delete "$BRANCH" 2>/dev/null || true
|
||||
git checkout -b "$BRANCH"
|
||||
git add nix/package.nix
|
||||
git commit -m "chore: bump nix package to v${VERSION}"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "chore: bump nix package to v${VERSION}" \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--body "$(cat <<EOF
|
||||
Automated bump triggered by release \`${TAG}\`.
|
||||
|
||||
- \`version\` → \`${VERSION}\`
|
||||
- \`npmDepsHash\` → \`${HASH}\` (computed via \`prefetch-npm-deps package-lock.json\`)
|
||||
|
||||
Merge this so Nix users (NixOS, Home Manager, \`nix run github:siddharthvaddem/openscreen\`) pick up the new release.
|
||||
|
||||
> Note: PRs opened by \`GITHUB_TOKEN\` don't auto-trigger CI. The diff is two lines — review the change here, then merge. If you want CI to run, push an empty commit to this branch or close-and-reopen the PR.
|
||||
EOF
|
||||
)"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Publish release to WinGet
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to publish to winget (e.g. v1.4.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: SiddharthVaddem.OpenScreen
|
||||
# Match the Windows installer asset attached to each release.
|
||||
# Today: "Openscreen.Setup.latest.exe". Adjust this regex if you
|
||||
# ever rename the installer to include a version (e.g. "Setup\.\d+\.\d+\.\d+\.exe").
|
||||
installers-regex: 'Setup\..*\.exe$'
|
||||
release-tag: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||
@@ -0,0 +1,168 @@
|
||||
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}"
|
||||
|
||||
# #{version} is Ruby interpolation written literally to the cask
|
||||
# file (bash heredoc leaves "#{...}" alone). \${VERSION}, \${ARM_SHA},
|
||||
# etc. are bash variables expanded by the heredoc. The literal
|
||||
# #{version} fixes Homebrew's "URL is unversioned" audit warning by
|
||||
# making the version string statically detectable.
|
||||
cat > "tap/Casks/${CASK_NAME}.rb" <<EOF
|
||||
cask "${CASK_NAME}" do
|
||||
version "${VERSION}"
|
||||
|
||||
on_arm do
|
||||
sha256 "${ARM_SHA}"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${ARM_NAME}"
|
||||
end
|
||||
on_intel do
|
||||
sha256 "${X64_SHA}"
|
||||
|
||||
url "https://github.com/${REPO}/releases/download/v#{version}/${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/Caches/com.siddharthvaddem.openscreen",
|
||||
"~/Library/Logs/Openscreen",
|
||||
"~/Library/Preferences/com.siddharthvaddem.openscreen.plist",
|
||||
"~/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
|
||||
@@ -50,7 +50,20 @@ Download the latest installer for your platform from the [GitHub Releases](https
|
||||
|
||||
### macOS
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
The easiest way to install on macOS is via [Homebrew](https://brew.sh):
|
||||
|
||||
```bash
|
||||
brew install --cask siddharthvaddem/openscreen/openscreen
|
||||
```
|
||||
|
||||
Brew automatically picks the right build for Apple Silicon or Intel, and verifies the download against a notarized signature so Gatekeeper won't block it.
|
||||
|
||||
To update later: `brew upgrade --cask openscreen`
|
||||
To uninstall: `brew uninstall --cask openscreen` (add `--zap` to also remove app data)
|
||||
|
||||
#### Manual install (if you prefer)
|
||||
|
||||
If you'd rather grab the `.dmg` directly from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) and encounter Gatekeeper blocking the app, you can bypass it by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
|
||||
@@ -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 |
|
||||
@@ -51,8 +51,8 @@
|
||||
"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.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
|
||||
Vendored
+26
-3
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -15,12 +15,14 @@ import commonKo from "../src/i18n/locales/ko-KR/common.json";
|
||||
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
|
||||
import commonTr from "../src/i18n/locales/tr/common.json";
|
||||
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
|
||||
import commonVi from "../src/i18n/locales/vi/common.json";
|
||||
import dialogsVi from "../src/i18n/locales/vi/dialogs.json";
|
||||
import commonZh from "../src/i18n/locales/zh-CN/common.json";
|
||||
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar";
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
@@ -34,6 +36,7 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||
tr: { common: commonTr, dialogs: dialogsTr },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
@@ -48,7 +51,8 @@ export function setMainLocale(locale: string) {
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "tr" ||
|
||||
locale === "ar"
|
||||
locale === "ar" ||
|
||||
locale === "vi"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
+112
-14
@@ -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";
|
||||
|
||||
@@ -717,6 +718,32 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("request-screen-access", async () => {
|
||||
if (process.platform !== "darwin") {
|
||||
return { success: true, granted: true, status: "granted" };
|
||||
}
|
||||
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (status === "granted") {
|
||||
return { success: true, granted: true, status };
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt
|
||||
// is triggered by desktopCapturer.getSources(). Fire it and return so
|
||||
// the renderer can re-check status after the user responds.
|
||||
if (status === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
return { success: true, granted: false, status: "not-determined" };
|
||||
}
|
||||
|
||||
return { success: true, granted: false, status };
|
||||
} catch (error) {
|
||||
console.error("Failed to request screen access:", error);
|
||||
return { success: false, granted: false, status: "unknown", error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// macOS Accessibility prompt for global click capture. First call shows the
|
||||
// system dialog; the user has to toggle the app in System Settings (no
|
||||
// programmatic grant exists for Accessibility).
|
||||
@@ -985,20 +1012,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 +1050,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 +1086,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 +1374,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) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+53
-31
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
desktopCapturer,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -333,6 +333,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 +365,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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -453,23 +450,48 @@ app.whenReady().then(async () => {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
// Allow microphone/media permission checks
|
||||
// Allow microphone/media/screen permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||
const allowed = [
|
||||
"media",
|
||||
"audioCapture",
|
||||
"microphone",
|
||||
"videoCapture",
|
||||
"camera",
|
||||
"screen",
|
||||
"display-capture",
|
||||
];
|
||||
return allowed.includes(permission);
|
||||
});
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||
const allowed = [
|
||||
"media",
|
||||
"audioCapture",
|
||||
"microphone",
|
||||
"videoCapture",
|
||||
"camera",
|
||||
"screen",
|
||||
"display-capture",
|
||||
];
|
||||
callback(allowed.includes(permission));
|
||||
});
|
||||
|
||||
// Request microphone permission from macOS
|
||||
// Request microphone and screen recording permissions from macOS
|
||||
if (process.platform === "darwin") {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
|
||||
if (micStatus !== "granted") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
|
||||
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
|
||||
// triggered by the first desktopCapturer.getSources() call. Firing it here
|
||||
// at startup settles the permission state early and prevents repeated prompts
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
|
||||
+21
-2
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -33,7 +33,7 @@ buildNpmPackage {
|
||||
);
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
|
||||
npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
|
||||
|
||||
Generated
+36
-55
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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,16 @@
|
||||
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 viDialogs from "@/i18n/locales/vi/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 +40,15 @@ 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,
|
||||
vi: viDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ export const SUPPORTED_LOCALES = [
|
||||
"ko-KR",
|
||||
"ja-JP",
|
||||
"ar",
|
||||
"ru",
|
||||
"vi",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Все файлы"
|
||||
}
|
||||
}
|
||||
@@ -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": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
|
||||
}
|
||||
}
|
||||
@@ -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": "Оставить текущий язык"
|
||||
}
|
||||
}
|
||||
@@ -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": "Язык"
|
||||
}
|
||||
}
|
||||
@@ -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": "Кадр вперёд"
|
||||
}
|
||||
}
|
||||
@@ -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}} предложений масштабирования на основе курсора"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Hủy",
|
||||
"save": "Lưu",
|
||||
"delete": "Xóa",
|
||||
"close": "Đóng",
|
||||
"share": "Chia sẻ",
|
||||
"done": "Hoàn tất",
|
||||
"open": "Mở",
|
||||
"upload": "Tải lên",
|
||||
"export": "Xuất",
|
||||
"showInFolder": "Hiển thị trong thư mục",
|
||||
"file": "Tệp",
|
||||
"edit": "Chỉnh sửa",
|
||||
"view": "Xem",
|
||||
"window": "Cửa sổ",
|
||||
"quit": "Thoát",
|
||||
"stopRecording": "Dừng ghi hình"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Phát",
|
||||
"pause": "Tạm dừng",
|
||||
"fullscreen": "Toàn màn hình",
|
||||
"exitFullscreen": "Thoát toàn màn hình"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Tiếng Việt",
|
||||
"short": "VI"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Xuất hoàn tất",
|
||||
"yourFormatReady": "{{format}} của bạn đã sẵn sàng",
|
||||
"showInFolder": "Hiển thị trong thư mục",
|
||||
"finalizingVideo": "Đang hoàn tất xuất video...",
|
||||
"compilingGifProgress": "Đang biên dịch GIF... {{progress}}%",
|
||||
"compilingGifWait": "Đang biên dịch GIF... Có thể mất một lúc",
|
||||
"takeMoment": "Có thể mất một chút thời gian...",
|
||||
"failed": "Xuất thất bại",
|
||||
"tryAgain": "Vui lòng thử lại",
|
||||
"finalizingVideoTitle": "Hoàn tất video",
|
||||
"compilingGif": "Biên dịch GIF",
|
||||
"exportingFormat": "Đang xuất {{format}}",
|
||||
"compiling": "Đang biên dịch",
|
||||
"renderingFrames": "Kết xuất khung hình",
|
||||
"processing": "Đang xử lý...",
|
||||
"finalizing": "Đang hoàn tất...",
|
||||
"compilingStatus": "Đang biên dịch...",
|
||||
"status": "Trạng thái",
|
||||
"format": "Định dạng",
|
||||
"frames": "Khung hình",
|
||||
"cancelExport": "Hủy xuất",
|
||||
"savedSuccessfully": "Đã lưu {{format}} thành công!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Cách hoạt động của công cụ cắt",
|
||||
"title": "Cách cắt video",
|
||||
"description": "Hiểu cách cắt bỏ các phần không mong muốn trong video của bạn.",
|
||||
"explanationBefore": "Công cụ Cắt hoạt động bằng cách xác định các đoạn bạn muốn",
|
||||
"remove": "xóa",
|
||||
"explanationMiddle": " — bất cứ thứ gì",
|
||||
"covered": "được bao phủ",
|
||||
"explanationAfter": "bởi một đoạn cắt màu đỏ sẽ bị loại bỏ khi bạn xuất.",
|
||||
"visualExample": "Ví dụ trực quan",
|
||||
"removed": "ĐÃ XÓA",
|
||||
"kept": "Giữ lại",
|
||||
"part1": "Phần 1",
|
||||
"part2": "Phần 2",
|
||||
"part3": "Phần 3",
|
||||
"finalVideo": "Video cuối cùng",
|
||||
"step1Title": "1. Thêm đoạn cắt",
|
||||
"step1DescriptionBefore": "Nhấn ",
|
||||
"step1DescriptionAfter": " hoặc nhấp vào biểu tượng cái kéo để đánh dấu một phần cần xóa.",
|
||||
"step2Title": "2. Điều chỉnh",
|
||||
"step2Description": "Kéo các cạnh của vùng màu đỏ để bao phủ chính xác những gì bạn muốn cắt bỏ."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Thay đổi chưa được lưu",
|
||||
"message": "Bạn có các thay đổi chưa được lưu.",
|
||||
"detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?",
|
||||
"saveAndClose": "Lưu & Đóng",
|
||||
"discardAndClose": "Bỏ qua & Đóng",
|
||||
"loadProject": "Tải dự án…",
|
||||
"saveProject": "Lưu dự án…",
|
||||
"saveProjectAs": "Lưu dự án thành…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Lưu GIF đã xuất",
|
||||
"saveVideo": "Lưu Video đã xuất",
|
||||
"selectVideo": "Chọn tệp video",
|
||||
"saveProject": "Lưu dự án OpenScreen",
|
||||
"openProject": "Mở dự án OpenScreen",
|
||||
"gifImage": "Hình ảnh GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"videoFiles": "Tệp Video",
|
||||
"openscreenProject": "Dự án OpenScreen",
|
||||
"allFiles": "Tất cả các tệp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Quay lại Trình ghi",
|
||||
"description": "Phiên hiện tại của bạn đã được lưu.",
|
||||
"cancel": "Hủy",
|
||||
"confirm": "Xác nhận"
|
||||
},
|
||||
"loadingVideo": "Đang tải video...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Chưa tải video nào",
|
||||
"videoNotReady": "Video chưa sẵn sàng",
|
||||
"unableToDetermineSourcePath": "Không thể xác định đường dẫn video gốc",
|
||||
"failedToSaveGif": "Không thể lưu GIF",
|
||||
"gifExportFailed": "Xuất GIF thất bại",
|
||||
"failedToSaveVideo": "Không thể lưu video",
|
||||
"exportFailed": "Xuất thất bại",
|
||||
"exportFailedWithError": "Xuất thất bại: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Xuất thất bại: không thể tải hình nền ({{url}})",
|
||||
"failedToSaveExport": "Không thể lưu bản xuất",
|
||||
"failedToSaveExportedVideo": "Không thể lưu video đã xuất",
|
||||
"failedToRevealInFolder": "Lỗi khi hiển thị trong thư mục: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Đã hủy xuất",
|
||||
"exportedSuccessfully": "Đã xuất {{format}} thành công"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Đã hủy lưu dự án",
|
||||
"failedToSave": "Lưu dự án thất bại",
|
||||
"savedTo": "Đã lưu dự án vào {{path}}",
|
||||
"failedToLoad": "Tải dự án thất bại",
|
||||
"invalidFormat": "Định dạng tệp dự án không hợp lệ",
|
||||
"loadedFrom": "Đã tải dự án từ {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Yêu cầu quyền truy cập máy ảnh thất bại.",
|
||||
"cameraBlocked": "Quyền truy cập máy ảnh bị chặn. Hãy bật nó trong cài đặt hệ thống để sử dụng webcam.",
|
||||
"systemAudioUnavailable": "Âm thanh hệ thống không khả dụng. Ghi hình không có âm thanh hệ thống.",
|
||||
"microphoneDenied": "Quyền truy cập micro bị từ chối. Sẽ tiếp tục ghi hình không có âm thanh.",
|
||||
"cameraDenied": "Quyền truy cập máy ảnh bị từ chối. Sẽ tiếp tục ghi hình không có webcam.",
|
||||
"cameraDisconnected": "Webcam bị ngắt kết nối.",
|
||||
"cameraNotFound": "Không tìm thấy máy ảnh.",
|
||||
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Ẩn HUD",
|
||||
"closeApp": "Đóng ứng dụng",
|
||||
"restartRecording": "Khởi động lại ghi hình",
|
||||
"cancelRecording": "Hủy ghi hình",
|
||||
"pauseRecording": "Tạm dừng ghi hình",
|
||||
"resumeRecording": "Tiếp tục ghi hình",
|
||||
"openVideoFile": "Mở tệp video",
|
||||
"openProject": "Mở dự án"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Bật âm thanh hệ thống",
|
||||
"disableSystemAudio": "Tắt âm thanh hệ thống",
|
||||
"enableMicrophone": "Bật micro",
|
||||
"disableMicrophone": "Tắt micro",
|
||||
"defaultMicrophone": "Micro Mặc định"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Bật webcam",
|
||||
"disableWebcam": "Tắt webcam",
|
||||
"defaultCamera": "Máy ảnh Mặc định",
|
||||
"searching": "Đang tìm kiếm...",
|
||||
"noneFound": "Không tìm thấy máy ảnh",
|
||||
"unavailable": "Máy ảnh không khả dụng"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Đang tải nguồn...",
|
||||
"screens": "Màn hình ({{count}})",
|
||||
"windows": "Cửa sổ ({{count}})",
|
||||
"defaultSourceName": "Màn hình"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Vui lòng chọn một nguồn để ghi"
|
||||
},
|
||||
"language": "Ngôn ngữ",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Sử dụng ngôn ngữ hệ thống của bạn?",
|
||||
"description": "Chúng tôi phát hiện {{language}} là ngôn ngữ hệ thống của bạn. Bạn có muốn chuyển OpenScreen sang {{language}} không?",
|
||||
"switch": "Chuyển sang {{language}}",
|
||||
"keepDefault": "Giữ ngôn ngữ hiện tại"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Mức độ thu phóng",
|
||||
"selectRegion": "Chọn vùng thu phóng để điều chỉnh",
|
||||
"deleteZoom": "Xóa thu phóng",
|
||||
"focusMode": {
|
||||
"title": "Chế độ lấy nét",
|
||||
"manual": "Thủ công",
|
||||
"auto": "Tự động",
|
||||
"autoDescription": "Máy ảnh đi theo vị trí con trỏ đã ghi"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Tốc độ phát",
|
||||
"selectRegion": "Chọn vùng tốc độ để điều chỉnh",
|
||||
"deleteRegion": "Xóa vùng tốc độ",
|
||||
"customPlaybackSpeed": "Tốc độ phát tùy chỉnh",
|
||||
"maxSpeedError": "Tốc độ không thể cao hơn 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Xóa vùng cắt"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Bố cục",
|
||||
"preset": "Cài đặt sẵn",
|
||||
"selectPreset": "Chọn cài đặt sẵn",
|
||||
"pictureInPicture": "Hình trong hình",
|
||||
"verticalStack": "Xếp chồng dọc",
|
||||
"dualFrame": "Khung kép",
|
||||
"webcamShape": "Hình dạng máy ảnh",
|
||||
"webcamSize": "Kích thước Webcam"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Hiệu ứng video",
|
||||
"blurBg": "Làm mờ nền",
|
||||
"motionBlur": "Làm mờ chuyển động",
|
||||
"off": "tắt",
|
||||
"shadow": "Bóng đổ",
|
||||
"roundness": "Độ bo tròn",
|
||||
"padding": "Phần đệm"
|
||||
},
|
||||
"background": {
|
||||
"title": "Nền",
|
||||
"image": "Hình ảnh",
|
||||
"color": "Màu sắc",
|
||||
"gradient": "Dải màu",
|
||||
"uploadCustom": "Tải lên tùy chỉnh",
|
||||
"gradientLabel": "Dải màu {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Cắt xén",
|
||||
"cropVideo": "Cắt xén video",
|
||||
"dragInstruction": "Kéo ở mỗi cạnh để điều chỉnh vùng cắt xén",
|
||||
"ratio": "Tỷ lệ",
|
||||
"free": "Tự do",
|
||||
"done": "Hoàn tất",
|
||||
"lockAspectRatio": "Khóa tỷ lệ khung hình",
|
||||
"unlockAspectRatio": "Mở khóa tỷ lệ khung hình"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Video MP4",
|
||||
"mp4Description": "Tệp video chất lượng cao",
|
||||
"gifAnimation": "Ảnh động GIF",
|
||||
"gifDescription": "Hình ảnh động để chia sẻ"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Chất lượng xuất",
|
||||
"low": "Thấp",
|
||||
"medium": "Trung bình",
|
||||
"high": "Cao"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Tốc độ khung hình GIF",
|
||||
"size": "Kích thước GIF",
|
||||
"loop": "Lặp lại GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "Lưu dự án",
|
||||
"load": "Tải dự án"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Xuất Video",
|
||||
"gifButton": "Xuất GIF",
|
||||
"chooseSaveLocation": "Chọn vị trí lưu"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Báo cáo lỗi",
|
||||
"starOnGithub": "Đánh giá sao trên GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Loại tệp không hợp lệ",
|
||||
"jpgOnly": "Vui lòng tải lên tệp hình ảnh JPG hoặc JPEG.",
|
||||
"uploadSuccess": "Tải lên hình ảnh tùy chỉnh thành công!",
|
||||
"failedToUpload": "Tải lên hình ảnh thất bại",
|
||||
"errorReading": "Đã xảy ra lỗi khi đọc tệp."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Cài đặt chú thích",
|
||||
"active": "Hoạt động",
|
||||
"typeText": "Văn bản",
|
||||
"typeImage": "Hình ảnh",
|
||||
"typeArrow": "Mũi tên",
|
||||
"typeBlur": "Làm mờ",
|
||||
"textContent": "Nội dung văn bản",
|
||||
"textPlaceholder": "Nhập văn bản của bạn...",
|
||||
"fontStyle": "Kiểu phông chữ",
|
||||
"selectStyle": "Chọn kiểu",
|
||||
"size": "Kích thước",
|
||||
"customFonts": "Phông chữ tùy chỉnh",
|
||||
"textColor": "Màu văn bản",
|
||||
"background": "Nền",
|
||||
"none": "Không có",
|
||||
"color": "Màu sắc",
|
||||
"clearBackground": "Xóa nền",
|
||||
"uploadImage": "Tải lên hình ảnh",
|
||||
"supportedFormats": "Định dạng hỗ trợ: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Hướng mũi tên",
|
||||
"strokeWidth": "Độ dày nét: {{width}}px",
|
||||
"arrowColor": "Màu mũi tên",
|
||||
"blurType": "Loại làm mờ",
|
||||
"blurTypeBlur": "Làm mờ",
|
||||
"blurTypeMosaic": "Khảm",
|
||||
"blurColor": "Màu làm mờ",
|
||||
"blurColorWhite": "Trắng",
|
||||
"blurColorBlack": "Đen",
|
||||
"blurShape": "Hình dạng làm mờ",
|
||||
"blurIntensity": "Cường độ làm mờ",
|
||||
"mosaicBlockSize": "Kích thước khối khảm",
|
||||
"blurShapeRectangle": "Chữ nhật",
|
||||
"blurShapeOval": "Bầu dục",
|
||||
"blurShapeFreehand": "Vẽ tự do",
|
||||
"deleteAnnotation": "Xóa chú thích",
|
||||
"shortcutsAndTips": "Phím tắt & Mẹo",
|
||||
"tipMovePlayhead": "Di chuyển đầu phát đến phần chú thích chồng chéo và chọn một mục.",
|
||||
"tipTabCycle": "Sử dụng Tab để chuyển qua các mục chồng chéo.",
|
||||
"tipShiftTabCycle": "Sử dụng Shift+Tab để chuyển ngược lại.",
|
||||
"invalidImageType": "Loại tệp không hợp lệ",
|
||||
"imageFormatsOnly": "Vui lòng tải lên tệp hình ảnh JPG, PNG, GIF hoặc WebP.",
|
||||
"imageUploadSuccess": "Tải lên hình ảnh thành công!",
|
||||
"failedImageUpload": "Tải lên hình ảnh thất bại"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Cổ điển",
|
||||
"editor": "Trình chỉnh sửa",
|
||||
"strong": "Đậm",
|
||||
"typewriter": "Máy đánh chữ",
|
||||
"deco": "Trang trí",
|
||||
"simple": "Đơn giản",
|
||||
"modern": "Hiện đại",
|
||||
"clean": "Sạch sẽ"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Thêm Google Font",
|
||||
"urlLabel": "URL nhập Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Lấy từ Google Fonts: Chọn một phông chữ → Nhấp \"Get font\" → Sao chép URL @import",
|
||||
"nameLabel": "Tên hiển thị",
|
||||
"namePlaceholder": "Phông chữ tùy chỉnh của tôi",
|
||||
"nameHelp": "Đây là cách phông chữ sẽ xuất hiện trong bộ chọn phông chữ",
|
||||
"addButton": "Thêm phông chữ",
|
||||
"addingButton": "Đang thêm...",
|
||||
"errorEmptyUrl": "Vui lòng nhập URL nhập Google Fonts",
|
||||
"errorInvalidUrl": "Vui lòng nhập URL Google Fonts hợp lệ",
|
||||
"errorEmptyName": "Vui lòng nhập tên phông chữ",
|
||||
"errorExtractFailed": "Không thể trích xuất họ phông chữ từ URL",
|
||||
"successMessage": "Thêm phông chữ \"{{fontName}}\" thành công",
|
||||
"failedToAdd": "Thêm phông chữ thất bại",
|
||||
"errorTimeout": "Tải phông chữ mất quá nhiều thời gian. Vui lòng kiểm tra URL và thử lại.",
|
||||
"errorLoadFailed": "Không thể tải phông chữ. Vui lòng xác minh URL Google Fonts là chính xác."
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Phím tắt",
|
||||
"customize": "Tùy chỉnh",
|
||||
"configurable": "Có thể định cấu hình",
|
||||
"fixed": "Cố định",
|
||||
"pressKey": "Nhấn một phím…",
|
||||
"clickToChange": "Nhấp để thay đổi",
|
||||
"pressEscToCancel": "Nhấn Esc để hủy",
|
||||
"helpText": "Nhấp vào một phím tắt rồi nhấn tổ hợp phím mới. Nhấn Esc để hủy.",
|
||||
"resetToDefaults": "Khôi phục mặc định",
|
||||
"alreadyUsedBy": "Đã được sử dụng bởi {{action}}",
|
||||
"swap": "Hoán đổi",
|
||||
"reservedShortcut": "Phím tắt này được dành riêng cho \"{{label}}\" và không thể gán lại.",
|
||||
"savedToast": "Đã lưu phím tắt",
|
||||
"resetToast": "Đã đặt lại về phím tắt mặc định — nhấp Lưu để áp dụng",
|
||||
"actions": {
|
||||
"addZoom": "Thêm Thu phóng",
|
||||
"addTrim": "Thêm Cắt",
|
||||
"addSpeed": "Thêm Tốc độ",
|
||||
"addAnnotation": "Thêm Chú thích",
|
||||
"addBlur": "Thêm Làm mờ",
|
||||
"addKeyframe": "Thêm Khung hình chính",
|
||||
"deleteSelected": "Xóa mục đã chọn",
|
||||
"playPause": "Phát / Tạm dừng"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Hoàn tác",
|
||||
"redo": "Làm lại",
|
||||
"cycleAnnotationsForward": "Chuyển tiếp qua các chú thích",
|
||||
"cycleAnnotationsBackward": "Chuyển lùi qua các chú thích",
|
||||
"deleteSelectedAlt": "Xóa mục đã chọn (alt)",
|
||||
"panTimeline": "Xoay Trục thời gian",
|
||||
"zoomTimeline": "Thu phóng Trục thời gian",
|
||||
"frameBack": "Lùi một khung hình",
|
||||
"frameForward": "Tiến một khung hình"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Thêm Thu phóng (Z)",
|
||||
"suggestZooms": "Đề xuất Thu phóng từ Con trỏ",
|
||||
"addTrim": "Thêm Cắt (T)",
|
||||
"addAnnotation": "Thêm Chú thích (A)",
|
||||
"addBlur": "Thêm Làm mờ (B)",
|
||||
"addSpeed": "Thêm Tốc độ (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Nhấn Z để thêm thu phóng",
|
||||
"pressTrim": "Nhấn T để thêm cắt",
|
||||
"pressAnnotation": "Nhấn A để thêm chú thích",
|
||||
"pressBlur": "Nhấn B để thêm vùng làm mờ",
|
||||
"pressSpeed": "Nhấn S để thêm tốc độ"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Xoay",
|
||||
"zoom": "Thu phóng",
|
||||
"trim": "Cắt",
|
||||
"speed": "Tốc độ",
|
||||
"zoomItem": "Thu phóng {{index}}",
|
||||
"trimItem": "Cắt {{index}}",
|
||||
"speedItem": "Tốc độ {{index}}",
|
||||
"annotationItem": "Chú thích",
|
||||
"blurItem": "Làm mờ {{index}}",
|
||||
"imageItem": "Hình ảnh",
|
||||
"emptyText": "Văn bản trống"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Chưa tải video",
|
||||
"dragAndDrop": "Kéo và thả video để bắt đầu chỉnh sửa"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Không thể đặt thu phóng ở đây",
|
||||
"zoomExistsAtLocation": "Thu phóng đã tồn tại ở vị trí này hoặc không có đủ không gian.",
|
||||
"zoomSuggestionUnavailable": "Trình xử lý đề xuất thu phóng không khả dụng",
|
||||
"noCursorTelemetry": "Không có dữ liệu từ xa của con trỏ",
|
||||
"noCursorTelemetryDescription": "Ghi hình màn hình trước để tạo các đề xuất dựa trên con trỏ.",
|
||||
"noUsableTelemetry": "Không có dữ liệu từ xa của con trỏ có thể sử dụng",
|
||||
"noUsableTelemetryDescription": "Bản ghi không chứa đủ dữ liệu chuyển động của con trỏ.",
|
||||
"noDwellMoments": "Không tìm thấy khoảnh khắc dừng con trỏ rõ ràng",
|
||||
"noDwellMomentsDescription": "Thử ghi hình với các lần tạm dừng con trỏ chậm hơn ở các thao tác quan trọng.",
|
||||
"noAutoZoomSlots": "Không có khe thu phóng tự động nào",
|
||||
"noAutoZoomSlotsDescription": "Các điểm dừng được phát hiện chồng chéo với các vùng thu phóng hiện có.",
|
||||
"cannotPlaceTrim": "Không thể đặt cắt ở đây",
|
||||
"trimExistsAtLocation": "Cắt đã tồn tại ở vị trí này hoặc không có đủ không gian.",
|
||||
"cannotPlaceSpeed": "Không thể đặt tốc độ ở đây",
|
||||
"speedExistsAtLocation": "Vùng tốc độ đã tồn tại ở vị trí này hoặc không có đủ không gian."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ",
|
||||
"addedZoomSuggestionsPlural": "Đã thêm {{count}} đề xuất thu phóng dựa trên con trỏ"
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user