Compare commits

..
Author SHA1 Message Date
SiddharthandClaude Opus 4.7 9fc85270bc Merge main into feat/copy-paste-for-timeline; resolve conflicts
- Move copy/paste props (canCopySelectedItem, canPasteTimelineItem,
  onCopySelectedItem, onPasteTimelineItem) to <TimelineEditor> where
  they belong; SettingsPanel was being mid-conflict due to line drift.
- Keep both timelineClipboard state (PR) and showCloseConfirmDialog
  state (main).
- Keep both useClipboardFeedback (PR) and effectiveCursorHighlight +
  useI18n destructuring (main).
- Add feedback.copied / feedback.pasted entries to ar, ja-JP, ru, vi
  timeline locales (kept synced with the en baseline added in the PR).
- Add zh-TW dialogs import to tutorialHelpTranslations.test.ts (was
  missing on main; SUPPORTED_LOCALES includes zh-TW so the test would
  hit undefined without it).
- Regenerate package-lock.json.

Verified: tsc --noEmit clean, npm test 129/129 pass, biome only flags
pre-existing useScreenRecorder warnings on main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:15:44 -07:00
Siddharth e3d4a330df ui revamp 2026-05-09 19:18:16 -07:00
Siddharth 7bbb855e8e update readme 2026-05-09 17:32:43 -07:00
Siddharth 52cb709a88 readme update 2026-05-09 17:12:27 -07:00
Sid 68a95c642a Merge pull request #567 from siddharthvaddem/chore/bump-nix-1.4.0
chore: bump nix package to v1.4.0
2026-05-09 17:05:05 -07:00
github-actions[bot] 2ae7aca185 chore: bump nix package to v1.4.0 2026-05-10 00:04:08 +00:00
Sid 8afca89520 Merge pull request #566 from siddharthvaddem/chore/nix-auto-bump-workflow
chore: add nix package auto-bump workflow
2026-05-09 16:59:34 -07:00
SiddharthandClaude Opus 4.7 7feb05cca7 add nix package auto-bump workflow
On every published GitHub Release, opens a PR bumping nix/package.nix:
- version => the new release version
- npmDepsHash => freshly computed via prefetch-npm-deps package-lock.json

Mirrors the brew + winget release-bump pattern, but lands the change in
this repo (not a separate tap), so it opens a PR instead of pushing
directly. Uses GITHUB_TOKEN — note that PRs created by GITHUB_TOKEN do
not auto-trigger CI; the diff is two lines, easy to review and merge.

Refs the long-standing manual-bump pain (e.g. PR #504 fixing a stale
hash). After this lands, Nix users get new releases without anyone
having to remember the manual edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:58:51 -07:00
Sid 9ac4e778f8 Merge pull request #565 from siddharthvaddem/chore/winget-releaser-workflow
chore: add winget-releaser workflow
2026-05-09 16:42:27 -07:00
SiddharthandClaude Opus 4.7 ed825d8b37 add winget-releaser workflow
Auto-publishes new releases to winget via vedantmgoyal9/winget-releaser.
On every "released" event (not pre-release), the action opens a PR against
microsoft/winget-pkgs bumping SiddharthVaddem.OpenScreen.

Requires:
- WINGET_ACC_TOKEN secret: classic PAT with public_repo scope
  (fine-grained PATs are NOT supported by the action).
- A fork of microsoft/winget-pkgs under siddharthvaddem (or pass fork-user
  if forked elsewhere).

Closes #299

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:39:31 -07:00
Siddharth b48370e3d0 update readme w brew 2026-05-09 16:34:26 -07:00
Sid 8c0c555640 Merge pull request #564 from siddharthvaddem/chore/homebrew-cask-final-style
chore: final homebrew cask style cleanup
2026-05-09 16:28:49 -07:00
SiddharthandClaude Opus 4.7 24be97bae7 fix: final homebrew cask style + audit cleanup
- Drop unnecessary verified: stanza (URL host matches homepage host).
- Add blank line between sha256 and url inside on_arm/on_intel
  (rubocop treats them as separate stanza groups).
- Keep no blank line between on_arm and on_intel blocks
  (same outer stanza group).

After re-running the bump workflow, the cask passes both
brew audit --cask and brew style --cask cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:28:15 -07:00
Sid c820b416f9 Merge pull request #563 from siddharthvaddem/chore/homebrew-cask-audit-fix
chore: fix homebrew cask audit warnings
2026-05-09 16:25:08 -07:00
SiddharthandClaude Opus 4.7 f42c478725 fix homebrew cask audit warnings
- Use #{version} interpolation in URLs so brew detects them as versioned
  (silences "Use sha256 :no_check when URL is unversioned").
- Drop blank line between on_arm and on_intel (same stanza group).
- Alphabetize zap trash array.
- Add verified: stanza for the GitHub release URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:22:58 -07:00
Sid 64c2a43006 Merge pull request #562 from siddharthvaddem/chore/homebrew-cask-workflow
chore: add homebrew cask bump workflow
2026-05-09 16:07:47 -07:00
SiddharthandClaude Opus 4.7 4a0878c3d0 add homebrew cask bump workflow
Auto-updates the openscreen Homebrew tap on each published release:
finds the macOS DMGs, computes sha256, and rewrites Casks/openscreen.rb
in siddharthvaddem/homebrew-openscreen.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:03:50 -07:00
Sid d8da26a41a Merge pull request #561 from auberginewly/fix/electron-screen-capture-permissions
fix(macOS): fix three screen capture permission issues in Electron layer
2026-05-09 15:10:47 -07:00
Sid f0699cc8b0 Merge pull request #507 from hthienloc/add-vietnamese-i18n-1022783609047552672
Add Vietnamese i18n support (vi locale)
2026-05-09 14:48:31 -07:00
Siddharth 3ad3e22a16 test(i18n): add vi to tutorialHelpTranslations locale map 2026-05-09 14:43:56 -07:00
Siddharth 2381e48a46 Merge main into add-vietnamese-i18n-1022783609047552672
Resolve conflict in electron/i18n.ts by keeping both `ar` (from main) and `vi` (from this branch). Also add `vi` to SUPPORTED_LOCALES in src/i18n/config.ts so Vietnamese is selectable in the language picker.
2026-05-09 14:35:03 -07:00
Siddharth 68c35ff01c zoom precision position 2026-05-09 14:32:50 -07:00
auberginewly 3dd7b85ebb fix(build): remove misplaced entitlement key from mac.extendInfo
com.apple.security.device.audio-input is an entitlement key and should
only appear in macos.entitlements. Placing it in extendInfo writes it
into Info.plist where it has no effect and is misleading.

