Merge remote-tracking branch 'upstream/main' into codex/allow-png-background-upload

# Conflicts:
#	src/components/video-editor/SettingsPanel.tsx
#	src/i18n/locales/ja-JP/settings.json
This commit is contained in:
Sunwood-ai-labs
2026-05-10 14:30:22 +09:00
94 changed files with 6659 additions and 2794 deletions
+8 -8
View File
@@ -20,18 +20,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Windows app
run: npm run build:win
env:
@@ -234,8 +231,10 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
# bsdtar (from libarchive-tools) is required by fpm to build pacman
# packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
- name: Install pacman build dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Build Linux app
run: npm run build:linux
@@ -250,4 +249,5 @@ jobs:
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
release/**/*.pacman
retention-days: 30
+118
View File
@@ -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
)"
+1
View File
@@ -41,6 +41,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test
- run: npm run test:browser:install
- run: npm run test:browser
+26
View File
@@ -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 }}
+168
View File
@@ -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
+87 -17
View File
@@ -1,5 +1,5 @@
> [!WARNING]
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
> This started as a side project that took off — it's not production grade and you'll hit bugs, but hopefully it covers what you need.
<p align="center">
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
@@ -21,11 +21,11 @@
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need - quick, polished product demos and walkthroughs you'd post on X, Reddit. OpenScreen does not offer all Screen Studio features, but covers the basics well!
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
**100% free** for both **personal** and **commercial** use. Use it, modify it, distribute it — just be cool 😁 and shout out the project if you feel like it.
<p align="center">
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
@@ -33,16 +33,19 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist
</p>
## Core Features
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record a specific window, region, or your whole screen.
- Record microphone and system audio.
- Crop video recordings to hide parts.
- Choose between wallpapers, solid colors, gradients or a custom background.
- Motion blur for smoother pan and zoom effects.
- Add annotations (text, arrows, images).
- Trim sections of the clip.
- Customize the speed of different segments.
- Export in different aspect ratios and resolutions.
- Webcam overlay with picture-in-picture, drag-to-position, and shape options.
- Auto or manual zooms with adjustable depth, duration, easing, and pixel-precise position.
- Wallpapers, solid colors, gradients, or a custom background.
- Motion blur for smoother pan and zoom transitions.
- Crop, trim, and per-segment speed control on the timeline.
- Blur effects to hide sensitive parts of the screen.
- Cursor and click highlighting.
- Text, arrow, and image annotations.
- Save and reopen projects without re-recording.
- Export to MP4 or GIF in multiple aspect ratios and resolutions.
- Translated into Arabic, English, Spanish, French, Japanese, Korean, Russian, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese.
## Installation
@@ -50,7 +53,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
@@ -60,18 +76,72 @@ Note: Give your terminal Full Disk Access in **System Settings > Privacy & Secur
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
### Windows
Install via [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/):
```bash
winget install SiddharthVaddem.OpenScreen
```
To update later: `winget upgrade SiddharthVaddem.OpenScreen`
To uninstall: `winget uninstall SiddharthVaddem.OpenScreen`
If you'd rather grab the `.exe` installer directly, download it from the [Releases page](https://github.com/siddharthvaddem/openscreen/releases).
### Linux
Download the `.AppImage` file from the releases page. Make it executable and run:
Three packages are published to the [Releases page](https://github.com/siddharthvaddem/openscreen/releases) for each version. Pick the one that matches your distro:
**Debian / Ubuntu / Pop!_OS (`.deb`)**
```bash
sudo apt install ./Openscreen-Linux-latest.deb
```
**Arch / Manjaro (`.pacman`)**
```bash
sudo pacman -U Openscreen-Linux-latest.pacman
```
**Any distro (`.AppImage`)**
```bash
chmod +x Openscreen-Linux-*.AppImage
./Openscreen-Linux-*.AppImage
```
**NixOS / Nix (flake)**
Try without installing:
```bash
nix run github:siddharthvaddem/openscreen
```
Install into your user profile:
```bash
nix profile install github:siddharthvaddem/openscreen
```
For a NixOS system config (flake):
```nix
{
inputs.openscreen.url = "github:siddharthvaddem/openscreen";
outputs = { nixpkgs, openscreen, ... }: {
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
modules = [
openscreen.nixosModules.default
{ programs.openscreen.enable = true; }
];
};
};
}
```
For Home Manager, use `openscreen.homeManagerModules.default` with the same `programs.openscreen.enable = true;`.
You may need to grant screen recording permissions depending on your desktop environment.
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
**Sandbox error:** If the AppImage fails to launch with a "sandbox" error, run it with `--no-sandbox`:
```bash
./Openscreen-Linux-*.AppImage --no-sandbox
```
@@ -94,16 +164,16 @@ System audio capture relies on Electron's [desktopCapturer](https://www.electron
---
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
## Documentation
See the documentation here:
[OpenScreen Docs](https://deepwiki.com/siddharthvaddem/openscreen)
Refresh if outdated.
## Contributing
Contributions are welcome! If youd like to help out or see whats currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
Contributions are welcome - please **include screenshots or a short video** for any UI change or new user-facing feature. If it touches what users see or do, show it. Skip only when it genuinely doesn't apply. PRs that don't follow this will be closed.
## Star History
+149
View File
@@ -0,0 +1,149 @@
# Writing Tests
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
## Unit tests
**Config:** `vitest.config.ts`
**Runs in:** jsdom (simulated DOM, no real browser)
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
**CI command:** `npm run test`
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
### File placement
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
```
src/lib/compositeLayout.ts
src/lib/compositeLayout.test.ts # co-located
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
```
### Example
```ts
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
});
```
### Path aliases
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
```ts
import { SUPPORTED_LOCALES } from "@/i18n/config";
```
### Running locally
```bash
npm run test # run once
npm run test:watch # watch mode
```
---
## Browser tests
**Config:** `vitest.browser.config.ts`
**Runs in:** real Chromium via Playwright (headless)
**File pattern:** `src/**/*.browser.test.ts`
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
### File placement
Name the file `<subject>.browser.test.ts` and place it next to the source file.
```
src/lib/exporter/videoExporter.ts
src/lib/exporter/videoExporter.browser.test.ts
```
### Loading fixture assets
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
```ts
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
```
### Example
```ts
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
});
});
```
### Timeouts
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
### Running locally
First install the browser (one-time):
```bash
npm run test:browser:install
```
Then run the tests:
```bash
npm run test:browser
```
---
## Choosing the right type
| Situation | Use |
|---|---|
| Pure function / data transformation | Unit test |
| i18n key coverage | Unit test |
| React hook logic (no real browser APIs) | Unit test |
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
| File export producing a real `Blob` | Browser test |
+10 -3
View File
@@ -3,6 +3,11 @@
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.siddharthvaddem.openscreen",
"asar": true,
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
"asarUnpack": [
"node_modules/uiohook-napi/**/*",
"**/*.node"
],
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
@@ -46,13 +51,15 @@
"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": {
"target": [
"AppImage"
"AppImage",
"deb",
"pacman"
],
"icon": "icons/icons/png",
"artifactName": "${productName}-Linux-${version}.${ext}",
+33 -3
View File
@@ -37,6 +37,11 @@ interface Window {
status: string;
error?: string;
}>;
requestAccessibilityAccess: () => Promise<{
success: boolean;
granted: boolean;
error?: string;
}>;
assetBaseUrl: string;
storeRecordedVideo: (
videoData: ArrayBuffer,
@@ -68,15 +73,31 @@ interface Window {
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
clicks: number[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
pickExportSavePath: (
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
exportFolder?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
writeExportToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
@@ -136,6 +157,7 @@ interface Window {
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
@@ -143,7 +165,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 }>;
};
}
+10 -2
View File
@@ -1,6 +1,8 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
import commonAr from "../src/i18n/locales/ar/common.json";
import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
@@ -13,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";
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>;
@@ -31,6 +35,8 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
"ja-JP": { common: commonJa, dialogs: dialogsJa },
"ko-KR": { common: commonKo, dialogs: dialogsKo },
tr: { common: commonTr, dialogs: dialogsTr },
ar: { common: commonAr, dialogs: dialogsAr },
vi: { common: commonVi, dialogs: dialogsVi },
};
let currentLocale: Locale = "en";
@@ -44,7 +50,9 @@ export function setMainLocale(locale: string) {
locale === "fr" ||
locale === "ja-JP" ||
locale === "ko-KR" ||
locale === "tr"
locale === "tr" ||
locale === "ar" ||
locale === "vi"
) {
currentLocale = locale;
}
+329 -63
View File
@@ -1,6 +1,11 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const nodeRequire = createRequire(import.meta.url);
import {
app,
BrowserWindow,
@@ -56,6 +61,21 @@ function isPathAllowed(filePath: string): boolean {
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
}
/**
* Helper function to build dialog options with a parent window only when it's valid.
* This prevents passing stale or destroyed BrowserWindow references to dialog calls.
*/
function buildDialogOptions<T extends Electron.OpenDialogOptions | Electron.SaveDialogOptions>(
baseOptions: T,
parentWindow: BrowserWindow | null,
): T & { parent?: BrowserWindow } {
const mainWindow = parentWindow;
if (mainWindow && !mainWindow.isDestroyed()) {
return { ...baseOptions, parent: mainWindow };
}
return baseOptions;
}
function hasAllowedImportVideoExtension(filePath: string): boolean {
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
@@ -280,19 +300,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
const telemetryPath = `${screenVideoPath}.cursor.json`;
const pendingBatch = cursorTelemetryBuffer.takeNextBatch();
if (pendingBatch && pendingBatch.samples.length > 0) {
const pendingClicks = takeCursorClickTimestamps();
if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) {
try {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples },
{
version: CURSOR_TELEMETRY_VERSION,
samples: pendingBatch?.samples ?? [],
clicks: pendingClicks,
},
null,
2,
),
"utf-8",
);
} catch (err) {
cursorTelemetryBuffer.prependBatch(pendingBatch);
if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch);
throw err;
}
}
@@ -321,15 +346,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({
maxActiveSamples: MAX_CURSOR_SAMPLES,
});
// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility).
const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour
let cursorClickTimestampsMs: number[] = [];
let uioHookInstance: {
start: () => void;
stop: () => void;
on: (...a: unknown[]) => void;
off?: (...a: unknown[]) => void;
removeListener?: (...a: unknown[]) => void;
} | null = null;
let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null;
let uioHookFailureLogged = false;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function loadUioHookForClicks(): typeof uioHookInstance {
try {
// Dynamic require + try/catch so a broken native binary doesn't crash startup.
const mod = nodeRequire("uiohook-napi");
const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default;
if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") {
return candidate;
}
return null;
} catch (error) {
if (!uioHookFailureLogged) {
uioHookFailureLogged = true;
console.warn("[clickCapture] uiohook-napi unavailable:", error);
}
return null;
}
}
function startClickCapture() {
if (process.platform !== "darwin") return;
if (uioHookInstance) return;
// Passive check — the prompt fires from the renderer when the user toggles
// "Only on clicks" so it doesn't stack with the screen-recording prompt.
try {
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
if (!uioHookFailureLogged) {
uioHookFailureLogged = true;
console.warn(
"[clickCapture] Accessibility permission not granted — click capture disabled.",
);
}
return;
}
} catch {
// fall through; uiohook will fail defensively below
}
const hook = loadUioHookForClicks();
if (!hook) return;
uioHookMouseDownHandler = (event) => {
const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs);
void event;
if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return;
cursorClickTimestampsMs.push(elapsed);
};
try {
hook.on("mousedown", uioHookMouseDownHandler);
hook.start();
uioHookInstance = hook;
} catch (error) {
if (!uioHookFailureLogged) {
uioHookFailureLogged = true;
console.warn("[clickCapture] failed to start uiohook:", error);
}
uioHookMouseDownHandler = null;
}
}
function stopClickCapture() {
if (!uioHookInstance) return;
try {
if (uioHookMouseDownHandler) {
if (typeof uioHookInstance.off === "function") {
uioHookInstance.off("mousedown", uioHookMouseDownHandler);
} else if (typeof uioHookInstance.removeListener === "function") {
uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler);
}
}
uioHookInstance.stop();
} catch (error) {
console.warn("[clickCapture] failed to stop uiohook:", error);
}
uioHookInstance = null;
uioHookMouseDownHandler = null;
}
function takeCursorClickTimestamps(): number[] {
const out = cursorClickTimestampsMs;
cursorClickTimestampsMs = [];
return out;
}
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
cursorCaptureInterval = null;
}
stopClickCapture();
}
function sampleCursorPoint() {
@@ -594,6 +718,50 @@ 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(() => {
// Ignore prompt trigger failures; the renderer will re-check status.
});
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).
ipcMain.handle("request-accessibility-access", () => {
if (process.platform !== "darwin") {
return { success: true, granted: true };
}
try {
const granted = systemPreferences.isTrustedAccessibilityClient(true);
return { success: true, granted };
} catch (error) {
console.error("Failed to request accessibility access:", error);
return { success: false, granted: false, error: String(error) };
}
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
@@ -723,6 +891,8 @@ export function registerIpcHandlers(
const id = typeof recordingId === "number" ? recordingId : Date.now();
cursorTelemetryBuffer.startSession(id);
cursorCaptureStartTimeMs = Date.now();
cursorClickTimestampsMs = [];
startClickCapture();
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else {
@@ -787,11 +957,19 @@ export function registerIpcHandlers(
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
return { success: true, samples };
const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : [];
const clicks: number[] = rawClicks
.map((value: unknown) =>
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null,
)
.filter((v: number | null): v is number => v !== null)
.sort((a: number, b: number) => a - b);
return { success: true, samples, clicks };
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
return { success: true, samples: [] };
return { success: true, samples: [], clicks: [] };
}
console.error("Failed to load cursor telemetry:", error);
return {
@@ -799,6 +977,7 @@ export function registerIpcHandlers(
message: "Failed to load cursor telemetry",
error: String(error),
samples: [],
clicks: [],
};
}
});
@@ -835,38 +1014,72 @@ export function registerIpcHandlers(
* @returns Object with success status, optional file path, and error details.
*/
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
const result = await dialog.showSaveDialog({
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
});
// Prefer the user's last export folder if it still exists, otherwise fall
// back to ~/Downloads. Validation must happen here because the renderer
// can't stat the filesystem.
let defaultDir = app.getPath("downloads");
if (exportFolder) {
try {
const stats = await fs.stat(exportFolder);
if (stats.isDirectory()) {
defaultDir = exportFolder;
}
} catch (err) {
console.warn(
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
err,
);
}
}
const dialogOptions = buildDialogOptions(
{
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(defaultDir, fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
},
getMainWindow(),
);
const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
return { success: false, canceled: true, message: "Export canceled" };
}
// --- FIX: Normalize the path for Windows compatibility ---
const normalizedPath = path.normalize(result.filePath);
return { success: true, path: path.normalize(result.filePath) };
} catch (error) {
console.error("Failed to show save dialog:", error);
return {
success: false,
message: "Failed to show save dialog",
error: String(error),
};
}
});
// Ensure the parent directory exists (Windows may fail if the folder is missing)
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
try {
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
// but a stale state bug shouldn't be able to clobber arbitrary files.
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
return { success: false, message: "Invalid path" };
}
const lower = filePath.toLowerCase();
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
return { success: false, message: "Invalid file type" };
}
const normalizedPath = path.normalize(filePath);
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
// --- END FIX ---
await fs.writeFile(normalizedPath, Buffer.from(videoData));
return {
@@ -875,7 +1088,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",
@@ -885,18 +1098,22 @@ export function registerIpcHandlers(
});
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: mainT("dialogs", "fileDialogs.selectVideo"),
defaultPath: RECORDINGS_DIR,
filters: [
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.selectVideo"),
defaultPath: RECORDINGS_DIR,
filters: [
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
},
getMainWindow(),
);
const result = await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
@@ -975,18 +1192,22 @@ export function registerIpcHandlers(
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
const result = await dialog.showSaveDialog({
title: mainT("dialogs", "fileDialogs.saveProject"),
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
});
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.saveProject"),
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
},
getMainWindow(),
);
const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
@@ -1017,19 +1238,23 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
const result = await dialog.showOpenDialog({
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
filters: [
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
},
getMainWindow(),
);
const result = await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: "Open project canceled" };
@@ -1151,4 +1376,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) };
}
},
);
}
+169 -57
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
desktopCapturer,
ipcMain,
Menu,
nativeImage,
@@ -30,6 +30,18 @@ if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
// Enable Wayland support for proper screen capture and window management
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
if (process.platform === "linux") {
const isWayland =
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
if (isWayland) {
app.commandLine.appendSwitch("ozone-platform", "wayland");
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
}
}
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
@@ -124,15 +136,30 @@ function setupApplicationMenu() {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{
role: "about",
label: mainT("common", "actions.about") || "About OpenScreen",
},
{ type: "separator" },
{ role: "services" },
{
role: "services",
label: mainT("common", "actions.services") || "Services",
},
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{
role: "hide",
label: mainT("common", "actions.hide") || "Hide OpenScreen",
},
{
role: "hideOthers",
label: mainT("common", "actions.hideOthers") || "Hide Others",
},
{
role: "unhide",
label: mainT("common", "actions.unhide") || "Show All",
},
{ type: "separator" },
{ role: "quit" },
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
],
});
}
@@ -156,40 +183,89 @@ function setupApplicationMenu() {
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
...(isMac
? []
: [
{ type: "separator" as const },
{
role: "quit" as const,
label: mainT("common", "actions.quit") || "Quit",
},
]),
],
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
{
role: "selectAll",
label: mainT("common", "actions.selectAll") || "Select All",
},
],
},
{
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{
role: "reload",
label: mainT("common", "actions.reload") || "Reload",
},
{
role: "forceReload",
label: mainT("common", "actions.forceReload") || "Force Reload",
},
{
role: "toggleDevTools",
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
},
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{
role: "resetZoom",
label: mainT("common", "actions.actualSize") || "Actual Size",
},
{
role: "zoomIn",
label: mainT("common", "actions.zoomIn") || "Zoom In",
},
{
role: "zoomOut",
label: mainT("common", "actions.zoomOut") || "Zoom Out",
},
{ type: "separator" },
{ role: "togglefullscreen" },
{
role: "togglefullscreen",
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
},
],
},
{
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
? [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{ role: "zoom" },
{ type: "separator" },
{ role: "front" },
]
: [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{
role: "close",
label: mainT("common", "actions.close") || "Close",
},
],
},
);
@@ -220,7 +296,11 @@ function getTrayIcon(filename: string, size: number) {
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const trayToolTip = recording
? mainT("common", "actions.recordingStatus", {
source: selectedSourceName,
}) || `Recording: ${selectedSourceName}`
: "OpenScreen";
const menuTemplate = recording
? [
{
@@ -253,6 +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;
@@ -284,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
});
});
}
@@ -340,10 +417,11 @@ function createCountdownOverlayWindowWrapper() {
return countdownOverlayWindow;
}
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
// Closing every window quits the app entirely (tray icon goes too).
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
// so closing the last window is an explicit "I'm done" signal.
app.on("window-all-closed", () => {
// Keep app running (macOS behavior)
app.quit();
});
app.on("activate", () => {
@@ -365,23 +443,57 @@ app.on("activate", () => {
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Allow microphone/media permission checks
// Force the app into "regular" activation policy so the Dock icon appears.
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
// window we open, and AppKit otherwise classifies us as an accessory app.
if (process.platform === "darwin") {
app.dock?.show();
}
// Allow microphone/media/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(() => {
// Ignore prompt trigger failures; later capture attempts report status.
});
}
}
// Listen for HUD overlay quit event (macOS only)
+27 -2
View File
@@ -16,6 +16,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
@@ -40,6 +43,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
requestAccessibilityAccess: () => {
return ipcRenderer.invoke("request-accessibility-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
@@ -68,8 +74,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
pickExportSavePath: (fileName: string, exportFolder?: string) => {
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
},
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
@@ -131,6 +140,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
saveDiagnostic: (payload: {
error: string;
stack?: string;
projectState: unknown;
logs: string[];
}) => {
return ipcRenderer.invoke("save-diagnostic", payload);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
@@ -163,4 +180,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);
},
});
+7
View File
@@ -24,6 +24,12 @@ ipcMain.on("hud-overlay-hide", () => {
}
});
ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true });
}
});
/**
* Creates the always-on-top HUD overlay window centred at the bottom of the
* primary display. The window is frameless, transparent, and follows the user
@@ -63,6 +69,7 @@ export function createHudOverlayWindow(): BrowserWindow {
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true, { forward: true });
// Follow the user across macOS Spaces (virtual desktops).
// Without this the HUD stays pinned to the Space it was first opened on.
+4
View File
@@ -21,5 +21,9 @@
<!-- Camera (webcam capture) -->
<key>com.apple.security.device.camera</key>
<true/>
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
<key>com.apple.security.device.screen-capture</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -11,7 +11,7 @@
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
version = "1.3.0";
version = "1.4.0";
src =
let
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
+129 -1016
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
@@ -21,15 +21,17 @@
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky"
"prepare": "husky",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"postinstall": "npm run rebuild:native"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -71,11 +73,13 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"uiohook-napi": "^1.5.5",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.12",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
+21
View File
@@ -0,0 +1,21 @@
import { spawnSync } from "node:child_process";
import process from "node:process";
// uiohook-napi click capture is macOS-only at runtime (gated in
// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
// without X11 dev headers don't fail npm install. The library's prebuilt
// .node binaries are still bundled and loadable; we just don't need a fresh
// build against Electron's ABI on platforms where we don't load it.
if (process.platform !== "darwin") {
console.log(
`[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
);
process.exit(0);
}
const result = spawnSync(
process.execPath,
["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
{ stdio: "inherit" },
);
process.exit(result.status ?? 0);
+26 -5
View File
@@ -1,14 +1,19 @@
import { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
const VideoEditor = lazy(() => import("./components/video-editor/VideoEditor"));
const ShortcutsConfigDialog = lazy(() =>
import("./components/video-editor/ShortcutsConfigDialog").then((module) => ({
default: module.ShortcutsConfigDialog,
})),
);
export default function App() {
const [windowType, setWindowType] = useState(
() => new URLSearchParams(window.location.search).get("windowType") || "",
@@ -25,6 +30,20 @@ export default function App() {
document.documentElement.style.background = "transparent";
document.getElementById("root")?.style.setProperty("background", "transparent");
}
// HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow
// so the renderer can't introduce scrollbars (see issue #305).
if (type === "hud-overlay") {
document.documentElement.style.height = "100%";
document.documentElement.style.overflow = "hidden";
document.body.style.height = "100%";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
const root = document.getElementById("root");
root?.style.setProperty("height", "100%");
root?.style.setProperty("min-height", "0");
root?.style.setProperty("overflow", "hidden");
}
}, [windowType]);
useEffect(() => {
@@ -45,8 +64,10 @@ export default function App() {
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
<Suspense fallback={<div className="h-screen bg-background" />}>
<VideoEditor />
<ShortcutsConfigDialog />
</Suspense>
</ShortcutsProvider>
);
default:
+12 -12
View File
@@ -44,13 +44,13 @@
position: fixed;
right: 0;
top: 0;
width: 12rem;
padding: 0.375rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.14);
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
backdrop-filter: blur(14px);
width: 11rem;
padding: 0.25rem;
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(8, 9, 12, 0.96);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), inset 0 1px 0 rgba(255, 255, 255, 0.045);
backdrop-filter: blur(18px) saturate(140%);
pointer-events: auto;
box-sizing: border-box;
}
@@ -60,10 +60,10 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
padding: 0.425rem 0.5rem;
border-radius: 0.45rem;
font-size: 11px;
color: rgba(255, 255, 255, 0.88);
color: rgba(255, 255, 255, 0.72);
background: transparent;
border: 0;
cursor: pointer;
@@ -72,12 +72,12 @@
.languageMenuItem:hover,
.languageMenuItem:focus-visible {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.075);
color: #ffffff;
outline: none;
}
.languageMenuItemActive {
background: rgba(255, 255, 255, 0.12);
background: rgba(52, 178, 123, 0.14);
color: #ffffff;
}
+41 -16
View File
@@ -62,16 +62,16 @@ function getIcon(name: IconName, className?: string) {
}
const hudGroupClasses =
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
"flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]";
const hudIconBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95";
const hudAuxIconBtnClasses =
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
"flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
@@ -87,6 +87,7 @@ export function LaunchWindow() {
resolveSystemLocaleSuggestion,
} = useI18n();
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase();
const {
recording,
@@ -248,6 +249,13 @@ export function LaunchWindow() {
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
useEffect(() => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
return () => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
};
}, []);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -314,7 +322,19 @@ export function LaunchWindow() {
};
return (
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
// 100vw can exceed the inner layout width when scrollbars affect the
// viewport (notably on Windows), causing a horizontal scrollbar once the
// recording toolbar widened (issue #305).
<div
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
onPointerMove={(event) => {
const target = event.target as HTMLElement | null;
const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']"));
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture);
}}
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
>
{systemLocaleSuggestion && (
<div
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
@@ -354,12 +374,13 @@ export function LaunchWindow() {
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
data-hud-interactive="true"
className={`fixed bottom-[68px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
>
{/* Mic selector */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsMicHovered(true)}
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
@@ -403,7 +424,7 @@ export function LaunchWindow() {
{/* Webcam selector */}
{showWebcamControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
className={`flex h-9 items-center gap-2 overflow-hidden rounded-xl border border-white/[0.08] bg-[#0b0c10]/90 px-3 py-1.5 shadow-[0_18px_42px_rgba(0,0,0,0.4)] backdrop-blur-2xl transition-all duration-300 ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsWebcamHovered(true)}
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
@@ -479,7 +500,8 @@ export function LaunchWindow() {
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
data-hud-interactive="true"
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
@@ -488,13 +510,15 @@ export function LaunchWindow() {
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
@@ -542,7 +566,7 @@ export function LaunchWindow() {
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "bg-red-500/12 hover:bg-red-500/16"
: "bg-white/5 hover:bg-white/[0.08]"
: "bg-white/[0.06] hover:bg-white/[0.10]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
@@ -618,11 +642,12 @@ export function LaunchWindow() {
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
>
<div className="flex w-full items-center justify-center">
<Languages size={13} className="text-white/75" />
</div>
<Languages size={13} className="text-white/70" />
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
{activeLanguageLabel}
</span>
</button>
</div>
+24 -22
View File
@@ -1,8 +1,8 @@
.glassContainer {
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 30px;
background: linear-gradient(145deg, rgba(13, 14, 17, 0.94) 0%, rgba(8, 9, 12, 0.9) 100%);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
border-radius: 24px;
corner-shape: squircle;
/*
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
@@ -11,34 +11,36 @@
/* box-shadow:
0 0px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
border: 1.5px solid rgba(60, 60, 80, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sourceCard {
corner-shape: squircle;
border-radius: 20px;
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
border: 1px solid rgba(60, 60, 80, 0.22);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
border-radius: 13px;
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.07);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
cursor: pointer;
}
.sourceCard:hover {
border-color: rgba(120, 120, 160, 0.35);
background: rgba(255, 255, 255, 0.065);
border-color: rgba(255, 255, 255, 0.14);
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.selected {
border: 1.5px solid #34b27b;
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
border-color: rgba(52, 178, 123, 0.68);
background: linear-gradient(145deg, rgba(52, 178, 123, 0.13), rgba(255, 255, 255, 0.045));
box-shadow:
0 0 12px rgba(52, 178, 123, 0.15),
0 0 4px rgba(52, 178, 123, 0.1);
0 0 0 1px rgba(52, 178, 123, 0.18) inset,
0 12px 28px rgba(0, 0, 0, 0.22);
}
.selected:hover {
@@ -46,16 +48,16 @@
}
.icon {
width: 13px;
height: 13px;
width: 12px;
height: 12px;
color: #c7d2fe;
}
.name {
font-size: 0.8rem;
font-size: 0.72rem;
color: #e4e4e7;
font-weight: 500;
letter-spacing: 0.01em;
letter-spacing: 0;
}
.cardText {
@@ -65,14 +67,14 @@
/* Checkmark badge */
.checkBadge {
width: 18px;
height: 18px;
width: 17px;
height: 17px;
background: #34b27b;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35);
}
/* scrollbar */
+15 -15
View File
@@ -77,24 +77,24 @@ export function SourceSelector() {
return (
<div
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-1.5`}
onClick={() => handleSourceSelect(source)}
>
<div className="relative mb-1.5">
<div className="relative mb-1.5 overflow-hidden rounded-lg border border-white/[0.06] bg-black/30">
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
className="w-full aspect-video object-cover"
/>
{isSelected && (
<div className="absolute -top-1 -right-1">
<div className="absolute right-1.5 top-1.5">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
<MdCheck size={11} className="text-white" />
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 px-1 pb-0.5">
{source.appIcon && (
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
)}
@@ -106,21 +106,21 @@ export function SourceSelector() {
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<div className="flex-1 flex flex-col w-full px-3.5 pt-3.5">
<Tabs
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
<TabsList className="mb-3 grid h-8 grid-cols-2 rounded-xl border border-white/[0.06] bg-white/[0.04] p-0.5">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
>
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="rounded-lg py-1 text-[11px] text-zinc-400 transition-all data-[state=active]:bg-white/[0.12] data-[state=active]:text-white"
>
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
@@ -128,14 +128,14 @@ export function SourceSelector() {
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid h-[282px] auto-rows-min grid-cols-2 gap-2.5 overflow-y-auto pr-1.5 pt-1 ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
@@ -143,18 +143,18 @@ export function SourceSelector() {
</div>
</Tabs>
</div>
<div className="p-3 justify-center flex gap-2">
<div className="flex justify-center gap-2 border-t border-white/[0.06] p-3">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
className="h-8 rounded-lg px-5 text-[11px] text-zinc-400 transition-transform duration-150 hover:bg-white/5 hover:text-white active:scale-95"
>
{tc("actions.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
className="h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95 disabled:bg-zinc-700 disabled:opacity-30"
>
{tc("actions.share")}
</Button>
@@ -82,7 +82,7 @@ export function AnnotationOverlay({
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
const blurType = "mosaic";
const blurOverlayColor =
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
const mosaicGridOverlayColor =
@@ -106,7 +106,7 @@ export function AnnotationOverlay({
const { x, y, width, height } = liveRect;
useEffect(() => {
if (annotation.type !== "blur" || blurType !== "mosaic") {
if (annotation.type !== "blur") {
return;
}
void previewFrameVersion;
@@ -173,7 +173,6 @@ export function AnnotationOverlay({
);
}, [
annotation,
blurType,
containerHeight,
containerWidth,
height,
@@ -7,7 +7,6 @@ import {
ChevronDown,
Copy,
Image as ImageIcon,
Info,
Italic,
Trash2,
Type,
@@ -148,39 +147,39 @@ export function AnnotationSettingsPanel({
};
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="mb-3">
<div className="mb-4">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("annotation.active")}
</span>
<div className="mt-1 text-xl font-semibold text-slate-100">{t("annotation.title")}</div>
</div>
{/* Type Selector */}
<Tabs
value={annotation.type}
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="mb-6"
className="mb-4"
>
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsList className="mb-4 bg-white/[0.035] border border-white/[0.06] p-0.5 w-full grid grid-cols-3 h-9 rounded-xl">
<TabsTrigger
value="text"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<Type className="w-4 h-4" />
{t("annotation.typeText")}
</TabsTrigger>
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<ImageIcon className="w-4 h-4" />
{t("annotation.typeImage")}
</TabsTrigger>
<TabsTrigger
value="figure"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 rounded-lg transition-all gap-1.5 text-[11px]"
>
<svg
className="w-4 h-4"
@@ -623,18 +622,6 @@ export function AnnotationSettingsPanel({
{t("annotation.deleteAnnotation")}
</Button>
</div>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
<li>{t("annotation.tipTabCycle")}</li>
<li>{t("annotation.tipShiftTabCycle")}</li>
</ul>
</div>
</div>
</div>
);
@@ -1,12 +1,5 @@
import { Info, Trash2 } from "lucide-react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { getBlurOverlayColor } from "@/lib/blurEffects";
@@ -19,9 +12,7 @@ import {
DEFAULT_BLUR_BLOCK_SIZE,
DEFAULT_BLUR_DATA,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MIN_BLUR_BLOCK_SIZE,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
@@ -49,13 +40,15 @@ export function BlurSettingsPanel({
];
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
<div className="min-w-0 p-4 flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="mb-3">
<div className="mb-4">
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">
{t("annotation.blurTypeMosaic")}
</span>
<div className="mt-1 text-xl font-semibold text-slate-100">
{t("annotation.typeBlur")}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -69,6 +62,7 @@ export function BlurSettingsPanel({
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: "mosaic",
shape: shape.value,
};
onBlurDataChange(nextBlurData);
@@ -77,7 +71,7 @@ export function BlurSettingsPanel({
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
"h-12 rounded-lg border flex items-center justify-center transition-all p-2 gap-2",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
@@ -99,7 +93,7 @@ export function BlurSettingsPanel({
)}
/>
)}
<span className="text-[10px] leading-none">
<span className="text-[10px] leading-none font-medium">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
@@ -107,34 +101,6 @@ export function BlurSettingsPanel({
})}
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurType")}
</label>
<Select
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
onValueChange={(value) => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: value === "mosaic" ? "mosaic" : "blur",
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurColor")}
@@ -150,6 +116,7 @@ export function BlurSettingsPanel({
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: "mosaic",
color: option.value,
};
onBlurDataChange(nextBlurData);
@@ -183,40 +150,29 @@ export function BlurSettingsPanel({
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="mt-4 p-3 rounded-lg editor-control-surface">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{blurRegion.blurData?.type === "mosaic"
? t("annotation.mosaicBlockSize")
: t("annotation.blurIntensity")}
{t("annotation.mosaicBlockSize")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
)}
{Math.round(blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)}
px
</span>
</div>
<Slider
value={[
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
]}
value={[blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
...(blurRegion.blurData?.type === "mosaic"
? { blockSize: values[0] }
: { intensity: values[0] }),
type: "mosaic",
blockSize: values[0],
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
min={MIN_BLUR_BLOCK_SIZE}
max={MAX_BLUR_BLOCK_SIZE}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -231,16 +187,6 @@ export function BlurSettingsPanel({
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
+437 -279
View File
@@ -31,7 +31,12 @@ import {
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import {
getExportFolder,
loadUserPreferences,
parentDirectoryOf,
saveUserPreferences,
} from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
@@ -67,13 +72,16 @@ import {
DEFAULT_ZOOM_DEPTH,
type FigureData,
type PlaybackSpeed,
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
export default function VideoEditor() {
@@ -103,6 +111,7 @@ export default function VideoEditor() {
webcamMaskShape,
webcamSizePreset,
webcamPosition,
cursorHighlight,
} = editorState;
// ── Non-undoable state
@@ -121,6 +130,7 @@ export default function VideoEditor() {
const durationRef = useRef(duration);
durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
@@ -144,6 +154,7 @@ export default function VideoEditor() {
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
const playerContainerRef = useRef<HTMLDivElement>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
@@ -153,6 +164,12 @@ export default function VideoEditor() {
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
// renderers while keeping the persisted value intact for round-tripping.
const effectiveCursorHighlight = useMemo(
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
[cursorHighlight, isMac],
);
const { locale, setLocale, t: rawT } = useI18n();
const t = useScopedT("editor");
const ts = useScopedT("settings");
@@ -452,6 +469,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
cursorHighlight,
};
const projectData = createProjectData(currentProjectMedia, editorState);
@@ -513,6 +531,7 @@ export default function VideoEditor() {
videoPath,
t,
webcamSizePreset,
cursorHighlight,
],
);
@@ -527,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]);
@@ -587,6 +628,7 @@ export default function VideoEditor() {
if (!sourcePath) {
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
}
return;
}
@@ -595,11 +637,13 @@ export default function VideoEditor() {
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
}
} catch (telemetryError) {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
}
}
}
@@ -689,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] }));
@@ -708,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] }));
@@ -791,6 +837,7 @@ export default function VideoEditor() {
? {
...region,
depth,
customScale: ZOOM_DEPTH_SCALES[depth],
focus: clampFocusToDepth(region.focus, depth),
}
: region,
@@ -800,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;
@@ -824,6 +889,23 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomRotationPresetChange = useCallback(
(preset: Rotation3DPreset | null) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) => {
if (region.id !== selectedZoomId) return region;
if (preset === null) {
const { rotationPreset: _p, ...rest } = region;
return rest;
}
return { ...region, rotationPreset: preset };
}),
}));
},
[selectedZoomId, pushState],
);
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({
@@ -1288,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,
@@ -1309,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 {
@@ -1340,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);
@@ -1394,6 +1501,8 @@ export default function VideoEditor() {
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1404,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) {
@@ -1413,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");
}
@@ -1437,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);
@@ -1463,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);
@@ -1480,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) {
@@ -1489,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;
@@ -1534,6 +1642,8 @@ export default function VideoEditor() {
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1544,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) {
@@ -1553,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");
}
@@ -1617,6 +1723,8 @@ export default function VideoEditor() {
exportQuality,
handleExportSaved,
cursorTelemetry,
cursorClickTimestamps,
effectiveCursorHighlight,
t,
],
);
@@ -1693,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">
@@ -1748,7 +1869,7 @@ export default function VideoEditor() {
</Dialog>
<div
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
className="h-11 flex-shrink-0 bg-[#070809]/85 backdrop-blur-xl border-b border-white/[0.07] flex items-center justify-between px-5 z-50 shadow-[0_1px_0_rgba(255,255,255,0.03)]"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
@@ -1756,7 +1877,7 @@ export default function VideoEditor() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
>
<Languages size={14} />
<select
@@ -1775,7 +1896,7 @@ export default function VideoEditor() {
<button
type="button"
onClick={() => setShowNewRecordingDialog(true)}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<Video size={14} />
{t("newRecording.title")}
@@ -1783,7 +1904,7 @@ export default function VideoEditor() {
<button
type="button"
onClick={handleLoadProject}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<FolderOpen size={14} />
{ts("project.load")}
@@ -1791,7 +1912,7 @@ export default function VideoEditor() {
<button
type="button"
onClick={handleSaveProject}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-white/50 hover:text-white/90 hover:bg-white/[0.08] transition-all duration-150 text-[11px] font-medium"
>
<Save size={14} />
{ts("project.save")}
@@ -1799,261 +1920,291 @@ export default function VideoEditor() {
</div>
</div>
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
{/* Left Column - Video & Timeline */}
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} maxSize={70} minSize={40}>
<div
ref={playerContainerRef}
className={
isFullscreen
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
: "w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden relative"
}
>
{/* Video preview */}
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
<div
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
style={{
aspectRatio:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
onZoomFocusDragEnd={commitState}
isPlaying={isPlaying}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurAmount={motionBlurAmount}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
blurRegions={blurRegions}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onBlurPositionChange={handleAnnotationPositionChange}
onBlurSizeChange={handleAnnotationSizeChange}
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
/>
<div className="editor-workspace flex-1 min-h-0 relative">
<PanelGroup direction="vertical" className="gap-3 min-h-0">
{/* Top section: preview and contextual settings */}
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
<div className="editor-main-deck h-full min-h-0">
<div className="editor-preview-zone min-w-0 h-full">
<div
ref={playerContainerRef}
className={
isFullscreen
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
}
>
{/* Video preview */}
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
<div
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
style={{
aspectRatio:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
onZoomFocusDragEnd={commitState}
isPlaying={isPlaying}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurAmount={motionBlurAmount}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
blurRegions={blurRegions}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onBlurPositionChange={handleAnnotationPositionChange}
onBlurSizeChange={handleAnnotationSizeChange}
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
cursorHighlight={effectiveCursorHighlight}
cursorClickTimestamps={cursorClickTimestamps}
/>
</div>
</div>
</div>
{/* Playback controls */}
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
<div className="w-full max-w-[700px]">
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
{/* Playback controls */}
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
<div className="w-full max-w-[760px]">
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</div>
</div>
</Panel>
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Timeline section */}
<Panel defaultSize={30} maxSize={60} minSize={30}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={cursorTelemetry}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
<div className="editor-settings-rail min-w-0 h-full">
<SettingsPanel
cursorHighlight={cursorHighlight}
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
cursorHighlightSupportsClicks={isMac}
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomCustomScale={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
: null
}
onZoomCustomScaleChange={handleZoomCustomScaleChange}
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) =>
selectedZoomId && handleZoomFocusModeChange(mode)
}
selectedZoomFocus={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
: null
}
onZoomFocusCoordinateChange={(focus) =>
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
}
onZoomFocusCoordinateCommit={commitState}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
onZoomDelete={handleZoomDelete}
selectedZoomRotationPreset={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
: null
}
onZoomRotationPresetChange={handleZoomRotationPresetChange}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
speedRegions={speedRegions}
onSpeedAdded={handleSpeedAdded}
onSpeedSpanChange={handleSpeedSpanChange}
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
blurRegions={blurRegions}
onBlurAdded={handleBlurAdded}
onBlurSpanChange={handleAnnotationSpanChange}
onBlurDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={(v) => updateState({ shadowIntensity: v })}
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
padding={padding}
onPaddingChange={(v) => updateState({ padding: v })}
onPaddingCommit={commitState}
cropRegion={cropRegion}
onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
hasWebcam={Boolean(webcamVideoPath)}
webcamLayoutPreset={webcamLayoutPreset}
onWebcamLayoutPresetChange={(preset) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
? "picture-in-picture"
: webcamLayoutPreset,
webcamLayoutPreset: preset,
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
})
}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
<SettingsPanel
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={(v) => updateState({ shadowIntensity: v })}
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
padding={padding}
onPaddingChange={(v) => updateState({ padding: v })}
onPaddingCommit={commitState}
cropRegion={cropRegion}
onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
hasWebcam={Boolean(webcamVideoPath)}
webcamLayoutPreset={webcamLayoutPreset}
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
})
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatio === "native"
? getNativeAspectRatioValue(
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
onGifLoopChange={setGifLoop}
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDuplicate={handleAnnotationDuplicate}
onAnnotationDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
blurRegions={blurRegions}
onBlurDataChange={handleBlurDataPanelChange}
onBlurDataCommit={commitState}
onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
/>
</div>
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDuplicate={handleAnnotationDuplicate}
onAnnotationDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
blurRegions={blurRegions}
onBlurDataChange={handleBlurDataPanelChange}
onBlurDataCommit={commitState}
onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
onSaveDiagnostic={handleSaveDiagnostic}
/>
</div>
</div>
</Panel>
<PanelResizeHandle className="editor-resize-handle group">
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
</PanelResizeHandle>
{/* Full-width timeline */}
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
<div className="editor-timeline-panel h-full overflow-hidden flex flex-col">
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={cursorTelemetry}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
trimRegions={trimRegions}
onTrimAdded={handleTrimAdded}
onTrimSpanChange={handleTrimSpanChange}
onTrimDelete={handleTrimDelete}
selectedTrimId={selectedTrimId}
onSelectTrim={handleSelectTrim}
speedRegions={speedRegions}
onSpeedAdded={handleSpeedAdded}
onSpeedSpanChange={handleSpeedSpanChange}
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
blurRegions={blurRegions}
onBlurAdded={handleBlurAdded}
onBlurSpanChange={handleAnnotationSpanChange}
onBlurDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
? "picture-in-picture"
: webcamLayoutPreset,
})
}
/>
</div>
</Panel>
</PanelGroup>
</div>
<ExportDialog
@@ -2069,6 +2220,13 @@ export default function VideoEditor() {
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
}
/>
<UnsavedChangesDialog
isOpen={showCloseConfirmDialog}
onSaveAndClose={handleCloseConfirmSave}
onDiscardAndClose={handleCloseConfirmDiscard}
onCancel={handleCloseConfirmCancel}
/>
</div>
);
}
+379 -190
View File
@@ -36,10 +36,14 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
computeRotation3DContainScale,
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
@@ -51,8 +55,18 @@ import {
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import {
adaptiveSmoothFactor,
interpolateCursorAt,
smoothCursorFocus,
} from "./videoPlayback/cursorFollowUtils";
import {
type CursorHighlightConfig,
clickEmphasisAlpha,
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -66,6 +80,13 @@ import {
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
type BlurPreviewCanvasSource = {
clientHeight?: number;
clientWidth?: number;
height: number;
width: number;
};
interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
@@ -110,6 +131,8 @@ interface VideoPlaybackProps {
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
cursorHighlight?: CursorHighlightConfig;
cursorClickTimestamps?: number[];
}
export interface VideoPlaybackRef {
@@ -168,6 +191,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
cursorClickTimestamps = [],
},
ref,
) => {
@@ -186,11 +211,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const composite3DRef = useRef<HTMLDivElement | null>(null);
const outerWrapperRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
const cursorClickTimestampsRef = useRef<number[]>([]);
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -215,6 +245,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const maskGraphicsRef = useRef<Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const isScrubbingRef = useRef(false);
const scrubEndTimerRef = useRef<number | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{
width: number;
@@ -230,10 +263,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
}, []);
const blurPreviewSnapshotRef = useRef<{
bucket: number;
canvas: BlurPreviewCanvasSource | null;
height: number;
width: number;
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
const updateOverlayForRegion = useCallback(
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
@@ -415,7 +450,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);
@@ -515,6 +550,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
cursorHighlightRef.current = cursorHighlight;
if (cursorHighlightGraphicsRef.current) {
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
}
}, [cursorHighlight]);
useEffect(() => {
cursorClickTimestampsRef.current = cursorClickTimestamps;
}, [cursorClickTimestamps]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -583,6 +629,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, [pixiReady, videoReady, layoutVideoContent]);
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
// navigating, not previewing) and restore native DPR on play/idle so the
// preview stays faithful. Mutating renderer.resolution per-frame would
// thrash texture uploads; we only do it on scrub-state transitions.
useEffect(() => {
if (!pixiReady) return;
const app = appRef.current;
const container = containerRef.current;
if (!app || !container) return;
const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
if (app.renderer.resolution === targetResolution) return;
app.renderer.resolution = targetResolution;
app.renderer.resize(container.clientWidth, container.clientHeight);
layoutVideoContentRef.current?.();
}, [isScrubbing, pixiReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
updateOverlayForRegion(selectedZoom);
@@ -738,6 +802,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
const cursorHighlightGraphics = new Graphics();
cursorHighlightGraphics.visible = false;
videoContainer.addChild(cursorHighlightGraphics);
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
@@ -770,6 +840,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
trimRegionsRef,
speedRegionsRef,
isScrubbingRef,
scrubEndTimerRef,
onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
});
video.addEventListener("play", handlePlay);
@@ -797,6 +870,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
if (cursorHighlightGraphicsRef.current) {
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
cursorHighlightGraphicsRef.current.destroy();
cursorHighlightGraphicsRef.current = null;
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
@@ -858,8 +936,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
let lastMotionBlurActive: boolean | null = null;
let lastTransformIsIdentity = true;
let lastPerspectiveValue = 0;
const ticker = () => {
const { region, strength, blendedScale, transition } = findDominantRegion(
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{
@@ -879,7 +959,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;
@@ -1016,7 +1096,41 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionVector,
);
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
const cursorGraphics = cursorHighlightGraphicsRef.current;
const cursorConfig = cursorHighlightRef.current;
const lockedDims = lockedVideoDimensionsRef.current;
if (cursorGraphics) {
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
const emphasisAlpha = clickEmphasisAlpha(
currentTimeRef.current,
cursorClickTimestampsRef.current,
cursorConfig,
);
const cursorPoint =
emphasisAlpha > 0
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
: null;
if (cursorPoint) {
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
cursorGraphics.position.set(
baseOffset.x + cx * lockedDims.width * baseScale,
baseOffset.y + cy * lockedDims.height * baseScale,
);
cursorGraphics.alpha = emphasisAlpha;
cursorGraphics.visible = true;
} else {
cursorGraphics.visible = false;
}
} else {
cursorGraphics.visible = false;
}
}
const isMotionBlurActive =
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
if (isMotionBlurActive) {
@@ -1032,6 +1146,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lastMotionBlurActive = false;
}
}
const composite3D = composite3DRef.current;
const outerWrapper = outerWrapperRef.current;
if (composite3D && outerWrapper) {
const effectiveRotation =
region && targetProgress > 0 && !shouldShowUnzoomedView
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
: DEFAULT_ROTATION_3D;
const isIdentity = isRotation3DIdentity(effectiveRotation);
if (isIdentity) {
if (!lastTransformIsIdentity) {
composite3D.style.transform = "";
composite3D.style.willChange = "auto";
lastTransformIsIdentity = true;
}
if (lastPerspectiveValue !== 0) {
outerWrapper.style.perspective = "";
lastPerspectiveValue = 0;
}
} else {
const wrapperW = outerWrapper.clientWidth || 1;
const wrapperH = outerWrapper.clientHeight || 1;
const persp = rotation3DPerspective(wrapperW, wrapperH);
const containScale = computeRotation3DContainScale(
effectiveRotation,
wrapperW,
wrapperH,
persp,
);
composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
composite3D.style.willChange = "transform";
lastTransformIsIdentity = false;
if (persp !== lastPerspectiveValue) {
outerWrapper.style.perspective = `${persp}px`;
lastPerspectiveValue = persp;
}
}
}
};
app.ticker.add(ticker);
@@ -1153,6 +1305,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
if (scrubEndTimerRef.current !== null) {
window.clearTimeout(scrubEndTimerRef.current);
scrubEndTimerRef.current = null;
}
};
}, []);
@@ -1169,6 +1325,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={outerWrapperRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -1193,189 +1350,221 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}}
/>
<div
ref={containerRef}
ref={composite3DRef}
className="absolute inset-0"
style={{
filter:
showShadow && shadowIntensity > 0
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: "none",
transformStyle: "preserve-3d",
transformOrigin: "center center",
}}
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
>
<div
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "auto", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
onPointerLeave={handleOverlayPointerLeave}
>
<div
ref={focusIndicatorRef}
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
return false;
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
});
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
const previewSnapshotCanvas =
filteredBlurRegions.length > 0
? (() => {
const app = appRef.current;
if (!app?.renderer?.extract) return null;
try {
return app.renderer.extract.canvas(app.stage);
} catch {
return null;
}
})()
: null;
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
/>
));
ref={containerRef}
className="absolute inset-0"
style={{
filter:
showShadow && shadowIntensity > 0
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: "none",
}}
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
</div>
)}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "auto", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
onPointerLeave={handleOverlayPointerLeave}
>
<div
ref={focusIndicatorRef}
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (
typeof annotation.startMs !== "number" ||
typeof annotation.endMs !== "number"
)
return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (
typeof blurRegion.startMs !== "number" ||
typeof blurRegion.endMs !== "number"
)
return false;
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
});
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
const previewSnapshotBucket = Math.floor(currentTime * 10);
const previewSnapshotCanvas =
filteredBlurRegions.length > 0
? (() => {
const cached = blurPreviewSnapshotRef.current;
if (
cached.bucket === previewSnapshotBucket &&
cached.width === overlaySize.width &&
cached.height === overlaySize.height
) {
return cached.canvas;
}
const app = appRef.current;
if (!app?.renderer?.extract) return cached.canvas;
try {
const canvas = app.renderer.extract.canvas(app.stage);
blurPreviewSnapshotRef.current = {
bucket: previewSnapshotBucket,
canvas,
height: overlaySize.height,
width: overlaySize.width,
};
return canvas;
} catch {
return cached.canvas;
}
})()
: null;
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={previewSnapshotBucket}
/>
));
})()}
</div>
)}
</div>
<video
ref={videoRef}
src={videoPath}
@@ -132,7 +132,7 @@ describe("projectPersistence media compatibility", () => {
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
expect(editor.annotationRegions[1].blurData?.type).toBe("mosaic");
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
});
@@ -80,6 +80,7 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -99,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
case "no-webcam":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
@@ -250,6 +252,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const validPreset =
region.rotationPreset === "iso" ||
region.rotationPreset === "left" ||
region.rotationPreset === "right"
? region.rotationPreset
: undefined;
return {
id: region.id,
startMs,
@@ -260,6 +268,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
...(validPreset ? { rotationPreset: validPreset } : {}),
};
})
: [];
@@ -494,6 +503,52 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
};
}
function normalizeCursorHighlight(
value: unknown,
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
if (!value || typeof value !== "object") return fallback;
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
return {
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
sizePx:
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
color:
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
? v.color
: fallback.color,
opacity:
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
? v.opacity
: fallback.opacity,
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
clickEmphasisDurationMs:
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
? v.clickEmphasisDurationMs
: fallback.clickEmphasisDurationMs,
offsetXNorm:
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
? Math.max(-1, Math.min(1, v.offsetXNorm))
: fallback.offsetXNorm,
offsetYNorm:
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
? Math.max(-1, Math.min(1, v.offsetYNorm))
: fallback.offsetYNorm,
};
}
+11 -7
View File
@@ -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",
@@ -99,7 +101,7 @@ export default function Item({
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected,
)}
style={{ height: 40, color: "#fff", minWidth: 24 }}
style={{ height: 30, color: "#fff", minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
@@ -128,13 +130,15 @@ export default function Item({
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="relative z-10 flex min-w-0 flex-col items-center justify-center text-white/90 opacity-85 group-hover:opacity-100 transition-opacity select-none overflow-hidden px-3">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
<span className="text-[11px] font-semibold whitespace-nowrap">
{zoomCustomScale != null
? `${zoomCustomScale.toFixed(2)}×`
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
@@ -146,21 +150,21 @@ export default function Item({
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold whitespace-nowrap">
{t("labels.trim")}
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
<span className="text-[11px] font-semibold truncate whitespace-nowrap">
{children}
</span>
</>
@@ -1,39 +1,39 @@
.glassGreen {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(52, 178, 123, 0.15);
border: 1px solid rgba(52, 178, 123, 0.3);
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: linear-gradient(180deg, rgba(52, 178, 123, 0.28), rgba(31, 115, 82, 0.2));
border: 1px solid rgba(77, 221, 157, 0.36);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.1) inset,
0 8px 22px rgba(0, 0, 0, 0.22);
margin: 3px 0;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassGreen:hover {
background: rgba(52, 178, 123, 0.25);
border-color: rgba(52, 178, 123, 0.5);
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
background: linear-gradient(180deg, rgba(52, 178, 123, 0.36), rgba(31, 115, 82, 0.25));
border-color: rgba(77, 221, 157, 0.62);
}
.glassGreen.selected {
background: rgba(52, 178, 123, 0.35);
background: linear-gradient(180deg, rgba(52, 178, 123, 0.48), rgba(31, 115, 82, 0.32));
border-color: #34b27b;
box-shadow:
0 0 0 1px #34b27b,
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
0 0 0 1px rgba(52, 178, 123, 0.95),
0 0 0 4px rgba(52, 178, 123, 0.14),
0 12px 26px rgba(0, 0, 0, 0.28);
z-index: 10;
}
.glassRed {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -56,12 +56,12 @@
.glassYellow {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -84,12 +84,12 @@
.glassAmber {
position: relative;
border-radius: 8px;
border-radius: 10px;
-corner-smoothing: antialiased;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
margin: 2px 0;
margin: 3px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -137,13 +137,13 @@
.zoomEndCap.left {
left: 0;
cursor: ew-resize;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
border-top-left-radius: 9px;
border-bottom-left-radius: 9px;
}
.zoomEndCap.right {
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
border-top-right-radius: 9px;
border-bottom-right-radius: 9px;
}
+4 -14
View File
@@ -3,31 +3,21 @@ import { useRow } from "dnd-timeline";
interface RowProps extends RowDefinition {
children: React.ReactNode;
label?: string;
hint?: string;
isEmpty?: boolean;
labelColor?: string;
}
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
export default function Row({ id, children, hint, isEmpty }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
return (
<div
className="border-b border-[#18181b] bg-[#18181b] relative"
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
style={{ ...rowWrapperStyle, minHeight: 36 }}
>
{label && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: "horizontal-tb" }}
>
{label}
</div>
)}
{isEmpty && hint && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
@@ -26,7 +26,6 @@ import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
import type {
AnnotationRegion,
CursorTelemetryPoint,
@@ -102,6 +101,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -377,7 +377,7 @@ function PlaybackCursor({
}}
>
<div
className="absolute top-0 bottom-0 w-[2px] bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.5)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_15px_rgba(52,178,123,0.7)] transition-shadow"
className="absolute top-0 bottom-0 w-[2px] bg-[#6C55FF] shadow-[0_0_18px_rgba(108,85,255,0.68)] cursor-ew-resize pointer-events-auto hover:shadow-[0_0_24px_rgba(108,85,255,0.85)] transition-shadow"
style={{
[sideProperty]: `${offset}px`,
}}
@@ -388,10 +388,10 @@ function PlaybackCursor({
}}
>
<div
className="absolute -top-1 left-1/2 -translate-x-1/2 hover:scale-125 transition-transform"
style={{ width: "16px", height: "16px" }}
className="absolute -top-2 left-1/2 -translate-x-1/2 hover:scale-110 transition-transform"
style={{ width: "20px", height: "20px" }}
>
<div className="w-3 h-3 mx-auto mt-[2px] bg-[#34B27B] rotate-45 rounded-sm shadow-lg border border-white/20" />
<div className="w-4 h-4 mx-auto mt-[2px] bg-[#6C55FF] rotate-45 rounded-[5px] shadow-lg shadow-[#6C55FF]/30 border border-white/30" />
</div>
{isDragging && (
<div className="absolute -top-6 left-1/2 -translate-x-1/2 px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg pointer-events-none">
@@ -474,7 +474,7 @@ function TimelineAxis({
return (
<div
className="h-8 bg-[#09090b] border-b border-white/5 relative overflow-hidden select-none"
className="h-9 bg-[#0c0d10] border-b border-white/[0.07] relative overflow-hidden select-none"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
}}
@@ -485,7 +485,7 @@ function TimelineAxis({
return (
<div
key={`minor-${time}`}
className="absolute bottom-0 h-1 w-[1px] bg-white/5"
className="absolute bottom-0 h-1.5 w-[1px] bg-white/[0.07]"
style={{ [sideProperty]: `${offset}px` }}
/>
);
@@ -507,7 +507,7 @@ function TimelineAxis({
return (
<div key={marker.time} style={markerStyle}>
<div className="flex flex-col items-center pb-1">
<div className="h-2 w-[1px] bg-white/20 mb-1" />
<div className="h-2.5 w-[1px] bg-white/20 mb-1" />
<span
className={cn(
"text-[10px] font-medium tabular-nums tracking-tight",
@@ -658,11 +658,11 @@ function Timeline({
<div
ref={setRefs}
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
onClick={handleTimelineClick}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
currentTimeMs={currentTimeMs}
@@ -683,6 +683,7 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomCustomScale={item.zoomCustomScale}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
@@ -1339,6 +1340,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",
}));
@@ -1445,14 +1447,14 @@ export default function TimelineEditor({
}
return (
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
<div className="flex items-center gap-1">
<div className="flex-1 min-h-0 flex flex-col bg-[#09090b] overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-white/[0.06] bg-[#08090b]/95">
<div className="flex items-center gap-0.5 rounded-xl border border-white/[0.06] bg-white/[0.025] p-0.5">
<Button
onClick={handleAddZoom}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title={t("buttons.addZoom")}
>
<ZoomIn className="w-4 h-4" />
@@ -1461,7 +1463,7 @@ export default function TimelineEditor({
onClick={handleSuggestZooms}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title={t("buttons.suggestZooms")}
>
<WandSparkles className="w-4 h-4" />
@@ -1470,7 +1472,7 @@ export default function TimelineEditor({
onClick={handleAddTrim}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
title={t("buttons.addTrim")}
>
<Scissors className="w-4 h-4" />
@@ -1479,7 +1481,7 @@ export default function TimelineEditor({
onClick={handleAddAnnotation}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
title={t("buttons.addAnnotation")}
>
<MessageSquare className="w-4 h-4" />
@@ -1488,7 +1490,7 @@ export default function TimelineEditor({
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
@@ -1507,19 +1509,19 @@ export default function TimelineEditor({
onClick={handleAddSpeed}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
className="h-7 w-7 rounded-lg text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
title={t("buttons.addSpeed")}
>
<Gauge className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1"
className="h-7 px-2 rounded-lg text-[11px] text-slate-400 hover:text-slate-200 hover:bg-white/[0.07] transition-all gap-1"
>
<span className="font-medium">{getAspectRatioLabel(aspectRatio)}</span>
<ChevronDown className="w-3 h-3" />
@@ -1538,11 +1540,9 @@ export default function TimelineEditor({
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[1px] h-4 bg-white/10" />
<TutorialHelp />
</div>
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<div className="hidden md:flex items-center gap-3 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
{scrollLabels.pan}
@@ -1559,7 +1559,7 @@ export default function TimelineEditor({
</div>
<div
ref={timelineContainerRef}
className="flex-1 overflow-hidden bg-[#09090b] relative"
className="flex-1 min-h-0 overflow-auto custom-scrollbar bg-[#09090b] relative"
onClick={() => setSelectedKeyframeId(null)}
>
<TimelineWrapper
@@ -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 };
},
+144 -1
View File
@@ -26,6 +26,37 @@ export interface ZoomFocus {
cy: number; // normalized vertical center (0-1)
}
export interface Rotation3D {
rotationX: number;
rotationY: number;
rotationZ: number;
}
export const DEFAULT_ROTATION_3D: Rotation3D = {
rotationX: 0,
rotationY: 0,
rotationZ: 0,
};
export type Rotation3DPreset = "iso" | "left" | "right";
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
};
export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
/** Perspective distance in CSS px is computed at render-time as this factor times
* min(viewport width, viewport height). Same factor used in preview and export so
* the visual look is identical regardless of canvas resolution. */
export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
export function rotation3DPerspective(width: number, height: number): number {
return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
}
export interface ZoomRegion {
id: string;
startMs: number;
@@ -33,6 +64,106 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
/** Custom scale overriding the preset depth (1.05.0, two decimal precision). */
customScale?: number;
}
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
return ROTATION_3D_PRESETS[region.rotationPreset];
}
export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
}
export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
return {
rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
};
}
/**
* Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
* of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
* inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
*
* Math: project each rotated corner onto the screen via x' = x·P/(Pz); take the worst-case
* |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
* recording sit *inside* the zoom window instead of bleeding past it.
*/
export function computeRotation3DContainScale(
rot: Rotation3D,
width: number,
height: number,
perspective: number,
): number {
const a = (rot.rotationX * Math.PI) / 180;
const b = (rot.rotationY * Math.PI) / 180;
const g = (rot.rotationZ * Math.PI) / 180;
const ca = Math.cos(a);
const sa = Math.sin(a);
const cb = Math.cos(b);
const sb = Math.sin(b);
const cg = Math.cos(g);
const sg = Math.sin(g);
const halfW = width / 2;
const halfH = height / 2;
const corners: Array<[number, number]> = [
[-halfW, -halfH],
[halfW, -halfH],
[halfW, halfH],
[-halfW, halfH],
];
let maxAbsX = 0;
let maxAbsY = 0;
for (const [x0, y0] of corners) {
// CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
let px = x0;
let py = y0;
let pz = 0;
// rotateZ
const zx = px * cg - py * sg;
const zy = px * sg + py * cg;
px = zx;
py = zy;
// rotateY
const yx = px * cb + pz * sb;
const yz = -px * sb + pz * cb;
px = yx;
pz = yz;
// rotateX
const xy = py * ca - pz * sa;
const xz = py * sa + pz * ca;
py = xy;
pz = xz;
// Perspective projection: viewer at (0, 0, P), looking toward z. A point at z=pz
// is scaled by P / (P pz). When perspective ≤ 0 we treat as orthographic.
if (perspective > 0) {
const denom = perspective - pz;
if (denom <= 0) return 1; // pathological — skip scaling rather than crash
const f = perspective / denom;
px *= f;
py *= f;
}
if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
}
if (maxAbsX === 0 || maxAbsY === 0) return 1;
const sx = halfW / maxAbsX;
const sy = halfH / maxAbsY;
return Math.min(sx, sy, 1);
}
export interface CursorTelemetryPoint {
@@ -163,7 +294,7 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
];
export const DEFAULT_BLUR_DATA: BlurData = {
type: "blur",
type: "mosaic",
shape: "rectangle",
color: "white",
intensity: DEFAULT_BLUR_INTENSITY,
@@ -227,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),
@@ -0,0 +1,125 @@
import type { Graphics } from "pixi.js";
export type CursorHighlightStyle = "dot" | "ring";
export interface CursorHighlightConfig {
enabled: boolean;
style: CursorHighlightStyle;
sizePx: number;
color: string;
opacity: number;
// Show only on clicks (macOS — depends on click telemetry from uiohook).
onlyOnClicks: boolean;
clickEmphasisDurationMs: number;
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
// but window recordings frame a subset of the display so the highlight
// lands offset. Users dial these in once to align with the actual cursor.
offsetXNorm: number;
offsetYNorm: number;
}
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
// click-only mode; in click-only mode fades 1→0 across each click's window.
export function clickEmphasisAlpha(
timeMs: number,
clickTimestampsMs: number[] | undefined,
config: CursorHighlightConfig,
): number {
if (!config.onlyOnClicks) return 1;
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
const window = Math.max(1, config.clickEmphasisDurationMs);
for (let i = 0; i < clickTimestampsMs.length; i++) {
const dt = timeMs - clickTimestampsMs[i];
if (dt >= 0 && dt <= window) {
return 1 - dt / window;
}
}
return 0;
}
function parseHexColor(hex: string): number {
const cleaned = hex.replace("#", "");
if (cleaned.length === 3) {
const r = cleaned[0];
const g = cleaned[1];
const b = cleaned[2];
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
}
return Number.parseInt(cleaned.slice(0, 6), 16);
}
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
g.clear();
if (!config.enabled) return;
const color = parseHexColor(config.color);
const radius = Math.max(1, config.sizePx / 2);
const alpha = Math.max(0, Math.min(1, config.opacity));
switch (config.style) {
case "dot": {
g.circle(0, 0, radius);
g.fill({ color, alpha });
break;
}
case "ring": {
g.circle(0, 0, radius);
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
break;
}
}
}
export function drawCursorHighlightCanvas(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
config: CursorHighlightConfig,
pixelScale = 1,
): void {
if (!config.enabled) return;
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
const alpha = Math.max(0, Math.min(1, config.opacity));
const color = config.color;
ctx.save();
ctx.globalAlpha = alpha;
switch (config.style) {
case "dot": {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
break;
}
case "ring": {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, radius * 0.18);
ctx.stroke();
break;
}
}
ctx.restore();
}
@@ -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,6 +1,11 @@
import type React from "react";
import type { SpeedRegion, TrimRegion } from "../types";
// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
// fires `seeking`/`seeked` dozens of times per second, and toggling effects
// each time would flicker.
const SCRUB_END_DEBOUNCE_MS = 150;
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
isScrubbingRef?: React.MutableRefObject<boolean>;
scrubEndTimerRef?: React.MutableRefObject<number | null>;
onScrubChange?: (scrubbing: boolean) => void;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
isScrubbingRef,
scrubEndTimerRef,
onScrubChange,
} = params;
const clearScrubEndTimer = () => {
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
window.clearTimeout(scrubEndTimerRef.current);
scrubEndTimerRef.current = null;
}
};
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeked = () => {
isSeekingRef.current = false;
if (isScrubbingRef && scrubEndTimerRef) {
clearScrubEndTimer();
scrubEndTimerRef.current = window.setTimeout(() => {
isScrubbingRef.current = false;
scrubEndTimerRef.current = null;
onScrubChange?.(false);
}, SCRUB_END_DEBOUNCE_MS);
}
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeking = () => {
isSeekingRef.current = true;
if (isScrubbingRef) {
clearScrubEndTimer();
if (!isScrubbingRef.current) {
isScrubbingRef.current = true;
onScrubChange?.(true);
}
}
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
@@ -1,5 +1,5 @@
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } 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: {
@@ -164,6 +164,7 @@ function getActiveRegion(
},
strength: activeRegions[0].strength,
blendedScale: null,
rotation3D: getRotation3D(activeRegion),
};
}
@@ -175,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,
@@ -189,6 +190,7 @@ function getConnectedRegionHold(
},
strength: 1,
blendedScale: null,
rotation3D: getRotation3D(pair.nextRegion),
};
}
}
@@ -212,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 =
@@ -233,6 +235,11 @@ function getConnectedRegionTransition(
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
const transitionRotation = lerpRotation3D(
getRotation3D(currentRegion),
getRotation3D(nextRegion),
transitionProgress,
);
return {
region: {
@@ -241,6 +248,7 @@ function getConnectedRegionTransition(
},
strength: 1,
blendedScale: transitionScale,
rotation3D: transitionRotation,
transition: {
progress: transitionProgress,
startFocus: currentFocus,
@@ -254,34 +262,92 @@ function getConnectedRegionTransition(
return null;
}
type DominantRegionResult = {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
rotation3D: Rotation3D;
transition: ConnectedPanTransition | null;
};
// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
// unchanged inputs (especially while paused). Reusing the previous result when
// inputs match avoids the per-frame O(N) region scan + allocations.
let dominantRegionCache: {
regions: ZoomRegion[];
timeMsKey: number;
telemetry: CursorTelemetryPoint[] | undefined;
connectZooms: boolean;
viewportRatio: ViewportRatio | undefined;
result: DominantRegionResult;
} | null = null;
export function findDominantRegion(
regions: ZoomRegion[],
timeMs: number,
options: DominantRegionOptions = {},
): {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
): DominantRegionResult {
const connectZooms = !!options.connectZooms;
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
const timeMsKey = Math.round(timeMs);
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
if (
dominantRegionCache &&
dominantRegionCache.regions === regions &&
dominantRegionCache.timeMsKey === timeMsKey &&
dominantRegionCache.telemetry === telemetry &&
dominantRegionCache.connectZooms === connectZooms &&
dominantRegionCache.viewportRatio === vr
) {
return dominantRegionCache.result;
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
let result: DominantRegionResult;
if (connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
result = connectedTransition;
} else {
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
result = { ...connectedHold, transition: null };
} else {
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
: {
region: null,
strength: 0,
blendedScale: null,
rotation3D: DEFAULT_ROTATION_3D,
transition: null,
};
}
}
} else {
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
: {
region: null,
strength: 0,
blendedScale: null,
rotation3D: DEFAULT_ROTATION_3D,
transition: null,
};
}
dominantRegionCache = {
regions,
timeMsKey,
telemetry,
connectZooms,
viewportRatio: vr,
result,
};
return result;
}
+6
View File
@@ -17,6 +17,10 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import {
type CursorHighlightConfig,
DEFAULT_CURSOR_HIGHLIGHT,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
@@ -39,6 +43,7 @@ export interface EditorState {
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
cursorHighlight: CursorHighlightConfig;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -58,6 +63,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+9 -9
View File
@@ -408,6 +408,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
});
const safeHideCountdownOverlay = useCallback(async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
}, []);
useEffect(() => {
let cleanup: (() => void) | undefined;
@@ -450,7 +458,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
teardownMedia();
};
}, [teardownMedia]);
}, [teardownMedia, safeHideCountdownOverlay]);
const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
@@ -477,14 +485,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
const safeHideCountdownOverlay = async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
};
const isCountdownRunActive = (runId?: number) =>
runId === undefined || countdownRunId.current === runId;
@@ -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", () => {
+3
View File
@@ -8,6 +8,9 @@ export const SUPPORTED_LOCALES = [
"tr",
"ko-KR",
"ja-JP",
"ar",
"ru",
"vi",
] as const;
export const I18N_NAMESPACES = [
"common",
+50
View File
@@ -0,0 +1,50 @@
{
"actions": {
"cancel": "الغاء",
"save": "حفظ",
"delete": "حذف",
"close": "اغلاق",
"share": "مشاركة",
"done": "تم",
"open": "فتح",
"upload": "رفع",
"export": "تصدير",
"showInFolder": "عرض في المجلد",
"file": "ملف",
"edit": "تعديل",
"view": "عرض",
"window": "نافذة",
"quit": "خروج",
"stopRecording": "إيقاف التسجيل",
"undo": "تراجع",
"redo": "إعادة",
"cut": "قص",
"copy": "نسخ",
"paste": "لصق",
"selectAll": "تحديد الكل",
"minimize": "تصغير",
"reload": "إعادة تحميل",
"forceReload": "إعادة تحميل إجبارية",
"toggleDevTools": "أدوات المطور",
"actualSize": "الحجم الفعلي",
"zoomIn": "تكبير",
"zoomOut": "تصغير",
"toggleFullScreen": "ملء الشاشة",
"recordingStatus": "جاري التسجيل: {{source}}",
"about": "حول OpenScreen",
"services": "خدمات",
"hide": "إخفاء OpenScreen",
"hideOthers": "إخفاء الآخرين",
"unhide": "إظهار الكل"
},
"playback": {
"play": "تشغيل",
"pause": "ايقاف مؤقت",
"fullscreen": "ملء الشاشة",
"exitFullscreen": "خروج من ملء الشاشة"
},
"locale": {
"name": "عربي",
"short": "AR"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "اكتمل التصدير",
"yourFormatReady": "{{format}} الخاص بك جاهز",
"showInFolder": "عرض في المجلد",
"finalizingVideo": "جاري إنهاء تصدير الفيديو...",
"compilingGifProgress": "جاري تجميع GIF... {{progress}}%",
"compilingGifWait": "جاري تجميع GIF... قد يستغرق هذا بعض الوقت",
"takeMoment": "قد يستغرق هذا لحظة...",
"failed": "فشل التصدير",
"tryAgain": "يرجى المحاولة مرة أخرى",
"finalizingVideoTitle": "إنهاء الفيديو",
"compilingGif": "تجميع GIF",
"exportingFormat": "تصدير {{format}}",
"compiling": "تجميع",
"renderingFrames": "تصيير الإطارات",
"processing": "جاري المعالجة...",
"finalizing": "جاري الإنهاء...",
"compilingStatus": "جاري التجميع...",
"status": "الحالة",
"format": "الصيغة",
"frames": "الإطارات",
"cancelExport": "إلغاء التصدير",
"savedSuccessfully": "تم حفظ {{format}} بنجاح!"
},
"tutorial": {
"triggerLabel": "كيف يعمل القص",
"title": "كيف يعمل القص",
"description": "فهم كيفية قص الأجزاء غير المرغوب فيها من الفيديو الخاص بك.",
"explanationBefore": "تعمل أداة القص من خلال تحديد المقاطع التي تريد",
"remove": "إزالتها",
"explanationMiddle": " — أي شيء",
"covered": "مغطى",
"explanationAfter": "بمقطع قص أحمر سيتم قصه عند التصدير.",
"visualExample": "مثال مرئي",
"removed": "مُزال",
"kept": "مُحتفظ به",
"part1": "الجزء 1",
"part2": "الجزء 2",
"part3": "الجزء 3",
"finalVideo": "الفيديو النهائي",
"step1Title": "1. إضافة قص",
"step1DescriptionBefore": "اضغط على ",
"step1DescriptionAfter": " أو انقر على أيقونة المقص لتحديد قسم لإزالته.",
"step2Title": "2. تعديل",
"step2Description": "اسحب حواف المنطقة الحمراء لتغطي بالضبط ما تريد قصه."
},
"unsavedChanges": {
"title": "تغييرات غير محفوظة",
"message": "لديك تغييرات غير محفوظة.",
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
"saveAndClose": "حفظ وإغلاق",
"discardAndClose": "تجاهل وإغلاق",
"loadProject": "تحميل مشروع...",
"saveProject": "حفظ المشروع...",
"saveProjectAs": "حفظ المشروع باسم..."
},
"fileDialogs": {
"saveGif": "حفظ GIF المصدر",
"saveVideo": "حفظ الفيديو المصدر",
"selectVideo": "حدد ملف فيديو",
"saveProject": "حفظ مشروع OpenScreen",
"openProject": "فتح مشروع OpenScreen",
"gifImage": "صورة GIF",
"mp4Video": "فيديو MP4",
"videoFiles": "ملفات فيديو",
"openscreenProject": "مشروع OpenScreen",
"allFiles": "جميع الملفات"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"newRecording": {
"title": "العودة إلى المسجل",
"description": "تم حفظ جلستك الحالية.",
"cancel": "إلغاء",
"confirm": "تأكيد"
},
"loadingVideo": "جاري تحميل الفيديو...",
"errors": {
"noVideoLoaded": "لم يتم تحميل أي فيديو",
"videoNotReady": "الفيديو غير جاهز",
"unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
"failedToSaveGif": "فشل حفظ GIF",
"gifExportFailed": "فشل تصدير GIF",
"failedToSaveVideo": "فشل حفظ الفيديو",
"exportFailed": "فشل التصدير",
"exportFailedWithError": "فشل التصدير: {{error}}",
"exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
"failedToSaveExport": "فشل حفظ التصدير",
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
},
"export": {
"canceled": "تم إلغاء التصدير",
"exportedSuccessfully": "تم تصدير {{format}} بنجاح"
},
"project": {
"saveCanceled": "تم إلغاء حفظ المشروع",
"failedToSave": "فشل حفظ المشروع",
"savedTo": "تم حفظ المشروع في {{path}}",
"failedToLoad": "فشل تحميل المشروع",
"invalidFormat": "تنسيق ملف المشروع غير صالح",
"loadedFrom": "تم تحميل المشروع من {{path}}"
},
"recording": {
"failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.",
"cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.",
"systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.",
"microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.",
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
"cameraDisconnected": "تم فصل كاميرا الويب.",
"cameraNotFound": "لم يتم العثور على كاميرا.",
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"tooltips": {
"hideHUD": "إخفاء واجهة العرض",
"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": "الاحتفاظ باللغة الحالية"
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"zoom": {
"level": "مستوى التكبير",
"selectRegion": "حدد منطقة التكبير للتعديل",
"deleteZoom": "حذف التكبير",
"focusMode": {
"title": "وضع التركيز",
"manual": "يدوي",
"auto": "تلقائي",
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
}
},
"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": "رابط استيراد خطوط Google",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
"nameLabel": "اسم العرض",
"namePlaceholder": "خطي المخصص",
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
"addButton": "إضافة خط",
"addingButton": "جاري الإضافة...",
"errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
"errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
"errorEmptyName": "يرجى إدخال اسم الخط",
"errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
"successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
"failedToAdd": "فشل في إضافة الخط",
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
},
"language": {
"title": "اللغة"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "اختصارات لوحة المفاتيح",
"customize": "تخصيص",
"configurable": "قابل للتكوين",
"fixed": "ثابت",
"pressKey": "اضغط على مفتاح...",
"clickToChange": "انقر للتغيير",
"pressEscToCancel": "اضغط على Esc للإلغاء",
"helpText": "انقر على اختصار ثم اضغط على مجموعة المفاتيح الجديدة. اضغط على Esc للإلغاء.",
"resetToDefaults": "إعادة تعيين إلى الافتراضيات",
"alreadyUsedBy": "مستخدم بالفعل بواسطة {{action}}",
"swap": "تبديل",
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
"actions": {
"addZoom": "إضافة تكبير",
"addTrim": "إضافة قص",
"addSpeed": "إضافة سرعة",
"addAnnotation": "إضافة شرح",
"addBlur": "إضافة تمويه",
"addKeyframe": "إضافة إطار رئيسي",
"deleteSelected": "حذف المحدد",
"playPause": "تشغيل / إيقاف مؤقت"
},
"fixedActions": {
"undo": "تراجع",
"redo": "إعادة",
"cycleAnnotationsForward": "التنقل بين الشروح للأمام",
"cycleAnnotationsBackward": "التنقل بين الشروح للخلف",
"deleteSelectedAlt": "حذف المحدد (alt)",
"panTimeline": "تحريك المخطط الزمني",
"zoomTimeline": "تكبير المخطط الزمني",
"frameBack": "إطار للخلف",
"frameForward": "إطار للأمام"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"buttons": {
"addZoom": "إضافة تكبير (Z)",
"suggestZooms": "اقتراح تكبير من المؤشر",
"addTrim": "إضافة قص (T)",
"addAnnotation": "إضافة شرح (A)",
"addBlur": "إضافة تمويه (B)",
"addSpeed": "إضافة سرعة (S)"
},
"hints": {
"pressZoom": "اضغط Z لإضافة تكبير",
"pressTrim": "اضغط T لإضافة قص",
"pressAnnotation": "اضغط A لإضافة شرح",
"pressBlur": "اضغط B لإضافة منطقة تمويه",
"pressSpeed": "اضغط S لإضافة سرعة"
},
"labels": {
"pan": "تحريك",
"zoom": "تكبير",
"trim": "قص",
"speed": "سرعة",
"zoomItem": "تكبير {{index}}",
"trimItem": "قص {{index}}",
"speedItem": "سرعة {{index}}",
"annotationItem": "شرح",
"blurItem": "تمويه {{index}}",
"imageItem": "صورة",
"emptyText": "نص فارغ"
},
"emptyState": {
"noVideo": "لم يتم تحميل أي فيديو",
"dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
},
"errors": {
"cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
"zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
"noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
"noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
"noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
"noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
"noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
"noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
"noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
"noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
"cannotPlaceTrim": "لا يمكن وضع القص هنا",
"trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
"speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة."
},
"success": {
"addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
"addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
}
}
+21 -1
View File
@@ -15,7 +15,27 @@
"view": "View",
"window": "Window",
"quit": "Quit",
"stopRecording": "Stop Recording"
"stopRecording": "Stop Recording",
"undo": "Undo",
"redo": "Redo",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All",
"minimize": "Minimize",
"reload": "Reload",
"forceReload": "Force Reload",
"toggleDevTools": "Toggle Developer Tools",
"actualSize": "Actual Size",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"toggleFullScreen": "Toggle Full Screen",
"recordingStatus": "Recording: {{source}}",
"about": "About OpenScreen",
"services": "Services",
"hide": "Hide OpenScreen",
"hideOthers": "Hide Others",
"unhide": "Show All"
},
"playback": {
"play": "Play",
+31 -1
View File
@@ -1,6 +1,7 @@
{
"zoom": {
"level": "Zoom Level",
"customScale": "Custom Zoom",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
@@ -8,6 +9,20 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
},
"threeD": {
"title": "3D Rotation",
"preset": {
"iso": "Iso",
"left": "Left",
"right": "Right"
}
},
"position": {
"title": "Focus Position",
"x": "X (%)",
"y": "Y (%)",
"hint": "0 = leftmost / topmost, 100 = rightmost / bottommost"
}
},
"speed": {
@@ -27,6 +42,7 @@
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"dualFrame": "Dual Frame",
"noWebcam": "No Webcam",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
@@ -35,9 +51,23 @@
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
"on": "on",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
"padding": "Padding",
"cursorHighlight": {
"title": "Cursor highlight",
"style": "Style",
"dot": "Dot",
"ring": "Ring",
"size": "Size",
"onlyOnClicks": "Only on clicks",
"color": "Color",
"offsetX": "Offset X (window recordings)",
"offsetY": "Offset Y",
"accessibilityPermissionTitle": "Accessibility permission needed",
"accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
}
},
"background": {
"title": "Background",
+8
View File
@@ -8,6 +8,14 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
},
"threeD": {
"title": "Rotación 3D",
"preset": {
"iso": "Iso",
"left": "Izquierda",
"right": "Derecha"
}
}
},
"speed": {
+8
View File
@@ -15,6 +15,14 @@
"fast": "Rapide",
"smooth": "Fluide",
"lazy": "Lent"
},
"threeD": {
"title": "Rotation 3D",
"preset": {
"iso": "Iso",
"left": "Gauche",
"right": "Droite"
}
}
},
"speed": {
+2 -2
View File
@@ -7,7 +7,7 @@
"share": "共有",
"done": "完了",
"open": "開く",
"upload": "アップロード",
"upload": "読み込む",
"export": "エクスポート",
"showInFolder": "フォルダに表示",
"file": "ファイル",
@@ -15,7 +15,7 @@
"view": "表示",
"window": "ウィンドウ",
"quit": "終了",
"stopRecording": "録画停止"
"stopRecording": "録画停止"
},
"playback": {
"play": "再生",
+12 -12
View File
@@ -1,22 +1,22 @@
{
"export": {
"complete": "エクスポート完了",
"yourFormatReady": "あなたの{{format}}準備できました",
"yourFormatReady": "{{format}}準備できました",
"showInFolder": "フォルダで表示",
"finalizingVideo": "ビデオのエクスポートを最終処理中...",
"compilingGifProgress": "GIFをコンパイル中... {{progress}}%",
"compilingGifWait": "GIFをコンパイル中... しばらくお待ちください",
"finalizingVideo": "動画のエクスポートを仕上げています...",
"compilingGifProgress": "GIFを生成中... {{progress}}%",
"compilingGifWait": "GIFを生成中... しばらくお待ちください",
"takeMoment": "少々お待ちください...",
"failed": "エクスポートに失敗しました",
"tryAgain": "もう一度お試しください",
"finalizingVideoTitle": "ビデオの最終処理",
"compilingGif": "GIFをコンパイル中",
"finalizingVideoTitle": "動画の仕上げ",
"compilingGif": "GIFを生成中",
"exportingFormat": "{{format}}をエクスポート中",
"compiling": "コンパイル中",
"compiling": "生成中",
"renderingFrames": "フレームをレンダリング中",
"processing": "処理中...",
"finalizing": "最終処理中...",
"compilingStatus": "コンパイル中...",
"compilingStatus": "生成中...",
"status": "ステータス",
"format": "フォーマット",
"frames": "フレーム",
@@ -58,13 +58,13 @@
},
"fileDialogs": {
"saveGif": "エクスポートしたGIFを保存",
"saveVideo": "エクスポートしたビデオを保存",
"selectVideo": "ビデオファイルを選択",
"saveVideo": "エクスポートした動画を保存",
"selectVideo": "動画ファイルを選択",
"saveProject": "OpenScreen プロジェクトを保存",
"openProject": "OpenScreen プロジェクトを開く",
"gifImage": "GIF 画像",
"mp4Video": "MP4 ビデオ",
"videoFiles": "ビデオファイル",
"mp4Video": "MP4 動画",
"videoFiles": "動画ファイル",
"openscreenProject": "OpenScreen プロジェクト",
"allFiles": "すべてのファイル"
}
+8 -8
View File
@@ -5,18 +5,18 @@
"cancel": "キャンセル",
"confirm": "確認"
},
"loadingVideo": "ビデオを読み込み中...",
"loadingVideo": "動画を読み込み中...",
"errors": {
"noVideoLoaded": "ビデオが読み込まれていません",
"videoNotReady": "ビデオが準備できていません",
"unableToDetermineSourcePath": "ソースビデオのパスを特定できません",
"noVideoLoaded": "動画が読み込まれていません",
"videoNotReady": "動画の準備できていません",
"unableToDetermineSourcePath": "元動画のパスを特定できません",
"failedToSaveGif": "GIFの保存に失敗しました",
"gifExportFailed": "GIFのエクスポートに失敗しました",
"failedToSaveVideo": "ビデオの保存に失敗しました",
"failedToSaveVideo": "動画の保存に失敗しました",
"exportFailed": "エクスポートに失敗しました",
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
"failedToSaveExport": "エクスポートの保存に失敗しました",
"failedToSaveExportedVideo": "エクスポートしたビデオの保存に失敗しました",
"failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました",
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})"
},
@@ -35,8 +35,8 @@
"recording": {
"failedCameraAccess": "カメラのアクセス要求に失敗しました。",
"cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。",
"systemAudioUnavailable": "システムオーディオが利用できません。システムオーディオなしで録画します。",
"microphoneDenied": "マイクのアクセスが拒否されました。オーディオなしで録画を続行します。",
"systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。",
"microphoneDenied": "マイクのアクセスが拒否されました。音声なしで録画を続行します。",
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
"cameraDisconnected": "ウェブカメラが切断されました。",
+3 -3
View File
@@ -6,12 +6,12 @@
"cancelRecording": "録画をキャンセル",
"pauseRecording": "録画を一時停止",
"resumeRecording": "録画を再開",
"openVideoFile": "ビデオファイルを開く",
"openVideoFile": "動画ファイルを開く",
"openProject": "プロジェクトを開く"
},
"audio": {
"enableSystemAudio": "システムオーディオを有効にする",
"disableSystemAudio": "システムオーディオを無効にする",
"enableSystemAudio": "システム音声を有効にする",
"disableSystemAudio": "システム音声を無効にする",
"enableMicrophone": "マイクを有効にする",
"disableMicrophone": "マイクを無効にする",
"defaultMicrophone": "デフォルトのマイク"
+25 -24
View File
@@ -7,20 +7,21 @@
"title": "フォーカスモード",
"manual": "手動",
"auto": "自動",
"autoDescription": "カメラが録画中のカーソル位置に追従します"
"autoDescription": "表示範囲が録画中のカーソル位置に追従します"
},
"speed": {
"title": "ズーム速度",
"instant": "即時",
"fast": "高速",
"smooth": "滑らか",
"lazy": "遅延"
"threeD": {
"title": "3D回転",
"preset": {
"iso": "Iso",
"left": "",
"right": ""
}
}
},
"speed": {
"playbackSpeed": "再生速度",
"selectRegion": "速度範囲を選択して調整",
"deleteRegion": "速度範囲を削除",
"selectRegion": "再生速度範囲を選択して調整",
"deleteRegion": "再生速度範囲を削除",
"customPlaybackSpeed": "カスタム再生速度",
"maxSpeedError": "速度は16×を超えることはできません"
},
@@ -31,14 +32,14 @@
"title": "レイアウト",
"preset": "プリセット",
"selectPreset": "プリセットを選択",
"pictureInPicture": "ピクチャーインピクチャ",
"verticalStack": "縦積み",
"pictureInPicture": "ピクチャーインピクチャ",
"verticalStack": "縦並び",
"dualFrame": "デュアルフレーム",
"webcamShape": "カメラの形状",
"webcamSize": "カメラのサイズ"
},
"effects": {
"title": "ビデオ効果",
"title": "動画効果",
"blurBg": "背景をぼかす",
"motionBlur": "モーションブラー",
"off": "オフ",
@@ -51,14 +52,14 @@
"image": "画像",
"color": "色",
"gradient": "グラデーション",
"uploadCustom": "カスタムをアップロード",
"uploadCustom": "カスタム画像を読み込む",
"gradientLabel": "グラデーション {{index}}",
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット"
},
"crop": {
"title": "クロップ",
"cropVideo": "ビデオをクロップ",
"cropVideo": "動画をクロップ",
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
"ratio": "比率",
"free": "自由",
@@ -69,8 +70,8 @@
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 ビデオ",
"mp4Description": "高品質のビデオファイル",
"mp4Video": "MP4 動画",
"mp4Description": "高品質の動画ファイル",
"gifAnimation": "GIF アニメーション",
"gifDescription": "共有用のアニメーション画像"
},
@@ -90,7 +91,7 @@
"load": "プロジェクトを読み込む"
},
"export": {
"videoButton": "ビデオをエクスポート",
"videoButton": "動画をエクスポート",
"gifButton": "GIF をエクスポート",
"chooseSaveLocation": "保存場所を選択"
},
@@ -100,9 +101,9 @@
},
"imageUpload": {
"invalidFileType": "無効なファイル形式",
"jpgOnly": "JPG、JPEG、または PNG 画像ファイルをアップロードしてください。",
"uploadSuccess": "カスタム画像が正常にアップロードされました",
"failedToUpload": "画像のアップロードに失敗しました",
"jpgOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"uploadSuccess": "カスタム画像を読み込みました",
"failedToUpload": "画像の読み込みに失敗しました",
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
},
"annotation": {
@@ -125,7 +126,7 @@
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット",
"clearBackground": "背景をクリア",
"uploadImage": "画像をアップロード",
"uploadImage": "画像を読み込む",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
"arrowDirection": "矢印の方向",
"strokeWidth": "線の太さ: {{width}}px",
@@ -148,9 +149,9 @@
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
"tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
"invalidImageType": "無効なファイル形式",
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルをアップロードしてください。",
"imageUploadSuccess": "画像が正常にアップロードされました",
"failedImageUpload": "画像のアップロードに失敗しました"
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"imageUploadSuccess": "画像を読み込みました",
"failedImageUpload": "画像の読み込みに失敗しました"
},
"fontStyles": {
"classic": "クラシック",
+9 -9
View File
@@ -5,23 +5,23 @@
"addTrim": "トリムを追加 (T)",
"addAnnotation": "注釈を追加 (A)",
"addBlur": "ぼかしを追加 (B)",
"addSpeed": "速度を追加 (S)"
"addSpeed": "再生速度を追加 (S)"
},
"hints": {
"pressZoom": "Zキーを押してズームを追加",
"pressTrim": "Tキーを押してトリムを追加",
"pressAnnotation": "Aキーを押して注釈を追加",
"pressBlur": "Bキーを押してぼかしを追加",
"pressSpeed": "Sキーを押して速度を追加"
"pressSpeed": "Sキーを押して再生速度を追加"
},
"labels": {
"pan": "移動",
"zoom": "ズーム",
"trim": "トリム",
"speed": "速度",
"speed": "再生速度",
"zoomItem": "ズーム {{index}}",
"trimItem": "トリム {{index}}",
"speedItem": "速度 {{index}}",
"speedItem": "再生速度 {{index}}",
"annotationItem": "注釈",
"blurItem": "ぼかし {{index}}",
"imageItem": "画像",
@@ -36,17 +36,17 @@
"zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
"zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
"noCursorTelemetry": "カーソルの動きが記録されていません",
"noCursorTelemetryDescription": "まず画面録を行い、カーソルに基づく提案を生成してください。",
"noCursorTelemetryDescription": "まず画面録を行い、カーソルに基づく提案を生成してください。",
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
"noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
"noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
"cannotPlaceTrim": "ここに切り取りを配置できません",
"trimExistsAtLocation": "この場所にはすでに切り取りが存在するか、十分なスペースがありません。",
"cannotPlaceSpeed": "ここに速度を配置できません",
"speedExistsAtLocation": "この場所にはすでに速度が存在するか、十分なスペースがありません。"
"cannotPlaceTrim": "ここにトリムを配置できません",
"trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
"cannotPlaceSpeed": "ここに再生速度を配置できません",
"speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。"
},
"success": {
"addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
+8
View File
@@ -8,6 +8,14 @@
"manual": "수동",
"auto": "자동",
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
},
"threeD": {
"title": "3D 회전",
"preset": {
"iso": "Iso",
"left": "왼쪽",
"right": "오른쪽"
}
}
},
"speed": {
+50
View File
@@ -0,0 +1,50 @@
{
"actions": {
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"close": "Закрыть",
"share": "Поделиться",
"done": "Готово",
"open": "Открыть",
"upload": "Загрузить",
"export": "Экспорт",
"showInFolder": "Показать в папке",
"file": "Файл",
"edit": "Редактировать",
"view": "Вид",
"window": "Окно",
"quit": "Выход",
"stopRecording": "Остановить запись",
"undo": "Отменить",
"redo": "Повторить",
"cut": "Вырезать",
"copy": "Копировать",
"paste": "Вставить",
"selectAll": "Выделить всё",
"minimize": "Свернуть",
"reload": "Перезагрузить",
"forceReload": "Принудительная перезагрузка",
"toggleDevTools": "Переключить инструменты разработчика",
"actualSize": "Реальный размер",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить",
"toggleFullScreen": "Полноэкранный режим",
"recordingStatus": "Запись: {{source}}",
"about": "О OpenScreen",
"services": "Сервисы",
"hide": "Скрыть OpenScreen",
"hideOthers": "Скрыть остальные",
"unhide": "Показать все"
},
"playback": {
"play": "Воспроизвести",
"pause": "Пауза",
"fullscreen": "Полный экран",
"exitFullscreen": "Выход из полного экрана"
},
"locale": {
"name": "Русский",
"short": "RU"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"export": {
"complete": "Экспорт завершён",
"yourFormatReady": "Ваш {{format}} готов",
"showInFolder": "Показать в папке",
"finalizingVideo": "Завершение экспорта видео...",
"compilingGifProgress": "Сборка GIF... {{progress}}%",
"compilingGifWait": "Сборка GIF... Это может занять некоторое время",
"takeMoment": "Это может занять некоторое время...",
"failed": "Экспорт не удался",
"tryAgain": "Пожалуйста, попробуйте снова",
"finalizingVideoTitle": "Завершение видео",
"compilingGif": "Сборка GIF",
"exportingFormat": "Экспорт {{format}}",
"compiling": "Сборка",
"renderingFrames": "Рендеринг кадров",
"processing": "Обработка...",
"finalizing": "Завершение...",
"compilingStatus": "Сборка...",
"status": "Статус",
"format": "Формат",
"frames": "Кадры",
"cancelExport": "Отменить экспорт",
"savedSuccessfully": "{{format}} успешно сохранён!"
},
"tutorial": {
"triggerLabel": "Как работает обрезка",
"title": "Как работает обрезка",
"description": "Как вырезать ненужные части видео.",
"explanationBefore": "Инструмент обрезки работает путём определения сегментов, которые вы хотите",
"remove": "удалить",
"explanationMiddle": " — всё, что",
"covered": "покрыто",
"explanationAfter": "красным сегментом обрезки, будет вырезано при экспорте.",
"visualExample": "Визуальный пример",
"removed": "УДАЛЕНО",
"kept": "Сохранено",
"part1": "Часть 1",
"part2": "Часть 2",
"part3": "Часть 3",
"finalVideo": "Итоговое видео",
"step1Title": "1. Добавить обрезку",
"step1DescriptionBefore": "Нажмите ",
"step1DescriptionAfter": " или нажмите на значок ножниц, чтобы отметить секцию для удаления.",
"step2Title": "2. Настроить",
"step2Description": "Перетащите края красной области, чтобы точно покрыть то, что вы хотите вырезать."
},
"unsavedChanges": {
"title": "Несохранённые изменения",
"message": "У вас есть несохранённые изменения.",
"detail": "Хотите сохранить проект перед закрытием?",
"saveAndClose": "Сохранить и закрыть",
"discardAndClose": "Отменить и закрыть",
"loadProject": "Загрузить проект…",
"saveProject": "Сохранить проект…",
"saveProjectAs": "Сохранить проект как…"
},
"fileDialogs": {
"saveGif": "Сохранить экспортированный GIF",
"saveVideo": "Сохранить экспортированное видео",
"selectVideo": "Выбрать видеофайл",
"saveProject": "Сохранить проект OpenScreen",
"openProject": "Открыть проект OpenScreen",
"gifImage": "GIF изображение",
"mp4Video": "MP4 видео",
"videoFiles": "Видеофайлы",
"openscreenProject": "Проект OpenScreen",
"allFiles": "Все файлы"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"newRecording": {
"title": "Вернуться к записи",
"description": "Ваша текущая сессия была сохранена.",
"cancel": "Отмена",
"confirm": "Подтвердить"
},
"loadingVideo": "Загрузка видео...",
"errors": {
"noVideoLoaded": "Видео не загружено",
"videoNotReady": "Видео не готово",
"unableToDetermineSourcePath": "Не удалось определить путь к исходному видео",
"failedToSaveGif": "Не удалось сохранить GIF",
"gifExportFailed": "Экспорт GIF не удался",
"failedToSaveVideo": "Не удалось сохранить видео",
"exportFailed": "Экспорт не удался",
"exportFailedWithError": "Экспорт не удался: {{error}}",
"exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})",
"failedToSaveExport": "Не удалось сохранить экспорт",
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
"failedToRevealInFolder": "Ошибка при показе в папке: {{error}}"
},
"export": {
"canceled": "Экспорт отменён",
"exportedSuccessfully": "{{format}} успешно экспортирован"
},
"project": {
"saveCanceled": "Сохранение проекта отменено",
"failedToSave": "Не удалось сохранить проект",
"savedTo": "Проект сохранён в {{path}}",
"failedToLoad": "Не удалось загрузить проект",
"invalidFormat": "Неверный формат файла проекта",
"loadedFrom": "Проект загружен из {{path}}"
},
"recording": {
"failedCameraAccess": "Не удалось запросить доступ к камере.",
"cameraBlocked": "Доступ к камере заблокирован. Включите его в системных настройках для использования веб-камеры.",
"systemAudioUnavailable": "Системное аудио недоступно. Запись без системного аудио.",
"microphoneDenied": "Доступ к микрофону запрещён. Запись продолжится без аудио.",
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
"cameraDisconnected": "Веб-камера отключена.",
"cameraNotFound": "Камера не найдена.",
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"tooltips": {
"hideHUD": "Скрыть HUD",
"closeApp": "Закрыть приложение",
"restartRecording": "Перезапустить запись",
"cancelRecording": "Отменить запись",
"pauseRecording": "Приостановить запись",
"resumeRecording": "Возобновить запись",
"openVideoFile": "Открыть видеофайл",
"openProject": "Открыть проект"
},
"audio": {
"enableSystemAudio": "Включить системное аудио",
"disableSystemAudio": "Отключить системное аудио",
"enableMicrophone": "Включить микрофон",
"disableMicrophone": "Отключить микрофон",
"defaultMicrophone": "Микрофон по умолчанию"
},
"webcam": {
"enableWebcam": "Включить веб-камеру",
"disableWebcam": "Отключить веб-камеру",
"defaultCamera": "Камера по умолчанию",
"searching": "Поиск...",
"noneFound": "Камера не найдена",
"unavailable": "Камера недоступна"
},
"sourceSelector": {
"loading": "Загрузка источников...",
"screens": "Экраны ({{count}})",
"windows": "Окна ({{count}})",
"defaultSourceName": "Экран"
},
"recording": {
"selectSource": "Пожалуйста, выберите источник для записи"
},
"language": "Язык",
"systemLanguagePrompt": {
"title": "Использовать системный язык?",
"description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?",
"switch": "Переключить на {{language}}",
"keepDefault": "Оставить текущий язык"
}
}
+202
View File
@@ -0,0 +1,202 @@
{
"zoom": {
"level": "Уровень масштабирования",
"selectRegion": "Выберите область масштабирования для настройки",
"deleteZoom": "Удалить масштабирование",
"focusMode": {
"title": "Режим фокуса",
"manual": "Ручной",
"auto": "Авто",
"autoDescription": "Камера следует за записанной позицией курсора"
},
"threeD": {
"title": "3D вращение",
"preset": {
"iso": "Изометрия",
"left": "Слева",
"right": "Справа"
}
}
},
"speed": {
"playbackSpeed": "Скорость воспроизведения",
"selectRegion": "Выберите область скорости для настройки",
"deleteRegion": "Удалить область скорости",
"customPlaybackSpeed": "Пользовательская скорость воспроизведения",
"maxSpeedError": "Скорость не может быть выше 16×"
},
"trim": {
"deleteRegion": "Удалить область обрезки"
},
"layout": {
"title": "Макет",
"preset": "Пресет",
"selectPreset": "Выбрать пресет",
"pictureInPicture": "Картинка в картинке",
"verticalStack": "Вертикальный стек",
"dualFrame": "Двойной кадр",
"webcamShape": "Форма камеры",
"webcamSize": "Размер веб-камеры"
},
"effects": {
"title": "Видеоэффекты",
"blurBg": "Размытие фона",
"motionBlur": "Размытие движения",
"off": "выкл",
"on": "вкл",
"shadow": "Тень",
"roundness": "Скругление",
"padding": "Отступ",
"cursorHighlight": {
"title": "Подсветка курсора",
"style": "Стиль",
"dot": "Точка",
"ring": "Кольцо",
"size": "Размер",
"onlyOnClicks": "Только при кликах",
"color": "Цвет",
"offsetX": "Смещение X (записи окон)",
"offsetY": "Смещение Y",
"accessibilityPermissionTitle": "Требуется разрешение на доступность",
"accessibilityPermissionDescription": "Откройте Системные настройки → Конфиденциальность и безопасность → Универсальный доступ, включите Openscreen, затем перезапустите приложение."
}
},
"background": {
"title": "Фон",
"image": "Изображение",
"color": "Цвет",
"gradient": "Градиент",
"uploadCustom": "Загрузить свой",
"gradientLabel": "Градиент {{index}}",
"colorWheel": "Цветовой круг",
"colorPalette": "Палитра цветов"
},
"crop": {
"title": "Обрезка",
"cropVideo": "Обрезать видео",
"dragInstruction": "Перетащите каждую сторону для настройки области обрезки",
"ratio": "Соотношение сторон",
"free": "Свободно",
"done": "Готово",
"lockAspectRatio": "Заблокировать соотношение сторон",
"unlockAspectRatio": "Разблокировать соотношение сторон"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 видео",
"mp4Description": "Видеофайл высокого качества",
"gifAnimation": "GIF анимация",
"gifDescription": "Анимированное изображение для обмена"
},
"exportQuality": {
"title": "Качество экспорта",
"low": "Низкое",
"medium": "Среднее",
"high": "Высокое"
},
"gifSettings": {
"frameRate": "Частота кадров GIF",
"size": "Размер GIF",
"loop": "Зациклить GIF"
},
"project": {
"save": "Сохранить проект",
"load": "Загрузить проект"
},
"export": {
"videoButton": "Экспорт видео",
"gifButton": "Экспорт GIF",
"chooseSaveLocation": "Выбрать место сохранения"
},
"links": {
"reportBug": "Сообщить об ошибке",
"starOnGithub": "Звезда на GitHub"
},
"imageUpload": {
"invalidFileType": "Неверный тип файла",
"jpgOnly": "Пожалуйста, загрузите изображение JPG или JPEG.",
"uploadSuccess": "Пользовательское изображение успешно загружено!",
"failedToUpload": "Не удалось загрузить изображение",
"errorReading": "Произошла ошибка при чтении файла."
},
"annotation": {
"title": "Настройки аннотаций",
"active": "Активно",
"typeText": "Текст",
"typeImage": "Изображение",
"typeArrow": "Стрелка",
"typeBlur": "Размытие",
"textContent": "Содержание текста",
"textPlaceholder": "Введите ваш текст...",
"fontStyle": "Стиль шрифта",
"selectStyle": "Выбрать стиль",
"size": "Размер",
"customFonts": "Пользовательские шрифты",
"textColor": "Цвет текста",
"background": "Фон",
"none": "Нет",
"color": "Цвет",
"colorWheel": "Цветовой круг",
"colorPalette": "Палитра цветов",
"clearBackground": "Очистить фон",
"uploadImage": "Загрузить изображение",
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
"arrowDirection": "Направление стрелки",
"strokeWidth": "Толщина линии: {{width}}px",
"arrowColor": "Цвет стрелки",
"blurType": "Тип размытия",
"blurTypeBlur": "Размытие",
"blurTypeMosaic": "Мозаичное размытие",
"blurColor": "Цвет размытия",
"blurColorWhite": "Белый",
"blurColorBlack": "Чёрный",
"blurShape": "Форма размытия",
"blurIntensity": "Интенсивность размытия",
"mosaicBlockSize": "Размер блока мозаики",
"blurShapeRectangle": "Прямоугольник",
"blurShapeOval": "Овал",
"blurShapeFreehand": "От руки",
"deleteAnnotation": "Удалить аннотацию",
"shortcutsAndTips": "Горячие клавиши и советы",
"tipMovePlayhead": "Переместите курсор воспроизведения к перекрывающейся секции аннотации и выберите элемент.",
"tipTabCycle": "Используйте Tab для циклического переключения между перекрывающимися элементами.",
"tipShiftTabCycle": "Используйте Shift+Tab для циклического переключения в обратном направлении.",
"invalidImageType": "Неверный тип файла",
"imageFormatsOnly": "Пожалуйста, загрузите изображение JPG, PNG, GIF или WebP.",
"imageUploadSuccess": "Изображение успешно загружено!",
"failedImageUpload": "Не удалось загрузить изображение"
},
"fontStyles": {
"classic": "Классический",
"editor": "Редактор",
"strong": "Жирный",
"typewriter": "Пишущая машинка",
"deco": "Декоративный",
"simple": "Простой",
"modern": "Современный",
"clean": "Чистый"
},
"customFont": {
"dialogTitle": "Добавить шрифт Google",
"urlLabel": "URL импорта Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
"nameLabel": "Отображаемое имя",
"namePlaceholder": "Мой пользовательский шрифт",
"nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
"addButton": "Добавить шрифт",
"addingButton": "Добавление...",
"errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
"errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
"errorEmptyName": "Пожалуйста, введите имя шрифта",
"errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
"successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
"failedToAdd": "Не удалось добавить шрифт",
"errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
"errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
},
"language": {
"title": "Язык"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Горячие клавиши",
"customize": "Настроить",
"configurable": "Настраиваемые",
"fixed": "Фиксированные",
"pressKey": "Нажмите клавишу…",
"clickToChange": "Нажмите для изменения",
"pressEscToCancel": "Нажмите Esc для отмены",
"helpText": "Нажмите на горячую клавишу, затем нажмите новую комбинацию клавиш. Нажмите Esc для отмены.",
"resetToDefaults": "Сбросить по умолчанию",
"alreadyUsedBy": "Уже используется для {{action}}",
"swap": "Поменять",
"reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.",
"savedToast": "Горячие клавиши сохранены",
"resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения",
"actions": {
"addZoom": "Добавить масштабирование",
"addTrim": "Добавить обрезку",
"addSpeed": "Изменить скорость",
"addAnnotation": "Добавить аннотацию",
"addBlur": "Добавить размытие",
"addKeyframe": "Добавить ключевой кадр",
"deleteSelected": "Удалить выбранное",
"playPause": "Воспроизведение / Пауза"
},
"fixedActions": {
"undo": "Отменить",
"redo": "Повторить",
"cycleAnnotationsForward": "Циклически переключить аннотации вперёд",
"cycleAnnotationsBackward": "Циклически переключить аннотации назад",
"deleteSelectedAlt": "Удалить выбранное (альт)",
"panTimeline": "Панорамирование таймлайна",
"zoomTimeline": "Масштабирование таймлайна",
"frameBack": "Кадр назад",
"frameForward": "Кадр вперёд"
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"buttons": {
"addZoom": "Добавить масштабирование (Z)",
"suggestZooms": "Предложить масштабирование на основе курсора",
"addTrim": "Добавить обрезку (T)",
"addAnnotation": "Добавить аннотацию (A)",
"addBlur": "Добавить размытие (B)",
"addSpeed": "Изменить скорость (S)"
},
"hints": {
"pressZoom": "Нажмите Z для добавления масштабирования",
"pressTrim": "Нажмите T для добавления обрезки",
"pressAnnotation": "Нажмите A для добавления аннотации",
"pressBlur": "Нажмите B для добавления области размытия",
"pressSpeed": "Нажмите S для изменения скорости"
},
"labels": {
"pan": "Панорамирование",
"zoom": "Масштабирование",
"trim": "Обрезка",
"speed": "Скорость воспроизведения",
"zoomItem": "Масштабирование {{index}}",
"trimItem": "Обрезка {{index}}",
"speedItem": "Скорость воспроизведения {{index}}",
"annotationItem": "Аннотация",
"blurItem": "Размытие {{index}}",
"imageItem": "Изображение",
"emptyText": "Пустой текст"
},
"emptyState": {
"noVideo": "Видео не загружено",
"dragAndDrop": "Перетащите видео для начала редактирования"
},
"errors": {
"cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
"zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
"zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
"noCursorTelemetry": "Нет данных телеметрии курсора",
"noCursorTelemetryDescription": "Сначала запишите screencast для генерации предложений на основе курсора.",
"noUsableTelemetry": "Нет пригодной телеметрии курсора",
"noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
"noDwellMoments": "Не найдено чётких моментов задержки курсора",
"noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
"noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
"noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
"cannotPlaceTrim": "Невозможно разместить обрезку здесь",
"trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
"cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
"speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места."
},
"success": {
"addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
"addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
}
}
+8
View File
@@ -8,6 +8,14 @@
"manual": "Manuel",
"auto": "Otomatik",
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
},
"threeD": {
"title": "3D Döndürme",
"preset": {
"iso": "Iso",
"left": "Sol",
"right": "Sağ"
}
}
},
"speed": {
+30
View File
@@ -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"
}
}
+70
View File
@@ -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"
}
}
+45
View File
@@ -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."
}
}
+43
View File
@@ -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"
}
}
+176
View File
@@ -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ữ"
}
}
+37
View File
@@ -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"
}
}
+55
View File
@@ -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ỏ"
}
}
+8
View File
@@ -8,6 +8,14 @@
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
},
"threeD": {
"title": "3D 旋转",
"preset": {
"iso": "Iso",
"left": "左",
"right": "右"
}
}
},
"speed": {
+8
View File
@@ -15,6 +15,14 @@
"fast": "快速",
"smooth": "平滑",
"lazy": "緩慢"
},
"threeD": {
"title": "3D 旋轉",
"preset": {
"iso": "Iso",
"left": "左",
"right": "右"
}
}
},
"speed": {
+164 -4
View File
@@ -66,11 +66,154 @@
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
overflow: hidden;
text-rendering: geometricPrecision;
}
}
/* Smooth timeline cursor animations */
@layer utilities {
.editor-workspace {
display: flex;
flex-direction: column;
padding: 14px;
background:
radial-gradient(circle at 18% 0%, rgba(52, 178, 123, 0.08), transparent 30%),
linear-gradient(180deg, #08090b 0%, #050606 100%);
}
.editor-main-deck {
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(286px, 20vw, 352px);
gap: 14px;
min-height: 0;
}
.editor-preview-zone,
.editor-settings-rail {
min-width: 0;
min-height: 0;
}
.editor-preview-panel,
.editor-timeline-panel,
.editor-inspector-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#090a0c;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 18px;
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.045);
}
.editor-timeline-panel {
border-radius: 16px;
background:
linear-gradient(180deg, rgba(18, 20, 24, 0.96), rgba(8, 9, 11, 0.98)),
#090a0c;
}
.editor-resize-handle {
height: 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
transition:
background-color 160ms ease,
opacity 160ms ease;
opacity: 0.8;
}
.editor-resize-handle:hover {
background: rgba(255, 255, 255, 0.035);
opacity: 1;
}
.editor-panel-section {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.editor-control-surface {
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 10px;
background: rgba(255, 255, 255, 0.032);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.editor-inspector-shell {
color: #d9e2ee;
}
.editor-inspector-shell button,
.editor-inspector-shell [role="button"] {
letter-spacing: 0;
}
.editor-inspector-shell button:focus-visible,
.editor-inspector-shell [role="button"]:focus-visible {
outline: 1px solid rgba(52, 178, 123, 0.68);
outline-offset: 2px;
}
.editor-inspector-shell [data-radix-collection-item],
.editor-inspector-shell input,
.editor-inspector-shell textarea {
letter-spacing: 0;
}
.editor-inspector-shell [role="slider"] {
box-shadow: 0 0 0 4px rgba(52, 178, 123, 0.12);
}
.editor-inspector-shell .editor-panel-section > h3 {
display: none;
}
.editor-inspector-shell .editor-panel-section > div {
overflow: visible;
}
@media (max-width: 1240px) {
.editor-workspace {
gap: 10px;
padding: 10px;
}
.editor-main-deck {
grid-template-columns: minmax(0, 1fr) 286px;
gap: 10px;
}
}
@media (max-width: 1020px) {
.editor-main-deck {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) 180px;
}
.editor-settings-rail {
min-height: 180px;
}
}
@media (min-width: 1900px) {
.editor-workspace {
padding: 18px;
gap: 18px;
}
.editor-main-deck {
grid-template-columns: minmax(0, 1fr) 372px;
gap: 18px;
}
}
.timeline-cursor-smooth {
will-change: transform;
transition:
@@ -78,14 +221,31 @@
right 33ms linear;
}
/* Hidden scrollbar - still scrollable but invisible */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.32) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
width: 10px;
height: 10px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.22);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.55);
border: 3px solid transparent;
background-clip: content-box;
}
.squircle {
+2 -2
View File
@@ -69,12 +69,12 @@ describe("blur color helpers", () => {
it("returns a dark overlay when black blur color is selected", () => {
expect(
getBlurOverlayColor({
type: "blur",
type: "mosaic",
shape: "rectangle",
color: "black",
intensity: 12,
blockSize: 12,
}),
).toBe("rgba(0, 0, 0, 0.18)");
).toBe("rgba(0, 0, 0, 0.72)");
});
});
+3 -7
View File
@@ -16,7 +16,8 @@ function clamp(value: number, min: number, max: number) {
}
export function normalizeBlurType(value: unknown): BlurType {
return value === "mosaic" ? "mosaic" : "blur";
void value;
return "mosaic";
}
export function normalizeBlurColor(value: unknown): BlurColor {
@@ -42,13 +43,8 @@ export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFa
export function getBlurOverlayColor(blurData?: BlurData | null): string {
const blurColor = normalizeBlurColor(blurData?.color);
const blurType = normalizeBlurType(blurData?.type);
if (blurColor === "black") {
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
}
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
return blurColor === "black" ? "rgba(0, 0, 0, 0.72)" : "rgba(255, 255, 255, 0.06)";
}
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
+31 -1
View File
@@ -15,7 +15,11 @@ export interface Size {
height: number;
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
export type WebcamLayoutPreset =
| "picture-in-picture"
| "vertical-stack"
| "dual-frame"
| "no-webcam";
/** Webcam size as a percentage of the canvas reference dimension (1050). */
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);
+205 -50
View File
@@ -11,13 +11,18 @@ import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type {
AnnotationRegion,
CropRegion,
Rotation3D,
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
@@ -28,9 +33,15 @@ import {
} from "@/components/video-editor/videoPlayback/constants";
import {
adaptiveSmoothFactor,
interpolateCursorAt,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import {
type CursorHighlightConfig,
clickEmphasisAlpha,
drawCursorHighlightCanvas,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -54,6 +65,7 @@ import {
parseCssGradient,
resolveLinearGradientAngle,
} from "./gradientParser";
import { createThreeDPass, type ThreeDPass } from "./threeDPass";
interface FrameRenderConfig {
width: number;
@@ -79,6 +91,8 @@ interface FrameRenderConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
cursorHighlight?: CursorHighlightConfig;
cursorClickTimestamps?: number[];
platform: string;
}
@@ -116,8 +130,12 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private foregroundCanvas: HTMLCanvasElement | null = null;
private foregroundCtx: CanvasRenderingContext2D | null = null;
private rasterCanvas: HTMLCanvasElement | null = null;
private rasterCtx: CanvasRenderingContext2D | null = null;
private threeDPass: ThreeDPass | null = null;
private currentRotation3D: Rotation3D = { ...DEFAULT_ROTATION_3D };
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
@@ -209,6 +227,19 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for raster canvas");
}
// Foreground canvas: holds recording + shadow + webcam + cursor + annotations,
// transparent background. The 3D rotation pass operates only on this layer so
// the wallpaper stays flat behind the rotated content (matching preview).
this.foregroundCanvas = document.createElement("canvas");
this.foregroundCanvas.width = this.config.width;
this.foregroundCanvas.height = this.config.height;
this.foregroundCtx = this.foregroundCanvas.getContext("2d", {
willReadFrequently: this.isLinux,
});
if (!this.foregroundCtx) {
throw new Error("Failed to get 2D context for foreground canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -227,6 +258,13 @@ export class FrameRenderer {
this.maskGraphics = new Graphics();
this.videoContainer.addChild(this.maskGraphics);
this.videoContainer.mask = this.maskGraphics;
try {
this.threeDPass = createThreeDPass(this.config.width, this.config.height);
} catch (error) {
console.warn("[FrameRenderer] 3D pass unavailable, rotation fields will be ignored:", error);
this.threeDPass = null;
}
}
private async setupBackground(): Promise<void> {
@@ -384,16 +422,58 @@ export class FrameRenderer {
// Render the PixiJS stage to its canvas (video only, transparent background)
this.app.renderer.render(this.app.stage);
// Composite with shadows to final output canvas
this.compositeWithShadows(webcamFrame);
// Skip baking the shadow when the WebGL rotation pass will run — it'd alias to
// a hard edge through bilinear sampling. We re-apply shadow fresh after rotation.
const willRotate = !isRotation3DIdentity(this.currentRotation3D);
this.compositeWithShadows(webcamFrame, !willRotate);
// Render annotations on top if present
// Cursor highlight overlay (rendered above video, below annotations)
// Drawn onto foreground so it rotates with the recording.
if (
this.config.cursorHighlight?.enabled &&
this.config.cursorTelemetry &&
this.config.cursorTelemetry.length > 0 &&
this.foregroundCtx
) {
const emphasisAlpha = clickEmphasisAlpha(
timeMs,
this.config.cursorClickTimestamps,
this.config.cursorHighlight,
);
const cursorPoint =
emphasisAlpha > 0 ? interpolateCursorAt(this.config.cursorTelemetry, timeMs) : null;
if (cursorPoint) {
const cx = cursorPoint.cx + this.config.cursorHighlight.offsetXNorm;
const cy = cursorPoint.cy + this.config.cursorHighlight.offsetYNorm;
const stageX =
layoutCache.baseOffset.x + cx * this.config.videoWidth * layoutCache.baseScale;
const stageY =
layoutCache.baseOffset.y + cy * this.config.videoHeight * layoutCache.baseScale;
const appliedScale = this.animationState.appliedScale;
const canvasX = stageX * appliedScale + this.animationState.x;
const canvasY = stageY * appliedScale + this.animationState.y;
const previewW = this.config.previewWidth ?? this.config.width;
const previewH = this.config.previewHeight ?? this.config.height;
const cursorScale = (this.config.width / previewW + this.config.height / previewH) / 2;
drawCursorHighlightCanvas(
this.foregroundCtx,
canvasX,
canvasY,
{
...this.config.cursorHighlight,
opacity: this.config.cursorHighlight.opacity * emphasisAlpha,
},
appliedScale * cursorScale,
);
}
}
// Render annotations on top of foreground (so they rotate with recording).
if (
this.config.annotationRegions &&
this.config.annotationRegions.length > 0 &&
this.compositeCtx
this.foregroundCtx
) {
// Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth ?? this.config.width;
const previewHeight = this.config.previewHeight ?? this.config.height;
const scaleX = this.config.width / previewWidth;
@@ -401,7 +481,7 @@ export class FrameRenderer {
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.foregroundCtx,
this.config.annotationRegions,
this.config.width,
this.config.height,
@@ -409,6 +489,58 @@ export class FrameRenderer {
scaleFactor,
);
}
// Apply 3D rotation to foreground only. Wallpaper (on compositeCanvas) is untouched.
if (willRotate && this.threeDPass && this.foregroundCanvas && this.foregroundCtx) {
const passCanvas = this.threeDPass.apply(this.foregroundCanvas, this.currentRotation3D);
const w = this.foregroundCanvas.width;
const h = this.foregroundCanvas.height;
this.foregroundCtx.clearRect(0, 0, w, h);
if (this.isLinux) {
// drawImage(webglCanvas) is unreliable on Linux/Wayland — use readPixels.
const pixels = this.threeDPass.readPixels();
const imageData = this.foregroundCtx.createImageData(w, h);
imageData.data.set(pixels);
this.foregroundCtx.putImageData(imageData, 0, 0);
} else {
this.foregroundCtx.drawImage(passCanvas, 0, 0);
}
}
// Apply shadow fresh on the rotated silhouette (flat path already baked it
// in compositeWithShadows, so guard on willRotate to avoid doubling).
// Same 3-layer filter chain as `main` — keeps the soft Gaussian intact.
if (
willRotate &&
this.config.showShadow &&
this.config.shadowIntensity > 0 &&
this.shadowCanvas &&
this.shadowCtx &&
this.foregroundCanvas
) {
const shadowCtx = this.shadowCtx;
const w = this.foregroundCanvas.width;
const h = this.foregroundCanvas.height;
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
const baseBlur2 = 16 * intensity;
const baseBlur3 = 8 * intensity;
const baseAlpha1 = 0.7 * intensity;
const baseAlpha2 = 0.5 * intensity;
const baseAlpha3 = 0.3 * intensity;
const baseOffset = 12 * intensity;
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(this.foregroundCanvas, 0, 0, w, h);
shadowCtx.restore();
if (this.compositeCtx) {
this.compositeCtx.drawImage(this.shadowCanvas, 0, 0);
}
} else if (this.compositeCtx && this.foregroundCanvas) {
// Flat path or 3D-without-shadow: stamp foreground directly.
this.compositeCtx.drawImage(this.foregroundCanvas, 0, 0);
}
}
private updateLayout(webcamFrame?: VideoFrame | null): void {
@@ -494,29 +626,28 @@ export class FrameRenderer {
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
// Cache layout info. baseOffset is the stage position of the FULL
// (uncropped) video sprite's top-left — matches preview semantics so
// downstream consumers (e.g. cursor highlight) can map normalized
// recording-space coordinates to stage coordinates uniformly:
// stagePos = baseOffset + (cx, cy) * (videoWidth, videoHeight) * baseScale
this.layoutCache = {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
baseOffset: {
x: compositeLayout.screenRect.x + coverOffsetX - cropPixelX,
y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY,
},
maskRect: compositeLayout.screenRect,
webcamRect: compositeLayout.webcamRect,
};
}
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;
const { region, strength, blendedScale, transition } = findDominantRegion(
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
@@ -527,9 +658,14 @@ export class FrameRenderer {
let targetFocus = { ...defaultFocus };
let targetProgress = 0;
this.currentRotation3D =
region && strength > 0
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, strength)
: { ...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;
@@ -699,38 +835,52 @@ export class FrameRenderer {
return this.rasterCanvas;
}
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
// `applyShadowToRecording` is false when the 3D pass will rotate this canvas
// next — the shadow gets re-applied after rotation to avoid aliasing.
private compositeWithShadows(
webcamFrame: VideoFrame | null | undefined,
applyShadowToRecording: boolean,
): void {
if (
!this.compositeCanvas ||
!this.compositeCtx ||
!this.foregroundCanvas ||
!this.foregroundCtx ||
!this.app
)
return;
const videoCanvas = this.isLinux
? this.readbackVideoCanvas()
: (this.app.canvas as HTMLCanvasElement);
const ctx = this.compositeCtx;
const bgCtx = this.compositeCtx;
const fgCtx = this.foregroundCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
// Clear composite canvas
ctx.clearRect(0, 0, w, h);
// Step 1: Draw background layer (with optional blur, not affected by zoom)
// Background layer (compositeCanvas): wallpaper only. Stays flat — never
// touched by the 3D rotation pass, matching preview behavior.
bgCtx.clearRect(0, 0, w, h);
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite;
if (this.config.showBlur) {
ctx.save();
ctx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
ctx.drawImage(bgCanvas, 0, 0, w, h);
ctx.restore();
bgCtx.save();
bgCtx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
bgCtx.drawImage(bgCanvas, 0, 0, w, h);
bgCtx.restore();
} else {
ctx.drawImage(bgCanvas, 0, 0, w, h);
bgCtx.drawImage(bgCanvas, 0, 0, w, h);
}
} else {
console.warn("[FrameRenderer] No background sprite found during compositing!");
}
// Draw video layer with shadows on top of background
// Foreground (transparent): recording + webcam. Shadow only baked here on
// the flat path; the 3D path applies it after rotation (see renderFrame).
fgCtx.clearRect(0, 0, w, h);
if (
applyShadowToRecording &&
this.config.showShadow &&
this.config.shadowIntensity > 0 &&
this.shadowCanvas &&
@@ -740,7 +890,6 @@ export class FrameRenderer {
shadowCtx.clearRect(0, 0, w, h);
shadowCtx.save();
// Calculate shadow parameters based on intensity (0-1)
const intensity = this.config.shadowIntensity;
const baseBlur1 = 48 * intensity;
const baseBlur2 = 16 * intensity;
@@ -753,9 +902,9 @@ export class FrameRenderer {
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
shadowCtx.restore();
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h);
} else {
ctx.drawImage(videoCanvas, 0, 0, w, h);
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
}
const webcamRect = this.layoutCache?.webcamRect ?? null;
@@ -778,9 +927,9 @@ export class FrameRenderer {
sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect);
const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2));
const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2));
ctx.save();
fgCtx.save();
drawCanvasClipPath(
ctx,
fgCtx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
@@ -789,15 +938,15 @@ export class FrameRenderer {
webcamRect.borderRadius,
);
if (preset.shadow) {
ctx.shadowColor = preset.shadow.color;
ctx.shadowBlur = preset.shadow.blur;
ctx.shadowOffsetX = preset.shadow.offsetX;
ctx.shadowOffsetY = preset.shadow.offsetY;
fgCtx.shadowColor = preset.shadow.color;
fgCtx.shadowBlur = preset.shadow.blur;
fgCtx.shadowOffsetX = preset.shadow.offsetX;
fgCtx.shadowOffsetY = preset.shadow.offsetY;
}
ctx.fillStyle = "#000000";
ctx.fill();
ctx.clip();
ctx.drawImage(
fgCtx.fillStyle = "#000000";
fgCtx.fill();
fgCtx.clip();
fgCtx.drawImage(
webcamFrame as unknown as CanvasImageSource,
sourceCropX,
sourceCropY,
@@ -808,7 +957,7 @@ export class FrameRenderer {
webcamRect.width,
webcamRect.height,
);
ctx.restore();
fgCtx.restore();
}
}
@@ -842,7 +991,13 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.foregroundCanvas = null;
this.foregroundCtx = null;
this.rasterCanvas = null;
this.rasterCtx = null;
if (this.threeDPass) {
this.threeDPass.destroy();
this.threeDPass = null;
}
}
}
+4
View File
@@ -51,6 +51,8 @@ interface GifExporterConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
cursorClickTimestamps?: number[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -161,6 +163,8 @@ export class GifExporter {
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
cursorClickTimestamps: this.config.cursorClickTimestamps,
cursorHighlight: this.config.cursorHighlight,
platform,
});
await this.renderer.initialize();
+356
View File
@@ -0,0 +1,356 @@
import type { Rotation3D } from "@/components/video-editor/types";
import {
computeRotation3DContainScale,
isRotation3DIdentity,
rotation3DPerspective,
} from "@/components/video-editor/types";
// CSS uses +y down, WebGL clip space uses +y up. We do all rotation math in CSS
// convention (top-left origin, +y down) to match the preview, then flip
// gl_Position.y at the end so WebGL's clip space lands the input's top edge at
// the top of the output viewport.
const VERTEX_SHADER = `#version 300 es
in vec2 aPos;
in vec2 aUV;
out vec2 vUV;
uniform mat4 uMvp;
uniform vec2 uSize;
void main() {
vUV = aUV;
vec2 px = (aPos - 0.5) * uSize;
vec4 clip = uMvp * vec4(px, 0.0, 1.0);
clip.y = -clip.y;
gl_Position = clip;
}
`;
const FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 vUV;
out vec4 fragColor;
uniform sampler2D uTex;
void main() {
fragColor = texture(uTex, vUV);
}
`;
function deg2rad(deg: number): number {
return (deg * Math.PI) / 180;
}
function multiplyMat4(a: Float32Array, b: Float32Array): Float32Array {
const out = new Float32Array(16);
for (let i = 0; i < 4; i += 1) {
for (let j = 0; j < 4; j += 1) {
let s = 0;
for (let k = 0; k < 4; k += 1) {
s += a[k * 4 + j] * b[i * 4 + k];
}
out[i * 4 + j] = s;
}
}
return out;
}
function rotationXMat(rad: number): Float32Array {
const c = Math.cos(rad);
const s = Math.sin(rad);
return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]);
}
function rotationYMat(rad: number): Float32Array {
const c = Math.cos(rad);
const s = Math.sin(rad);
return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]);
}
function rotationZMat(rad: number): Float32Array {
const c = Math.cos(rad);
const s = Math.sin(rad);
return new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}
function translationMat(x: number, y: number, z: number): Float32Array {
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]);
}
function perspectiveMat(fovY: number, aspect: number, near: number, far: number): Float32Array {
const f = 1 / Math.tan(fovY / 2);
const nf = 1 / (near - far);
return new Float32Array([
f / aspect,
0,
0,
0,
0,
f,
0,
0,
0,
0,
(far + near) * nf,
-1,
0,
0,
2 * far * near * nf,
0,
]);
}
function scaleMat(s: number): Float32Array {
return new Float32Array([s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}
export function buildMvpMatrix(rot: Rotation3D, w: number, h: number): Float32Array {
const rx = rotationXMat(deg2rad(rot.rotationX));
const ry = rotationYMat(deg2rad(rot.rotationY));
const rz = rotationZMat(deg2rad(rot.rotationZ));
const rotMat = multiplyMat4(rz, multiplyMat4(ry, rx));
const perspective = rotation3DPerspective(w, h);
const containScale = computeRotation3DContainScale(rot, w, h, perspective);
const rotScaled = multiplyMat4(rotMat, scaleMat(containScale));
const d = perspective;
const fovY = 2 * Math.atan2(h / 2, d);
const proj = perspectiveMat(fovY, w / h, 0.1, d * 4 + Math.max(w, h));
const view = translationMat(0, 0, -d);
return multiplyMat4(proj, multiplyMat4(view, rotScaled));
}
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) throw new Error("Failed to create shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Shader compile failed: ${info}`);
}
return shader;
}
function createProgram(gl: WebGL2RenderingContext): WebGLProgram {
const vs = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
const program = gl.createProgram();
if (!program) throw new Error("Failed to create program");
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`Program link failed: ${info}`);
}
gl.deleteShader(vs);
gl.deleteShader(fs);
return program;
}
export interface ThreeDPass {
apply(srcCanvas: HTMLCanvasElement | OffscreenCanvas, rot: Rotation3D): HTMLCanvasElement;
/**
* Reads back the most recent apply() result into a Uint8ClampedArray suitable
* for ImageData. Use this on platforms where drawImage(webglCanvas) is unreliable.
*/
readPixels(): Uint8ClampedArray;
resize(width: number, height: number): void;
destroy(): void;
}
export function createThreeDPass(width: number, height: number): ThreeDPass {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, alpha: true });
if (!gl) throw new Error("WebGL2 not available for 3D pass");
const program = createProgram(gl);
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook
gl.useProgram(program);
const aPos = gl.getAttribLocation(program, "aPos");
const aUV = gl.getAttribLocation(program, "aUV");
const uMvp = gl.getUniformLocation(program, "uMvp");
const uSize = gl.getUniformLocation(program, "uSize");
const uTex = gl.getUniformLocation(program, "uTex");
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// Quad: two triangles sharing UVs consistently per corner.
// pos.y ranges 0 (top of input) → 1 (bottom of input) following CSS convention.
// UV.y is inverted (1 - pos.y) so that with UNPACK_FLIP_Y_WEBGL the texture
// sample at the top of the input lands at the top of the rendered quad.
// TL: pos(0,0) uv(0,1) TR: pos(1,0) uv(1,1)
// BL: pos(0,1) uv(0,0) BR: pos(1,1) uv(1,0)
const verts = new Float32Array([
// aPos.x, aPos.y, aUV.x, aUV.y
0,
0,
0,
1, // TL
1,
0,
1,
1, // TR
0,
1,
0,
0, // BL
0,
1,
0,
0, // BL
1,
0,
1,
1, // TR (was 1,0,1,0 — broken)
1,
1,
1,
0, // BR
]);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8);
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// Plain bilinear, NO mipmaps. Mipmaps pre-blur the texture for downsampling, but
// at our moderate rotation angles (≤22°) the receding edge would still pick a
// smaller mipmap level, which softens fine details — specifically the few-pixel
// rounded-corner anti-alias ramp and the shadow's Gaussian falloff. The result
// is "rounding looks like a hard corner / shadow looks grimy". Sampling level 0
// directly preserves the source crispness.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Anisotropic filtering still helps without mipmaps: at oblique viewing angles
// it samples multiple texels along the gradient direction at level 0, recovering
// detail that plain bilinear would lose. Cap to the device max (16× typical).
const anisoExt =
gl.getExtension("EXT_texture_filter_anisotropic") ||
gl.getExtension("MOZ_EXT_texture_filter_anisotropic") ||
gl.getExtension("WEBKIT_EXT_texture_filter_anisotropic");
if (anisoExt) {
const maxAniso = gl.getParameter(anisoExt.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number;
gl.texParameterf(gl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxAniso));
}
gl.uniform1i(uTex, 0);
let currentSize = { width, height };
const apply = (
srcCanvas: HTMLCanvasElement | OffscreenCanvas,
rot: Rotation3D,
): HTMLCanvasElement => {
gl.viewport(0, 0, currentSize.width, currentSize.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
// CRITICAL: premultiply on upload. The source 2D canvas stores non-premultiplied
// RGBA (alpha=0 areas have RGB=0). Bilinear filtering between an inside-the-shape
// texel (alpha=1, RGB=color) and an outside texel (alpha=0, RGB=0) in
// non-premultiplied space yields (color/2, alpha=0.5), which the
// premultipliedAlpha:true canvas then interprets as half-strength color — visible
// as a dark halo around rounded corners and softened/grimy shadows. Premultiplying
// at upload time makes the bilinear math operate in linear-light premultiplied
// space, which is exactly the math used for compositing. Edges and shadows then
// reproduce the source crisply.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
srcCanvas as TexImageSource,
);
const mvp = isRotation3DIdentity(rot)
? buildMvpMatrix(
{ rotationX: 0, rotationY: 0, rotationZ: 0 },
currentSize.width,
currentSize.height,
)
: buildMvpMatrix(rot, currentSize.width, currentSize.height);
gl.uniformMatrix4fv(uMvp, false, mvp);
gl.uniform2f(uSize, currentSize.width, currentSize.height);
gl.drawArrays(gl.TRIANGLES, 0, 6);
return canvas;
};
const resize = (w: number, h: number) => {
if (w === currentSize.width && h === currentSize.height) return;
canvas.width = w;
canvas.height = h;
currentSize = { width: w, height: h };
};
const readPixels = (): Uint8ClampedArray => {
const w = currentSize.width;
const h = currentSize.height;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
// gl.readPixels is bottom-up; flip to top-down for ImageData. We also need
// to un-premultiply the alpha here: the framebuffer holds premultiplied RGBA
// (we set UNPACK_PREMULTIPLY_ALPHA_WEBGL=true on upload), but ImageData /
// putImageData expect non-premultiplied. Without this divide, semi-transparent
// pixels get interpreted as darker than they should be.
const rowSize = w * 4;
const out = new Uint8ClampedArray(buf.length);
for (let row = 0; row < h; row += 1) {
const src = (h - 1 - row) * rowSize;
const dst = row * rowSize;
for (let col = 0; col < rowSize; col += 4) {
const r = buf[src + col];
const g = buf[src + col + 1];
const b = buf[src + col + 2];
const a = buf[src + col + 3];
if (a === 0) {
out[dst + col] = 0;
out[dst + col + 1] = 0;
out[dst + col + 2] = 0;
out[dst + col + 3] = 0;
} else if (a === 255) {
out[dst + col] = r;
out[dst + col + 1] = g;
out[dst + col + 2] = b;
out[dst + col + 3] = 255;
} else {
const inv = 255 / a;
out[dst + col] = Math.min(255, Math.round(r * inv));
out[dst + col + 1] = Math.min(255, Math.round(g * inv));
out[dst + col + 2] = Math.min(255, Math.round(b * inv));
out[dst + col + 3] = a;
}
}
}
return out;
};
const destroy = () => {
gl.deleteProgram(program);
gl.deleteBuffer(vbo);
gl.deleteVertexArray(vao);
gl.deleteTexture(texture);
};
return { apply, readPixels, resize, destroy };
}
+4
View File
@@ -42,6 +42,8 @@ interface VideoExporterConfig extends ExportConfig {
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
cursorClickTimestamps?: number[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -156,6 +158,8 @@ export class VideoExporter {
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
cursorClickTimestamps: this.config.cursorClickTimestamps,
cursorHighlight: this.config.cursorHighlight,
platform,
});
this.renderer = renderer;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parentDirectoryOf } from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
});
it("returns the directory for a Windows path", () => {
expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
});
it("preserves the POSIX root when the file is at /", () => {
expect(parentDirectoryOf("/video.mp4")).toBe("/");
});
it("preserves the Windows drive root with its trailing separator", () => {
expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
});
it("returns null when no separator is present", () => {
expect(parentDirectoryOf("video.mp4")).toBeNull();
expect(parentDirectoryOf("")).toBeNull();
});
});
+41
View File
@@ -23,6 +23,8 @@ export interface UserPreferences {
exportQuality: ExportQuality;
/** Default export format */
exportFormat: ExportFormat;
/** Folder used for the most recent successful export, if any */
exportFolder: string | null;
}
const DEFAULT_PREFS: UserPreferences = {
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
exportFolder: null,
};
function safeJsonParse(text: string | null): Record<string, unknown> | null {
@@ -76,9 +79,47 @@ export function loadUserPreferences(): UserPreferences {
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
? (raw.exportFormat as ExportFormat)
: DEFAULT_PREFS.exportFormat,
exportFolder:
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
? raw.exportFolder
: DEFAULT_PREFS.exportFolder,
};
}
/**
* Extracts the parent directory from a saved file path. Handles both POSIX
* and Windows separators since the path comes from the OS save dialog.
*
* Root directories are preserved with their trailing separator so that the
* value is still a valid directory path:
* "/video.mp4" -> "/"
* "C:\\video.mp4" -> "C:\\"
*
* Returns null if no separator is found.
*/
export function parentDirectoryOf(filePath: string): string | null {
const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
if (lastSep < 0) return null;
// POSIX root, e.g. "/video.mp4" -> "/"
if (lastSep === 0) return filePath[0];
// Windows drive root, e.g. "C:\\video.mp4" -> "C:\\"
if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) {
return filePath.slice(0, lastSep + 1);
}
return filePath.slice(0, lastSep);
}
/**
* Returns the remembered export folder as `string | undefined`, suitable for
* passing directly to IPC handlers that treat absence as "use the default".
*/
export function getExportFolder(): string | undefined {
return loadUserPreferences().exportFolder ?? undefined;
}
/**
* Persist user preferences to localStorage.
* Only the explicitly provided fields are updated.
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
globals: true,
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
exclude: ["src/**/*.browser.test.{ts,tsx}"],
},
resolve: {
alias: {