The correct entry already exists in macos.entitlements; this removes
the redundant, incorrectly-placed duplicate.
2026-05-10 05:30:48 +08:00
auberginewly be4e2d0c94 fix(electron/macOS): proactively check screen recording permission on startup
Microphone permission is checked at startup via getMediaAccessStatus, and
camera has a dedicated request-camera-access IPC handler, but screen
recording relied entirely on desktopCapturer.getSources() to implicitly
trigger the TCC prompt — causing the permission dialog to reappear on
every launch (issue #558).

Note: askForMediaAccess() only accepts "microphone" | "camera"; screen
recording TCC is triggered via desktopCapturer.getSources() instead.

Fix:
- Import desktopCapturer in main.ts
- Call getMediaAccessStatus("screen") in app.whenReady(); trigger the
  TCC prompt via getSources when status is "not-determined"
- Add request-screen-access IPC handler symmetric to request-camera-access
2026-05-10 05:30:42 +08:00
auberginewly c9b6074626 fix(electron): add screen and display-capture to Electron permission allowlists
setPermissionCheckHandler and setPermissionRequestHandler only allowed
["media", "audioCapture", "microphone", "videoCapture", "camera"], causing
any renderer-side getUserMedia/desktopCapturer request using a screen source
to be silently denied by Electron before macOS TCC is ever consulted.

Fix: add "screen" and "display-capture" to both handler allowlists.
2026-05-10 05:24:19 +08:00
Siddharth c1f6cf67b2 loc first and then export processing 2026-05-09 11:59:52 -07:00
Siddharth 5bd17f4346 fix layout 2026-05-09 11:46:09 -07:00
Sid d3e397e249 Merge pull request #399 from muratclk/fix/trim-handle-boundary-clamp
fix: clamp trim handle end position to timeline boundary
2026-05-09 10:19:12 -07:00
Murat Çelik c771bf8bb9 fix: clamp trim handle end position to timeline boundary
The right-side trim handle could be dragged past the end of the
timeline because clampSpanToBounds did not cap the computed end
value at totalMs. This adds Math.min(…, totalMs) so the handle
snaps to the timeline edge.

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

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

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

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

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

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

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

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

- Add 'noWebcam' i18n translation key (en)
2026-05-07 12:19:48 +05:30
psychosomat 9336e3d3c6 Fix Russian translation typo and reorder imports 2026-05-06 13:16:21 +03:00
psychosomat 6130c66be6 Add Russian localization 2026-05-06 12:55:01 +03:00
makaradam e2bdfee653 fix: scope IPC close-confirm responses to the originating window
Both ipcMain.once handlers now check event.sender.id against
windowToClose.webContents.id and ignore messages from any other
renderer, preventing cross-window response mix-ups if multiple editor
windows are ever open simultaneously.
2026-05-02 14:36:59 +02:00
makaradam e7e493294b fix: use relative path for logo so it resolves in packaged app
./openscreen.png resolves correctly both in dev (Vite serves public/)
and in production (loadFile sets base to dist/, where public assets land
inside the asar). getAssetPath points to extraResources, which is the
wrong location for bundled dist assets.
2026-05-02 14:33:14 +02:00
makaradam b2cc722613 fix: use getAssetPath for logo so it resolves correctly in packaged app 2026-05-02 13:43:20 +02:00
makaradam 36076aaf2a fix: address code review feedback on custom close dialog 2026-05-02 13:08:52 +02:00
makaradam b3469c469b feat: replace native OS close dialog with custom in-app dialog 2026-05-02 12:28:04 +02:00
AbhinRustagi b801c1ccea fix: resolve comments 2026-05-02 01:19:44 +05:30
AbhinRustagi c40727672f feat: implement handlers to store last export location 2026-05-02 01:05:17 +05:30
AbhinRustagi a38454a7fb feat: update saveExportedVideo fn signature 2026-05-02 01:02:42 +05:30
google-labs-jules[bot]andhthienloc 37c1ea5984 Add Vietnamese i18n support (vi locale)
Co-authored-by: hthienloc <148019203+hthienloc@users.noreply.github.com>
2026-04-30 16:27:57 +00:00
david dc7259ba09 fix: bumped npmDepsHash on package.nix 2026-04-29 10:31:08 +02:00
Lorenzo Lancia 42468c13b6 fix 2026-04-24 18:09:42 +02:00
Lorenzo Lancia dca91b4f86 fix: copy/paste notification 2026-04-24 17:39:33 +02:00
LorenzoLancia 80a3b1e4d3 fix: clone position from original 2026-04-23 22:15:45 +02:00
LorenzoLancia 2704c280bd feat: add copy and paste support for components in timeline 2026-04-22 22:26:45 +02:00
102 changed files with 4579 additions and 1927 deletions
+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 you’d like to help out or see what’s 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 |
+2 -2
View File
@@ -51,8 +51,8 @@
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
"NSCameraUseContinuityCameraDeviceType": true
}
},
"linux": {
+27 -3
View File
@@ -79,10 +79,25 @@ interface Window {
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
pickExportSavePath: (
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
exportFolder?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
writeExportToPath: (
videoData: ArrayBuffer,
filePath: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
@@ -142,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>;
@@ -149,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 }>;
};
}
+6 -2
View File
@@ -15,12 +15,14 @@ import commonKo from "../src/i18n/locales/ko-KR/common.json";
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
import commonTr from "../src/i18n/locales/tr/common.json";
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
import commonVi from "../src/i18n/locales/vi/common.json";
import dialogsVi from "../src/i18n/locales/vi/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar";
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
@@ -34,6 +36,7 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
"ko-KR": { common: commonKo, dialogs: dialogsKo },
tr: { common: commonTr, dialogs: dialogsTr },
ar: { common: commonAr, dialogs: dialogsAr },
vi: { common: commonVi, dialogs: dialogsVi },
};
let currentLocale: Locale = "en";
@@ -48,7 +51,8 @@ export function setMainLocale(locale: string) {
locale === "ja-JP" ||
locale === "ko-KR" ||
locale === "tr" ||
locale === "ar"
locale === "ar" ||
locale === "vi"
) {
currentLocale = locale;
}
+112 -14
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -717,6 +718,32 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("request-screen-access", async () => {
if (process.platform !== "darwin") {
return { success: true, granted: true, status: "granted" };
}
try {
const status = systemPreferences.getMediaAccessStatus("screen");
if (status === "granted") {
return { success: true, granted: true, status };
}
// Screen recording has no askForMediaAccess equivalent — the TCC prompt
// is triggered by desktopCapturer.getSources(). Fire it and return so
// the renderer can re-check status after the user responds.
if (status === "not-determined") {
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
return { success: true, granted: false, status: "not-determined" };
}
return { success: true, granted: false, status };
} catch (error) {
console.error("Failed to request screen access:", error);
return { success: false, granted: false, status: "unknown", error: String(error) };
}
});
// macOS Accessibility prompt for global click capture. First call shows the
// system dialog; the user has to toggle the app in System Settings (no
// programmatic grant exists for Accessibility).
@@ -985,20 +1012,36 @@ export function registerIpcHandlers(
* @returns Object with success status, optional file path, and error details.
*/
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
// Prefer the user's last export folder if it still exists, otherwise fall
// back to ~/Downloads. Validation must happen here because the renderer
// can't stat the filesystem.
let defaultDir = app.getPath("downloads");
if (exportFolder) {
try {
const stats = await fs.stat(exportFolder);
if (stats.isDirectory()) {
defaultDir = exportFolder;
}
} catch (err) {
console.warn(
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
err,
);
}
}
const dialogOptions = buildDialogOptions(
{
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
defaultPath: path.join(defaultDir, fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
},
@@ -1007,20 +1050,34 @@ export function registerIpcHandlers(
const result = await dialog.showSaveDialog(dialogOptions);
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
return { success: false, canceled: true, message: "Export canceled" };
}
// --- FIX: Normalize the path for Windows compatibility ---
const normalizedPath = path.normalize(result.filePath);
return { success: true, path: path.normalize(result.filePath) };
} catch (error) {
console.error("Failed to show save dialog:", error);
return {
success: false,
message: "Failed to show save dialog",
error: String(error),
};
}
});
// Ensure the parent directory exists (Windows may fail if the folder is missing)
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
try {
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
// but a stale state bug shouldn't be able to clobber arbitrary files.
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
return { success: false, message: "Invalid path" };
}
const lower = filePath.toLowerCase();
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
return { success: false, message: "Invalid file type" };
}
const normalizedPath = path.normalize(filePath);
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
// --- END FIX ---
await fs.writeFile(normalizedPath, Buffer.from(videoData));
return {
@@ -1029,7 +1086,7 @@ export function registerIpcHandlers(
message: "Video exported successfully",
};
} catch (error) {
console.error("Failed to save exported video:", error);
console.error("Failed to write exported video:", error);
return {
success: false,
message: "Failed to save exported video",
@@ -1317,4 +1374,45 @@ export function registerIpcHandlers(
return { success: false, error: String(error) };
}
});
ipcMain.handle(
"save-diagnostic",
async (
_,
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
) => {
const { filePath, canceled } = await dialog.showSaveDialog({
title: "Save Diagnostic File",
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (canceled || !filePath) return { success: false, canceled: true };
const diagnostic = {
timestamp: new Date().toISOString(),
appVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
osRelease: os.release(),
osVersion: os.version(),
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
nodeVersion: process.versions.node,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
error: payload.error,
stack: payload.stack,
projectState: payload.projectState,
recentLogs: payload.logs,
};
try {
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
return { success: true, path: filePath };
} catch (error) {
console.error("Failed to write diagnostic file:", error);
return { success: false, error: String(error) };
}
},
);
}
+53 -31
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import {
app,
BrowserWindow,
dialog,
desktopCapturer,
ipcMain,
Menu,
nativeImage,
@@ -333,6 +333,7 @@ function updateTrayMenu(recording: boolean = false) {
let editorHasUnsavedChanges = false;
let isForceClosing = false;
let isCloseConfirmInFlight = false;
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
@@ -364,39 +365,35 @@ function createEditorWindowWrapper() {
editorHasUnsavedChanges = false;
mainWindow.on("close", (event) => {
if (isForceClosing || !editorHasUnsavedChanges) return;
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
event.preventDefault();
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
isCloseConfirmInFlight = true;
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
// Ask renderer to show the custom in-app dialog
windowToClose.webContents.send("request-close-confirm");
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
if (event.sender.id !== windowToClose?.webContents.id) return;
isCloseConfirmInFlight = false;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === "save") {
// Tell renderer to save the project, then close when done
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
if (event.sender.id !== windowToClose?.webContents.id) return;
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === "discard") {
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
}
// "cancel": flag reset, window stays open
});
});
}
@@ -453,23 +450,48 @@ app.whenReady().then(async () => {
app.dock?.show();
}
// Allow microphone/media permission checks
// Allow microphone/media/screen permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
const allowed = [
"media",
"audioCapture",
"microphone",
"videoCapture",
"camera",
"screen",
"display-capture",
];
callback(allowed.includes(permission));
});
// Request microphone permission from macOS
// Request microphone and screen recording permissions from macOS
if (process.platform === "darwin") {
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
if (micStatus !== "granted") {
await systemPreferences.askForMediaAccess("microphone");
}
// Screen recording has no askForMediaAccess equivalent — the TCC prompt is
// triggered by the first desktopCapturer.getSources() call. Firing it here
// at startup settles the permission state early and prevents repeated prompts
// driven by later getSources() calls (fixes repeated permission dialog).
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
if (screenStatus === "not-determined") {
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
}
}
// Listen for HUD overlay quit event (macOS only)
+24 -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);
},
@@ -71,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");
@@ -134,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);
},
@@ -166,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>
+1 -1
View File
@@ -33,7 +33,7 @@ buildNpmPackage {
);
};
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
npmDepsHash = "sha256-tOpoJPzaZDK3HJijGHpZ0+jWsbrYyQUuw1pO0Uxcifg=";
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "openscreen",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.3.0",
"version": "1.4.0",
"hasInstallScript": true,
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
+1
View File
@@ -5,6 +5,7 @@
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
+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;
}
+34 -15
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);
@@ -320,6 +328,12 @@ export function LaunchWindow() {
// 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
@@ -360,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)}
@@ -409,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)}
@@ -485,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}`}>
@@ -494,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 */}
@@ -548,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}
@@ -624,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>
);
}
File diff suppressed because it is too large Load Diff
+38 -13
View File
@@ -38,13 +38,12 @@ import {
type BlurData,
computeRotation3DContainScale,
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
@@ -67,7 +66,7 @@ import {
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { clampFocusToScale } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -81,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;
@@ -257,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) => {
@@ -442,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);
@@ -951,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;
@@ -1453,15 +1461,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 null;
if (!app?.renderer?.extract) return cached.canvas;
try {
return app.renderer.extract.canvas(app.stage);
const canvas = app.renderer.extract.canvas(app.stage);
blurPreviewSnapshotRef.current = {
bucket: previewSnapshotBucket,
canvas,
height: overlaySize.height,
width: overlaySize.width,
};
return canvas;
} catch {
return null;
return cached.canvas;
}
})()
: null;
@@ -1533,7 +1558,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
previewFrameVersion={previewSnapshotBucket}
/>
));
})()}
@@ -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);
});
@@ -100,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
): WebcamLayoutPreset {
switch (webcamLayoutPreset) {
case "picture-in-picture":
case "no-webcam":
return webcamLayoutPreset;
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
+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,
@@ -86,6 +85,10 @@ interface TimelineEditorProps {
onSpeedDelete?: (id: string) => void;
selectedSpeedId?: string | null;
onSelectSpeed?: (id: string | null) => void;
canCopySelectedItem?: boolean;
canPasteTimelineItem?: boolean;
onCopySelectedItem?: () => void;
onPasteTimelineItem?: () => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
@@ -102,6 +105,7 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
zoomCustomScale?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
@@ -377,7 +381,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 +392,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 +478,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 +489,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 +511,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 +662,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 +687,7 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomCustomScale={item.zoomCustomScale}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
@@ -799,6 +804,10 @@ export default function TimelineEditor({
onSpeedDelete,
selectedSpeedId,
onSelectSpeed,
canCopySelectedItem = false,
canPasteTimelineItem = false,
onCopySelectedItem,
onPasteTimelineItem,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
@@ -1227,7 +1236,42 @@ export default function TimelineEditor({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
const target = e.target;
const isEditableTarget =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable);
if (isEditableTarget) {
return;
}
const mod = isMac ? e.metaKey : e.ctrlKey;
const key = e.key.toLowerCase();
if (
mod &&
!e.shiftKey &&
!e.altKey &&
key === "c" &&
canCopySelectedItem &&
onCopySelectedItem
) {
e.preventDefault();
onCopySelectedItem();
return;
}
if (
mod &&
!e.shiftKey &&
!e.altKey &&
key === "v" &&
canPasteTimelineItem &&
onPasteTimelineItem
) {
e.preventDefault();
onPasteTimelineItem();
return;
}
@@ -1319,6 +1363,10 @@ export default function TimelineEditor({
onSelectAnnotation,
keyShortcuts,
isMac,
canCopySelectedItem,
canPasteTimelineItem,
onCopySelectedItem,
onPasteTimelineItem,
]);
const clampedRange = useMemo<Range>(() => {
@@ -1339,6 +1387,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 +1494,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 +1510,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 +1519,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 +1528,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 +1537,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 +1556,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 +1587,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 +1606,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 };
},
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
cloneAnnotationRegion,
getPastedAnnotationPosition,
spansOverlap,
} from "./timelineClipboardUtils";
import type { AnnotationRegion } from "./types";
function createAnnotationRegion(overrides: Partial<AnnotationRegion> = {}): AnnotationRegion {
return {
id: "annotation-1",
startMs: 100,
endMs: 600,
type: "blur",
content: "",
position: { x: 10, y: 15 },
size: { width: 30, height: 20 },
style: {
color: "#fff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
},
zIndex: 1,
blurData: {
type: "mosaic",
shape: "freehand",
color: "white",
intensity: 12,
blockSize: 8,
freehandPoints: [
{ x: 10, y: 20 },
{ x: 30, y: 40 },
],
},
...overrides,
};
}
describe("timelineClipboardUtils", () => {
it("deep clones nested annotation data", () => {
const original = createAnnotationRegion();
const cloned = cloneAnnotationRegion(original);
expect(cloned).toEqual(original);
expect(cloned).not.toBe(original);
expect(cloned.position).not.toBe(original.position);
expect(cloned.size).not.toBe(original.size);
expect(cloned.style).not.toBe(original.style);
expect(cloned.blurData).not.toBe(original.blurData);
expect(cloned.blurData?.freehandPoints).not.toBe(original.blurData?.freehandPoints);
expect(cloned.blurData?.freehandPoints?.[0]).not.toBe(original.blurData?.freehandPoints?.[0]);
});
it("detects true overlaps but not adjacent spans", () => {
expect(spansOverlap(100, 200, 150, 250)).toBe(true);
expect(spansOverlap(100, 200, 200, 300)).toBe(false);
expect(spansOverlap(100, 200, 0, 100)).toBe(false);
});
it("preserves pasted annotation positions when they are already in bounds", () => {
expect(getPastedAnnotationPosition({ x: 10, y: 15 }, { width: 30, height: 20 })).toEqual({
x: 10,
y: 15,
});
});
it("clamps pasted annotation positions when the source would overflow its bounds", () => {
expect(getPastedAnnotationPosition({ x: 94, y: 93 }, { width: 12, height: 9 })).toEqual({
x: 88,
y: 91,
});
});
it("pins oversized pasted annotations to the visible origin", () => {
expect(getPastedAnnotationPosition({ x: 50, y: 50 }, { width: 140, height: 120 })).toEqual({
x: 0,
y: 0,
});
});
});
@@ -0,0 +1,38 @@
import type { AnnotationPosition, AnnotationRegion, AnnotationSize } from "./types";
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function cloneAnnotationRegion(region: AnnotationRegion): AnnotationRegion {
return {
...region,
position: { ...region.position },
size: { ...region.size },
style: { ...region.style },
figureData: region.figureData ? { ...region.figureData } : undefined,
blurData: region.blurData
? {
...region.blurData,
freehandPoints: region.blurData.freehandPoints?.map((point) => ({ ...point })),
}
: undefined,
};
}
export function spansOverlap(startA: number, endA: number, startB: number, endB: number): boolean {
return startA < endB && endA > startB;
}
export function getPastedAnnotationPosition(
position: AnnotationPosition,
size: AnnotationSize,
): AnnotationPosition {
const maxX = Math.max(0, 100 - size.width);
const maxY = Math.max(0, 100 - size.height);
return {
x: clamp(position.x, 0, maxX),
y: clamp(position.y, 0, maxY),
};
}
+15 -1
View File
@@ -65,6 +65,8 @@ export interface ZoomRegion {
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
/** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
customScale?: number;
}
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
@@ -292,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,
@@ -356,8 +358,20 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
6: 5.0,
};
export const MIN_ZOOM_SCALE = 1.0;
export const MAX_ZOOM_SCALE = 5.0;
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
export function getZoomScale(region: ZoomRegion): number {
if (region.customScale != null) {
const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
if (Number.isFinite(clamped)) return clamped;
}
return ZOOM_DEPTH_SCALES[region.depth];
}
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
@@ -44,7 +44,7 @@ interface ViewportRatio {
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
export function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
@@ -1,5 +1,5 @@
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
import { clampFocusToStage } from "./focusUtils";
import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
import { clampFocusToScale } from "./focusUtils";
interface OverlayUpdateParams {
overlayEl: HTMLDivElement;
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
return;
}
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
width: stageWidth,
height: stageHeight,
});
const zoomScale = getZoomScale(region);
const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
const indicatorWidth = stageWidth / zoomScale;
@@ -1,5 +1,5 @@
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
@@ -155,7 +155,7 @@ function getActiveRegion(
}
const activeRegion = activeRegions[0].region;
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
const activeScale = getZoomScale(activeRegion);
return {
region: {
@@ -176,7 +176,7 @@ function getConnectedRegionHold(
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
const nextScale = getZoomScale(pair.nextRegion);
return {
region: {
...pair.nextRegion,
@@ -214,8 +214,8 @@ function getConnectedRegionTransition(
const transitionProgress = easeConnectedPan(
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
);
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const currentScale = getZoomScale(currentRegion);
const nextScale = getZoomScale(nextRegion);
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
+30
View File
@@ -0,0 +1,30 @@
import { useCallback } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
export type ClipboardAction = "copy" | "paste";
export function useClipboardFeedback() {
const t = useScopedT("timeline");
const notifyClipboardAction = useCallback(
(action: ClipboardAction) => {
toast.success(action === "copy" ? t("feedback.copied") : t("feedback.pasted"));
},
[t],
);
const notifyCopied = useCallback(() => {
notifyClipboardAction("copy");
}, [notifyClipboardAction]);
const notifyPasted = useCallback(() => {
notifyClipboardAction("paste");
}, [notifyClipboardAction]);
return {
notifyClipboardAction,
notifyCopied,
notifyPasted,
};
}
@@ -6,6 +6,7 @@ import frDialogs from "@/i18n/locales/fr/dialogs.json";
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
import trDialogs from "@/i18n/locales/tr/dialogs.json";
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
const tutorialHelpKeys = [
"triggerLabel",
@@ -35,6 +36,7 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
const dialogsByLocale = {
en: enDialogs,
"zh-CN": zhCNDialogs,
"zh-TW": zhTWDialogs,
es: esDialogs,
fr: frDialogs,
tr: trDialogs,
+1 -11
View File
@@ -1,15 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = [
"en",
"zh-CN",
"zh-TW",
"es",
"fr",
"tr",
"ko-KR",
"ja-JP",
"ar",
] as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "es", "fr", "tr", "ko-KR"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
+4
View File
@@ -51,5 +51,9 @@
"success": {
"addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
"addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
},
"feedback": {
"copied": "تم النسخ!",
"pasted": "تم اللصق!"
}
}
+1 -22
View File
@@ -9,33 +9,12 @@
"open": "Open",
"upload": "Upload",
"export": "Export",
"showInFolder": "Show in Folder",
"file": "File",
"edit": "Edit",
"view": "View",
"window": "Window",
"quit": "Quit",
"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"
"stopRecording": "Stop Recording"
},
"playback": {
"play": "Play",
-2
View File
@@ -5,7 +5,6 @@
"cancel": "Cancel",
"confirm": "Confirm"
},
"loadingVideo": "Loading video...",
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
@@ -15,7 +14,6 @@
"failedToSaveVideo": "Failed to save video",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
"exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
+8 -27
View File
@@ -9,13 +9,12 @@
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
},
"threeD": {
"title": "3D Rotation",
"preset": {
"iso": "Iso",
"left": "Left",
"right": "Right"
}
"speed": {
"title": "Zoom Speed",
"instant": "Instant",
"fast": "Fast",
"smooth": "Smooth",
"lazy": "Lazy"
}
},
"speed": {
@@ -43,23 +42,9 @@
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
"on": "on",
"shadow": "Shadow",
"roundness": "Roundness",
"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."
}
"padding": "Padding"
},
"background": {
"title": "Background",
@@ -67,9 +52,7 @@
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"gradientLabel": "Gradient {{index}}",
"colorWheel": "Color Wheel",
"colorPalette": "Color Palette"
"gradientLabel": "Gradient {{index}}"
},
"crop": {
"title": "Crop",
@@ -137,8 +120,6 @@
"background": "Background",
"none": "None",
"color": "Color",
"colorWheel": "Color Wheel",
"colorPalette": "Color Palette",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
+4 -2
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "Pan",
"zoom": "Zoom",
"trim": "Trim",
"speed": "Speed",
"zoomItem": "Zoom {{index}}",
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
@@ -51,5 +49,9 @@
"success": {
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
},
"feedback": {
"copied": "Copied!",
"pasted": "Pasted!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "Abrir",
"upload": "Subir",
"export": "Exportar",
"showInFolder": "Mostrar en carpeta",
"file": "Archivo",
"edit": "Editar",
"view": "Vista",
-8
View File
@@ -8,7 +8,6 @@
"failedToSaveVideo": "Error al guardar el video",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
"exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
@@ -34,12 +33,5 @@
"cameraDisconnected": "Cámara web desconectada.",
"cameraNotFound": "Cámara no encontrada.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
},
"loadingVideo": "Cargando video...",
"newRecording": {
"title": "Volver a la grabadora",
"description": "Tu sesión actual ha sido guardada.",
"cancel": "Cancelar",
"confirm": "Confirmar"
}
}
+7 -12
View File
@@ -9,13 +9,12 @@
"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": {
"title": "Velocidad de zoom",
"instant": "Instantáneo",
"fast": "Rápido",
"smooth": "Suave",
"lazy": "Lento"
}
},
"speed": {
@@ -53,9 +52,7 @@
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"gradientLabel": "Degradado {{index}}",
"colorWheel": "Rueda de colores",
"colorPalette": "Paleta de colores"
"gradientLabel": "Degradado {{index}}"
},
"crop": {
"title": "Recortar",
@@ -123,8 +120,6 @@
"background": "Fondo",
"none": "Ninguno",
"color": "Color",
"colorWheel": "Rueda de colores",
"colorPalette": "Paleta de colores",
"clearBackground": "Quitar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
+4 -2
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "Desplazar",
"zoom": "Zoom",
"trim": "Recortar",
"speed": "Velocidad",
"zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
"speedItem": "Velocidad {{index}}",
@@ -51,5 +49,9 @@
"success": {
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
},
"feedback": {
"copied": "¡Copiado!",
"pasted": "¡Pegado!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "Ouvrir",
"upload": "Téléverser",
"export": "Exporter",
"showInFolder": "Afficher dans le dossier",
"file": "Fichier",
"edit": "Éditer",
"view": "Affichage",
+1 -1
View File
@@ -29,7 +29,7 @@
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
"explanationBefore": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"remove": "supprimer",
"explanationMiddle": " — tout ce qui est",
"explanationMiddle": " — tout élément",
"covered": "couvert",
"explanationAfter": "par un segment de coupe rouge sera coupé lors de l'export.",
"visualExample": "Exemple visuel",
+1 -11
View File
@@ -1,10 +1,4 @@
{
"newRecording": {
"title": "Retour à l'enregistreur",
"description": "Votre session actuelle a été enregistrée.",
"cancel": "Annuler",
"confirm": "Confirmer"
},
"errors": {
"noVideoLoaded": "Aucune vidéo chargée",
"videoNotReady": "Vidéo non prête",
@@ -14,7 +8,6 @@
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
"exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
@@ -37,9 +30,6 @@
"systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.",
"microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.",
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
"cameraDisconnected": "Webcam déconnectée.",
"cameraNotFound": "Caméra introuvable.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
},
"loadingVideo": "Chargement de la vidéo..."
}
}
+1 -7
View File
@@ -33,11 +33,5 @@
"recording": {
"selectSource": "Veuillez sélectionner une source à enregistrer"
},
"language": "Langue",
"systemLanguagePrompt": {
"title": "Utiliser la langue de votre système ?",
"description": "Nous avons détecté {{language}} comme langue système. Voulez-vous passer OpenScreen en {{language}} ?",
"switch": "Passer en {{language}}",
"keepDefault": "Conserver la langue actuelle"
}
"language": "Langue"
}
+3 -26
View File
@@ -8,29 +8,12 @@
"manual": "Manuel",
"auto": "Auto",
"autoDescription": "La caméra suit la position du curseur enregistré"
},
"speed": {
"title": "Vitesse du zoom",
"instant": "Instantané",
"fast": "Rapide",
"smooth": "Fluide",
"lazy": "Lent"
},
"threeD": {
"title": "Rotation 3D",
"preset": {
"iso": "Iso",
"left": "Gauche",
"right": "Droite"
}
}
},
"speed": {
"playbackSpeed": "Vitesse de lecture",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"deleteRegion": "Supprimer la région de vitesse",
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
"maxSpeedError": "La vitesse ne peut pas dépasser 16×"
"deleteRegion": "Supprimer la région de vitesse"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
@@ -41,9 +24,7 @@
"selectPreset": "Choisir un préréglage",
"pictureInPicture": "Incrustation d'image",
"verticalStack": "Empilement vertical",
"dualFrame": "Double cadre",
"webcamShape": "Forme de la caméra",
"webcamSize": "Taille de la caméra"
"webcamShape": "Forme de la caméra"
},
"effects": {
"title": "Effets vidéo",
@@ -60,9 +41,7 @@
"color": "Couleur",
"gradient": "Dégradé",
"uploadCustom": "Téléverser une image",
"gradientLabel": "Dégradé {{index}}",
"colorWheel": "Roue chromatique",
"colorPalette": "Palette de couleurs"
"gradientLabel": "Dégradé {{index}}"
},
"crop": {
"title": "Recadrage",
@@ -130,8 +109,6 @@
"background": "Arrière-plan",
"none": "Aucun",
"color": "Couleur",
"colorWheel": "Roue chromatique",
"colorPalette": "Palette de couleurs",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+9 -7
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "Panoramique",
"zoom": "Zoom",
"trim": "Couper",
"speed": "Vitesse",
"zoomItem": "Zoom {{index}}",
"trimItem": "Coupe {{index}}",
"speedItem": "Vitesse {{index}}",
@@ -33,23 +31,27 @@
},
"errors": {
"cannotPlaceZoom": "Impossible de placer le zoom ici",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant."
},
"success": {
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
"addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
},
"feedback": {
"copied": "Copié !",
"pasted": "Collé !"
}
}
+4
View File
@@ -51,5 +51,9 @@
"success": {
"addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
"addedZoomSuggestionsPlural": "カーソルに基づくズーム提案を {{count}} 件追加しました"
},
"feedback": {
"copied": "コピーしました!",
"pasted": "貼り付けました!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "열기",
"upload": "업로드",
"export": "내보내기",
"showInFolder": "폴더에 표시",
"file": "파일",
"edit": "편집",
"view": "보기",
+1 -5
View File
@@ -5,7 +5,6 @@
"cancel": "취소",
"confirm": "확인"
},
"loadingVideo": "비디오 로드 중...",
"errors": {
"noVideoLoaded": "불러온 비디오가 없습니다",
"videoNotReady": "비디오가 준비되지 않았습니다",
@@ -15,7 +14,6 @@
"failedToSaveVideo": "비디오 저장에 실패했습니다",
"exportFailed": "내보내기에 실패했습니다",
"exportFailedWithError": "내보내기 실패: {{error}}",
"exportBackgroundLoadFailed": "내보내기 실패: 배경 이미지를 불러올 수 없습니다 ({{url}})",
"failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}"
@@ -38,8 +36,6 @@
"systemAudioUnavailable": "시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화합니다.",
"microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.",
"cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.",
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
"cameraNotFound": "카메라를 찾을 수 없습니다."
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요."
}
}
+1 -7
View File
@@ -33,11 +33,5 @@
"recording": {
"selectSource": "녹화할 소스를 선택해 주세요"
},
"language": "언어",
"systemLanguagePrompt": {
"title": "시스템 언어를 사용하시겠습니까?",
"description": "시스템 언어가 {{language}}(으)로 감지되었습니다. OpenScreen을 {{language}}(으)로 전환하시겠습니까?",
"switch": "{{language}}(으)로 전환",
"keepDefault": "현재 언어 유지"
}
"language": "언어"
}
+3 -29
View File
@@ -8,14 +8,6 @@
"manual": "수동",
"auto": "자동",
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
},
"threeD": {
"title": "3D 회전",
"preset": {
"iso": "Iso",
"left": "왼쪽",
"right": "오른쪽"
}
}
},
"speed": {
@@ -35,8 +27,7 @@
"pictureInPicture": "화면 속 화면",
"verticalStack": "세로 배치",
"webcamShape": "카메라 모양",
"webcamSize": "웹캠 크기",
"dualFrame": "듀얼 프레임"
"webcamSize": "웹캠 크기"
},
"effects": {
"title": "비디오 효과",
@@ -53,9 +44,7 @@
"color": "색상",
"gradient": "그라디언트",
"uploadCustom": "직접 업로드",
"gradientLabel": "그라디언트 {{index}}",
"colorWheel": "색상 휠",
"colorPalette": "색상 팔레트"
"gradientLabel": "그라디언트 {{index}}"
},
"crop": {
"title": "자르기",
@@ -122,8 +111,6 @@
"background": "배경",
"none": "없음",
"color": "색상",
"colorWheel": "색상 휠",
"colorPalette": "색상 팔레트",
"clearBackground": "배경 지우기",
"uploadImage": "이미지 업로드",
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
@@ -138,20 +125,7 @@
"invalidImageType": "지원하지 않는 파일 형식입니다",
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
"failedImageUpload": "이미지 업로드에 실패했습니다",
"blurColor": "블러 색상",
"blurColorBlack": "검정",
"blurColorWhite": "흰색",
"blurIntensity": "블러 강도",
"blurShape": "블러 모양",
"blurShapeFreehand": "자유 곡선",
"blurShapeOval": "타원",
"blurShapeRectangle": "사각형",
"blurType": "블러 종류",
"blurTypeBlur": "블러",
"blurTypeMosaic": "모자이크 블러",
"mosaicBlockSize": "모자이크 블록 크기",
"typeBlur": "블러"
"failedImageUpload": "이미지 업로드에 실패했습니다"
},
"fontStyles": {
"classic": "클래식",
+1 -2
View File
@@ -20,8 +20,7 @@
"addAnnotation": "주석 추가",
"addKeyframe": "키프레임 추가",
"deleteSelected": "선택 항목 삭제",
"playPause": "재생 / 일시정지",
"addBlur": "블러 추가"
"playPause": "재생 / 일시정지"
},
"fixedActions": {
"undo": "실행 취소",
+7 -8
View File
@@ -4,28 +4,23 @@
"suggestZooms": "커서 기반 줌 제안",
"addTrim": "트림 추가 (T)",
"addAnnotation": "주석 추가 (A)",
"addSpeed": "속도 추가 (S)",
"addBlur": "블러 추가 (B)"
"addSpeed": "속도 추가 (S)"
},
"hints": {
"pressZoom": "Z를 눌러 줌 추가",
"pressTrim": "T를 눌러 트림 추가",
"pressAnnotation": "A를 눌러 주석 추가",
"pressSpeed": "S를 눌러 속도 추가",
"pressBlur": "B 키를 눌러 블러 영역을 추가하세요"
"pressSpeed": "S를 눌러 속도 추가"
},
"labels": {
"pan": "이동",
"zoom": "줌",
"trim": "트림",
"speed": "속도",
"zoomItem": "줌 {{index}}",
"trimItem": "트림 {{index}}",
"speedItem": "속도 {{index}}",
"annotationItem": "주석",
"imageItem": "이미지",
"emptyText": "빈 텍스트",
"blurItem": "블러 {{index}}"
"emptyText": "빈 텍스트"
},
"emptyState": {
"noVideo": "불러온 비디오 없음",
@@ -51,5 +46,9 @@
"success": {
"addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
"addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
},
"feedback": {
"copied": "복사됨!",
"pasted": "붙여넣음!"
}
}
+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": "Кадр вперёд"
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"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}} предложений масштабирования на основе курсора"
},
"feedback": {
"copied": "Скопировано!",
"pasted": "Вставлено!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "Aç",
"upload": "Yükle",
"export": "Dışa Aktar",
"showInFolder": "Klasörde Göster",
"file": "Dosya",
"edit": "Düzenle",
"view": "Görünüm",
+1 -11
View File
@@ -8,7 +8,6 @@
"failedToSaveVideo": "Video kaydedilemedi",
"exportFailed": "Dışa aktarım başarısız oldu",
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
"exportBackgroundLoadFailed": "Dışa aktarım başarısız: arka plan görüntüsü yüklenemedi ({{url}})",
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
@@ -31,15 +30,6 @@
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.",
"cameraDisconnected": "Webcam bağlantısı kesildi.",
"cameraNotFound": "Kamera bulunamadı."
},
"loadingVideo": "Video yükleniyor...",
"newRecording": {
"title": "Kaydediciye Dön",
"description": "Mevcut oturumunuz kaydedildi.",
"cancel": "İptal",
"confirm": "Onayla"
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
}
}
+1 -7
View File
@@ -44,11 +44,5 @@
"recording": {
"selectSource": "Lütfen kayıt için bir kaynak seçin"
},
"language": "Dil",
"systemLanguagePrompt": {
"title": "Sistem dilinizi kullanmak ister misiniz?",
"description": "Sistem diliniz {{language}} olarak algılandı. OpenScreen i {{language}} diline geçirmek ister misiniz?",
"switch": "{{language}} diline geç",
"keepDefault": "Mevcut dili koru"
}
"language": "Dil"
}
+4 -27
View File
@@ -8,22 +8,12 @@
"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": {
"playbackSpeed": "Oynatma Hızı",
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
"deleteRegion": "Hız Bölgesini Sil",
"customPlaybackSpeed": "Özel Oynatma Hızı",
"maxSpeedError": "Hız 16× değerinden yüksek olamaz"
"deleteRegion": "Hız Bölgesini Sil"
},
"trim": {
"deleteRegion": "Kırpma Bölgesini Sil"
@@ -34,9 +24,7 @@
"selectPreset": "Ön ayar seçin",
"pictureInPicture": "Resim İçinde Resim",
"verticalStack": "Dikey Yığın",
"webcamShape": "Kamera Şekli",
"dualFrame": "Çift Kare",
"webcamSize": "Webcam Boyutu"
"webcamShape": "Kamera Şekli"
},
"effects": {
"title": "Video Efektleri",
@@ -53,9 +41,7 @@
"color": "Renk",
"gradient": "Gradyan",
"uploadCustom": "Özel Yükle",
"gradientLabel": "Gradyan {{index}}",
"colorWheel": "Renk çarkı",
"colorPalette": "Renk paleti"
"gradientLabel": "Gradyan {{index}}"
},
"crop": {
"title": "Kırpma",
@@ -123,8 +109,6 @@
"background": "Arka Plan",
"none": "Yok",
"color": "Renk",
"colorWheel": "Renk çarkı",
"colorPalette": "Renk paleti",
"clearBackground": "Arka Planı Temizle",
"uploadImage": "Görüntü Yükle",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
@@ -144,14 +128,7 @@
"invalidImageType": "Geçersiz dosya türü",
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
"failedImageUpload": "Görüntü yüklenemedi",
"blurColor": "Bulanıklık Rengi",
"blurColorBlack": "Siyah",
"blurColorWhite": "Beyaz",
"blurType": "Bulanıklık Türü",
"blurTypeBlur": "Bulanık",
"blurTypeMosaic": "Mozaik Bulanıklık",
"mosaicBlockSize": "Mozaik Blok Boyutu"
"failedImageUpload": "Görüntü yüklenemedi"
},
"fontStyles": {
"classic": "Klasik",
+4 -2
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "Kaydır",
"zoom": "Yakınlaştır",
"trim": "Kırp",
"speed": "Hız",
"zoomItem": "Yakınlaştırma {{index}}",
"trimItem": "Kırpma {{index}}",
"speedItem": "Hız {{index}}",
@@ -51,5 +49,9 @@
"success": {
"addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
"addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
},
"feedback": {
"copied": "Kopyalandı!",
"pasted": "Yapıştırıldı!"
}
}
+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"
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"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ỏ"
},
"feedback": {
"copied": "Đã sao chép!",
"pasted": "Đã dán!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "打开",
"upload": "上传",
"export": "导出",
"showInFolder": "在文件夹中显示",
"file": "文件",
"edit": "编辑",
"view": "视图",
-2
View File
@@ -5,7 +5,6 @@
"cancel": "取消",
"confirm": "确认"
},
"loadingVideo": "正在加载视频...",
"errors": {
"noVideoLoaded": "未加载视频",
"videoNotReady": "视频未就绪",
@@ -15,7 +14,6 @@
"failedToSaveVideo": "保存视频失败",
"exportFailed": "导出失败",
"exportFailedWithError": "导出失败:{{error}}",
"exportBackgroundLoadFailed": "导出失败:无法加载背景图片({{url}})",
"failedToSaveExport": "保存导出文件失败",
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
+8 -20
View File
@@ -9,13 +9,12 @@
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
},
"threeD": {
"title": "3D 旋转",
"preset": {
"iso": "Iso",
"left": "左",
"right": "右"
}
"speed": {
"title": "缩放速度",
"instant": "即时",
"fast": "快速",
"smooth": "平滑",
"lazy": "缓慢"
}
},
"speed": {
@@ -53,9 +52,7 @@
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"gradientLabel": "渐变 {{index}}",
"colorWheel": "颜色轮",
"colorPalette": "颜色调色板"
"gradientLabel": "渐变 {{index}}"
},
"crop": {
"title": "裁剪",
@@ -123,8 +120,6 @@
"background": "背景",
"none": "无",
"color": "颜色",
"colorWheel": "颜色轮",
"colorPalette": "颜色调色板",
"clearBackground": "清除背景",
"uploadImage": "上传图片",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
@@ -144,14 +139,7 @@
"invalidImageType": "无效的文件类型",
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
"imageUploadSuccess": "图片上传成功!",
"failedImageUpload": "上传图片失败",
"blurColor": "模糊颜色",
"blurColorBlack": "黑色",
"blurColorWhite": "白色",
"blurType": "模糊类型",
"blurTypeBlur": "模糊",
"blurTypeMosaic": "马赛克模糊",
"mosaicBlockSize": "马赛克块大小"
"failedImageUpload": "上传图片失败"
},
"fontStyles": {
"classic": "经典",
+4 -2
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "平移",
"zoom": "缩放",
"trim": "剪辑",
"speed": "速度",
"zoomItem": "缩放 {{index}}",
"trimItem": "剪辑 {{index}}",
"speedItem": "速度 {{index}}",
@@ -51,5 +49,9 @@
"success": {
"addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
"addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
},
"feedback": {
"copied": "已复制!",
"pasted": "已粘贴!"
}
}
-1
View File
@@ -9,7 +9,6 @@
"open": "開啟",
"upload": "上傳",
"export": "匯出",
"showInFolder": "在資料夾中顯示",
"file": "檔案",
"edit": "編輯",
"view": "檢視",
+1 -5
View File
@@ -5,7 +5,6 @@
"cancel": "取消",
"confirm": "確認"
},
"loadingVideo": "正在載入影片...",
"errors": {
"noVideoLoaded": "未載入影片",
"videoNotReady": "影片未就緒",
@@ -15,7 +14,6 @@
"failedToSaveVideo": "儲存影片失敗",
"exportFailed": "匯出失敗",
"exportFailedWithError": "匯出失敗:{{error}}",
"exportBackgroundLoadFailed": "匯出失敗:無法載入背景圖片({{url}})",
"failedToSaveExport": "儲存匯出檔案失敗",
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
@@ -38,8 +36,6 @@
"systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。",
"microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。",
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。",
"cameraDisconnected": "網路攝影機已中斷連線。",
"cameraNotFound": "找不到攝影機。"
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。"
}
}
+1 -7
View File
@@ -33,11 +33,5 @@
"recording": {
"selectSource": "請選擇要錄製的來源"
},
"language": "語言",
"systemLanguagePrompt": {
"title": "要使用系統語言嗎?",
"description": "偵測到系統語言為 {{language}}。要將 OpenScreen 切換為 {{language}} 嗎?",
"switch": "切換為 {{language}}",
"keepDefault": "保持目前語言"
}
"language": "語言"
}
+2 -21
View File
@@ -15,14 +15,6 @@
"fast": "快速",
"smooth": "平滑",
"lazy": "緩慢"
},
"threeD": {
"title": "3D 旋轉",
"preset": {
"iso": "Iso",
"left": "左",
"right": "右"
}
}
},
"speed": {
@@ -60,9 +52,7 @@
"color": "顏色",
"gradient": "漸層",
"uploadCustom": "上傳自訂",
"gradientLabel": "漸層 {{index}}",
"colorWheel": "色輪",
"colorPalette": "調色盤"
"gradientLabel": "漸層 {{index}}"
},
"crop": {
"title": "裁剪",
@@ -130,8 +120,6 @@
"background": "背景",
"none": "無",
"color": "顏色",
"colorWheel": "色輪",
"colorPalette": "調色盤",
"clearBackground": "清除背景",
"uploadImage": "上傳圖片",
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
@@ -151,14 +139,7 @@
"invalidImageType": "無效的檔案類型",
"imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
"imageUploadSuccess": "圖片上傳成功!",
"failedImageUpload": "上傳圖片失敗",
"blurColor": "模糊顏色",
"blurColorBlack": "黑色",
"blurColorWhite": "白色",
"blurType": "模糊類型",
"blurTypeBlur": "模糊",
"blurTypeMosaic": "馬賽克模糊",
"mosaicBlockSize": "馬賽克區塊大小"
"failedImageUpload": "上傳圖片失敗"
},
"fontStyles": {
"classic": "經典",
+4 -2
View File
@@ -17,8 +17,6 @@
"labels": {
"pan": "平移",
"zoom": "縮放",
"trim": "剪輯",
"speed": "速度",
"zoomItem": "縮放 {{index}}",
"trimItem": "剪輯 {{index}}",
"speedItem": "速度 {{index}}",
@@ -51,5 +49,9 @@
"success": {
"addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
"addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
},
"feedback": {
"copied": "已複製!",
"pasted": "已貼上!"
}
}
+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 (10–50). */
export type WebcamSizePreset = number;
@@ -126,6 +130,21 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
},
shadow: null,
},
"no-webcam": {
label: "No Webcam",
transform: {
type: "overlay",
marginFraction: 0,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: 0,
min: 0,
fraction: 0,
},
shadow: null,
},
};
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
@@ -172,6 +191,17 @@ export function computeCompositeLayout(params: {
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: screenWidth, height: screenHeight } = screenSize;
// "no-webcam" preset: hide the webcam entirely, screen fills the canvas normally
if (layoutPreset === "no-webcam") {
const screenRect = centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
});
return { screenRect, webcamRect: null };
}
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
+4 -13
View File
@@ -15,14 +15,13 @@ import type {
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import {
DEFAULT_ROTATION_3D,
getZoomScale,
isRotation3DIdentity,
lerpRotation3D,
ZOOM_DEPTH_SCALES,
} from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
@@ -42,7 +41,7 @@ import {
clickEmphasisAlpha,
drawCursorHighlightCanvas,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
applyZoomTransform,
@@ -645,14 +644,6 @@ export class FrameRenderer {
};
}
private clampFocusToStage(
focus: { cx: number; cy: number },
depth: ZoomDepth,
): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
}
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
@@ -673,8 +664,8 @@ export class FrameRenderer {
: { ...DEFAULT_ROTATION_3D };
if (region && strength > 0) {
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
const zoomScale = blendedScale ?? getZoomScale(region);
const regionFocus = clampFocusToScale(region.focus, zoomScale);
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { parentDirectoryOf } from "./userPreferences";
describe("parentDirectoryOf", () => {
it("returns the directory for a POSIX path", () => {
expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
});
it("returns the directory for a Windows path", () => {
expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
});
it("preserves the POSIX root when the file is at /", () => {
expect(parentDirectoryOf("/video.mp4")).toBe("/");
});
it("preserves the Windows drive root with its trailing separator", () => {
expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
});
it("returns null when no separator is present", () => {
expect(parentDirectoryOf("video.mp4")).toBeNull();
expect(parentDirectoryOf("")).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More