diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ce0e08b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+APP_NAME=Openscreen
+BUNDLE_ID=com.siddharthvaddem.openscreen
+
+APPLE_ID=
+TEAM_ID=
+SIGN_IDENTITY="Developer ID Application: Samir Patil ()"
+CSC_NAME="Samir Patil ()"
+
+NOTARY_PROFILE=OpenScreen-notary
+APPLE_APP_SPECIFIC_PASSWORD=
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4cc446f..f42a92d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,6 +3,16 @@ name: Build Electron App
on:
workflow_dispatch:
+ inputs:
+ arch:
+ description: 'Architecture to build'
+ required: true
+ default: 'both'
+ type: choice
+ options:
+ - arm64
+ - x64
+ - both
jobs:
build-windows:
@@ -36,38 +46,180 @@ jobs:
build-macos:
runs-on: macos-latest
+ strategy:
+ matrix:
+ arch: ${{ github.event.inputs.arch == 'both' && fromJSON('["arm64", "x64"]') || fromJSON(format('["{0}"]', github.event.inputs.arch)) }}
+
steps:
+ # ─── Checkout ─────────────────────────────────────────────
- name: Checkout code
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
+ # ─── Setup Node.js ────────────────────────────────────────
- name: Setup Node.js
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
- node-version: '22'
+ node-version: 22
+ cache: npm
+ # ─── Setup Python (needed by some native deps) ────────────
- name: Setup Python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: '3.11'
+ # ─── Install Dependencies ─────────────────────────────────
- name: Install dependencies
run: npm ci
- - name: Install app dependencies
- run: npx electron-builder install-app-deps
-
- - name: Build macOS app
- run: npm run build:mac
+ # ─── Import Code Signing Certificate ──────────────────────
+ # This is the KEY step that makes CI signing work.
+ # We create a temporary keychain, import the .p12 cert into it,
+ # and set it as the default so codesign can find it.
+ - name: Import code signing certificate
env:
+ MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }}
+ MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
+ run: |
+ # Create a temporary keychain
+ KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db
+ KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
+
+ # Create and configure keychain
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+
+ # Decode and import certificate
+ echo "$MAC_CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12
+ security import $RUNNER_TEMP/certificate.p12 \
+ -k "$KEYCHAIN_PATH" \
+ -P "$MAC_CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign \
+ -T /usr/bin/security
+
+ # Allow codesign to access the keychain without UI prompt
+ security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+
+ # Add to keychain search path (makes it the default)
+ security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
+
+ # Verify the identity is available
+ security find-identity -v -p codesigning "$KEYCHAIN_PATH"
+
+ # Clean up the .p12 file
+ rm -f $RUNNER_TEMP/certificate.p12
+
+ # ─── Build Vite + Electron ────────────────────────────────
+ - name: Build Vite + Electron
+ run: npx tsc && npx vite build
+
+ # ─── Package with electron-builder ────────────────────────
+ # electron-builder handles deep codesigning the .app bundle
+ # "notarize: false" in electron-builder.json5 prevents it from
+ # trying its own notarization flow
+ - name: Package .app bundle
+ run: npx electron-builder --mac --${{ matrix.arch }} --dir
+ env:
+ CSC_NAME: "Samir Patil (N26FZ4GW28)"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- - name: Upload macOS build
+ # ─── Read version from package.json ───────────────────────
+ - name: Get version
+ id: version
+ run: echo "version=$(node -p 'require(\"./package.json\").version')" >> $GITHUB_OUTPUT
+
+ # ─── Locate the .app bundle ───────────────────────────────
+ - name: Find .app bundle
+ id: find_app
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ echo "=== Release directory contents ==="
+ ls -laR "release/${VERSION}/" || echo "release/${VERSION}/ not found"
+ echo "=== Searching for .app bundle ==="
+ APP_BUNDLE=$(find "release/${VERSION}" -maxdepth 4 -name "*.app" -type d | head -n1)
+ if [ -z "$APP_BUNDLE" ]; then
+ echo "::error::No .app bundle found in release/${VERSION}/"
+ exit 1
+ fi
+ echo "app_bundle=$APP_BUNDLE" >> $GITHUB_OUTPUT
+ echo "Found: $APP_BUNDLE"
+
+ # ─── Verify .app signature ────────────────────────────────
+ - name: Verify .app code signature
+ run: codesign --verify --deep --strict "${{ steps.find_app.outputs.app_bundle }}"
+
+ # ─── Create DMG ───────────────────────────────────────────
+ - name: Create DMG
+ id: dmg
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ ARCH="${{ matrix.arch }}"
+ DMG_NAME="Openscreen-Mac-${ARCH}-${VERSION}.dmg"
+ RELEASE_DIR="release/${VERSION}"
+ DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
+ STAGING="${RELEASE_DIR}/dmg-staging"
+
+ mkdir -p "$STAGING"
+ cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/"
+ ln -s /Applications "$STAGING/Applications"
+
+ hdiutil create \
+ -srcfolder "$STAGING" \
+ -volname "Openscreen" \
+ -fs HFS+ \
+ -fsargs "-c c=64,a=16,e=16" \
+ -format UDBZ \
+ "$DMG_OUTPUT"
+
+ rm -rf "$STAGING"
+
+ echo "dmg_path=$DMG_OUTPUT" >> $GITHUB_OUTPUT
+ echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
+
+ # ─── Sign DMG ─────────────────────────────────────────────
+ - name: Sign DMG
+ run: |
+ codesign --force \
+ --sign "Developer ID Application: Samir Patil (N26FZ4GW28)" \
+ --timestamp \
+ "${{ steps.dmg.outputs.dmg_path }}"
+
+ # ─── Notarize DMG ────────────────────────────────────────
+ # On CI we can't use keychain profiles for notarytool, so we
+ # pass credentials directly via env vars / flags
+ - name: Notarize DMG
+ run: |
+ xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \
+ --apple-id "${{ secrets.APPLE_ID }}" \
+ --team-id "${{ secrets.APPLE_TEAM_ID }}" \
+ --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \
+ --wait
+ timeout-minutes: 15
+
+ # ─── Staple ───────────────────────────────────────────────
+ - name: Staple notarization ticket
+ run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}"
+
+ # ─── Validate ─────────────────────────────────────────────
+ - name: Validate stapled DMG
+ run: |
+ xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}"
+ spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}"
+
+ # ─── Upload Artifact ──────────────────────────────────────
+ - name: Upload notarized DMG
uses: actions/upload-artifact@v4
with:
- name: macos-installer
- path: release/**/*.dmg
+ name: openscreen-mac-${{ matrix.arch }}
+ path: ${{ steps.dmg.outputs.dmg_path }}
retention-days: 30
+ # ─── Cleanup Keychain ─────────────────────────────────────
+ - name: Cleanup keychain
+ if: always()
+ run: security delete-keychain $RUNNER_TEMP/build.keychain-db || true
+
build-linux:
runs-on: ubuntu-latest
steps:
@@ -97,4 +249,5 @@ jobs:
path: |
release/**/*.AppImage
release/**/*.zsync
+ release/**/*.deb
retention-days: 30
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 757d997..4194797 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,19 @@ jobs:
- run: npm ci
- run: npx tsc --noEmit
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm run test:browser:install
+ - run: npm run test:browser
+
build:
name: Build
runs-on: ubuntu-latest
@@ -42,26 +55,3 @@ jobs:
cache: npm
- run: npm ci
- run: npx vite build
-
- e2e:
- name: E2E Tests
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - run: npm ci
- - run: npx playwright install --with-deps chromium
- # Install Electron system dependencies not covered by Playwright's chromium deps
- - run: npx electron . --version || sudo apt-get install -y libgbm-dev
- - run: npm run build-vite
- # xvfb provides a virtual display; Electron needs one on Linux even with show:false
- - run: xvfb-run --auto-servernum npm run test:e2e
- - uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: playwright-report
- path: playwright-report/
- retention-days: 7
diff --git a/.github/workflows/discord.yaml b/.github/workflows/discord.yaml
new file mode 100644
index 0000000..97d23e7
--- /dev/null
+++ b/.github/workflows/discord.yaml
@@ -0,0 +1,509 @@
+name: PR to Discord Forum
+
+on:
+ pull_request:
+ types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, edited, labeled, unlabeled, closed]
+ pull_request_review:
+ types: [submitted]
+ issue_comment:
+ types: [created]
+ schedule:
+ - cron: "0 12 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: read
+
+jobs:
+ notify:
+ if: github.event_name != 'schedule'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Sync PR activity to Discord forum thread
+ id: sync
+ uses: actions/github-script@v7
+ env:
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
+ DISCORD_PR_FORUM_WEBHOOK: ${{ secrets.DISCORD_PR_FORUM_WEBHOOK }}
+ DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
+ DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
+ DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
+ DISCORD_REVIEWER_ROLE_ID: ${{ secrets.DISCORD_REVIEWER_ROLE_ID }}
+ DISCORD_ALERT_WEBHOOK_URL: ${{ secrets.DISCORD_ALERT_WEBHOOK_URL }}
+ with:
+ script: |
+ const WEBHOOK_USERNAME = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
+ const WEBHOOK_AVATAR = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
+
+ const THREAD_MARKER_REGEX = //i;
+ const webhookUrl = (process.env.DISCORD_WEBHOOK_URL || process.env.DISCORD_PR_FORUM_WEBHOOK || "").trim();
+ const botToken = (process.env.DISCORD_BOT_TOKEN || "").trim();
+ const reviewerRoleId = (process.env.DISCORD_REVIEWER_ROLE_ID || "").trim();
+ const alertWebhookUrl = (process.env.DISCORD_ALERT_WEBHOOK_URL || "").trim();
+
+ const TAGS = {
+ open: "1493976692967080096",
+ draft: "1493976782028935279",
+ ready: "1493976833626996756",
+ changes: "1493976909875515564",
+ approved: "1493976951038152764",
+ merged: "1493977049709281320",
+ closed: "1493977108102516786",
+ };
+
+ const labelTagMap = {
+ bug: "1493977562773458975",
+ enhancement: "1493977619216207993",
+ documentation: "1493978565153394830",
+ };
+
+ function cleanDescription(text, maxLen = 3500) {
+ if (!text) return "No description provided.";
+ const normalized = text
+ .replace(/\r\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+ if (normalized.length <= maxLen) return normalized;
+ return `${normalized.slice(0, maxLen - 1)}…`;
+ }
+
+ function trimThreadName(name) {
+ return name.length > 95 ? name.slice(0, 95) : name;
+ }
+
+ function extractThreadId(body) {
+ if (!body) return null;
+ const match = body.match(THREAD_MARKER_REGEX);
+ return match ? match[1] : null;
+ }
+
+ function upsertThreadMarker(body, threadId) {
+ const cleaned = (body || "").replace(THREAD_MARKER_REGEX, "").trim();
+ return `${cleaned}\n\n`.trim();
+ }
+
+ async function discordPost(payload, options = {}) {
+ const endpoint = new URL(webhookUrl);
+ endpoint.searchParams.set("wait", "true");
+ if (options.threadId) endpoint.searchParams.set("thread_id", String(options.threadId));
+
+ const response = await fetch(endpoint.toString(), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: WEBHOOK_USERNAME,
+ avatar_url: WEBHOOK_AVATAR,
+ allowed_mentions: { parse: [] },
+ ...payload,
+ })
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Discord API error ${response.status}: ${text}`);
+ }
+
+ const text = await response.text();
+ return text ? JSON.parse(text) : {};
+ }
+
+ async function patchDiscordThread(threadId, patchBody) {
+ if (!botToken || !threadId) return;
+ const response = await fetch(`https://discord.com/api/v10/channels/${threadId}`, {
+ method: "PATCH",
+ headers: {
+ "Authorization": `Bot ${botToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(patchBody),
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ core.warning(`Discord thread patch failed (${response.status}): ${text}`);
+ }
+ }
+
+ function desiredStatusTag(prState) {
+ if (prState.merged && TAGS.merged) return TAGS.merged;
+ if (prState.closed && !prState.merged && TAGS.closed) return TAGS.closed;
+ if (prState.reviewState === "CHANGES_REQUESTED" && TAGS.changes) return TAGS.changes;
+ if (prState.reviewState === "APPROVED" && TAGS.approved) return TAGS.approved;
+ if (prState.draft && TAGS.draft) return TAGS.draft;
+ if (!prState.draft && TAGS.ready) return TAGS.ready;
+ return TAGS.open || null;
+ }
+
+ function tagIdsFromLabels(labels) {
+ const out = [];
+ for (const label of labels) {
+ const mapped = labelTagMap[label.toLowerCase()] || labelTagMap[label];
+ if (mapped) out.push(String(mapped));
+ }
+ return out;
+ }
+
+ async function getPullRequest() {
+ if (context.eventName === "pull_request" || context.eventName === "pull_request_review") {
+ return context.payload.pull_request || null;
+ }
+ if (context.eventName === "issue_comment") {
+ const issue = context.payload.issue;
+ if (!issue?.pull_request) return null;
+ const { data } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: issue.number,
+ });
+ return data;
+ }
+ return null;
+ }
+
+ async function getReviewState(owner, repo, pullNumber) {
+ const { data } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: 100 });
+ let hasChanges = false;
+ let hasApproved = false;
+ for (const r of data) {
+ const s = (r.state || "").toUpperCase();
+ if (s === "CHANGES_REQUESTED") hasChanges = true;
+ if (s === "APPROVED") hasApproved = true;
+ }
+ if (hasChanges) return "CHANGES_REQUESTED";
+ if (hasApproved) return "APPROVED";
+ return "NONE";
+ }
+
+ async function sendFailureAlert(message) {
+ if (!alertWebhookUrl) return;
+ try {
+ await fetch(alertWebhookUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "OpenScreen",
+ avatar_url: WEBHOOK_AVATAR,
+ content: `⚠️ PR Discord sync failed\n${message}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
+ allowed_mentions: { parse: [] }
+ })
+ });
+ } catch {
+ core.warning("Failed to send failure alert webhook.");
+ }
+ }
+
+ try {
+ const pr = await getPullRequest();
+ if (!pr) {
+ core.info("No PR context found. Skipping.");
+ return;
+ }
+
+ const isForkPr = !!pr.head?.repo?.fork;
+ if (!webhookUrl) {
+ if (isForkPr) {
+ core.info("Skipping Discord sync: webhook secret is unavailable for fork PR events.");
+ return;
+ }
+ core.setFailed(
+ "Missing Discord webhook secret. Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets, or pass it explicitly if using reusable workflows."
+ );
+ return;
+ }
+
+ const action = context.payload.action || "";
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const number = pr.number;
+ const title = pr.title;
+ const author = pr.user?.login || "unknown";
+ const url = pr.html_url;
+ const authorUrl = pr.user?.html_url || "";
+ const authorAvatar = pr.user?.avatar_url || "";
+ const base = pr.base?.ref || "";
+ const head = pr.head?.ref || "";
+ const repoFullName = pr.base?.repo?.full_name || `${owner}/${repo}`;
+ const labels = (pr.labels || []).map((l) => l.name);
+ const body = (pr.body || "").trim();
+ const reviewState = await getReviewState(owner, repo, number);
+
+ let threadId = extractThreadId(body);
+ const shouldCreateThread =
+ context.eventName === "pull_request" &&
+ ["opened", "reopened", "ready_for_review"].includes(action) &&
+ !threadId;
+
+ if (shouldCreateThread) {
+ const fields = [
+ { name: "PR", value: `[#${number}](${url})`, inline: true },
+ { name: "Author", value: `[${author}](${authorUrl || url})`, inline: true },
+ { name: "Status", value: pr.draft ? "Draft" : "Open", inline: true },
+ { name: "Branches", value: `\`${head}\` -> \`${base}\``, inline: true },
+ { name: "Changes", value: `+${pr.additions} / -${pr.deletions}`, inline: true },
+ { name: "Files Changed", value: String(pr.changed_files), inline: true }
+ ];
+
+ if (labels.length) {
+ fields.push({
+ name: "Labels",
+ value: labels.map((l) => `\`${l}\``).join(" "),
+ inline: false,
+ });
+ }
+
+ const statusTag = desiredStatusTag({ draft: pr.draft, reviewState, merged: false, closed: false });
+ const mappedLabelTags = tagIdsFromLabels(labels);
+ const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
+
+ const createPayload = {
+ content: action === "ready_for_review" ? "🔔 PR is now ready for review" : "🔔 New pull request opened",
+ thread_name: trimThreadName(`PR #${number} - ${title}`),
+ applied_tags: appliedTags,
+ embeds: [
+ {
+ title: `PR #${number}: ${title}`,
+ url,
+ description: cleanDescription(body),
+ color: pr.draft ? 15105570 : 1998671,
+ author: {
+ name: author,
+ url: authorUrl || undefined,
+ icon_url: authorAvatar || undefined,
+ },
+ fields,
+ footer: { text: repoFullName },
+ timestamp: new Date().toISOString(),
+ },
+ ],
+ };
+
+ const result = await discordPost(createPayload);
+ const createdThreadId = result.channel_id || null;
+ if (createdThreadId) {
+ const updatedBody = upsertThreadMarker(body, createdThreadId);
+ await github.rest.pulls.update({ owner, repo, pull_number: number, body: updatedBody });
+ core.info(`Created Discord thread ${createdThreadId} and stored mapping.`);
+ } else {
+ core.warning("Discord thread created but channel_id missing in response.");
+ }
+ return;
+ }
+
+ if (!threadId) {
+ core.info("No mapped Discord thread ID found; skipping update event.");
+ return;
+ }
+
+ if (context.eventName === "pull_request" && ["edited", "labeled", "unlabeled", "ready_for_review", "converted_to_draft"].includes(action)) {
+ const statusTag = desiredStatusTag({
+ draft: action === "converted_to_draft" ? true : pr.draft,
+ reviewState,
+ merged: false,
+ closed: false,
+ });
+ const mappedLabelTags = tagIdsFromLabels(labels);
+ const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
+ await patchDiscordThread(threadId, {
+ name: trimThreadName(`PR #${number} - ${title}`),
+ ...(appliedTags.length ? { applied_tags: appliedTags } : {}),
+ });
+ }
+
+ let updateMessage = null;
+ let updateEmbed = null;
+
+ if (context.eventName === "pull_request") {
+ if (action === "synchronize") {
+ const { data: commits } = await github.rest.pulls.listCommits({ owner, repo, pull_number: number, per_page: 5 });
+ const list = commits.map((c) => `- \`${c.sha.slice(0, 7)}\` ${c.commit.message.split("\n")[0]}`).join("\n") || "- No commit details";
+ updateMessage = `🧩 New commits pushed to PR #${number}`;
+ updateEmbed = {
+ title: `Commit Update • PR #${number}`,
+ url: `${url}/files`,
+ description: `${list}`,
+ color: 1998671,
+ footer: { text: repoFullName },
+ timestamp: new Date().toISOString(),
+ };
+ } else if (action === "edited") {
+ updateMessage = `✏️ PR #${number} details were edited`;
+ updateEmbed = {
+ title: `PR Updated • #${number}`,
+ url,
+ description: cleanDescription(body, 1200),
+ color: 1998671,
+ timestamp: new Date().toISOString(),
+ };
+ } else if (action === "closed") {
+ const isMerged = !!pr.merged;
+ const statusTag = desiredStatusTag({ draft: false, reviewState, merged: isMerged, closed: true });
+ const mappedLabelTags = tagIdsFromLabels(labels);
+ const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
+ await patchDiscordThread(threadId, {
+ ...(appliedTags.length ? { applied_tags: appliedTags } : {}),
+ ...(isMerged ? { archived: true, locked: true } : {}),
+ });
+
+ updateMessage = isMerged
+ ? `✅ PR #${number} was merged`
+ : `🛑 PR #${number} was closed without merge`;
+ updateEmbed = {
+ title: isMerged ? `Merged • PR #${number}` : `Closed • PR #${number}`,
+ url,
+ description: isMerged ? "This PR has been merged into the base branch." : "This PR was closed before merge.",
+ color: isMerged ? 5763719 : 15158332,
+ timestamp: new Date().toISOString(),
+ };
+ } else if (action === "ready_for_review") {
+ updateMessage = `🚀 PR #${number} moved from draft to ready for review`;
+ if (reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
+ } else if (action === "converted_to_draft") {
+ updateMessage = `📝 PR #${number} converted to draft`;
+ }
+ } else if (context.eventName === "pull_request_review") {
+ const review = context.payload.review;
+ if (review) {
+ const state = (review.state || "commented").toUpperCase();
+ const reviewer = review.user?.login || "reviewer";
+ updateMessage = `🧪 Review ${state} by **${reviewer}** on PR #${number}`;
+ if (state === "CHANGES_REQUESTED" && reviewerRoleId) updateMessage += ` <@&${reviewerRoleId}>`;
+ updateEmbed = {
+ title: `Review ${state} • PR #${number}`,
+ url: review.html_url || url,
+ description: cleanDescription(review.body || "No review note.", 1000),
+ color: state === "APPROVED" ? 5763719 : state === "CHANGES_REQUESTED" ? 15158332 : 1998671,
+ timestamp: new Date().toISOString(),
+ };
+
+ if (state === "CHANGES_REQUESTED" || state === "APPROVED") {
+ const statusTag = desiredStatusTag({ draft: pr.draft, reviewState: state, merged: false, closed: false });
+ const mappedLabelTags = tagIdsFromLabels(labels);
+ const appliedTags = [...new Set([statusTag, ...mappedLabelTags].filter(Boolean))];
+ await patchDiscordThread(threadId, {
+ ...(appliedTags.length ? { applied_tags: appliedTags } : {}),
+ });
+ }
+ }
+ } else if (context.eventName === "issue_comment") {
+ const comment = context.payload.comment;
+ if (comment) {
+ const commenter = comment.user?.login || "user";
+ updateMessage = `💬 New comment by **${commenter}** on PR #${number}`;
+ updateEmbed = {
+ title: `New PR Comment • #${number}`,
+ url: comment.html_url || url,
+ description: cleanDescription(comment.body || "No comment body.", 1000),
+ color: 1998671,
+ timestamp: new Date().toISOString(),
+ };
+ }
+ }
+
+ if (!updateMessage && !updateEmbed) {
+ core.info("No Discord update message for this event/action. Skipping.");
+ return;
+ }
+
+ const payload = { content: updateMessage || "" };
+ if (updateEmbed) payload.embeds = [updateEmbed];
+ await discordPost(payload, { threadId });
+ core.info(`Posted update to Discord thread ${threadId}.`);
+ } catch (err) {
+ const msg = err && err.message ? err.message : String(err);
+ core.setFailed(msg);
+
+ const alertWebhook = process.env.DISCORD_ALERT_WEBHOOK_URL;
+ if (alertWebhook) {
+ try {
+ await fetch(alertWebhook, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ username: "OpenScreen",
+ avatar_url: WEBHOOK_AVATAR,
+ content: `⚠️ PR->Discord sync failed\n${msg}\nRun: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
+ allowed_mentions: { parse: [] }
+ })
+ });
+ } catch {
+ core.warning("Failed to send alert webhook.");
+ }
+ }
+ }
+
+ weekly-contributor-leaderboard:
+ if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Post weekly contributor leaderboard
+ uses: actions/github-script@v7
+ env:
+ DISCORD_SPOTLIGHT_WEBHOOK_URL: ${{ secrets.DISCORD_SPOTLIGHT_WEBHOOK_URL }}
+ DISCORD_WEBHOOK_USERNAME: ${{ secrets.DISCORD_WEBHOOK_USERNAME }}
+ DISCORD_WEBHOOK_AVATAR_URL: ${{ secrets.DISCORD_WEBHOOK_AVATAR_URL }}
+ with:
+ script: |
+ const spotlightWebhook = (process.env.DISCORD_SPOTLIGHT_WEBHOOK_URL || "").trim();
+ const webhookUsername = (process.env.DISCORD_WEBHOOK_USERNAME || "OpenScreen").trim();
+ const webhookAvatar = (process.env.DISCORD_WEBHOOK_AVATAR_URL || "").trim();
+ if (!spotlightWebhook) {
+ core.info("DISCORD_SPOTLIGHT_WEBHOOK_URL missing. Skipping leaderboard post.");
+ return;
+ }
+
+ const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ const q = `repo:${owner}/${repo} is:pr is:merged merged:>=${since.substring(0, 10)}`;
+ const search = await github.rest.search.issuesAndPullRequests({
+ q,
+ per_page: 100,
+ });
+
+ const counter = new Map();
+ for (const item of search.data.items) {
+ const login = item.user?.login;
+ if (!login) continue;
+ counter.set(login, (counter.get(login) || 0) + 1);
+ }
+
+ const ranked = [...counter.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10);
+
+ const totalMerged = search.data.items.length;
+ const lines = ranked.length
+ ? ranked.map(([user, count], idx) => `${idx + 1}. **${user}** - ${count} merged PR(s)`).join("\n")
+ : "No merged PRs this week.";
+
+ const payload = {
+ username: webhookUsername,
+ ...(webhookAvatar ? { avatar_url: webhookAvatar } : {}),
+ embeds: [
+ {
+ title: "🌟 Weekly Contributor Leaderboard",
+ description: lines,
+ color: 1998671,
+ fields: [
+ { name: "Merged PRs (7d)", value: String(totalMerged), inline: true },
+ { name: "Repository", value: `${owner}/${repo}`, inline: true },
+ { name: "Period", value: "Last 7 days", inline: true }
+ ],
+ timestamp: new Date().toISOString()
+ }
+ ],
+ allowed_mentions: { parse: [] }
+ };
+
+ const res = await fetch(`${spotlightWebhook}?wait=true`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+
+ if (!res.ok) {
+ const txt = await res.text();
+ core.setFailed(`Leaderboard post failed ${res.status}: ${txt}`);
+ }
diff --git a/.gitignore b/.gitignore
index 70cc387..040cada 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ dist
dist-electron
dist-ssr
*.local
+.env
# Editor directories and files
.vscode/*
@@ -29,4 +30,12 @@ release/**
# Playwright
test-results
-playwright-report/
\ No newline at end of file
+playwright-report/
+
+# Vitest browser mode screenshots
+__screenshots__/
+
+# Nix
+result
+result-*
+.direnv/
\ No newline at end of file
diff --git a/README.md b/README.md
index 0e9ed4d..074eaa7 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
# OpenScreen
@@ -21,21 +25,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open
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 !)
-
-
+
+
## Core Features
-- Record your whole screen or specific windows.
-- Add Automatic zooms or manual zooms (customizable depth levels).
-- Record microphone audio and system audio capture.
-- Customize the duration and position of zooms however you please.
+- Record specific windows or your whole screen.
+- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
+- 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 speed at different segments.
+- Customize the speed of different segments.
- Export in different aspect ratios and resolutions.
## Installation
@@ -74,9 +77,9 @@ You may need to grant screen recording permissions depending on your desktop env
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
-- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
+- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
- **Windows**: Works out of the box.
-- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
+- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
## Built with
- Electron
@@ -90,6 +93,11 @@ 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)
+
## 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.
diff --git a/electron-builder.json5 b/electron-builder.json5
index 40fce0a..18498df 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -20,16 +20,18 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
- "extraResources": [
- {
- "from": "public/wallpapers",
- "to": "assets/wallpapers"
- }
- ],
- "publish": [{"provider": "github"}],
-
- "mac": {
- "hardenedRuntime": false,
+ "extraResources": [
+ {
+ "from": "public/wallpapers",
+ "to": "assets/wallpapers"
+ }
+ ],
+
+ "mac": {
+ "notarize": false,
+ "hardenedRuntime": true,
+ "entitlements": "macos.entitlements",
+ "entitlementsInherit": "macos.entitlements",
"target": [
{
"target": "dmg",
@@ -38,13 +40,13 @@
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
- "extendInfo": {
- "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
- }
+ "extendInfo": {
+ "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
+ }
},
"linux": {
"target": [
@@ -54,14 +56,14 @@
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
- "win": {
- "target": [
- "nsis"
- ],
- "icon": "icons/icons/win/icon.ico"
- },
- "nsis": {
- "oneClick": false,
- "allowToChangeInstallationDirectory": true
- }
-}
+ "win": {
+ "target": [
+ "nsis"
+ ],
+ "icon": "icons/icons/win/icon.ico"
+ },
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true
+ }
+}
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 573aee8..b2a3720 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -26,6 +26,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise;
switchToEditor: () => Promise;
+ switchToHud: () => Promise;
+ startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise;
selectSource: (source: ProcessedDesktopSource) => Promise;
getSelectedSource: () => Promise;
diff --git a/electron/i18n.ts b/electron/i18n.ts
index b385008..2dfb4d3 100644
--- a/electron/i18n.ts
+++ b/electron/i18n.ts
@@ -5,10 +5,12 @@ import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
+import commonFr from "../src/i18n/locales/fr/common.json";
+import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
-type Locale = "en" | "zh-CN" | "es";
+type Locale = "en" | "zh-CN" | "es" | "fr";
type Namespace = "common" | "dialogs";
type MessageMap = Record;
@@ -16,12 +18,13 @@ const messages: Record> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
+ fr: { common: commonFr, dialogs: dialogsFr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
- if (locale === "en" || locale === "zh-CN" || locale === "es") {
+ if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
currentLocale = locale;
}
}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 78d8344..d0b42a3 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -14,6 +14,7 @@ import {
import {
normalizeProjectMedia,
normalizeRecordingSession,
+ type ProjectMedia,
type RecordingSession,
type StoreRecordedSessionInput,
} from "../../src/lib/recordingSession";
@@ -23,6 +24,143 @@ import { RECORDINGS_DIR } from "../main";
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_SESSION_SUFFIX = ".session.json";
+const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
+
+/**
+ * Paths explicitly approved by the user via file picker dialogs or project loads.
+ * These are added at runtime when the user selects files from outside the default directories.
+ */
+const approvedPaths = new Set();
+
+function approveFilePath(filePath: string): void {
+ approvedPaths.add(path.resolve(filePath));
+}
+
+function getAllowedReadDirs(): string[] {
+ return [RECORDINGS_DIR];
+}
+
+function isPathWithinDir(filePath: string, dirPath: string): boolean {
+ const resolved = path.resolve(filePath);
+ const resolvedDir = path.resolve(dirPath);
+ return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep);
+}
+
+function isPathAllowed(filePath: string): boolean {
+ const resolved = path.resolve(filePath);
+ if (approvedPaths.has(resolved)) return true;
+ return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
+}
+
+function hasAllowedImportVideoExtension(filePath: string): boolean {
+ return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
+}
+
+async function approveReadableVideoPath(
+ filePath?: string | null,
+ trustedDirs?: string[],
+): Promise {
+ const normalizedPath = normalizeVideoSourcePath(filePath);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ if (isPathAllowed(normalizedPath)) {
+ return normalizedPath;
+ }
+
+ if (!hasAllowedImportVideoExtension(normalizedPath)) {
+ return null;
+ }
+
+ // When called with trustedDirs (e.g. from project load), only auto-approve
+ // paths within those directories. This prevents malicious project files from
+ // approving reads to arbitrary filesystem locations.
+ if (trustedDirs) {
+ const resolved = path.resolve(normalizedPath);
+ const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
+ if (!withinTrusted) {
+ return null;
+ }
+ }
+
+ try {
+ const stats = await fs.stat(normalizedPath);
+ if (!stats.isFile()) {
+ return null;
+ }
+ } catch {
+ return null;
+ }
+
+ approveFilePath(normalizedPath);
+ return normalizedPath;
+}
+
+function resolveRecordingOutputPath(fileName: string): string {
+ const trimmed = fileName.trim();
+ if (!trimmed) {
+ throw new Error("Invalid recording file name");
+ }
+
+ const parsedPath = path.parse(trimmed);
+ const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === "..");
+ const isNestedPath =
+ parsedPath.dir !== "" ||
+ path.isAbsolute(trimmed) ||
+ trimmed.includes("/") ||
+ trimmed.includes("\\");
+ if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) {
+ throw new Error("Recording file name must not contain path segments");
+ }
+
+ return path.join(RECORDINGS_DIR, parsedPath.base);
+}
+
+async function getApprovedProjectSession(
+ project: unknown,
+ projectFilePath?: string,
+): Promise {
+ if (!project || typeof project !== "object") {
+ return null;
+ }
+
+ const rawProject = project as { media?: unknown; videoPath?: unknown };
+ const media: ProjectMedia | null =
+ normalizeProjectMedia(rawProject.media) ??
+ (typeof rawProject.videoPath === "string"
+ ? {
+ screenVideoPath: normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
+ }
+ : null);
+
+ if (!media) {
+ return null;
+ }
+
+ // Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
+ // This prevents crafted project files from approving reads to arbitrary locations.
+ const trustedDirs = [RECORDINGS_DIR];
+ if (projectFilePath) {
+ trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
+ }
+
+ const screenVideoPath = await approveReadableVideoPath(media.screenVideoPath, trustedDirs);
+ if (!screenVideoPath) {
+ throw new Error("Project references an invalid or unsupported screen video path");
+ }
+
+ const webcamVideoPath = media.webcamVideoPath
+ ? await approveReadableVideoPath(media.webcamVideoPath, trustedDirs)
+ : undefined;
+ if (media.webcamVideoPath && !webcamVideoPath) {
+ throw new Error("Project references an invalid or unsupported webcam video path");
+ }
+
+ return webcamVideoPath
+ ? { screenVideoPath, webcamVideoPath, createdAt: Date.now() }
+ : { screenVideoPath, createdAt: Date.now() };
+}
type SelectedSource = {
name: string;
@@ -121,12 +259,12 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
? payload.createdAt
: Date.now();
- const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName);
+ const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
let webcamVideoPath: string | undefined;
if (payload.webcam) {
- webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName);
+ webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
}
@@ -217,7 +355,24 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
+ switchToHud?: () => void,
) {
+ ipcMain.handle("switch-to-hud", () => {
+ if (switchToHud) switchToHud();
+ });
+ ipcMain.handle("start-new-recording", async () => {
+ try {
+ setCurrentRecordingSessionState(null);
+ if (switchToHud) {
+ switchToHud();
+ }
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to start new recording:", error);
+ return { success: false, error: String(error) };
+ }
+ });
+
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
@@ -346,12 +501,21 @@ export function registerIpcHandlers(
});
ipcMain.handle("read-binary-file", async (_, inputPath: string) => {
+ let normalizedPath: string | null = null;
try {
- const normalizedPath = normalizeVideoSourcePath(inputPath);
+ normalizedPath = normalizeVideoSourcePath(inputPath);
if (!normalizedPath) {
return { success: false, message: "Invalid file path" };
}
+ if (!isPathAllowed(normalizedPath)) {
+ console.warn(
+ "[read-binary-file] Rejected path outside allowed directories:",
+ normalizedPath,
+ );
+ return { success: false, message: "Access denied: path outside allowed directories" };
+ }
+
const data = await fs.readFile(normalizedPath);
return {
success: true,
@@ -364,6 +528,7 @@ export function registerIpcHandlers(
success: false,
message: "Failed to read binary file",
error: String(error),
+ path: normalizedPath,
};
}
});
@@ -396,6 +561,14 @@ export function registerIpcHandlers(
return { success: true, samples: [] };
}
+ if (!isPathAllowed(targetVideoPath)) {
+ console.warn(
+ "[get-cursor-telemetry] Rejected path outside allowed directories:",
+ targetVideoPath,
+ );
+ return { success: true, samples: [] };
+ }
+
const telemetryPath = `${targetVideoPath}.cursor.json`;
try {
const content = await fs.readFile(telemetryPath, "utf-8");
@@ -529,10 +702,17 @@ export function registerIpcHandlers(
return { success: false, canceled: true };
}
+ const approvedPath = await approveReadableVideoPath(result.filePaths[0]);
+ if (!approvedPath) {
+ return {
+ success: false,
+ message: "Selected file is not a supported video",
+ };
+ }
currentProjectPath = null;
return {
success: true,
- path: result.filePaths[0],
+ path: approvedPath,
};
} catch (error) {
console.error("Failed to open file picker:", error);
@@ -658,19 +838,9 @@ export function registerIpcHandlers(
const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
+ const session = await getApprovedProjectSession(project, filePath);
currentProjectPath = filePath;
- if (project && typeof project === "object") {
- const rawProject = project as { media?: unknown; videoPath?: unknown };
- const media =
- normalizeProjectMedia(rawProject.media) ??
- (typeof rawProject.videoPath === "string"
- ? {
- screenVideoPath:
- normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
- }
- : null);
- setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
- }
+ setCurrentRecordingSessionState(session);
return {
success: true,
@@ -695,18 +865,8 @@ export function registerIpcHandlers(
const content = await fs.readFile(currentProjectPath, "utf-8");
const project = JSON.parse(content);
- if (project && typeof project === "object") {
- const rawProject = project as { media?: unknown; videoPath?: unknown };
- const media =
- normalizeProjectMedia(rawProject.media) ??
- (typeof rawProject.videoPath === "string"
- ? {
- screenVideoPath:
- normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
- }
- : null);
- setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
- }
+ const session = await getApprovedProjectSession(project, currentProjectPath);
+ setCurrentRecordingSessionState(session);
return {
success: true,
path: currentProjectPath,
@@ -735,12 +895,22 @@ export function registerIpcHandlers(
});
ipcMain.handle("set-current-video-path", async (_, path: string) => {
- const restoredSession = await loadRecordedSessionForVideoPath(path);
+ const normalizedPath = normalizeVideoSourcePath(path);
+ if (!normalizedPath || !isPathAllowed(normalizedPath)) {
+ return { success: false, message: "Video path has not been approved" };
+ }
+
+ const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath);
if (restoredSession) {
+ // Approve all media paths from the restored session so they can be read later
+ approveFilePath(restoredSession.screenVideoPath);
+ if (restoredSession.webcamVideoPath) {
+ approveFilePath(restoredSession.webcamVideoPath);
+ }
setCurrentRecordingSessionState(restoredSession);
} else {
setCurrentRecordingSessionState({
- screenVideoPath: normalizeVideoSourcePath(path) ?? path,
+ screenVideoPath: normalizedPath,
createdAt: Date.now(),
});
}
diff --git a/electron/main.ts b/electron/main.ts
index 7e19d46..c399fd0 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -62,10 +62,12 @@ let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let selectedSourceName = "";
+const isMac = process.platform === "darwin";
+const trayIconSize = isMac ? 16 : 24;
// Tray Icons
-const defaultTrayIcon = getTrayIcon("openscreen.png");
-const recordingTrayIcon = getTrayIcon("rec-button.png");
+const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize);
+const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
function createWindow() {
mainWindow = createHudOverlayWindow();
@@ -199,12 +201,12 @@ function createTray() {
});
}
-function getTrayIcon(filename: string) {
+function getTrayIcon(filename: string, size: number) {
return nativeImage
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
.resize({
- width: 24,
- height: 24,
+ width: size,
+ height: size,
quality: "best",
});
}
@@ -371,6 +373,16 @@ app.whenReady().then(async () => {
// Ensure recordings directory exists
await ensureRecordingsDir();
+ function switchToHudWrapper() {
+ if (mainWindow) {
+ isForceClosing = true;
+ mainWindow.close();
+ isForceClosing = false;
+ mainWindow = null;
+ }
+ showMainWindow();
+ }
+
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
@@ -384,6 +396,7 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
+ switchToHudWrapper,
);
createWindow();
});
diff --git a/electron/preload.ts b/electron/preload.ts
index 8f1836b..eeca25c 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
+ switchToHud: () => {
+ return ipcRenderer.invoke("switch-to-hud");
+ },
+ startNewRecording: () => {
+ return ipcRenderer.invoke("start-new-recording");
+ },
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
diff --git a/electron/windows.ts b/electron/windows.ts
index fb9a655..dcd9f92 100644
--- a/electron/windows.ts
+++ b/electron/windows.ts
@@ -17,6 +17,11 @@ ipcMain.on("hud-overlay-hide", () => {
}
});
+/**
+ * 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
+ * across macOS Spaces so it is never lost when switching virtual desktops.
+ */
export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
@@ -51,6 +56,12 @@ export function createHudOverlayWindow(): BrowserWindow {
},
});
+ // Follow the user across macOS Spaces (virtual desktops).
+ // Without this the HUD stays pinned to the Space it was first opened on.
+ if (process.platform === "darwin") {
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+ }
+
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
@@ -74,6 +85,10 @@ export function createHudOverlayWindow(): BrowserWindow {
return win;
}
+/**
+ * Creates the main editor window. Starts maximised with a hidden title bar on
+ * macOS. This window is not always-on-top and appears in the taskbar/dock.
+ */
export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === "darwin";
@@ -120,6 +135,10 @@ export function createEditorWindow(): BrowserWindow {
return win;
}
+/**
+ * Creates the floating source-selector window used to pick a screen or window
+ * to record. Frameless, transparent, and follows the user across macOS Spaces.
+ */
export function createSourceSelectorWindow(): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
@@ -142,6 +161,12 @@ export function createSourceSelectorWindow(): BrowserWindow {
},
});
+ // Follow the user across macOS Spaces so the selector appears on the
+ // active desktop regardless of where the HUD was originally opened.
+ if (process.platform === "darwin") {
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+ }
+
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
} else {
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..77972fb
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1775710090,
+ "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "4c1018dae018162ec878d42fec712642d214fdfa",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..7b2d328
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,122 @@
+{
+ description = "OpenScreen — desktop screen recorder with built-in editor";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+
+ outputs =
+ { self, nixpkgs }:
+ let
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ ];
+ forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
+ in
+ {
+ # -- Per-system outputs (packages, dev shells) --
+
+ packages = forAllSystems (pkgs: {
+ openscreen = pkgs.callPackage ./nix/package.nix { };
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
+ });
+
+ devShells = forAllSystems (
+ pkgs:
+ let
+ electron = pkgs.electron;
+
+ # Libraries Electron needs at runtime on Linux
+ runtimeLibs = with pkgs; [
+ # X11
+ libx11
+ libxcomposite
+ libxdamage
+ libxext
+ libxfixes
+ libxrandr
+ libxtst
+ libxcb
+ libxshmfence
+
+ # Wayland
+ wayland
+
+ # GTK / UI toolkit
+ gtk3
+ glib
+ pango
+ cairo
+ gdk-pixbuf
+ atk
+ at-spi2-atk
+ at-spi2-core
+
+ # Graphics
+ mesa
+ libGL
+ libdrm
+ vulkan-loader
+
+ # Networking / crypto (NSS for Chromium)
+ nss
+ nspr
+
+ # Audio
+ alsa-lib
+ pipewire
+ pulseaudio
+
+ # System
+ dbus
+ cups
+ expat
+ libnotify
+ libsecret
+ util-linux # libuuid
+ ];
+ in
+ {
+ default = pkgs.mkShell {
+ packages = with pkgs; [
+ nodejs_22
+ electron
+
+ # Native module compilation
+ python3
+ pkg-config
+ gcc
+
+ # Playwright browser tests
+ playwright-driver.browsers
+ ];
+
+ # Electron's prebuilt binary needs these at runtime
+ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
+
+ # Tell the npm `electron` package to use the Nix-provided binary
+ # instead of downloading its own. vite-plugin-electron respects this.
+ ELECTRON_OVERRIDE_DIST_PATH = "${electron}/libexec/electron";
+
+ # Playwright browser path for test:browser / test:e2e
+ PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}";
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
+
+ shellHook = ''
+ echo "OpenScreen dev shell — node $(node --version), electron v$(electron --version 2>/dev/null | tr -d 'v')"
+ '';
+ };
+ }
+ );
+
+ # -- System-wide outputs (modules, overlay) --
+
+ overlays.default = final: _prev: {
+ openscreen = self.packages.${final.stdenv.hostPlatform.system}.openscreen;
+ };
+
+ nixosModules.default = import ./nix/module.nix self;
+ homeManagerModules.default = import ./nix/hm-module.nix self;
+ };
+}
diff --git a/icons/icons/mac/icon.icns b/icons/icons/mac/icon.icns
index 7d5a493..02de106 100644
Binary files a/icons/icons/mac/icon.icns and b/icons/icons/mac/icon.icns differ
diff --git a/macos.entitlements b/macos.entitlements
new file mode 100644
index 0000000..5c6ddcf
--- /dev/null
+++ b/macos.entitlements
@@ -0,0 +1,25 @@
+
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
+ com.apple.security.device.audio-input
+
+
+
+ com.apple.security.device.camera
+
+
+
diff --git a/nix/hm-module.nix b/nix/hm-module.nix
new file mode 100644
index 0000000..b04f827
--- /dev/null
+++ b/nix/hm-module.nix
@@ -0,0 +1,36 @@
+# Home Manager module for OpenScreen
+# Usage in flake-based Home Manager config:
+#
+# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
+#
+# { inputs, ... }: {
+# imports = [ inputs.openscreen.homeManagerModules.default ];
+# programs.openscreen.enable = true;
+# }
+self:
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ cfg = config.programs.openscreen;
+in
+{
+ options.programs.openscreen = {
+ enable = lib.mkEnableOption "OpenScreen screen recorder";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
+ defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
+ description = "The OpenScreen package to use.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ home.packages = [ cfg.package ];
+ };
+}
diff --git a/nix/module.nix b/nix/module.nix
new file mode 100644
index 0000000..3282d2d
--- /dev/null
+++ b/nix/module.nix
@@ -0,0 +1,42 @@
+# NixOS module for OpenScreen
+# Usage in flake-based NixOS config:
+#
+# inputs.openscreen.url = "github:siddharthvaddem/openscreen";
+#
+# { inputs, ... }: {
+# imports = [ inputs.openscreen.nixosModules.default ];
+# programs.openscreen.enable = true;
+# }
+self:
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ cfg = config.programs.openscreen;
+in
+{
+ options.programs.openscreen = {
+ enable = lib.mkEnableOption "OpenScreen screen recorder";
+
+ package = lib.mkOption {
+ type = lib.types.package;
+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.openscreen;
+ defaultText = lib.literalExpression "inputs.openscreen.packages.\${pkgs.stdenv.hostPlatform.system}.openscreen";
+ description = "The OpenScreen package to use.";
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ environment.systemPackages = [ cfg.package ];
+
+ # Screen capture on Wayland requires xdg-desktop-portal.
+ # We enable the base portal; users should also enable a
+ # desktop-specific portal (e.g. xdg-desktop-portal-gtk,
+ # xdg-desktop-portal-hyprland) in their DE config.
+ xdg.portal.enable = lib.mkDefault true;
+ };
+}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 0000000..195043f
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,124 @@
+{
+ lib,
+ buildNpmPackage,
+ nodejs_22,
+ electron,
+ makeWrapper,
+ makeDesktopItem,
+ copyDesktopItems,
+}:
+
+buildNpmPackage {
+ nodejs = nodejs_22;
+ pname = "openscreen";
+ version = "1.3.0";
+
+ src =
+ let
+ fs = lib.fileset;
+ # gitTracked fails when source is already a store path (path: flake inputs).
+ # Detect this and fall back to cleanSource which handles both cases.
+ isStorePath = builtins.storeDir == builtins.substring 0 (builtins.stringLength builtins.storeDir) (toString ../.);
+ baseFiles = if isStorePath then fs.fromSource (lib.cleanSource ../.) else fs.gitTracked ../.;
+ in
+ fs.toSource {
+ root = ../.;
+ fileset = fs.difference baseFiles (
+ fs.unions [
+ ../nix
+ ../flake.nix
+ ../flake.lock
+ (fs.fileFilter (file: file.hasExt "md") ../.)
+ ]
+ );
+ };
+
+ npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
+
+ env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
+
+ # electron-builder is not needed — we wrap system electron directly
+ npmFlags = [ "--ignore-scripts" ];
+ makeCacheWritable = true;
+
+ # vite-plugin-electron compiles electron/ sources into dist-electron/
+ # tsconfig has noEmit — tsc is type-check only
+ buildPhase = ''
+ runHook preBuild
+ npx vite build
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+
+ mkdir -p "$out/lib/openscreen"
+
+ # Renderer build output (index.html, JS chunks, copied public/ assets)
+ cp -r dist "$out/lib/openscreen/"
+
+ # Main process + preload (compiled by vite-plugin-electron)
+ cp -r dist-electron "$out/lib/openscreen/"
+
+ # Package manifest (electron reads "main" field to find entry point)
+ cp package.json "$out/lib/openscreen/"
+
+ # Strip devDependencies (electron, vitest, biome, playwright, etc.)
+ npm prune --omit=dev --no-save
+ cp -r node_modules "$out/lib/openscreen/"
+
+ # Asset resolution: when app.isPackaged is false, the main process resolves
+ # assets at /public/assets/. Mirror the electron-builder
+ # extraResources layout so wallpapers load correctly.
+ mkdir -p "$out/lib/openscreen/public/assets"
+ cp -r public/wallpapers "$out/lib/openscreen/public/assets/wallpapers"
+
+ # Wrap system electron with the app directory
+ mkdir -p "$out/bin"
+ makeWrapper "${electron}/bin/electron" "$out/bin/openscreen" \
+ --add-flags "$out/lib/openscreen" \
+ --set ELECTRON_IS_DEV 0
+
+ # Install icons to hicolor theme
+ for size in 16 24 32 48 64 128 256 512 1024; do
+ icon="icons/icons/png/''${size}x''${size}.png"
+ if [ -f "$icon" ]; then
+ install -Dm644 "$icon" \
+ "$out/share/icons/hicolor/''${size}x''${size}/apps/openscreen.png"
+ fi
+ done
+
+ runHook postInstall
+ '';
+
+ nativeBuildInputs = [
+ makeWrapper
+ copyDesktopItems
+ ];
+
+ desktopItems = [
+ (makeDesktopItem {
+ name = "openscreen";
+ desktopName = "OpenScreen";
+ genericName = "Screen Recorder";
+ exec = "openscreen %U";
+ icon = "openscreen";
+ comment = "Desktop screen recorder with built-in editor";
+ categories = [
+ "AudioVideo"
+ "Video"
+ "Recorder"
+ ];
+ startupWMClass = "Openscreen";
+ terminal = false;
+ })
+ ];
+
+ meta = {
+ description = "Desktop screen recorder with built-in editor";
+ homepage = "https://github.com/siddharthvaddem/openscreen";
+ license = lib.licenses.mit;
+ mainProgram = "openscreen";
+ platforms = lib.platforms.linux;
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index 70e3395..ba40beb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openscreen",
- "version": "1.2.0",
+ "version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
- "version": "1.2.0",
+ "version": "1.3.0",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -51,7 +51,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
- "@playwright/test": "^1.58.2",
+ "@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -60,6 +60,8 @@
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
+ "@vitest/browser": "^4.0.16",
+ "@vitest/browser-playwright": "^4.0.16",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
@@ -1898,14 +1900,13 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -1932,14 +1933,13 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
- "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -1966,14 +1966,13 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
- "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openharmony"
@@ -3191,12 +3190,12 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
- "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"dependencies": {
- "playwright": "1.58.2"
+ "playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
@@ -3205,6 +3204,12 @@
"node": ">=18"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4471,8 +4476,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
@@ -4665,7 +4669,6 @@
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
@@ -4691,8 +4694,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
@@ -4946,12 +4948,1377 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/browser": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.16.tgz",
+ "integrity": "sha512-t4toy8X/YTnjYEPoY0pbDBg3EvDPg1elCDrfc+VupPHwoN/5/FNQ8Z+xBYIaEnOE2vVEyKwqYBzZ9h9rJtZVcg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/mocker": "4.0.16",
+ "@vitest/utils": "4.0.16",
+ "magic-string": "^0.30.21",
+ "pixelmatch": "7.1.0",
+ "pngjs": "^7.0.0",
+ "sirv": "^3.0.2",
+ "tinyrainbow": "^3.0.3",
+ "ws": "^8.18.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.0.16"
+ }
+ },
+ "node_modules/@vitest/browser-playwright": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.16.tgz",
+ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/browser": "4.0.16",
+ "@vitest/mocker": "4.0.16",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "playwright": "*",
+ "vitest": "4.0.16"
+ },
+ "peerDependenciesMeta": {
+ "playwright": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
+ "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "4.0.16",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/@vitest/browser-playwright/node_modules/vite": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/@vitest/mocker": {
+ "version": "4.0.16",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
+ "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "4.0.16",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/pixelmatch": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
+ "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
+ "dev": true,
+ "dependencies": {
+ "pngjs": "^7.0.0"
+ },
+ "bin": {
+ "pixelmatch": "bin/pixelmatch"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
+ "node_modules/@vitest/browser/node_modules/vite": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
@@ -4969,7 +6336,6 @@
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
@@ -4982,7 +6348,6 @@
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.16",
"pathe": "^2.0.3"
@@ -4996,7 +6361,6 @@
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"magic-string": "^0.30.21",
@@ -5011,7 +6375,6 @@
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
"dev": true,
- "license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
@@ -5021,7 +6384,6 @@
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.16",
"tinyrainbow": "^3.0.3"
@@ -5728,7 +7090,6 @@
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=12"
}
@@ -6353,11 +7714,10 @@
}
},
"node_modules/chai": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz",
- "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=18"
}
@@ -7681,8 +9041,7 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@@ -7795,7 +9154,6 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
@@ -10647,6 +12005,15 @@
"node": ">=4"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11187,8 +12554,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/pe-library": {
"version": "0.4.1",
@@ -11472,12 +12838,12 @@
}
},
"node_modules/playwright": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
- "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"dependencies": {
- "playwright-core": "1.58.2"
+ "playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
@@ -11490,9 +12856,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.58.2",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
- "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
@@ -11548,9 +12914,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"funding": [
{
"type": "opencollective",
@@ -11565,7 +12931,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12916,6 +14281,20 @@
"node": ">=10"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -13147,8 +14526,7 @@
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/string_decoder": {
"version": "1.3.0",
@@ -13862,11 +15240,10 @@
}
},
"node_modules/tinyrainbow": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
- "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -13941,6 +15318,15 @@
"url": "https://github.com/sponsors/Borewit"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
@@ -14339,7 +15725,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -14413,14 +15798,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
- "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"aix"
@@ -14430,14 +15814,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
- "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -14447,14 +15830,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
- "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -14464,14 +15846,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
- "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
@@ -14481,14 +15862,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
- "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -14498,14 +15878,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
- "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -14515,14 +15894,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
- "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -14532,14 +15910,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
- "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
@@ -14549,14 +15926,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
- "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14566,14 +15942,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
- "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14583,14 +15958,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
- "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14600,14 +15974,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
- "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14617,14 +15990,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
- "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14634,14 +16006,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
- "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14651,14 +16022,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
- "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14668,14 +16038,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
- "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14685,14 +16054,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
- "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -14702,14 +16070,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
- "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"netbsd"
@@ -14719,14 +16086,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
- "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openbsd"
@@ -14736,14 +16102,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
- "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"sunos"
@@ -14753,14 +16118,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
- "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -14770,14 +16134,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
- "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -14787,14 +16150,13 @@
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
- "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -14808,7 +16170,6 @@
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.16",
"estree-walker": "^3.0.3",
@@ -14831,12 +16192,11 @@
}
},
"node_modules/vitest/node_modules/esbuild": {
- "version": "0.27.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
- "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
@@ -14844,32 +16204,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.2",
- "@esbuild/android-arm": "0.27.2",
- "@esbuild/android-arm64": "0.27.2",
- "@esbuild/android-x64": "0.27.2",
- "@esbuild/darwin-arm64": "0.27.2",
- "@esbuild/darwin-x64": "0.27.2",
- "@esbuild/freebsd-arm64": "0.27.2",
- "@esbuild/freebsd-x64": "0.27.2",
- "@esbuild/linux-arm": "0.27.2",
- "@esbuild/linux-arm64": "0.27.2",
- "@esbuild/linux-ia32": "0.27.2",
- "@esbuild/linux-loong64": "0.27.2",
- "@esbuild/linux-mips64el": "0.27.2",
- "@esbuild/linux-ppc64": "0.27.2",
- "@esbuild/linux-riscv64": "0.27.2",
- "@esbuild/linux-s390x": "0.27.2",
- "@esbuild/linux-x64": "0.27.2",
- "@esbuild/netbsd-arm64": "0.27.2",
- "@esbuild/netbsd-x64": "0.27.2",
- "@esbuild/openbsd-arm64": "0.27.2",
- "@esbuild/openbsd-x64": "0.27.2",
- "@esbuild/openharmony-arm64": "0.27.2",
- "@esbuild/sunos-x64": "0.27.2",
- "@esbuild/win32-arm64": "0.27.2",
- "@esbuild/win32-ia32": "0.27.2",
- "@esbuild/win32-x64": "0.27.2"
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/vitest/node_modules/fdir": {
@@ -14877,7 +16237,6 @@
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=12.0.0"
},
@@ -14891,11 +16250,10 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=12"
},
@@ -14904,24 +16262,23 @@
}
},
"node_modules/vitest/node_modules/vite": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
- "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "esbuild": "^0.27.0",
- "fdir": "^6.5.0",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rollup": "^4.43.0",
- "tinyglobby": "^0.2.15"
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -14930,14 +16287,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
- "less": "^4.0.0",
+ "less": "*",
"lightningcss": "^1.21.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -15137,6 +16494,27 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xhr": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz",
diff --git a/package.json b/package.json
index c367f9e..d41fd40 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,10 @@
"node": "22.22.1",
"npm": "10.9.4"
},
+ "author": {
+ "name": "Sid",
+ "email": "svaddem@asu.edu"
+ },
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -18,10 +22,12 @@
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
- "build:linux": "tsc && vite build && electron-builder --linux",
+ "build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
+ "test:browser": "vitest --config vitest.browser.config.ts --run",
+ "test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky"
},
@@ -69,7 +75,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
- "@playwright/test": "^1.58.2",
+ "@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -78,6 +84,8 @@
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
+ "@vitest/browser": "^4.0.16",
+ "@vitest/browser-playwright": "^4.0.16",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh
new file mode 100755
index 0000000..bd35710
--- /dev/null
+++ b/scripts/build_macos.sh
@@ -0,0 +1,216 @@
+#!/bin/bash
+#
+# OpenScreen macOS Build Script
+# Produces: release//OpenScreen-Mac--.dmg
+#
+# Usage: chmod +x scripts/build_macos.sh && ./scripts/build_macos.sh
+#
+
+set -euo pipefail
+
+# ── Load .env ─────────────────────────────────────────────────────────
+PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ENV_FILE="${PROJECT_ROOT}/.env"
+
+if [ -f "$ENV_FILE" ]; then
+ set -a
+ source "$ENV_FILE"
+ set +a
+else
+ echo "ERROR: .env file not found at ${ENV_FILE}"
+ echo "Create one with APP_NAME, SIGN_IDENTITY, NOTARY_PROFILE, etc."
+ exit 1
+fi
+
+# ── Config ────────────────────────────────────────────────────────────
+VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version")
+RELEASE_DIR="${PROJECT_ROOT}/release/${VERSION}"
+ENTITLEMENTS="${PROJECT_ROOT}/macos.entitlements"
+ARCHS=("arm64" "x64")
+
+# ── Colors ────────────────────────────────────────────────────────────
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+print_step() { echo -e "\n${CYAN}${BOLD}▸ $1${NC}"; }
+print_ok() { echo -e "${GREEN}✓ $1${NC}"; }
+print_warn() { echo -e "${YELLOW}⚠ $1${NC}"; }
+print_err() { echo -e "${RED}✗ $1${NC}"; }
+
+# ── Preflight ─────────────────────────────────────────────────────────
+echo -e "\n${BOLD}╔══════════════════════════════════════════╗${NC}"
+echo -e "${BOLD}║ ${APP_NAME} macOS Build Script v${VERSION} ║${NC}"
+echo -e "${BOLD}╚══════════════════════════════════════════╝${NC}"
+
+print_step "Checking prerequisites..."
+
+if [[ "$(uname)" != "Darwin" ]]; then
+ print_err "This script must be run on macOS."
+ exit 1
+fi
+print_ok "Running on macOS ($(uname -m))"
+
+if ! command -v node &> /dev/null; then
+ print_err "Node.js not found. Please install Node.js first."
+ exit 1
+fi
+print_ok "Node.js found: $(node -v)"
+
+if ! command -v npm &> /dev/null; then
+ print_err "npm not found."
+ exit 1
+fi
+print_ok "npm found: $(npm -v)"
+
+# Check signing identity
+if ! security find-identity -v -p codesigning | grep -q "$SIGN_IDENTITY"; then
+ print_err "Signing identity not found: ${SIGN_IDENTITY}"
+ print_err "Run 'security find-identity -v -p codesigning' to see available identities."
+ exit 1
+fi
+print_ok "Signing identity found: ${SIGN_IDENTITY}"
+
+# Check notary profile
+if ! xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &> /dev/null; then
+ print_err "Notary profile '${NOTARY_PROFILE}' not found in keychain."
+ print_err "Run: xcrun notarytool store-credentials \"${NOTARY_PROFILE}\" --apple-id \"${APPLE_ID}\" --team-id \"${TEAM_ID}\""
+ exit 1
+fi
+print_ok "Notary profile found: ${NOTARY_PROFILE}"
+
+# Check entitlements
+if [ ! -f "$ENTITLEMENTS" ]; then
+ print_err "Entitlements file not found: ${ENTITLEMENTS}"
+ exit 1
+fi
+print_ok "Entitlements file found"
+
+# ── Clean ─────────────────────────────────────────────────────────────
+cd "$PROJECT_ROOT"
+
+print_step "Cleaning previous build artifacts..."
+rm -rf dist dist-electron "${RELEASE_DIR}"
+print_ok "Clean complete"
+
+# ── Install Dependencies ─────────────────────────────────────────────
+print_step "Installing dependencies..."
+npm ci
+print_ok "Dependencies installed"
+
+# ── Build Vite + Electron ────────────────────────────────────────────
+print_step "Building Vite + Electron... (this may take a minute)"
+npx tsc && npx vite build
+print_ok "Vite + Electron build complete"
+
+# ── Package, Sign, Notarize per Architecture ─────────────────────────
+for ARCH in "${ARCHS[@]}"; do
+ echo ""
+ echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+ echo -e "${BOLD} Building for: ${ARCH}${NC}"
+ echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+
+ # ── Package with electron-builder ─────────────────────────────
+ print_step "[${ARCH}] Packaging with electron-builder..."
+
+ # Build .app only (--dir), electron-builder handles codesigning
+ # with hardenedRuntime + entitlements from electron-builder.json5
+ CSC_NAME="$CSC_NAME" npx electron-builder --mac --${ARCH} --dir
+
+ # Find the .app bundle
+ APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | grep -i "${ARCH}\|mac" | head -n1)
+ if [ -z "$APP_BUNDLE" ]; then
+ # Fallback: find any .app in the output
+ APP_BUNDLE=$(find "${RELEASE_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)
+ fi
+
+ if [ -z "$APP_BUNDLE" ]; then
+ print_err "[${ARCH}] Could not find .app bundle in ${RELEASE_DIR}"
+ exit 1
+ fi
+ print_ok "[${ARCH}] App bundle: $(basename "$APP_BUNDLE")"
+
+ # ── Verify codesign on .app ───────────────────────────────────
+ print_step "[${ARCH}] Verifying .app code signature..."
+ codesign --verify --deep --strict "$APP_BUNDLE" 2>&1 || print_warn "[${ARCH}] Deep verify had warnings (may be expected pre-notarization)"
+ print_ok "[${ARCH}] .app signature verified"
+
+ # ── Create DMG ────────────────────────────────────────────────
+ DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
+ DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}"
+ DMG_STAGING="${RELEASE_DIR}/dmg-staging-${ARCH}"
+
+ print_step "[${ARCH}] Creating DMG..."
+
+ rm -f "$DMG_OUTPUT"
+ rm -rf "$DMG_STAGING"
+
+ # Stage: app + Applications shortcut for drag-to-install
+ mkdir -p "$DMG_STAGING"
+ cp -R "$APP_BUNDLE" "$DMG_STAGING/"
+ ln -s /Applications "$DMG_STAGING/Applications"
+
+ hdiutil create \
+ -srcfolder "$DMG_STAGING" \
+ -volname "${APP_NAME}" \
+ -fs HFS+ \
+ -fsargs "-c c=64,a=16,e=16" \
+ -format UDBZ \
+ "$DMG_OUTPUT"
+
+ print_ok "[${ARCH}] DMG created: ${DMG_NAME}"
+ rm -rf "$DMG_STAGING"
+
+ # ── Sign DMG ──────────────────────────────────────────────────
+ print_step "[${ARCH}] Signing DMG..."
+ codesign --force --sign "$SIGN_IDENTITY" --timestamp "$DMG_OUTPUT"
+ print_ok "[${ARCH}] DMG signed"
+
+ # ── Notarize DMG ──────────────────────────────────────────────
+ print_step "[${ARCH}] Notarizing DMG with Apple... (this may take several minutes)"
+ xcrun notarytool submit "$DMG_OUTPUT" \
+ --keychain-profile "$NOTARY_PROFILE" \
+ --wait
+ print_ok "[${ARCH}] DMG notarized"
+
+ # ── Staple ────────────────────────────────────────────────────
+ print_step "[${ARCH}] Stapling notarization ticket..."
+ xcrun stapler staple "$DMG_OUTPUT"
+ print_ok "[${ARCH}] Ticket stapled"
+
+ # ── Validate ──────────────────────────────────────────────────
+ print_step "[${ARCH}] Validating stapled DMG..."
+ xcrun stapler validate "$DMG_OUTPUT"
+ print_ok "[${ARCH}] Validation passed"
+
+done
+
+# ── Clean up unpacked dirs (keep only DMGs) ───────────────────────────
+print_step "Cleaning up intermediate directories..."
+find "${RELEASE_DIR}" -maxdepth 1 -type d ! -name "$(basename "$RELEASE_DIR")" -exec rm -rf {} + 2>/dev/null || true
+print_ok "Cleanup complete"
+
+# ── Done ──────────────────────────────────────────────────────────────
+echo ""
+echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
+echo -e "${GREEN}${BOLD} Build & Notarization Complete!${NC}"
+echo -e "${GREEN}${BOLD}════════════════════════════════════════════${NC}"
+echo ""
+
+for ARCH in "${ARCHS[@]}"; do
+ DMG_NAME="${APP_NAME}-Mac-${ARCH}-${VERSION}.dmg"
+ DMG_PATH="${RELEASE_DIR}/${DMG_NAME}"
+ if [ -f "$DMG_PATH" ]; then
+ DMG_SIZE=$(du -h "$DMG_PATH" | cut -f1)
+ echo -e " 📦 ${BOLD}${ARCH}:${NC} ${DMG_PATH}"
+ echo -e " 📏 ${BOLD}Size:${NC} ${DMG_SIZE}"
+ echo ""
+ fi
+done
+
+echo -e " ${GREEN}All DMGs are fully signed, notarized, and stapled!${NC}"
+echo -e " ${GREEN}Ready for distribution outside the Mac App Store.${NC}"
+echo ""
diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs
index 699ae9e..ca5cb51 100644
--- a/scripts/i18n-check.mjs
+++ b/scripts/i18n-check.mjs
@@ -11,6 +11,7 @@ import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
+const COMPARE_LOCALES = ["zh-CN", "zh-TW", "es", "tr", "ko-KR"];
function getKeys(obj, prefix = "") {
const keys = [];
diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css
index ff68c3d..132fa0a 100644
--- a/src/components/launch/LaunchWindow.module.css
+++ b/src/components/launch/LaunchWindow.module.css
@@ -6,3 +6,78 @@
.electronNoDrag {
-webkit-app-region: no-drag;
}
+
+.languageMenuScroll {
+ max-height: 16rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
+}
+
+.languageMenuScroll::-webkit-scrollbar {
+ width: 8px;
+}
+
+.languageMenuScroll::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 999px;
+}
+
+.languageMenuScroll::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.languageMenuScroll::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
+}
+
+.languageMenuContainer {
+ position: relative;
+ z-index: 20;
+}
+
+.languageMenuPanel {
+ 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);
+ pointer-events: auto;
+ box-sizing: border-box;
+}
+
+.languageMenuItem {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.625rem;
+ border-radius: 0.5rem;
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.88);
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.languageMenuItem:hover,
+.languageMenuItem:focus-visible {
+ background: rgba(255, 255, 255, 0.1);
+ color: #ffffff;
+ outline: none;
+}
+
+.languageMenuItemActive {
+ background: rgba(255, 255, 255, 0.12);
+ color: #ffffff;
+}
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index f1b66b8..2914584 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -1,10 +1,12 @@
-import { ChevronDown, Languages } from "lucide-react";
-import { useEffect, useState } from "react";
-import { BsRecordCircle } from "react-icons/bs";
+import { Check, ChevronDown, Languages } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
+ MdCancel,
MdMic,
MdMicOff,
MdMonitor,
@@ -17,9 +19,7 @@ import {
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
-import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
-import { getLocaleName } from "@/i18n/loader";
-import { isMac as getIsMac } from "@/utils/platformUtils";
+import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -27,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
+import { Button } from "../ui/button";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";
@@ -41,8 +42,11 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
+ pause: { icon: BsPauseCircle, size: ICON_SIZE },
+ resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
+ cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
@@ -63,22 +67,35 @@ const hudGroupClasses =
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";
+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";
+
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]";
+const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
+
export function LaunchWindow() {
const t = useScopedT("launch");
- const { locale, setLocale } = useI18n();
- const [isMac, setIsMac] = useState(false);
-
- useEffect(() => {
- getIsMac().then(setIsMac);
- }, []);
+ const availableLocales = getAvailableLocales();
+ const {
+ locale,
+ setLocale,
+ systemLocaleSuggestion,
+ acceptSystemLocaleSuggestion,
+ dismissSystemLocaleSuggestion,
+ resolveSystemLocaleSuggestion,
+ } = useI18n();
+ const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
const {
recording,
+ paused,
+ elapsedSeconds,
toggleRecording,
+ togglePaused,
restartRecording,
+ cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -90,8 +107,6 @@ export function LaunchWindow() {
webcamDeviceId,
setWebcamDeviceId,
} = useScreenRecorder();
- const [recordingStart, setRecordingStart] = useState(null);
- const [elapsed, setElapsed] = useState(0);
const showMicControls = microphoneEnabled && !recording;
const showWebcamControls = webcamEnabled && !recording;
@@ -103,6 +118,18 @@ export function LaunchWindow() {
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
+ const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
+ const languageTriggerRef = useRef(null);
+ const languageMenuPanelRef = useRef(null);
+ const [languageMenuStyle, setLanguageMenuStyle] = useState<{
+ right: number;
+ top: number;
+ maxHeight: number;
+ }>({
+ right: 12,
+ top: 12,
+ maxHeight: 240,
+ });
const {
devices: micDevices,
@@ -146,25 +173,6 @@ export function LaunchWindow() {
}
}, [selectedCameraId, setWebcamDeviceId]);
- useEffect(() => {
- let timer: NodeJS.Timeout | null = null;
- if (recording) {
- if (!recordingStart) setRecordingStart(Date.now());
- timer = setInterval(() => {
- if (recordingStart) {
- setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
- }
- }, 1000);
- } else {
- setRecordingStart(null);
- setElapsed(0);
- if (timer) clearInterval(timer);
- }
- return () => {
- if (timer) clearInterval(timer);
- };
- }, [recording, recordingStart]);
-
useEffect(() => {
if (!import.meta.env.DEV) {
return;
@@ -175,6 +183,71 @@ export function LaunchWindow() {
});
}, []);
+ useEffect(() => {
+ if (!isLanguageMenuOpen) return;
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const target = event.target as Node;
+ const clickedTrigger = languageTriggerRef.current?.contains(target);
+ const clickedMenu = languageMenuPanelRef.current?.contains(target);
+ if (!clickedTrigger && !clickedMenu) {
+ setIsLanguageMenuOpen(false);
+ }
+ };
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setIsLanguageMenuOpen(false);
+ }
+ };
+
+ window.addEventListener("pointerdown", handlePointerDown);
+ window.addEventListener("keydown", handleEscape);
+
+ return () => {
+ window.removeEventListener("pointerdown", handlePointerDown);
+ window.removeEventListener("keydown", handleEscape);
+ };
+ }, [isLanguageMenuOpen]);
+
+ useEffect(() => {
+ if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
+
+ const updatePosition = () => {
+ if (!languageTriggerRef.current) return;
+ const rect = languageTriggerRef.current.getBoundingClientRect();
+ const gap = 8;
+ const viewportPadding = 8;
+ const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
+ const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
+
+ setLanguageMenuStyle({
+ right: Math.max(viewportPadding, window.innerWidth - rect.right),
+ top,
+ maxHeight: availableHeight,
+ });
+ };
+
+ updatePosition();
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+
+ return () => {
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [isLanguageMenuOpen]);
+
+ useEffect(() => {
+ if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
+ const id = requestAnimationFrame(() => {
+ if (languageMenuPanelRef.current) {
+ languageMenuPanelRef.current.scrollTop = 0;
+ }
+ });
+ return () => cancelAnimationFrame(id);
+ }, [isLanguageMenuOpen]);
+
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -241,25 +314,42 @@ export function LaunchWindow() {
};
return (
-
- {/* Language switcher — top-left, beside traffic lights */}
-
-
-
setLocale(e.target.value as Locale)}
- className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
- style={{ color: "inherit" }}
+
+ {systemLocaleSuggestion && (
+
- {SUPPORTED_LOCALES.map((loc) => (
-
- {getLocaleName(loc)}
-
- ))}
-
-
+
+ {t("systemLanguagePrompt.title")}
+
+
+ {t("systemLanguagePrompt.description", {
+ language: suggestedLanguageName,
+ })}
+
+
+
+ {t("systemLanguagePrompt.keepDefault")}
+
+
+ {t("systemLanguagePrompt.switch", {
+ language: suggestedLanguageName,
+ })}
+
+
+
+ )}
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
@@ -446,75 +536,151 @@ export function LaunchWindow() {
{/* Record/Stop group */}
- {recording ? (
- <>
- {getIcon("stop", "text-red-400")}
-
- {formatTimePadded(elapsed)}
+
+ {recording
+ ? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
+ : getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
+ {recording && (
+
+ {formatTimePadded(elapsedSeconds)}
- >
- ) : (
- getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
- )}
+ )}
+
- {/* Restart recording */}
{recording && (
-
-
+
- {getIcon("restart", "text-white/60")}
-
-
+
+ {getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
+
+
+
+
+ {getIcon("restart", "text-white/60")}
+
+
+
+
+ {getIcon("cancel", "text-white/60")}
+
+
+
)}
- {/* Open video file */}
-
-
- {getIcon("videoFile", "text-white/60")}
-
-
+ {!recording && (
+ <>
+ {/* Open video file */}
+
+
+ {getIcon("videoFile", "text-white/60")}
+
+
- {/* Open project */}
-
-
- {getIcon("folder", "text-white/60")}
-
-
+ {/* Open project */}
+
+
+ {getIcon("folder", "text-white/60")}
+
+
+ >
+ )}
- {/* Window controls */}
-
-
- {getIcon("minimize", "text-white")}
-
-
- {getIcon("close", "text-white")}
-
+ {/* Right sidebar controls */}
+
+
+
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}`}
+ >
+
+
+
+
+
+
+ {isLanguageMenuOpen
+ ? createPortal(
+
event.stopPropagation()}
+ >
+ {availableLocales.map((loc) => (
+ {
+ setLocale(loc);
+ resolveSystemLocaleSuggestion();
+ setIsLanguageMenuOpen(false);
+ }}
+ className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
+ >
+ {getLocaleName(loc)}
+ {loc === locale ? : null}
+
+ ))}
+
,
+ document.body,
+ )
+ : null}
+
+ {/* Window controls */}
+
+
+ {getIcon("minimize", "text-white")}
+
+
+ {getIcon("close", "text-white")}
+
+
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index c15187d..f4dd29f 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
+ React.ComponentPropsWithoutRef & {
+ portalled?: boolean;
+ }
+>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
+ const content = (
-
-));
+ );
+
+ if (!portalled) {
+ return content;
+ }
+
+ return {content} ;
+});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 53e21e6..3326ee9 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, position = "popper", ...props }, ref) => (
-
-
-
- & {
+ showScrollButtons?: boolean;
+ viewportClassName?: string;
+ }
+>(
+ (
+ {
+ className,
+ children,
+ position = "popper",
+ showScrollButtons = true,
+ viewportClassName,
+ ...props
+ },
+ ref,
+ ) => (
+
+
- {children}
-
-
-
-
-));
+ {showScrollButtons ? : null}
+
+ {children}
+
+ {showScrollButtons ? : null}
+
+
+ ),
+);
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx
index 11548c7..f416c32 100644
--- a/src/components/video-editor/AnnotationOverlay.tsx
+++ b/src/components/video-editor/AnnotationOverlay.tsx
@@ -1,8 +1,40 @@
-import { useRef } from "react";
+import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
import { Rnd } from "react-rnd";
+import {
+ getBlurOverlayColor,
+ getMosaicGridOverlayColor,
+ getNormalizedMosaicBlockSize,
+} from "@/lib/blurEffects";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
-import type { AnnotationRegion } from "./types";
+import {
+ type AnnotationRegion,
+ type BlurData,
+ DEFAULT_BLUR_BLOCK_SIZE,
+ DEFAULT_BLUR_DATA,
+ DEFAULT_BLUR_INTENSITY,
+} from "./types";
+
+const FREEHAND_POINT_THRESHOLD = 1;
+type PreviewCanvasSource = {
+ width: number;
+ height: number;
+ clientWidth?: number;
+ clientHeight?: number;
+};
+
+function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
+ if (points.length < 3) return undefined;
+ const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
+ return `polygon(${polygon})`;
+}
+
+function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
+ if (closed ? points.length < 3 : points.length < 2) return null;
+ const [firstPoint, ...rest] = points;
+ const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
+ return closed ? `${path} Z` : path;
+}
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
+ onBlurDataChange?: (id: string, blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
+ previewSourceCanvas?: PreviewCanvasSource | null;
+ previewFrameVersion?: number;
}
export function AnnotationOverlay({
@@ -23,16 +59,130 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
+ onBlurDataChange,
+ onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
+ previewSourceCanvas,
+ previewFrameVersion,
}: AnnotationOverlayProps) {
- const x = (annotation.position.x / 100) * containerWidth;
- const y = (annotation.position.y / 100) * containerHeight;
- const width = (annotation.size.width / 100) * containerWidth;
- const height = (annotation.size.height / 100) * containerHeight;
-
+ const committedX = (annotation.position.x / 100) * containerWidth;
+ const committedY = (annotation.position.y / 100) * containerHeight;
+ const committedWidth = (annotation.size.width / 100) * containerWidth;
+ const committedHeight = (annotation.size.height / 100) * containerHeight;
+ const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
+ const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
+ const isDrawingFreehandRef = useRef(false);
+ const freehandPointsRef = useRef>([]);
+ const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
+ const [draftFreehandPoints, setDraftFreehandPoints] = useState>(
+ [],
+ );
+ const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
+ const mosaicCanvasRef = useRef(null);
+ const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
+ const blurOverlayColor =
+ annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
+ const mosaicGridOverlayColor =
+ annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
+ const [liveRect, setLiveRect] = useState({
+ x: committedX,
+ y: committedY,
+ width: committedWidth,
+ height: committedHeight,
+ });
+
+ useEffect(() => {
+ setLiveRect({
+ x: committedX,
+ y: committedY,
+ width: committedWidth,
+ height: committedHeight,
+ });
+ }, [committedHeight, committedWidth, committedX, committedY]);
+
+ const { x, y, width, height } = liveRect;
+
+ useEffect(() => {
+ if (annotation.type !== "blur" || blurType !== "mosaic") {
+ return;
+ }
+ void previewFrameVersion;
+
+ const canvas = mosaicCanvasRef.current;
+ const sourceCanvas = previewSourceCanvas;
+ if (!canvas || !sourceCanvas) {
+ return;
+ }
+
+ const sourceWidth = sourceCanvas.width;
+ const sourceHeight = sourceCanvas.height;
+ const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
+ const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
+ if (
+ sourceWidth <= 0 ||
+ sourceHeight <= 0 ||
+ sourceClientWidth <= 0 ||
+ sourceClientHeight <= 0
+ ) {
+ return;
+ }
+
+ const drawWidth = Math.max(1, Math.round(width));
+ const drawHeight = Math.max(1, Math.round(height));
+ if (drawWidth <= 0 || drawHeight <= 0) {
+ return;
+ }
+
+ canvas.width = drawWidth;
+ canvas.height = drawHeight;
+
+ const context = canvas.getContext("2d", { willReadFrequently: true });
+ if (!context) {
+ return;
+ }
+
+ const scaleX = sourceWidth / sourceClientWidth;
+ const scaleY = sourceHeight / sourceClientHeight;
+ const sourceX = Math.max(0, Math.floor(x * scaleX));
+ const sourceY = Math.max(0, Math.floor(y * scaleY));
+ const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
+ const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
+ const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
+ const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
+ const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
+ const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
+ const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
+ canvas.width = downscaledWidth;
+ canvas.height = downscaledHeight;
+
+ context.clearRect(0, 0, downscaledWidth, downscaledHeight);
+ context.imageSmoothingEnabled = true;
+ context.drawImage(
+ sourceCanvas as CanvasImageSource,
+ sourceX,
+ sourceY,
+ clampedSampleWidth,
+ clampedSampleHeight,
+ 0,
+ 0,
+ downscaledWidth,
+ downscaledHeight,
+ );
+ }, [
+ annotation,
+ blurType,
+ containerHeight,
+ containerWidth,
+ height,
+ previewFrameVersion,
+ previewSourceCanvas,
+ width,
+ x,
+ y,
+ ]);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +193,95 @@ export function AnnotationOverlay({
return ;
};
+ const normalizePoint = (event: PointerEvent) => {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * 100;
+ const y = ((event.clientY - rect.top) / rect.height) * 100;
+ return {
+ x: Math.max(0, Math.min(100, x)),
+ y: Math.max(0, Math.min(100, y)),
+ };
+ };
+
+ const appendFreehandPoint = (point: { x: number; y: number }) => {
+ const points = freehandPointsRef.current;
+ const lastPoint = points[points.length - 1];
+ if (!lastPoint) {
+ points.push(point);
+ return;
+ }
+ const dx = point.x - lastPoint.x;
+ const dy = point.y - lastPoint.y;
+ // Sample freehand points in annotation-space percent units to avoid overly dense paths.
+ if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
+ points.push(point);
+ }
+ };
+
+ const handleFreehandPointerDown = (event: PointerEvent) => {
+ if (
+ !isSelected ||
+ annotation.type !== "blur" ||
+ annotation.blurData?.shape !== "freehand" ||
+ !onBlurDataChange
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ isDrawingFreehandRef.current = true;
+ setIsFreehandDrawing(true);
+ const point = normalizePoint(event);
+ freehandPointsRef.current = [point];
+ setDraftFreehandPoints([point]);
+ setLivePointerPoint(point);
+ };
+
+ const handleFreehandPointerMove = (event: PointerEvent) => {
+ if (!isDrawingFreehandRef.current) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const point = normalizePoint(event);
+ setLivePointerPoint(point);
+ appendFreehandPoint(point);
+ setDraftFreehandPoints([...freehandPointsRef.current]);
+ };
+
+ const finishFreehandPointer = (event: PointerEvent) => {
+ if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
+ isDrawingFreehandRef.current = false;
+ setIsFreehandDrawing(false);
+ try {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ } catch {
+ // no-op if already released
+ }
+ const points = [...freehandPointsRef.current];
+ if (livePointerPoint) {
+ const last = points[points.length - 1];
+ if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
+ points.push(livePointerPoint);
+ }
+ }
+ if (points.length >= 3) {
+ const closedPoints = [...points];
+ const first = closedPoints[0];
+ const last = closedPoints[closedPoints.length - 1];
+ if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
+ closedPoints.push({ ...first });
+ }
+ onBlurDataChange(annotation.id, {
+ ...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
+ shape: "freehand",
+ freehandPoints: closedPoints,
+ });
+ setDraftFreehandPoints(closedPoints);
+ onBlurDataCommit?.();
+ }
+ setLivePointerPoint(null);
+ };
+
const renderContent = () => {
switch (annotation.type) {
case "text":
@@ -113,6 +352,149 @@ export function AnnotationOverlay({
{renderArrow()}
);
+ case "blur": {
+ const shape = annotation.blurData?.shape ?? "rectangle";
+ const blurIntensity = Math.max(
+ 1,
+ Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
+ );
+ const blockSize = Math.max(
+ 1,
+ Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
+ );
+ const activeFreehandPoints =
+ shape === "freehand"
+ ? isFreehandDrawing
+ ? draftFreehandPoints
+ : (annotation.blurData?.freehandPoints ?? [])
+ : [];
+ const drawingPoints =
+ isFreehandDrawing && livePointerPoint
+ ? (() => {
+ const last = activeFreehandPoints[activeFreehandPoints.length - 1];
+ if (!last) return [livePointerPoint];
+ const dx = livePointerPoint.x - last.x;
+ const dy = livePointerPoint.y - last.y;
+ return Math.hypot(dx, dy) > 0.01
+ ? [...activeFreehandPoints, livePointerPoint]
+ : activeFreehandPoints;
+ })()
+ : activeFreehandPoints;
+ const clipPath =
+ shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
+ const freehandPath =
+ shape === "freehand"
+ ? buildBlurFreehandPath(
+ isFreehandDrawing ? drawingPoints : activeFreehandPoints,
+ !isFreehandDrawing,
+ )
+ : null;
+ const currentPointerPoint = isFreehandDrawing
+ ? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
+ : null;
+ const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
+ const shouldShowFreehandBlurFill =
+ shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
+ const shapeMaskStyle: CSSProperties = {
+ borderRadius: shapeBorderRadius,
+ clipPath: isFreehandDrawing ? undefined : clipPath,
+ WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
+ };
+ const isFreehandSelected = isSelectedFreehandBlur;
+ return (
+
+
+
+ {blurType === "mosaic" && shouldShowFreehandBlurFill && (
+
+ )}
+ {blurType === "mosaic" && shouldShowFreehandBlurFill && (
+
+ )}
+ {blurType === "mosaic" && (
+
+ )}
+ {isSelected && shape !== "freehand" && (
+
+ )}
+
+ {isSelected && shape === "freehand" && freehandPath && (
+
+
+ {currentPointerPoint && (
+
+ )}
+
+ )}
+ {isFreehandSelected && (
+
+ )}
+
+ );
+ }
+
default:
return null;
}
@@ -125,7 +507,19 @@ export function AnnotationOverlay({
onDragStart={() => {
isDraggingRef.current = true;
}}
+ onDrag={(_e, d) => {
+ setLiveRect((prev) => ({
+ ...prev,
+ x: d.x,
+ y: d.y,
+ }));
+ }}
onDragStop={(_e, d) => {
+ setLiveRect((prev) => ({
+ ...prev,
+ x: d.x,
+ y: d.y,
+ }));
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
@@ -135,7 +529,21 @@ export function AnnotationOverlay({
isDraggingRef.current = false;
}, 100);
}}
+ onResize={(_e, _direction, ref, _delta, position) => {
+ setLiveRect({
+ x: position.x,
+ y: position.y,
+ width: ref.offsetWidth,
+ height: ref.offsetHeight,
+ });
+ }}
onResizeStop={(_e, _direction, ref, _delta, position) => {
+ setLiveRect({
+ x: position.x,
+ y: position.y,
+ width: ref.offsetWidth,
+ height: ref.offsetHeight,
+ });
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
@@ -149,18 +557,23 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
- "cursor-move transition-all",
- isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
+ "cursor-move",
+ isSelected &&
+ annotation.type !== "blur" &&
+ "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
- border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
- backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
- boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
+ border:
+ isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
+ backgroundColor:
+ isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
+ boxShadow:
+ isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
- enableResizing={isSelected}
- disableDragging={!isSelected}
+ enableResizing={isSelected && !isSelectedFreehandBlur}
+ disableDragging={!isSelected || isSelectedFreehandBlur}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -206,11 +619,13 @@ export function AnnotationOverlay({
>
{renderContent()}
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index 0b0c174..4c26c88 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -33,7 +33,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
-import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
+import {
+ type AnnotationRegion,
+ type AnnotationType,
+ type ArrowDirection,
+ type FigureData,
+} from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx
new file mode 100644
index 0000000..09bfe3a
--- /dev/null
+++ b/src/components/video-editor/BlurSettingsPanel.tsx
@@ -0,0 +1,247 @@
+import { Info, 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";
+import { cn } from "@/lib/utils";
+import {
+ type AnnotationRegion,
+ type BlurColor,
+ type BlurData,
+ type BlurShape,
+ 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 {
+ blurRegion: AnnotationRegion;
+ onBlurDataChange: (blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
+ onDelete: () => void;
+}
+
+export function BlurSettingsPanel({
+ blurRegion,
+ onBlurDataChange,
+ onBlurDataCommit,
+ onDelete,
+}: BlurSettingsPanelProps) {
+ const t = useScopedT("settings");
+
+ const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
+ { value: "rectangle", labelKey: "blurShapeRectangle" },
+ { value: "oval", labelKey: "blurShapeOval" },
+ ];
+ const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
+ { value: "white", labelKey: "blurColorWhite" },
+ { value: "black", labelKey: "blurColorBlack" },
+ ];
+
+ return (
+
+
+
+ {t("annotation.blurShape")}
+
+ {t("annotation.active")}
+
+
+
+
+ {blurShapeOptions.map((shape) => {
+ const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
+ const isActive = activeShape === shape.value;
+ return (
+
{
+ const nextBlurData: BlurData = {
+ ...DEFAULT_BLUR_DATA,
+ ...blurRegion.blurData,
+ shape: shape.value,
+ };
+ onBlurDataChange(nextBlurData);
+ requestAnimationFrame(() => {
+ onBlurDataCommit?.();
+ });
+ }}
+ className={cn(
+ "h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
+ isActive
+ ? "bg-[#34B27B] border-[#34B27B]"
+ : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
+ )}
+ >
+ {shape.value === "rectangle" && (
+
+ )}
+ {shape.value === "oval" && (
+
+ )}
+
+ {t(`annotation.${shape.labelKey}`)}
+
+
+ );
+ })}
+
+
+
+
+ {t("annotation.blurType")}
+
+ {
+ const nextBlurData: BlurData = {
+ ...DEFAULT_BLUR_DATA,
+ ...blurRegion.blurData,
+ type: value === "mosaic" ? "mosaic" : "blur",
+ };
+ onBlurDataChange(nextBlurData);
+ requestAnimationFrame(() => {
+ onBlurDataCommit?.();
+ });
+ }}
+ >
+
+
+
+
+ {t("annotation.blurTypeBlur")}
+ {t("annotation.blurTypeMosaic")}
+
+
+
+
+
+
+ {t("annotation.blurColor")}
+
+
+ {blurColorOptions.map((option) => {
+ const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
+ const isActive = activeColor === option.value;
+ return (
+
{
+ const nextBlurData: BlurData = {
+ ...DEFAULT_BLUR_DATA,
+ ...blurRegion.blurData,
+ color: option.value,
+ };
+ onBlurDataChange(nextBlurData);
+ requestAnimationFrame(() => {
+ onBlurDataCommit?.();
+ });
+ }}
+ className={cn(
+ "h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
+ isActive
+ ? "bg-[#34B27B] border-[#34B27B]"
+ : "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
+ )}
+ >
+
+
+ {t(`annotation.${option.labelKey}`)}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {blurRegion.blurData?.type === "mosaic"
+ ? t("annotation.mosaicBlockSize")
+ : t("annotation.blurIntensity")}
+
+
+ {Math.round(
+ blurRegion.blurData?.type === "mosaic"
+ ? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
+ : (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
+ )}
+ px
+
+
+
{
+ onBlurDataChange({
+ ...DEFAULT_BLUR_DATA,
+ ...blurRegion.blurData,
+ ...(blurRegion.blurData?.type === "mosaic"
+ ? { blockSize: values[0] }
+ : { intensity: 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}
+ step={1}
+ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
+ {t("annotation.deleteAnnotation")}
+
+
+
+
+
+ {t("annotation.shortcutsAndTips")}
+
+
+ {t("annotation.tipMovePlayhead")}
+
+
+
+
+ );
+}
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx
index 5287365..b90b377 100644
--- a/src/components/video-editor/KeyboardShortcutsHelp.tsx
+++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx
@@ -37,8 +37,10 @@ export function KeyboardShortcutsHelp() {
{FIXED_SHORTCUTS.map((fixed) => (
-
-
{fixed.label}
+
+
+ {t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
+
{isMac
? fixed.display
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index d538666..4fb4193 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -42,20 +42,86 @@ import { cn } from "@/lib/utils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
+import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
+ BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
+ WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
-import { SPEED_OPTIONS } from "./types";
+import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
+
+function CustomSpeedInput({
+ value,
+ onChange,
+ onError,
+}: {
+ value: number;
+ onChange: (val: number) => void;
+ onError: () => void;
+}) {
+ const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
+ const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
+ const [isFocused, setIsFocused] = useState(false);
+
+ const prevValue = useRef(value);
+ if (!isFocused && prevValue.current !== value) {
+ prevValue.current = value;
+ setDraft(isPreset ? "" : String(Math.round(value)));
+ }
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const digits = e.target.value.replace(/\D/g, "");
+ if (digits === "") {
+ setDraft("");
+ return;
+ }
+ const num = Number(digits);
+ if (num > MAX_PLAYBACK_SPEED) {
+ onError();
+ return;
+ }
+ setDraft(digits);
+ if (num >= 1) onChange(num);
+ },
+ [onChange, onError],
+ );
+
+ const handleBlur = useCallback(() => {
+ setIsFocused(false);
+ if (!draft || Number(draft) < 1) {
+ setDraft(isPreset ? "" : String(Math.round(value)));
+ }
+ }, [draft, isPreset, value]);
+
+ return (
+
+ setIsFocused(true)}
+ onChange={handleChange}
+ onBlur={handleBlur}
+ onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
+ className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
+ />
+ ×
+
+ );
+}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
@@ -132,7 +198,11 @@ interface SettingsPanelProps {
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
- unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
+ unsavedExport?: {
+ arrayBuffer: ArrayBuffer;
+ fileName: string;
+ format: string;
+ } | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -142,6 +212,11 @@ interface SettingsPanelProps {
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDuplicate?: (id: string) => void;
onAnnotationDelete?: (id: string) => void;
+ selectedBlurId?: string | null;
+ blurRegions?: AnnotationRegion[];
+ onBlurDataChange?: (id: string, blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
+ onBlurDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
@@ -151,6 +226,12 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
+ selectedZoomInDuration?: number;
+ selectedZoomOutDuration?: number;
+ onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
+ webcamSizePreset?: WebcamSizePreset;
+ onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
+ onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -164,6 +245,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 6, label: "5×" },
];
+const ZOOM_SPEED_OPTIONS = [
+ { label: "Instant", zoomIn: 0, zoomOut: 0 },
+ { label: "Fast", zoomIn: 500, zoomOut: 350 },
+ { label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
+ { label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
+];
+
export function SettingsPanel({
selected,
onWallpaperChange,
@@ -216,6 +304,11 @@ export function SettingsPanel({
onAnnotationFigureDataChange,
onAnnotationDuplicate,
onAnnotationDelete,
+ selectedBlurId,
+ blurRegions = [],
+ onBlurDataChange,
+ onBlurDataCommit,
+ onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -225,6 +318,12 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
+ selectedZoomInDuration,
+ selectedZoomOutDuration,
+ onZoomDurationChange,
+ webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
+ onWebcamSizePresetChange,
+ onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState([]);
@@ -270,6 +369,7 @@ export function SettingsPanel({
const cropSnapshotRef = useRef(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
+ const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
@@ -448,6 +548,9 @@ export function SettingsPanel({
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
+ const selectedBlur = selectedBlurId
+ ? blurRegions.find((region) => region.id === selectedBlurId)
+ : null;
// If an annotation is selected, show annotation settings instead
if (
@@ -476,6 +579,17 @@ export function SettingsPanel({
);
}
+ if (selectedBlur && onBlurDataChange && onBlurDelete) {
+ return (
+ onBlurDataChange(selectedBlur.id, blurData)}
+ onBlurDataCommit={onBlurDataCommit}
+ onDelete={() => onBlurDelete(selectedBlur.id)}
+ />
+ );
+ }
+
return (
@@ -552,6 +666,39 @@ export function SettingsPanel({
)}
)}
+
+ {zoomEnabled && (
+
+
+ {t("zoom.speed.title") || "Zoom Speed"}
+
+
+ {ZOOM_SPEED_OPTIONS.map((opt) => {
+ const isActive =
+ selectedZoomInDuration !== undefined &&
+ selectedZoomOutDuration !== undefined &&
+ Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
+ Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
+ return (
+ onZoomDurationChange?.(opt.zoomIn, opt.zoomOut)}
+ className={cn(
+ "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
+ "duration-200 ease-out cursor-pointer",
+ isActive
+ ? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
+ : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
+ )}
+ >
+ {opt.label}
+
+ );
+ })}
+
+
+ )}
{zoomEnabled && (
)}
-
+
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -614,6 +761,29 @@ export function SettingsPanel({
);
})}
+
+
+
+ {t("speed.customPlaybackSpeed")}
+
+ {selectedSpeedId ? (
+
onSpeedChange?.(val)}
+ onError={() => toast.error(t("speed.maxSpeedError"))}
+ />
+ ) : (
+
+ )}
+
+
{!selectedSpeedId && (
{t("speed.selectRegion")}
)}
@@ -661,15 +831,17 @@ export function SettingsPanel({
- {WEBCAM_LAYOUT_PRESETS.filter(
- (preset) =>
- preset.value === "picture-in-picture" ||
- isPortraitAspectRatio(aspectRatio),
- ).map((preset) => (
+ {WEBCAM_LAYOUT_PRESETS.filter((preset) => {
+ if (preset.value === "picture-in-picture") return true;
+ if (preset.value === "vertical-stack") return isPortraitCanvas;
+ return !isPortraitCanvas;
+ }).map((preset) => (
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
- : t("layout.verticalStack")}
+ : preset.value === "vertical-stack"
+ ? t("layout.verticalStack")
+ : t("layout.dualFrame")}
))}
@@ -756,6 +928,27 @@ export function SettingsPanel({
)}
+ {webcamLayoutPreset === "picture-in-picture" && (
+
+
+
+ {t("layout.webcamSize")}
+
+
+ {webcamSizePreset}%
+
+
+
onWebcamSizePresetChange?.(values[0])}
+ onValueCommit={() => onWebcamSizePresetCommit?.()}
+ min={10}
+ max={50}
+ step={1}
+ className="w-full"
+ />
+
+ )}
)}
@@ -884,7 +1077,7 @@ export function SettingsPanel({
-
+
{
setGradient(g);
onWallpaperChange(g);
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx
index 775cb8c..faa7513 100644
--- a/src/components/video-editor/ShortcutsConfigDialog.tsx
+++ b/src/components/video-editor/ShortcutsConfigDialog.tsx
@@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {
{t("fixed")}
- {FIXED_SHORTCUTS.map(({ label, display }) => (
+ {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
-
{label}
+
+ {t(`fixedActions.${i18nKey}`, { defaultValue: label })}
+
{display}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index f489e2d..9046cb4 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,13 +1,21 @@
import type { Span } from "dnd-timeline";
-import { FolderOpen, Languages, Save } from "lucide-react";
+import { FolderOpen, Languages, Save, Video } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
-import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
-import { getLocaleName } from "@/i18n/loader";
+import { type Locale } from "@/i18n/config";
+import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import {
calculateOutputDimensions,
type ExportFormat,
@@ -20,8 +28,10 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
+import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
+import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
@@ -31,8 +41,10 @@ import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
+ createProjectSnapshot,
deriveNextId,
fromFileUrl,
+ hasProjectUnsavedChanges,
normalizeProjectEditor,
resolveProjectMedia,
toFileUrl,
@@ -42,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
+ type BlurData,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_BLUR_DATA,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
@@ -60,6 +74,7 @@ import {
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
+import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants";
export default function VideoEditor() {
const {
@@ -86,6 +101,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
} = editorState;
@@ -100,15 +116,21 @@ export default function VideoEditor() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
+ const durationRef = useRef(duration);
+ durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState
([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
const [selectedTrimId, setSelectedTrimId] = useState(null);
const [selectedSpeedId, setSelectedSpeedId] = useState(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
+ const [selectedBlurId, setSelectedBlurId] = useState(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(null);
const [exportError, setExportError] = useState(null);
const [showExportDialog, setShowExportDialog] = useState(false);
+ const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState("good");
const [exportFormat, setExportFormat] = useState("mp4");
const [gifFrameRate, setGifFrameRate] = useState(15);
@@ -133,12 +155,22 @@ export default function VideoEditor() {
const { shortcuts, isMac } = useShortcuts();
const t = useScopedT("editor");
const ts = useScopedT("settings");
+ const availableLocales = getAvailableLocales();
const { locale, setLocale } = useI18n();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef(null);
+ const annotationOnlyRegions = useMemo(
+ () => annotationRegions.filter((region) => region.type !== "blur"),
+ [annotationRegions],
+ );
+ const blurRegions = useMemo(
+ () => annotationRegions.filter((region) => region.type === "blur"),
+ [annotationRegions],
+ );
+
const currentProjectMedia = useMemo(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
@@ -198,6 +230,7 @@ export default function VideoEditor() {
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
+ webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -210,6 +243,7 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -234,13 +268,11 @@ export default function VideoEditor() {
) + 1;
setLastSavedSnapshot(
- JSON.stringify(
- createProjectData(
- webcamSourcePath
- ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
- : { screenVideoPath: sourcePath },
- normalizedEditor,
- ),
+ createProjectSnapshot(
+ webcamSourcePath
+ ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
+ : { screenVideoPath: sourcePath },
+ normalizedEditor,
),
);
return true;
@@ -252,30 +284,28 @@ export default function VideoEditor() {
if (!currentProjectMedia) {
return null;
}
- return JSON.stringify(
- createProjectData(currentProjectMedia, {
- wallpaper,
- shadowIntensity,
- showBlur,
- motionBlurAmount,
- borderRadius,
- padding,
- cropRegion,
- zoomRegions,
- trimRegions,
- speedRegions,
- annotationRegions,
- aspectRatio,
- webcamLayoutPreset,
- webcamMaskShape,
- webcamPosition,
- exportQuality,
- exportFormat,
- gifFrameRate,
- gifLoop,
- gifSizePreset,
- }),
- );
+ return createProjectSnapshot(currentProjectMedia, {
+ wallpaper,
+ shadowIntensity,
+ showBlur,
+ motionBlurAmount,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ webcamLayoutPreset,
+ webcamMaskShape,
+ webcamPosition,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ });
}, [
currentProjectMedia,
wallpaper,
@@ -292,6 +322,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -300,12 +331,7 @@ export default function VideoEditor() {
gifSizePreset,
]);
- const hasUnsavedChanges = Boolean(
- currentProjectPath &&
- currentProjectSnapshot &&
- lastSavedSnapshot &&
- currentProjectSnapshot !== lastSavedSnapshot,
- );
+ const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
useEffect(() => {
async function loadInitialData() {
@@ -333,7 +359,14 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(null);
- setLastSavedSnapshot(null);
+ setLastSavedSnapshot(
+ createProjectSnapshot(
+ webcamSourcePath
+ ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
+ : { screenVideoPath: sourcePath },
+ INITIAL_EDITOR_STATE,
+ ),
+ );
return;
}
@@ -345,7 +378,9 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(null);
setWebcamVideoPath(null);
setCurrentProjectPath(null);
- setLastSavedSnapshot(null);
+ setLastSavedSnapshot(
+ createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
+ );
} else {
setError("No video to load. Please record or select a video.");
}
@@ -359,6 +394,28 @@ export default function VideoEditor() {
loadInitialData();
}, [applyLoadedProject]);
+ // Track whether user preferences have been loaded to avoid
+ // overwriting saved prefs with defaults on the first render
+ const [prefsHydrated, setPrefsHydrated] = useState(false);
+
+ // Load persisted user preferences on mount (intentionally runs once)
+ useEffect(() => {
+ const prefs = loadUserPreferences();
+ updateState({
+ padding: prefs.padding,
+ aspectRatio: prefs.aspectRatio,
+ });
+ setExportQuality(prefs.exportQuality);
+ setExportFormat(prefs.exportFormat);
+ setPrefsHydrated(true);
+ }, [updateState]);
+
+ // Auto-save user preferences when settings change
+ useEffect(() => {
+ if (!prefsHydrated) return;
+ saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
+ }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
+
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!videoPath) {
@@ -386,6 +443,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -441,6 +499,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -471,6 +530,16 @@ export default function VideoEditor() {
await saveProject(true);
}, [saveProject]);
+ const handleNewRecordingConfirm = useCallback(async () => {
+ const result = await window.electronAPI.startNewRecording();
+ if (result.success) {
+ setShowNewRecordingDialog(false);
+ } else {
+ console.error("Failed to start new recording:", result.error);
+ setError("Failed to start new recording: " + (result.error || "Unknown error"));
+ }
+ }, []);
+
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
@@ -572,7 +641,11 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
- if (id) setSelectedTrimId(null);
+ if (id) {
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
+ }
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
@@ -580,6 +653,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
}
}, []);
@@ -588,6 +662,17 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
+ setSelectedBlurId(null);
+ }
+ }, []);
+
+ const handleSelectBlur = useCallback((id: string | null) => {
+ setSelectedBlurId(id);
+ if (id) {
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedSpeedId(null);
}
}, []);
@@ -605,6 +690,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -623,6 +709,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -639,6 +726,7 @@ export default function VideoEditor() {
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -648,7 +736,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
- ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
+ ? {
+ ...region,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
: region,
),
}));
@@ -661,7 +753,11 @@ export default function VideoEditor() {
pushState((prev) => ({
trimRegions: prev.trimRegions.map((region) =>
region.id === id
- ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
+ ? {
+ ...region,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
: region,
),
}));
@@ -687,7 +783,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId
- ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
+ ? {
+ ...region,
+ depth,
+ focus: clampFocusToDepth(region.focus, depth),
+ }
: region,
),
}));
@@ -709,7 +809,9 @@ export default function VideoEditor() {
const handleZoomDelete = useCallback(
(id: string) => {
- pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
+ }));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
@@ -719,7 +821,9 @@ export default function VideoEditor() {
const handleTrimDelete = useCallback(
(id: string) => {
- pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
+ pushState((prev) => ({
+ trimRegions: prev.trimRegions.filter((r) => r.id !== id),
+ }));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
@@ -733,6 +837,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
}
}, []);
@@ -745,11 +850,14 @@ export default function VideoEditor() {
endMs: Math.round(span.end),
speed: DEFAULT_PLAYBACK_SPEED,
};
- pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
+ pushState((prev) => ({
+ speedRegions: [...prev.speedRegions, newRegion],
+ }));
setSelectedSpeedId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -810,10 +918,54 @@ export default function VideoEditor() {
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
- pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
+ pushState((prev) => ({
+ annotationRegions: [...prev.annotationRegions, newRegion],
+ }));
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
+ setSelectedBlurId(null);
+ },
+ [pushState],
+ );
+
+ const handleBlurAdded = useCallback(
+ (span: Span) => {
+ const id = `annotation-${nextAnnotationIdRef.current++}`;
+ const zIndex = nextAnnotationZIndexRef.current++;
+ const newRegion: AnnotationRegion = {
+ id,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ type: "blur",
+ content: "",
+ position: { ...DEFAULT_ANNOTATION_POSITION },
+ size: { ...DEFAULT_ANNOTATION_SIZE },
+ style: { ...DEFAULT_ANNOTATION_STYLE },
+ zIndex,
+ blurData: { ...DEFAULT_BLUR_DATA },
+ };
+ pushState((prev) => ({
+ annotationRegions: [...prev.annotationRegions, newRegion],
+ }));
+ setSelectedBlurId(id);
+ setSelectedAnnotationId(null);
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedSpeedId(null);
+ },
+ [pushState],
+ );
+
+ const handleZoomDurationChange = useCallback(
+ (id: string, zoomIn: number, zoomOut: number) => {
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
+ region.id === id
+ ? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut }
+ : region,
+ ),
+ }));
},
[pushState],
);
@@ -823,7 +975,11 @@ export default function VideoEditor() {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
- ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
+ ? {
+ ...region,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
: region,
),
}));
@@ -866,8 +1022,11 @@ export default function VideoEditor() {
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
+ if (selectedBlurId === id) {
+ setSelectedBlurId(null);
+ }
},
- [selectedAnnotationId, pushState],
+ [selectedAnnotationId, selectedBlurId, pushState],
);
const handleAnnotationContentChange = useCallback(
@@ -902,12 +1061,26 @@ export default function VideoEditor() {
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
+ } else if (type === "blur") {
+ updatedRegion.content = "";
+ if (!region.blurData) {
+ updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
+ }
}
return updatedRegion;
}),
}));
+
+ if (type === "blur" && selectedAnnotationId === id) {
+ setSelectedAnnotationId(null);
+ setSelectedBlurId(id);
+ setSelectedSpeedId(null);
+ } else if (type !== "blur" && selectedBlurId === id) {
+ setSelectedBlurId(null);
+ setSelectedAnnotationId(id);
+ }
},
- [pushState],
+ [pushState, selectedAnnotationId, selectedBlurId],
);
const handleAnnotationStyleChange = useCallback(
@@ -932,6 +1105,51 @@ export default function VideoEditor() {
[pushState],
);
+ const handleBlurDataPreviewChange = useCallback(
+ (id: string, blurData: BlurData) => {
+ updateState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ blurData,
+ // Freehand drawing area is the full video surface.
+ ...(blurData.shape === "freehand"
+ ? {
+ position: { x: 0, y: 0 },
+ size: { width: 100, height: 100 },
+ }
+ : {}),
+ }
+ : region,
+ ),
+ }));
+ },
+ [updateState],
+ );
+
+ const handleBlurDataPanelChange = useCallback(
+ (id: string, blurData: BlurData) => {
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ blurData,
+ ...(blurData.shape === "freehand"
+ ? {
+ position: { x: 0, y: 0 },
+ size: { width: 100, height: 100 },
+ }
+ : {}),
+ }
+ : region,
+ ),
+ }));
+ },
+ [pushState],
+ );
+
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
pushState((prev) => ({
@@ -972,6 +1190,40 @@ export default function VideoEditor() {
return;
}
+ // Frame-step navigation (arrow keys, no modifiers)
+ if (
+ (e.key === "ArrowLeft" || e.key === "ArrowRight") &&
+ !e.ctrlKey &&
+ !e.metaKey &&
+ !e.shiftKey &&
+ !e.altKey
+ ) {
+ const target = e.target;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const video = videoPlaybackRef.current?.video;
+ if (!video) {
+ return;
+ }
+ const direction = e.key === "ArrowLeft" ? "backward" : "forward";
+ const newTime = computeFrameStepTime(
+ video.currentTime,
+ Number.isFinite(video.duration) ? video.duration : durationRef.current,
+ direction,
+ );
+ video.currentTime = newTime;
+ return;
+ }
+
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
@@ -1011,11 +1263,14 @@ export default function VideoEditor() {
useEffect(() => {
if (
selectedAnnotationId &&
- !annotationRegions.some((region) => region.id === selectedAnnotationId)
+ !annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
- }, [selectedAnnotationId, annotationRegions]);
+ if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
+ setSelectedBlurId(null);
+ }
+ }, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
@@ -1137,6 +1392,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1270,6 +1526,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1340,6 +1597,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
@@ -1445,6 +1703,34 @@ export default function VideoEditor() {
return (
+
+
+
+ {t("newRecording.title")}
+ {t("newRecording.description")}
+
+
+ setShowNewRecordingDialog(false)}
+ className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
+ >
+ {t("newRecording.cancel")}
+
+
+ {t("newRecording.confirm")}
+
+
+
+
+
- {SUPPORTED_LOCALES.map((loc) => (
+ {availableLocales.map((loc) => (
{getLocaleName(loc)}
))}
+
setShowNewRecordingDialog(true)}
+ className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
+ >
+
+ {t("newRecording.title")}
+
updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1550,11 +1845,18 @@ export default function VideoEditor() {
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
+ blurRegions={blurRegions}
+ selectedBlurId={selectedBlurId}
+ onSelectBlur={handleSelectBlur}
+ onBlurPositionChange={handleAnnotationPositionChange}
+ onBlurSizeChange={handleAnnotationSizeChange}
+ onBlurDataChange={handleBlurDataPreviewChange}
+ onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
/>
@@ -1592,6 +1894,7 @@ export default function VideoEditor() {
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
+ onZoomDurationChange={handleZoomDurationChange}
onZoomDelete={handleZoomDelete}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
@@ -1607,18 +1910,25 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
+ blurRegions={blurRegions}
+ onBlurAdded={handleBlurAdded}
+ onBlurSpanChange={handleAnnotationSpanChange}
+ onBlurDelete={handleAnnotationDelete}
+ selectedBlurId={selectedBlurId}
+ onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
- !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
+ (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
+ (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
? "picture-in-picture"
: webcamLayoutPreset,
})
@@ -1671,11 +1981,14 @@ export default function VideoEditor() {
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
- webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
+ webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
})
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
+ webcamSizePreset={webcamSizePreset}
+ onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
+ onWebcamSizePresetCommit={commitState}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
@@ -1702,13 +2015,18 @@ export default function VideoEditor() {
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDuplicate={handleAnnotationDuplicate}
onAnnotationDelete={handleAnnotationDelete}
+ selectedBlurId={selectedBlurId}
+ blurRegions={blurRegions}
+ onBlurDataChange={handleBlurDataPanelChange}
+ onBlurDataCommit={commitState}
+ onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
@@ -1719,6 +2037,21 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
+ selectedZoomInDuration={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ??
+ Math.round(ZOOM_IN_TRANSITION_WINDOW_MS))
+ : undefined
+ }
+ selectedZoomOutDuration={
+ selectedZoomId
+ ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ??
+ Math.round(TRANSITION_WINDOW_MS))
+ : undefined
+ }
+ onZoomDurationChange={(zoomIn, zoomOut) =>
+ selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut)
+ }
/>
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index d659afe..b798641 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -24,6 +24,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
+ type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
@@ -34,6 +35,7 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
+ type BlurData,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -69,6 +71,7 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
+ webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -99,6 +102,13 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ blurRegions?: AnnotationRegion[];
+ selectedBlurId?: string | null;
+ onSelectBlur?: (id: string | null) => void;
+ onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
+ onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ onBlurDataChange?: (id: string, blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
@@ -119,6 +129,7 @@ const VideoPlayback = forwardRef
(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
+ webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -149,6 +160,13 @@ const VideoPlayback = forwardRef(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
+ blurRegions = [],
+ selectedBlurId,
+ onSelectBlur,
+ onBlurPositionChange,
+ onBlurSizeChange,
+ onBlurDataChange,
+ onBlurDataCommit,
cursorTelemetry = [],
},
ref,
@@ -163,6 +181,8 @@ const VideoPlayback = forwardRef(
const timeUpdateAnimationRef = useRef(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
+ const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
+ const [overlayElement, setOverlayElement] = useState(null);
const overlayRef = useRef(null);
const focusIndicatorRef = useRef(null);
const [webcamLayout, setWebcamLayout] = useState(null);
@@ -195,7 +215,10 @@ const VideoPlayback = forwardRef(
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
- const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
+ const lockedVideoDimensionsRef = useRef<{
+ width: number;
+ height: number;
+ } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef([]);
const speedRegionsRef = useRef([]);
@@ -283,6 +306,7 @@ const VideoPlayback = forwardRef(
padding,
webcamDimensions,
webcamLayoutPreset,
+ webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -314,6 +338,7 @@ const VideoPlayback = forwardRef(
padding,
webcamDimensions,
webcamLayoutPreset,
+ webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
@@ -322,6 +347,11 @@ const VideoPlayback = forwardRef(
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
+ const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
+ overlayRef.current = node;
+ setOverlayElement(node);
+ }, []);
+
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -615,7 +645,8 @@ const VideoPlayback = forwardRef(
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
- const overlayEl = overlayRef.current;
+ if (!pixiReady || !videoReady) return;
+ const overlayEl = overlayElement;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -624,7 +655,34 @@ const VideoPlayback = forwardRef(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
- }, [selectedZoom, isPlaying]);
+ }, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
+
+ useEffect(() => {
+ const overlayEl = overlayElement;
+ if (!overlayEl) return;
+
+ const updateOverlaySize = () => {
+ const width = overlayEl.clientWidth || 800;
+ const height = overlayEl.clientHeight || 600;
+ setOverlaySize((prev) => {
+ if (prev.width === width && prev.height === height) return prev;
+ return { width, height };
+ });
+ };
+
+ updateOverlaySize();
+
+ if (typeof ResizeObserver !== "undefined") {
+ const observer = new ResizeObserver(() => {
+ updateOverlaySize();
+ });
+ observer.observe(overlayEl);
+ return () => observer.disconnect();
+ }
+
+ window.addEventListener("resize", updateOverlaySize);
+ return () => window.removeEventListener("resize", updateOverlaySize);
+ }, [overlayElement]);
useEffect(() => {
const container = containerRef.current;
@@ -648,7 +706,11 @@ const VideoPlayback = forwardRef(
app.ticker.maxFPS = 60;
if (!mounted) {
- app.destroy(true, { children: true, texture: true, textureSource: true });
+ app.destroy(true, {
+ children: true,
+ texture: true,
+ textureSource: true,
+ });
return;
}
@@ -672,7 +734,11 @@ const VideoPlayback = forwardRef(
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
- app.destroy(true, { children: true, texture: true, textureSource: true });
+ app.destroy(true, {
+ children: true,
+ texture: true,
+ textureSource: true,
+ });
}
appRef.current = null;
cameraContainerRef.current = null;
@@ -849,16 +915,13 @@ const VideoPlayback = forwardRef(
};
const ticker = () => {
- const bm = baseMaskRef.current;
- const ss = stageSizeRef.current;
- const viewportRatio =
- bm.width > 0 && bm.height > 0
- ? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
- : undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
- { connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
+ {
+ connectZooms: true,
+ cursorTelemetry: cursorTelemetryRef.current,
+ },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -1264,7 +1327,7 @@ const VideoPlayback = forwardRef(
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
(
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
- const filtered = (annotationRegions || []).filter((annotation) => {
+ const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
- return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
+ return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
- // Sort by z-index (lowest to highest) so higher z-index renders on top
- const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
+ const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
+ if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
+ return false;
+
+ if (blurRegion.id === selectedBlurId) return true;
+
+ const timeMs = Math.round(currentTime * 1000);
+ return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
+ });
+
+ const sorted = [
+ ...filteredAnnotations.map((annotation) => ({
+ kind: "annotation" as const,
+ region: annotation,
+ })),
+ ...filteredBlurRegions.map((blurRegion) => ({
+ kind: "blur" as const,
+ region: blurRegion,
+ })),
+ ].sort((a, b) => a.region.zIndex - b.region.zIndex);
+ const previewSnapshotCanvas = (() => {
+ const app = appRef.current;
+ if (!app?.renderer?.extract) return null;
+ try {
+ return app.renderer.extract.canvas(app.stage);
+ } catch {
+ return null;
+ }
+ })();
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
- if (clickedId === selectedAnnotationId && sorted.length > 1) {
+ if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
- const currentIndex = sorted.findIndex((a) => a.id === clickedId);
- const nextIndex = (currentIndex + 1) % sorted.length;
- onSelectAnnotation(sorted[nextIndex].id);
+ const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
+ onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
- return sorted.map((annotation) => (
+ const handleBlurClick = (clickedId: string) => {
+ if (!onSelectBlur) return;
+
+ if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
+ const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
+ onSelectBlur(filteredBlurRegions[nextIndex].id);
+ } else {
+ onSelectBlur(clickedId);
+ }
+ };
+
+ return sorted.map((item) => (
onAnnotationPositionChange?.(id, position)}
- onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
- onClick={handleAnnotationClick}
- zIndex={annotation.zIndex}
- isSelectedBoost={annotation.id === selectedAnnotationId}
+ key={
+ item.kind === "blur"
+ ? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
+ : `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
+ }
+ annotation={item.region}
+ isSelected={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
+ containerWidth={overlaySize.width}
+ containerHeight={overlaySize.height}
+ onPositionChange={(id, position) =>
+ item.kind === "blur"
+ ? onBlurPositionChange?.(id, position)
+ : onAnnotationPositionChange?.(id, position)
+ }
+ onSizeChange={(id, size) =>
+ item.kind === "blur"
+ ? onBlurSizeChange?.(id, size)
+ : onAnnotationSizeChange?.(id, size)
+ }
+ onBlurDataChange={
+ item.kind === "blur"
+ ? (id, blurData) => onBlurDataChange?.(id, blurData)
+ : undefined
+ }
+ onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
+ onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
+ zIndex={item.region.zIndex}
+ isSelectedBoost={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
+ previewSourceCanvas={previewSnapshotCanvas}
+ previewFrameVersion={Math.round(currentTime * 1000)}
/>
));
})()}
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
index 3243aca..14dc240 100644
--- a/src/components/video-editor/projectPersistence.test.ts
+++ b/src/components/video-editor/projectPersistence.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
+ createProjectSnapshot,
+ hasProjectUnsavedChanges,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
@@ -42,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
+ webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -64,4 +67,133 @@ describe("projectPersistence media compatibility", () => {
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
+
+ it("normalizes blur region type and mosaic block size safely", () => {
+ const editor = normalizeProjectEditor({
+ annotationRegions: [
+ {
+ id: "annotation-1",
+ startMs: 0,
+ endMs: 500,
+ type: "blur",
+ content: "",
+ position: { x: 10, y: 10 },
+ size: { width: 20, 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: "rectangle",
+ color: "black",
+ intensity: 999,
+ blockSize: 999,
+ },
+ },
+ {
+ id: "annotation-2",
+ startMs: 0,
+ endMs: 500,
+ type: "blur",
+ content: "",
+ position: { x: 10, y: 10 },
+ size: { width: 20, height: 20 },
+ style: {
+ color: "#fff",
+ backgroundColor: "transparent",
+ fontSize: 32,
+ fontFamily: "Inter",
+ fontWeight: "bold",
+ fontStyle: "normal",
+ textDecoration: "none",
+ textAlign: "center",
+ },
+ zIndex: 2,
+ blurData: {
+ type: "invalid" as never,
+ shape: "rectangle",
+ color: "invalid" as never,
+ intensity: 10,
+ blockSize: 0,
+ },
+ },
+ ],
+ });
+
+ expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
+ 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?.color).toBe("white");
+ expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
+ });
+
+ it("accepts the dual frame webcam layout preset", () => {
+ expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
+ "dual-frame",
+ );
+ });
+
+ it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
+ expect(
+ normalizeProjectEditor({
+ aspectRatio: "9:16",
+ webcamLayoutPreset: "dual-frame",
+ }).webcamLayoutPreset,
+ ).toBe("picture-in-picture");
+ });
+
+ it("clears webcamPosition when the normalized preset is not picture in picture", () => {
+ expect(
+ normalizeProjectEditor({
+ webcamLayoutPreset: "dual-frame",
+ webcamPosition: { cx: 0.2, cy: 0.8 },
+ }).webcamPosition,
+ ).toBeNull();
+ });
+});
+
+it("creates stable snapshots for identical project state", () => {
+ const media = {
+ screenVideoPath: "/tmp/screen.webm",
+ webcamVideoPath: "/tmp/webcam.webm",
+ };
+ const editor = normalizeProjectEditor({
+ wallpaper: "/wallpapers/wallpaper1.jpg",
+ shadowIntensity: 0,
+ showBlur: false,
+ motionBlurAmount: 0,
+ borderRadius: 0,
+ padding: 50,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ zoomRegions: [],
+ trimRegions: [],
+ speedRegions: [],
+ annotationRegions: [],
+ aspectRatio: "16:9",
+ webcamLayoutPreset: "picture-in-picture",
+ webcamMaskShape: "circle",
+ exportQuality: "good",
+ exportFormat: "mp4",
+ gifFrameRate: 15,
+ gifLoop: true,
+ gifSizePreset: "medium",
+ });
+
+ expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
+});
+
+it("detects unsaved changes from differing snapshots", () => {
+ expect(hasProjectUnsavedChanges(null, null)).toBe(false);
+ expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
+ expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index d7111b1..c085e0d 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -1,29 +1,44 @@
+import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
-import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
+import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
+ clampPlaybackSpeed,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_BLUR_BLOCK_SIZE,
+ DEFAULT_BLUR_DATA,
+ DEFAULT_BLUR_FREEHAND_POINTS,
+ DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
+ DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
+ MAX_BLUR_BLOCK_SIZE,
+ MAX_BLUR_INTENSITY,
+ MAX_PLAYBACK_SPEED,
+ MIN_BLUR_BLOCK_SIZE,
+ MIN_BLUR_INTENSITY,
+ MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
+ type WebcamSizePreset,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
+const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
@@ -47,6 +62,7 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
+ webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
@@ -66,6 +82,26 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
+function computeNormalizedWebcamLayoutPreset(
+ webcamLayoutPreset: Partial["webcamLayoutPreset"],
+ normalizedAspectRatio: AspectRatio,
+): WebcamLayoutPreset {
+ switch (webcamLayoutPreset) {
+ case "picture-in-picture":
+ return webcamLayoutPreset;
+ case "vertical-stack":
+ return isPortraitAspectRatio(normalizedAspectRatio)
+ ? webcamLayoutPreset
+ : DEFAULT_WEBCAM_LAYOUT_PRESET;
+ case "dual-frame":
+ return isPortraitAspectRatio(normalizedAspectRatio)
+ ? DEFAULT_WEBCAM_LAYOUT_PRESET
+ : webcamLayoutPreset;
+ default:
+ return DEFAULT_WEBCAM_LAYOUT_PRESET;
+ }
+}
+
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -173,6 +209,26 @@ export function resolveProjectMedia(
export function normalizeProjectEditor(editor: Partial): ProjectEditorState {
const validAspectRatios = new Set(ASPECT_RATIOS);
+ const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
+ editor.aspectRatio as AspectRatio,
+ )
+ ? (editor.aspectRatio as AspectRatio)
+ : "16:9";
+ const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
+ editor.webcamLayoutPreset,
+ normalizedAspectRatio,
+ );
+ const normalizedWebcamPosition: WebcamPosition | null =
+ normalizedWebcamLayoutPreset === "picture-in-picture" &&
+ editor.webcamPosition &&
+ typeof editor.webcamPosition === "object" &&
+ isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
+ isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
+ ? {
+ cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
+ cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
+ }
+ : DEFAULT_WEBCAM_POSITION;
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
@@ -223,14 +279,10 @@ export function normalizeProjectEditor(editor: Partial): Pro
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
- region.speed === 0.25 ||
- region.speed === 0.5 ||
- region.speed === 0.75 ||
- region.speed === 1.25 ||
- region.speed === 1.5 ||
- region.speed === 1.75 ||
- region.speed === 2
- ? region.speed
+ isFiniteNumber(region.speed) &&
+ region.speed >= MIN_PLAYBACK_SPEED &&
+ region.speed <= MAX_PLAYBACK_SPEED
+ ? clampPlaybackSpeed(region.speed)
: DEFAULT_PLAYBACK_SPEED;
return {
@@ -252,12 +304,22 @@ export function normalizeProjectEditor(editor: Partial): Pro
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
+ const blurShape =
+ typeof region.blurData?.shape === "string" &&
+ VALID_BLUR_SHAPES.has(region.blurData.shape)
+ ? region.blurData.shape
+ : DEFAULT_BLUR_DATA.shape;
+ const blurType = normalizeBlurType(region.blurData?.type);
+ const blurColor = normalizeBlurColor(region.blurData?.color);
return {
id: region.id,
startMs,
endMs,
- type: region.type === "image" || region.type === "figure" ? region.type : "text",
+ type:
+ region.type === "image" || region.type === "figure" || region.type === "blur"
+ ? region.type
+ : "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -304,6 +366,42 @@ export function normalizeProjectEditor(editor: Partial): Pro
...region.figureData,
}
: undefined,
+ blurData:
+ region.blurData && typeof region.blurData === "object"
+ ? {
+ ...DEFAULT_BLUR_DATA,
+ ...region.blurData,
+ type: blurType,
+ shape: blurShape,
+ color: blurColor,
+ intensity: isFiniteNumber(region.blurData.intensity)
+ ? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
+ : DEFAULT_BLUR_INTENSITY,
+ blockSize: isFiniteNumber(region.blurData.blockSize)
+ ? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
+ : DEFAULT_BLUR_BLOCK_SIZE,
+ freehandPoints: Array.isArray(region.blurData.freehandPoints)
+ ? region.blurData.freehandPoints
+ .filter(
+ (
+ point,
+ ): point is {
+ x: number;
+ y: number;
+ } =>
+ Boolean(
+ point &&
+ isFiniteNumber((point as { x?: unknown }).x) &&
+ isFiniteNumber((point as { y?: unknown }).y),
+ ),
+ )
+ .map((point) => ({
+ x: clamp(point.x, 0, 100),
+ y: clamp(point.y, 0, 100),
+ }))
+ : DEFAULT_BLUR_FREEHAND_POINTS,
+ }
+ : undefined,
};
})
: [];
@@ -349,13 +447,8 @@ export function normalizeProjectEditor(editor: Partial): Pro
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
- aspectRatio:
- editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
- webcamLayoutPreset:
- editor.webcamLayoutPreset === "vertical-stack" ||
- editor.webcamLayoutPreset === "picture-in-picture"
- ? editor.webcamLayoutPreset
- : DEFAULT_WEBCAM_LAYOUT_PRESET,
+ aspectRatio: normalizedAspectRatio,
+ webcamLayoutPreset: normalizedWebcamLayoutPreset,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
@@ -363,16 +456,11 @@ export function normalizeProjectEditor(editor: Partial): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
- webcamPosition:
- editor.webcamPosition &&
- typeof editor.webcamPosition === "object" &&
- isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
- isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
- ? {
- cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
- cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
- }
- : DEFAULT_WEBCAM_POSITION,
+ webcamSizePreset:
+ typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
+ ? Math.max(10, Math.min(50, editor.webcamSizePreset))
+ : DEFAULT_WEBCAM_SIZE_PRESET,
+ webcamPosition: normalizedWebcamPosition,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
@@ -405,3 +493,19 @@ export function createProjectData(
editor,
};
}
+
+export function createProjectSnapshot(
+ media: ProjectMedia,
+ editor: Partial,
+): string {
+ return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
+}
+
+export function hasProjectUnsavedChanges(
+ currentSnapshot: string | null,
+ baselineSnapshot: string | null,
+): boolean {
+ return Boolean(
+ currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
+ );
+}
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index f265fe4..d89de94 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -1,8 +1,13 @@
import type { Span } from "dnd-timeline";
-import { useItem } from "dnd-timeline";
+import { useItem, useTimelineContext } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
+import {
+ DEFAULT_ZOOM_IN_MS,
+ DEFAULT_ZOOM_OUT_MS,
+ getDurations,
+} from "../videoPlayback/zoomRegionUtils";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -13,8 +18,11 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
+ zoomInDurationMs?: number;
+ zoomOutDurationMs?: number;
speedValue?: number;
- variant?: "zoom" | "trim" | "annotation" | "speed";
+ onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
+ variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
@@ -44,10 +52,14 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
+ zoomInDurationMs,
+ zoomOutDurationMs,
speedValue,
variant = "zoom",
children,
+ onZoomDurationChange,
}: ItemProps) {
+ const { pixelsToValue } = useTimelineContext();
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -79,6 +91,16 @@ export default function Item({
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
+ const { zoomIn, zoomOut } = useMemo(() => {
+ if (!isZoom) return { zoomIn: 0, zoomOut: 0 };
+ return getDurations({
+ startMs: span.start,
+ endMs: span.end,
+ zoomInDurationMs,
+ zoomOutDurationMs,
+ });
+ }, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]);
+
return (
+ {isZoom && (
+ <>
+ {/* Transition In Marker */}
+
+ {/* Draggable handle for Transition In */}
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ const target = e.currentTarget;
+ target.setPointerCapture(e.pointerId);
+
+ const startX = e.clientX;
+ const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
+ const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
+
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ const deltaPx = moveEvent.clientX - startX;
+ const deltaMs = pixelsToValue(deltaPx);
+ const newDuration = Math.max(
+ 0,
+ Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut),
+ );
+ onZoomDurationChange?.(id, newDuration, initialZoomOut);
+ };
+
+ const onPointerUp = () => {
+ target.releasePointerCapture(e.pointerId);
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", onPointerUp);
+ };
+
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", onPointerUp);
+ }}
+ />
+ {/* Transition Out Marker */}
+
+ {/* Draggable handle for Transition Out */}
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ const target = e.currentTarget;
+ target.setPointerCapture(e.pointerId);
+
+ const startX = e.clientX;
+ const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
+ const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
+
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored
+ const deltaMs = pixelsToValue(deltaPx);
+ const newDuration = Math.max(
+ 0,
+ Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn),
+ );
+ onZoomDurationChange?.(id, initialZoomIn, newDuration);
+ };
+
+ const onPointerUp = () => {
+ target.releasePointerCapture(e.pointerId);
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", onPointerUp);
+ };
+
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", onPointerUp);
+ }}
+ />
+ >
+ )}
void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
+ onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
@@ -73,6 +75,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
+ blurRegions?: AnnotationRegion[];
+ onBlurAdded?: (span: Span) => void;
+ onBlurSpanChange?: (id: string, span: Span) => void;
+ onBlurDelete?: (id: string) => void;
+ selectedBlurId?: string | null;
+ onSelectBlur?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
@@ -96,7 +104,9 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
- variant: "zoom" | "trim" | "annotation" | "speed";
+ zoomInDurationMs?: number;
+ zoomOutDurationMs?: number;
+ variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -525,11 +535,14 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
+ onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
+ selectedBlurId,
selectedSpeedId,
+ onZoomDurationChange,
keyframes = [],
}: {
items: TimelineRenderItem[];
@@ -540,11 +553,14 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
+ onSelectBlur?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
+ selectedBlurId?: string | null;
selectedSpeedId?: string | null;
+ onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
@@ -568,6 +584,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
+ onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -586,6 +603,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
+ onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -637,6 +655,7 @@ function Timeline({
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
+ const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
return (
@@ -668,6 +687,9 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
+ zoomInDurationMs={item.zoomInDurationMs}
+ zoomOutDurationMs={item.zoomOutDurationMs}
+ onZoomDurationChange={onZoomDurationChange}
variant="zoom"
>
{item.label}
@@ -711,6 +733,22 @@ function Timeline({
))}
+
+ {blurItems.map((item) => (
+ - onSelectBlur?.(item.id)}
+ variant={item.variant}
+ >
+ {item.label}
+
+ ))}
+
+
{speedItems.map((item) => (
- {
+ if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
+ onBlurDelete(selectedBlurId);
+ onSelectBlur(null);
+ }, [selectedBlurId, onBlurDelete, onSelectBlur]);
+
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -908,9 +959,10 @@ export default function TimelineEditor({
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
+ const isBlurItem = blurRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
- if (isAnnotationItem) {
+ if (isAnnotationItem || isBlurItem) {
return false;
}
@@ -937,7 +989,7 @@ export default function TimelineEditor({
return false;
},
- [zoomRegions, trimRegions, annotationRegions, speedRegions],
+ [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -1165,6 +1217,21 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
+ const handleAddBlur = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const endPos = Math.min(startPos + defaultDuration, totalMs);
+ onBlurAdded({ start: startPos, end: endPos });
+ }, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
+
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -1183,6 +1250,9 @@ export default function TimelineEditor({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
+ if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
+ handleAddBlur();
+ }
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
@@ -1223,6 +1293,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
+ } else if (selectedBlurId) {
+ deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1235,18 +1307,22 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
+ handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
+ deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
+ selectedBlurId,
selectedSpeedId,
annotationRegions,
+ blurRegions,
currentTime,
onSelectAnnotation,
keyShortcuts,
@@ -1271,6 +1347,8 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
+ zoomInDurationMs: region.zoomInDurationMs,
+ zoomOutDurationMs: region.zoomOutDurationMs,
variant: "zoom",
}));
@@ -1304,6 +1382,14 @@ export default function TimelineEditor({
};
});
+ const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
+ id: region.id,
+ rowId: BLUR_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: t("labels.blurItem", { index: String(index + 1) }),
+ variant: "blur",
+ }));
+
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
@@ -1313,8 +1399,8 @@ export default function TimelineEditor({
variant: "speed",
}));
- return [...zooms, ...trims, ...annotations, ...speeds];
- }, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
+ return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
+ }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1335,6 +1421,8 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
onAnnotationSpanChange?.(id, span);
+ } else if (blurRegions.some((r) => r.id === id)) {
+ onBlurSpanChange?.(id, span);
}
},
[
@@ -1342,10 +1430,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
+ blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
+ onBlurSpanChange,
],
);
@@ -1403,6 +1493,25 @@ export default function TimelineEditor({
>
+
+
+
+
+
+
+
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index de06ba1..87e4331 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
+/** Webcam size as a percentage of the canvas reference dimension (10–50). */
+export type WebcamSizePreset = number;
+
+export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
@@ -29,6 +33,8 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
+ zoomInDurationMs?: number;
+ zoomOutDurationMs?: number;
}
export interface CursorTelemetryPoint {
@@ -43,7 +49,7 @@ export interface TrimRegion {
endMs: number;
}
-export type AnnotationType = "text" | "image" | "figure";
+export type AnnotationType = "text" | "image" | "figure" | "blur";
export type ArrowDirection =
| "up"
@@ -61,6 +67,27 @@ export interface FigureData {
strokeWidth: number;
}
+export type BlurShape = "rectangle" | "oval" | "freehand";
+export type BlurType = "blur" | "mosaic";
+export type BlurColor = "white" | "black";
+
+export const MIN_BLUR_INTENSITY = 2;
+export const MAX_BLUR_INTENSITY = 40;
+export const DEFAULT_BLUR_INTENSITY = 12;
+export const MIN_BLUR_BLOCK_SIZE = 4;
+export const MAX_BLUR_BLOCK_SIZE = 48;
+export const DEFAULT_BLUR_BLOCK_SIZE = 12;
+
+export interface BlurData {
+ type: BlurType;
+ shape: BlurShape;
+ color: BlurColor;
+ intensity: number;
+ blockSize: number;
+ // Points are normalized (0-100) within the annotation bounds.
+ freehandPoints?: Array<{ x: number; y: number }>;
+}
+
export interface AnnotationPosition {
x: number;
y: number;
@@ -95,6 +122,7 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
+ blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -124,6 +152,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
strokeWidth: 4,
};
+export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
+ { x: 10, y: 30 },
+ { x: 25, y: 10 },
+ { x: 55, y: 8 },
+ { x: 82, y: 20 },
+ { x: 90, y: 45 },
+ { x: 78, y: 72 },
+ { x: 52, y: 90 },
+ { x: 22, y: 84 },
+ { x: 8, y: 58 },
+];
+
+export const DEFAULT_BLUR_DATA: BlurData = {
+ type: "blur",
+ shape: "rectangle",
+ color: "white",
+ intensity: DEFAULT_BLUR_INTENSITY,
+ blockSize: DEFAULT_BLUR_BLOCK_SIZE,
+ freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
+};
+
export interface CropRegion {
x: number;
y: number;
@@ -138,7 +187,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
-export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
+export type PlaybackSpeed = number;
+
+export const MIN_PLAYBACK_SPEED = 0.1;
+// Anything above 16x causes the playhead to stall during preview
+// due to the video decoder not being able to keep up.
+export const MAX_PLAYBACK_SPEED = 16;
+
+export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
+ return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
+}
export interface SpeedRegion {
id: string;
@@ -155,6 +213,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
+ { speed: 3, label: "3×" },
+ { speed: 4, label: "4×" },
+ { speed: 5, label: "5×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts
index 2444c39..4b713cf 100644
--- a/src/components/video-editor/videoPlayback/layoutUtils.ts
+++ b/src/components/video-editor/videoPlayback/layoutUtils.ts
@@ -5,6 +5,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
+ type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
@@ -20,6 +21,7 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
+ webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
+ webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
+ webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -136,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenRect.y,
screenRect.width,
screenRect.height,
- compositeLayout.screenCover ? 0 : borderRadius,
+ compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
);
maskGraphics.fill({ color: 0xffffff });
diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
index e5c16e1..88d08c1 100644
--- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
+++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
@@ -7,7 +7,6 @@ import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
-const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
@@ -38,26 +37,49 @@ function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
-export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
- const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
- const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
- const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
+export const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS;
+export const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS;
- if (timeMs < leadInStart || timeMs > leadOutEnd) {
+export function getDurations(region: {
+ startMs: number;
+ endMs: number;
+ zoomInDurationMs?: number;
+ zoomOutDurationMs?: number;
+}) {
+ let zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
+ let zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
+
+ const duration = region.endMs - region.startMs;
+ if (zoomIn + zoomOut > duration) {
+ const scale = duration / (zoomIn + zoomOut);
+ zoomIn *= scale;
+ zoomOut *= scale;
+ }
+
+ return { zoomIn, zoomOut };
+}
+
+export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
+ const { zoomIn, zoomOut } = getDurations(region);
+
+ if (timeMs < region.startMs || timeMs > region.endMs) {
return 0;
}
- if (timeMs < zoomInEnd) {
- const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
+ // Zooming in
+ if (timeMs < region.startMs + zoomIn) {
+ const progress = Math.max(0, Math.min(1, (timeMs - region.startMs) / zoomIn));
return easeOutScreenStudio(progress);
}
- if (timeMs <= region.endMs) {
- return 1;
+ // Zooming out
+ if (timeMs > region.endMs - zoomOut) {
+ const progress = Math.max(0, Math.min(1, (region.endMs - timeMs) / zoomOut));
+ return easeOutScreenStudio(progress);
}
- const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
- return 1 - easeOutScreenStudio(progress);
+ // Full zoom
+ return 1;
}
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts
index 61ced66..800949f 100644
--- a/src/components/video-editor/videoPlayback/zoomTransform.ts
+++ b/src/components/video-editor/videoPlayback/zoomTransform.ts
@@ -90,8 +90,10 @@ export function computeZoomTransform({
}
const progress = Math.min(1, Math.max(0, zoomProgress));
- const focusStagePxX = baseMask.x + focusX * baseMask.width;
- const focusStagePxY = baseMask.y + focusY * baseMask.height;
+ // Focus coordinates are stage-normalized (0-1 of full canvas),
+ // so map directly to stage pixels, not through baseMask.
+ const focusStagePxX = focusX * stageSize.width;
+ const focusStagePxY = focusY * stageSize.height;
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
const scale = 1 + (zoomScale - 1) * progress;
@@ -128,8 +130,8 @@ export function computeFocusFromTransform({
const focusStagePxY = (stageCenterY - y) / zoomScale;
return {
- cx: (focusStagePxX - baseMask.x) / baseMask.width,
- cy: (focusStagePxY - baseMask.y) / baseMask.height,
+ cx: focusStagePxX / stageSize.width,
+ cy: focusStagePxY / stageSize.height,
};
}
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx
index 0b75212..1056749 100644
--- a/src/contexts/I18nContext.tsx
+++ b/src/contexts/I18nContext.tsx
@@ -5,16 +5,11 @@ import {
useContext,
useEffect,
useMemo,
+ useRef,
useState,
} from "react";
-import {
- DEFAULT_LOCALE,
- type I18nNamespace,
- LOCALE_STORAGE_KEY,
- type Locale,
- SUPPORTED_LOCALES,
-} from "@/i18n/config";
-import { translate } from "@/i18n/loader";
+import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
+import { getAvailableLocales, translate } from "@/i18n/loader";
type TranslateVars = Record;
@@ -22,8 +17,14 @@ interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (qualifiedKey: string, vars?: TranslateVars) => string;
+ systemLocaleSuggestion: Locale | null;
+ acceptSystemLocaleSuggestion: () => void;
+ dismissSystemLocaleSuggestion: () => void;
+ resolveSystemLocaleSuggestion: () => void;
}
+const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen";
+
const I18nContext = createContext(null);
export function useI18n(): I18nContextValue {
@@ -41,7 +42,37 @@ export function useScopedT(namespace: I18nNamespace) {
}
function isSupportedLocale(value: string): value is Locale {
- return (SUPPORTED_LOCALES as readonly string[]).includes(value);
+ return getAvailableLocales().includes(value);
+}
+
+function getSupportedSystemLocale(): Locale | null {
+ if (typeof navigator === "undefined") return null;
+ const availableLocales = getAvailableLocales();
+
+ const candidates =
+ Array.isArray(navigator.languages) && navigator.languages.length > 0
+ ? navigator.languages
+ : [navigator.language];
+
+ for (const candidate of candidates) {
+ if (!candidate) continue;
+ if (isSupportedLocale(candidate)) return candidate;
+
+ const exactMatch = availableLocales.find(
+ (locale) => locale.toLowerCase() === candidate.toLowerCase(),
+ );
+ if (exactMatch) return exactMatch;
+
+ const baseLanguage = candidate.split("-")[0]?.toLowerCase();
+ if (!baseLanguage) continue;
+
+ if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
+
+ const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
+ if (baseMatch) return baseMatch;
+ }
+
+ return null;
}
function getInitialLocale(): Locale {
@@ -56,6 +87,16 @@ function getInitialLocale(): Locale {
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState(getInitialLocale);
+ const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState(null);
+ const hasRunSystemLocaleCheckRef = useRef(false);
+
+ const markPromptAsHandled = useCallback(() => {
+ try {
+ localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1");
+ } catch {
+ // localStorage may be unavailable
+ }
+ }, []);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
@@ -73,6 +114,48 @@ export function I18nProvider({ children }: { children: ReactNode }) {
document.documentElement.lang = locale;
}, [locale]);
+ useEffect(() => {
+ if (hasRunSystemLocaleCheckRef.current) return;
+ hasRunSystemLocaleCheckRef.current = true;
+
+ let hasStoredLocale = false;
+ let hasHandledSystemPrompt = false;
+ try {
+ const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
+ hasStoredLocale = Boolean(stored && isSupportedLocale(stored));
+ hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1";
+ } catch {
+ // localStorage may be unavailable
+ }
+
+ if (hasStoredLocale || hasHandledSystemPrompt) return;
+
+ const detectedSystemLocale = getSupportedSystemLocale();
+ if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) {
+ markPromptAsHandled();
+ return;
+ }
+
+ setSystemLocaleSuggestion(detectedSystemLocale);
+ }, [markPromptAsHandled]);
+
+ const acceptSystemLocaleSuggestion = useCallback(() => {
+ if (!systemLocaleSuggestion) return;
+ setLocale(systemLocaleSuggestion);
+ setSystemLocaleSuggestion(null);
+ markPromptAsHandled();
+ }, [markPromptAsHandled, setLocale, systemLocaleSuggestion]);
+
+ const dismissSystemLocaleSuggestion = useCallback(() => {
+ setSystemLocaleSuggestion(null);
+ markPromptAsHandled();
+ }, [markPromptAsHandled]);
+
+ const resolveSystemLocaleSuggestion = useCallback(() => {
+ setSystemLocaleSuggestion(null);
+ markPromptAsHandled();
+ }, [markPromptAsHandled]);
+
const t = useCallback(
(qualifiedKey: string, vars?: TranslateVars): string => {
const dotIndex = qualifiedKey.indexOf(".");
@@ -84,7 +167,26 @@ export function I18nProvider({ children }: { children: ReactNode }) {
[locale],
);
- const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
+ const value = useMemo(
+ () => ({
+ locale,
+ setLocale,
+ t,
+ systemLocaleSuggestion,
+ acceptSystemLocaleSuggestion,
+ dismissSystemLocaleSuggestion,
+ resolveSystemLocaleSuggestion,
+ }),
+ [
+ locale,
+ setLocale,
+ t,
+ systemLocaleSuggestion,
+ acceptSystemLocaleSuggestion,
+ dismissSystemLocaleSuggestion,
+ resolveSystemLocaleSuggestion,
+ ],
+ );
return {children} ;
}
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts
index d261c1f..cc19222 100644
--- a/src/hooks/useEditorHistory.ts
+++ b/src/hooks/useEditorHistory.ts
@@ -7,6 +7,7 @@ import type {
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
+ WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import {
@@ -14,6 +15,7 @@ import {
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
+ DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
@@ -34,6 +36,7 @@ export interface EditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
+ webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
}
@@ -52,6 +55,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
+ webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 0c418c1..5cbc54a 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -41,8 +41,12 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
+ paused: boolean;
+ elapsedSeconds: number;
toggleRecording: () => void;
+ togglePaused: () => void;
restartRecording: () => void;
+ cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
@@ -85,6 +89,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
const [recording, setRecording] = useState(false);
+ const [paused, setPaused] = useState(false);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState(undefined);
@@ -97,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const microphoneStream = useRef(null);
const webcamStream = useRef(null);
const mixingContext = useRef(null);
- const startTime = useRef(0);
const recordingId = useRef(0);
+ const accumulatedDurationMs = useRef(0);
+ const segmentStartedAt = useRef(null);
const finalizingRecordingId = useRef(null);
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef(null);
const restarting = useRef(false);
+ const getRecordingDurationMs = useCallback(() => {
+ const segmentDuration =
+ segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
+ return accumulatedDurationMs.current + segmentDuration;
+ }, []);
+
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
@@ -202,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
teardownMedia();
setRecording(false);
+ setPaused(false);
+ setElapsedSeconds(0);
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
void (async () => {
@@ -273,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
const activeWebcamRecorder = webcamRecorder.current;
- const duration = Date.now() - startTime.current;
+ const duration = getRecordingDurationMs();
const activeRecordingId = recordingId.current;
finalizeRecording(
@@ -283,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeRecordingId,
);
- if (activeScreenRecorder.recorder.state === "recording") {
+ if (
+ activeScreenRecorder.recorder.state === "recording" ||
+ activeScreenRecorder.recorder.state === "paused"
+ ) {
try {
activeScreenRecorder.recorder.stop();
} catch {
@@ -291,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (activeWebcamRecorder) {
- if (activeWebcamRecorder.recorder.state === "recording") {
+ if (
+ activeWebcamRecorder.recorder.state === "recording" ||
+ activeWebcamRecorder.recorder.state === "paused"
+ ) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
@@ -316,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = false;
discardRecordingId.current = null;
- if (screenRecorder.current?.recorder.state === "recording") {
+ if (
+ screenRecorder.current?.recorder.state === "recording" ||
+ screenRecorder.current?.recorder.state === "paused"
+ ) {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
- if (webcamRecorder.current?.recorder.state === "recording") {
+ if (
+ webcamRecorder.current?.recorder.state === "recording" ||
+ webcamRecorder.current?.recorder.state === "paused"
+ ) {
try {
webcamRecorder.current.recorder.stop();
} catch {
@@ -518,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
recordingId.current = Date.now();
- startTime.current = recordingId.current;
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true;
setRecording(true);
+ setPaused(false);
+ setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
@@ -536,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
- Math.max(0, Date.now() - startTime.current),
+ Math.max(0, getRecordingDurationMs()),
activeRecordingId,
);
},
@@ -552,12 +584,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
toast.error(errorMsg);
}
setRecording(false);
+ setPaused(false);
+ setElapsedSeconds(0);
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = null;
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
+ const togglePaused = () => {
+ const activeScreenRecorder = screenRecorder.current?.recorder;
+ if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
+ return;
+ }
+
+ const activeWebcamRecorder = webcamRecorder.current?.recorder;
+
+ if (activeScreenRecorder.state === "paused") {
+ try {
+ activeScreenRecorder.resume();
+ if (activeWebcamRecorder?.state === "paused") {
+ activeWebcamRecorder.resume();
+ }
+ segmentStartedAt.current = Date.now();
+ setPaused(false);
+ } catch (error) {
+ console.error("Failed to resume recording:", error);
+ }
+ return;
+ }
+
+ if (activeScreenRecorder.state !== "recording") {
+ return;
+ }
+
+ try {
+ accumulatedDurationMs.current = getRecordingDurationMs();
+ segmentStartedAt.current = null;
+ setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
+ activeScreenRecorder.pause();
+ if (activeWebcamRecorder?.state === "recording") {
+ activeWebcamRecorder.pause();
+ }
+ setPaused(true);
+ } catch (error) {
+ console.error("Failed to pause recording:", error);
+ }
+ };
+
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
@@ -566,14 +642,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (restarting.current) return;
const activeScreenRecorder = screenRecorder.current;
- if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
+ if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
- allowAutoFinalize.current = false;
const stopPromises = [
new Promise((resolve) => {
@@ -581,7 +656,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}),
];
- if (activeWebcamRecorder?.recorder.state === "recording") {
+ if (
+ activeWebcamRecorder?.recorder.state === "recording" ||
+ activeWebcamRecorder?.recorder.state === "paused"
+ ) {
stopPromises.push(
new Promise((resolve) => {
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
@@ -601,10 +679,43 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
+ useEffect(() => {
+ if (!recording) {
+ setElapsedSeconds(0);
+ return;
+ }
+
+ setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
+ if (paused) {
+ return;
+ }
+
+ const interval = window.setInterval(() => {
+ setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
+ }, 250);
+
+ return () => window.clearInterval(interval);
+ }, [getRecordingDurationMs, paused, recording]);
+
+ const cancelRecording = () => {
+ const activeScreenRecorder = screenRecorder.current;
+ if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
+
+ const activeRecordingId = recordingId.current;
+ discardRecordingId.current = activeRecordingId;
+ allowAutoFinalize.current = false;
+
+ stopRecording.current();
+ };
+
return {
recording,
+ paused,
+ elapsedSeconds,
toggleRecording,
+ togglePaused,
restartRecording,
+ cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
diff --git a/src/i18n/__tests__/tutorialHelpTranslations.test.ts b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
new file mode 100644
index 0000000..fcfa9d3
--- /dev/null
+++ b/src/i18n/__tests__/tutorialHelpTranslations.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "vitest";
+import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
+import enDialogs from "@/i18n/locales/en/dialogs.json";
+import esDialogs from "@/i18n/locales/es/dialogs.json";
+import frDialogs from "@/i18n/locales/fr/dialogs.json";
+import 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";
+
+const tutorialHelpKeys = [
+ "triggerLabel",
+ "title",
+ "description",
+ "explanationBefore",
+ "remove",
+ "explanationMiddle",
+ "covered",
+ "explanationAfter",
+ "visualExample",
+ "removed",
+ "kept",
+ "part1",
+ "part2",
+ "part3",
+ "finalVideo",
+ "step1Title",
+ "step1DescriptionBefore",
+ "step1DescriptionAfter",
+ "step2Title",
+ "step2Description",
+] as const;
+
+const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]);
+
+const dialogsByLocale = {
+ en: enDialogs,
+ "zh-CN": zhCNDialogs,
+ es: esDialogs,
+ fr: frDialogs,
+ tr: trDialogs,
+ "ko-KR": koKRDialogs,
+} satisfies Record }>;
+
+describe("TutorialHelp translations", () => {
+ it("defines every tutorial help key for each supported locale", () => {
+ for (const locale of SUPPORTED_LOCALES) {
+ const tutorial = dialogsByLocale[locale].tutorial;
+
+ for (const key of tutorialHelpKeys) {
+ const message = tutorial[key];
+ const label = `${locale} dialogs.tutorial.${key}`;
+ expect(message, label).toEqual(expect.any(String));
+ if (!keysThatMayBeEmpty.has(key)) {
+ expect((message as string).trim().length, label).toBeGreaterThan(0);
+ }
+ }
+ }
+ });
+});
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index a96c7ef..03761c8 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -1,5 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
-export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
+export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "es", "fr", "tr", "ko-KR"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
@@ -10,7 +10,7 @@ export const I18N_NAMESPACES = [
"timeline",
] as const;
-export type Locale = (typeof SUPPORTED_LOCALES)[number];
+export type Locale = string;
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
export const LOCALE_STORAGE_KEY = "openscreen-locale";
diff --git a/src/i18n/loader.ts b/src/i18n/loader.ts
index 4736db8..36d8eb6 100644
--- a/src/i18n/loader.ts
+++ b/src/i18n/loader.ts
@@ -1,6 +1,10 @@
-import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
+import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
type MessageMap = Record;
+type LocaleValidationError = {
+ locale: string;
+ missingNamespaces: I18nNamespace[];
+};
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
string,
@@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) {
messages[locale][namespace] = mod.default;
}
+const REQUIRED_NAMESPACES = new Set(I18N_NAMESPACES);
+
+const localeValidationErrors: LocaleValidationError[] = Object.keys(messages)
+ .map((locale) => {
+ const localeMessages = messages[locale] ?? {};
+ const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]);
+ return {
+ locale,
+ missingNamespaces,
+ };
+ })
+ .filter((entry) => entry.missingNamespaces.length > 0);
+
+const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale));
+
+const availableLocales = Object.keys(messages)
+ .filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale]))
+ .filter((locale) => !invalidLocales.has(locale))
+ .sort((a, b) => {
+ if (a === DEFAULT_LOCALE) return -1;
+ if (b === DEFAULT_LOCALE) return 1;
+ return a.localeCompare(b);
+ });
+
+if (localeValidationErrors.length > 0) {
+ console.error("[i18n] Incomplete locale folders were excluded:");
+ for (const entry of localeValidationErrors) {
+ console.error(
+ `[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`,
+ );
+ }
+}
+
+function hasRequiredNamespaces(localeMessages: Record | undefined): boolean {
+ if (!localeMessages) return false;
+ for (const namespace of REQUIRED_NAMESPACES) {
+ if (!localeMessages[namespace]) return false;
+ }
+ return true;
+}
+
+function isAvailableLocale(locale: string): locale is Locale {
+ return availableLocales.includes(locale);
+}
+
+export function getAvailableLocales(): Locale[] {
+ if (availableLocales.length === 0) {
+ return [DEFAULT_LOCALE];
+ }
+ return availableLocales;
+}
+
+export function getLocaleValidationErrors(): LocaleValidationError[] {
+ return localeValidationErrors;
+}
+
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
@@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record): strin
}
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
- return messages[locale]?.[namespace] ?? {};
+ const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
+ return messages[resolvedLocale]?.[namespace] ?? {};
}
export function getLocaleName(locale: Locale): string {
- return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
+ const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
+ return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
}
export function getLocaleShort(locale: Locale): string {
- return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
+ const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
+ return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
}
export function translate(
@@ -52,8 +115,10 @@ export function translate(
vars?: Record,
): string {
const value =
- getMessageValue(messages[locale]?.[namespace], key) ??
- getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
+ getMessageValue(
+ messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
+ key,
+ ) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json
index 66a33c2..a84b5fd 100644
--- a/src/i18n/locales/en/dialogs.json
+++ b/src/i18n/locales/en/dialogs.json
@@ -27,10 +27,11 @@
"triggerLabel": "How trimming works",
"title": "How Trimming Works",
"description": "Understanding how to cut out unwanted parts of your video.",
- "explanation": "The Trim tool works by defining the segments you want to",
- "explanationRemove": "remove",
- "explanationCovered": "covered",
- "explanationEnd": "by a red trim segment will be cut out when you export.",
+ "explanationBefore": "The Trim tool works by defining the segments you want to",
+ "remove": "remove",
+ "explanationMiddle": " — anything",
+ "covered": "covered",
+ "explanationAfter": "by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
@@ -39,7 +40,9 @@
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
- "step1Description": "Press T or click the scissors icon to mark a section for removal.",
+ "step1DescriptionBefore": "Press ",
+ "step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
+
"step2Title": "2. Adjust",
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
},
diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json
index 6fdc310..ea2ceaa 100644
--- a/src/i18n/locales/en/editor.json
+++ b/src/i18n/locales/en/editor.json
@@ -1,4 +1,10 @@
{
+ "newRecording": {
+ "title": "Return to Recorder",
+ "description": "Your current session has been saved.",
+ "cancel": "Cancel",
+ "confirm": "Confirm"
+ },
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json
index 6e4a4ed..e959a54 100644
--- a/src/i18n/locales/en/launch.json
+++ b/src/i18n/locales/en/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "Hide HUD",
"closeApp": "Close App",
"restartRecording": "Restart recording",
+ "cancelRecording": "Cancel recording",
+ "pauseRecording": "Pause recording",
+ "resumeRecording": "Resume recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
},
@@ -30,5 +33,11 @@
"recording": {
"selectSource": "Please select a source to record"
},
- "language": "Language"
+ "language": "Language",
+ "systemLanguagePrompt": {
+ "title": "Use your system language?",
+ "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?",
+ "switch": "Switch to {{language}}",
+ "keepDefault": "Keep current language"
+ }
}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 632a569..00e7c08 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -8,12 +8,21 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
+ },
+ "speed": {
+ "title": "Zoom Speed",
+ "instant": "Instant",
+ "fast": "Fast",
+ "smooth": "Smooth",
+ "lazy": "Lazy"
}
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
- "deleteRegion": "Delete Speed Region"
+ "deleteRegion": "Delete Speed Region",
+ "customPlaybackSpeed": "Custom Playback Speed",
+ "maxSpeedError": "Speed can't go higher than 16×"
},
"trim": {
"deleteRegion": "Delete Trim Region"
@@ -24,7 +33,9 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
- "webcamShape": "Camera Shape"
+ "dualFrame": "Dual Frame",
+ "webcamShape": "Camera Shape",
+ "webcamSize": "Webcam Size"
},
"effects": {
"title": "Video Effects",
@@ -98,6 +109,7 @@
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
+ "typeBlur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
@@ -114,6 +126,18 @@
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
+ "blurType": "Blur Type",
+ "blurTypeBlur": "Blur",
+ "blurTypeMosaic": "Mosaic Blur",
+ "blurColor": "Blur Color",
+ "blurColorWhite": "White",
+ "blurColorBlack": "Black",
+ "blurShape": "Blur Shape",
+ "blurIntensity": "Blur Intensity",
+ "mosaicBlockSize": "Mosaic Block Size",
+ "blurShapeRectangle": "Rectangle",
+ "blurShapeOval": "Oval",
+ "blurShapeFreehand": "Freehand",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json
index e943e61..2c26aa4 100644
--- a/src/i18n/locales/en/shortcuts.json
+++ b/src/i18n/locales/en/shortcuts.json
@@ -18,6 +18,7 @@
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
+ "addBlur": "Add Blur",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
@@ -29,6 +30,8 @@
"cycleAnnotationsBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
- "zoomTimeline": "Zoom Timeline"
+ "zoomTimeline": "Zoom Timeline",
+ "frameBack": "Frame Back",
+ "frameForward": "Frame Forward"
}
}
diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json
index f748621..b4d5bd8 100644
--- a/src/i18n/locales/en/timeline.json
+++ b/src/i18n/locales/en/timeline.json
@@ -4,12 +4,14 @@
"suggestZooms": "Suggest Zooms from Cursor",
"addTrim": "Add Trim (T)",
"addAnnotation": "Add Annotation (A)",
+ "addBlur": "Add Blur (B)",
"addSpeed": "Add Speed (S)"
},
"hints": {
"pressZoom": "Press Z to add zoom",
"pressTrim": "Press T to add trim",
"pressAnnotation": "Press A to add annotation",
+ "pressBlur": "Press B to add blur region",
"pressSpeed": "Press S to add speed"
},
"labels": {
@@ -19,6 +21,7 @@
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
+ "blurItem": "Blur {{index}}",
"imageItem": "Image",
"emptyText": "Empty text"
},
diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json
index acf2a04..f8a5e63 100644
--- a/src/i18n/locales/es/dialogs.json
+++ b/src/i18n/locales/es/dialogs.json
@@ -27,10 +27,11 @@
"triggerLabel": "Cómo funciona el recorte",
"title": "Cómo funciona el recorte",
"description": "Aprende a eliminar las partes no deseadas de tu video.",
- "explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas",
- "explanationRemove": "eliminar",
- "explanationCovered": "cubierto",
- "explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.",
+ "explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
+ "remove": "eliminar",
+ "explanationMiddle": " — cualquier parte",
+ "covered": "cubierta",
+ "explanationAfter": "por un segmento rojo será eliminada al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
@@ -39,7 +40,8 @@
"part3": "Parte 3",
"finalVideo": "Video final",
"step1Title": "1. Agregar recorte",
- "step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
+ "step1DescriptionBefore": "Presiona ",
+ "step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step2Title": "2. Ajustar",
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
},
diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json
index b25ec3d..68919aa 100644
--- a/src/i18n/locales/es/launch.json
+++ b/src/i18n/locales/es/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "Ocultar HUD",
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
+ "cancelRecording": "Cancelar grabación",
+ "pauseRecording": "Pausar grabación",
+ "resumeRecording": "Reanudar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
},
@@ -30,5 +33,11 @@
"recording": {
"selectSource": "Por favor selecciona una fuente para grabar"
},
- "language": "Idioma"
+ "language": "Idioma",
+ "systemLanguagePrompt": {
+ "title": "¿Usar el idioma del sistema?",
+ "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?",
+ "switch": "Cambiar a {{language}}",
+ "keepDefault": "Mantener idioma actual"
+ }
}
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 586e840..92160bd 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -8,12 +8,21 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
+ },
+ "speed": {
+ "title": "Velocidad de zoom",
+ "instant": "Instantáneo",
+ "fast": "Rápido",
+ "smooth": "Suave",
+ "lazy": "Lento"
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
- "deleteRegion": "Eliminar región de velocidad"
+ "deleteRegion": "Eliminar región de velocidad",
+ "customPlaybackSpeed": "Velocidad personalizada",
+ "maxSpeedError": "La velocidad no puede superar 16×"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
@@ -24,7 +33,9 @@
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
- "webcamShape": "Forma de cámara"
+ "dualFrame": "Marco dual",
+ "webcamShape": "Forma de cámara",
+ "webcamSize": "Tamaño de cámara"
},
"effects": {
"title": "Efectos de video",
@@ -98,6 +109,7 @@
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
+ "typeBlur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
@@ -114,6 +126,18 @@
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
+ "blurType": "Tipo de desenfoque",
+ "blurTypeBlur": "Desenfoque",
+ "blurTypeMosaic": "Desenfoque mosaico",
+ "blurColor": "Color del desenfoque",
+ "blurColorWhite": "Blanco",
+ "blurColorBlack": "Negro",
+ "blurShape": "Forma del desenfoque",
+ "blurIntensity": "Intensidad del desenfoque",
+ "mosaicBlockSize": "Tamano del bloque mosaico",
+ "blurShapeRectangle": "Rectángulo",
+ "blurShapeOval": "Óvalo",
+ "blurShapeFreehand": "Mano alzada",
"deleteAnnotation": "Eliminar anotación",
"shortcutsAndTips": "Atajos y consejos",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json
index ede18bf..888ded0 100644
--- a/src/i18n/locales/es/shortcuts.json
+++ b/src/i18n/locales/es/shortcuts.json
@@ -18,6 +18,7 @@
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
+ "addBlur": "Agregar desenfoque",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
@@ -29,6 +30,8 @@
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
- "zoomTimeline": "Zoom en línea de tiempo"
+ "zoomTimeline": "Zoom en línea de tiempo",
+ "frameBack": "Fotograma anterior",
+ "frameForward": "Fotograma siguiente"
}
}
diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json
index 9f11bc9..12a83b0 100644
--- a/src/i18n/locales/es/timeline.json
+++ b/src/i18n/locales/es/timeline.json
@@ -4,13 +4,15 @@
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
- "addSpeed": "Agregar velocidad (S)"
+ "addSpeed": "Agregar velocidad (S)",
+ "addBlur": "Agregar desenfoque (B)"
},
"hints": {
"pressZoom": "Presiona Z para agregar zoom",
"pressTrim": "Presiona T para agregar recorte",
"pressAnnotation": "Presiona A para agregar anotación",
- "pressSpeed": "Presiona S para agregar velocidad"
+ "pressSpeed": "Presiona S para agregar velocidad",
+ "pressBlur": "Presiona B para agregar una región de desenfoque"
},
"labels": {
"pan": "Desplazar",
@@ -20,7 +22,8 @@
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
- "emptyText": "Texto vacío"
+ "emptyText": "Texto vacío",
+ "blurItem": "Desenfoque {{index}}"
},
"emptyState": {
"noVideo": "No hay video cargado",
diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json
new file mode 100644
index 0000000..7eb7f83
--- /dev/null
+++ b/src/i18n/locales/fr/common.json
@@ -0,0 +1,29 @@
+{
+ "actions": {
+ "cancel": "Annuler",
+ "save": "Enregistrer",
+ "delete": "Supprimer",
+ "close": "Fermer",
+ "share": "Partager",
+ "done": "Terminer",
+ "open": "Ouvrir",
+ "upload": "Téléverser",
+ "export": "Exporter",
+ "file": "Fichier",
+ "edit": "Éditer",
+ "view": "Affichage",
+ "window": "Fenêtre",
+ "quit": "Quitter",
+ "stopRecording": "Arrêter l'enregistrement"
+ },
+ "playback": {
+ "play": "Lecture",
+ "pause": "Pause",
+ "fullscreen": "Plein écran",
+ "exitFullscreen": "Quitter le plein écran"
+ },
+ "locale": {
+ "name": "Français",
+ "short": "FR"
+ }
+}
diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json
new file mode 100644
index 0000000..fc32e6b
--- /dev/null
+++ b/src/i18n/locales/fr/dialogs.json
@@ -0,0 +1,70 @@
+{
+ "export": {
+ "complete": "Export terminé",
+ "yourFormatReady": "Votre {{format}} est prêt",
+ "showInFolder": "Afficher dans le dossier",
+ "finalizingVideo": "Finalisation de l'export vidéo...",
+ "compilingGifProgress": "Compilation du GIF... {{progress}}%",
+ "compilingGifWait": "Compilation du GIF... Cela peut prendre un moment",
+ "takeMoment": "Cela peut prendre un moment...",
+ "failed": "Export échoué",
+ "tryAgain": "Veuillez réessayer",
+ "finalizingVideoTitle": "Finalisation de la vidéo",
+ "compilingGif": "Compilation du GIF",
+ "exportingFormat": "Export de {{format}}",
+ "compiling": "Compilation en cours",
+ "renderingFrames": "Rendu des images",
+ "processing": "Traitement en cours...",
+ "finalizing": "Finalisation...",
+ "compilingStatus": "Compilation...",
+ "status": "Statut",
+ "format": "Format",
+ "frames": "Images",
+ "cancelExport": "Annuler l'export",
+ "savedSuccessfully": "{{format}} enregistré avec succès !"
+ },
+ "tutorial": {
+ "triggerLabel": "Comment fonctionne la coupe",
+ "title": "Comment fonctionne la coupe",
+ "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 élément",
+ "covered": "couvert",
+ "explanationAfter": "par un segment de coupe rouge sera coupé lors de l'export.",
+ "visualExample": "Exemple visuel",
+ "removed": "SUPPRIMÉ",
+ "kept": "Conservé",
+ "part1": "Partie 1",
+ "part2": "Partie 2",
+ "part3": "Partie 3",
+ "finalVideo": "Vidéo finale",
+ "step1Title": "1. Ajouter une coupe",
+ "step1DescriptionBefore": "Appuyez sur ",
+ "step1DescriptionAfter": " ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
+ "step2Title": "2. Ajuster",
+ "step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
+ },
+ "unsavedChanges": {
+ "title": "Modifications non enregistrées",
+ "message": "Vous avez des modifications non enregistrées.",
+ "detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
+ "saveAndClose": "Enregistrer et fermer",
+ "discardAndClose": "Ignorer et fermer",
+ "loadProject": "Charger un projet…",
+ "saveProject": "Enregistrer le projet…",
+ "saveProjectAs": "Enregistrer le projet sous…"
+ },
+ "fileDialogs": {
+ "saveGif": "Enregistrer le GIF exporté",
+ "saveVideo": "Enregistrer la vidéo exportée",
+ "selectVideo": "Sélectionner un fichier vidéo",
+ "saveProject": "Enregistrer le projet OpenScreen",
+ "openProject": "Ouvrir un projet OpenScreen",
+ "gifImage": "Image GIF",
+ "mp4Video": "Vidéo MP4",
+ "videoFiles": "Fichiers vidéo",
+ "openscreenProject": "Projet OpenScreen",
+ "allFiles": "Tous les fichiers"
+ }
+}
diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json
new file mode 100644
index 0000000..779bcd7
--- /dev/null
+++ b/src/i18n/locales/fr/editor.json
@@ -0,0 +1,35 @@
+{
+ "errors": {
+ "noVideoLoaded": "Aucune vidéo chargée",
+ "videoNotReady": "Vidéo non prête",
+ "unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
+ "failedToSaveGif": "Échec de l'enregistrement du GIF",
+ "gifExportFailed": "L'export du GIF a échoué",
+ "failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
+ "exportFailed": "L'export a échoué",
+ "exportFailedWithError": "L'export a échoué : {{error}}",
+ "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}}"
+ },
+ "export": {
+ "canceled": "Export annulé",
+ "exportedSuccessfully": "{{format}} exporté avec succès"
+ },
+ "project": {
+ "saveCanceled": "Enregistrement du projet annulé",
+ "failedToSave": "Échec de l'enregistrement du projet",
+ "savedTo": "Projet enregistré dans {{path}}",
+ "failedToLoad": "Échec du chargement du projet",
+ "invalidFormat": "Format de fichier projet invalide",
+ "loadedFrom": "Projet chargé depuis {{path}}"
+ },
+ "recording": {
+ "failedCameraAccess": "Échec de la demande d'accès à la caméra.",
+ "cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.",
+ "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.",
+ "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
+ }
+}
diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json
new file mode 100644
index 0000000..f4bfb27
--- /dev/null
+++ b/src/i18n/locales/fr/launch.json
@@ -0,0 +1,37 @@
+{
+ "tooltips": {
+ "hideHUD": "Masquer le HUD",
+ "closeApp": "Fermer l'application",
+ "restartRecording": "Redémarrer l'enregistrement",
+ "cancelRecording": "Annuler l'enregistrement",
+ "pauseRecording": "Mettre en pause l'enregistrement",
+ "resumeRecording": "Reprendre l'enregistrement",
+ "openVideoFile": "Ouvrir un fichier vidéo",
+ "openProject": "Ouvrir un projet"
+ },
+ "audio": {
+ "enableSystemAudio": "Activer l'audio système",
+ "disableSystemAudio": "Désactiver l'audio système",
+ "enableMicrophone": "Activer le microphone",
+ "disableMicrophone": "Désactiver le microphone",
+ "defaultMicrophone": "Microphone par défaut"
+ },
+ "webcam": {
+ "enableWebcam": "Activer la webcam",
+ "disableWebcam": "Désactiver la webcam",
+ "defaultCamera": "Caméra par défaut",
+ "searching": "Recherche en cours...",
+ "noneFound": "Aucune caméra trouvée",
+ "unavailable": "Caméra non disponible"
+ },
+ "sourceSelector": {
+ "loading": "Chargement des sources...",
+ "screens": "Écrans ({{count}})",
+ "windows": "Fenêtres ({{count}})",
+ "defaultSourceName": "Écran"
+ },
+ "recording": {
+ "selectSource": "Veuillez sélectionner une source à enregistrer"
+ },
+ "language": "Langue"
+}
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
new file mode 100644
index 0000000..ae98a59
--- /dev/null
+++ b/src/i18n/locales/fr/settings.json
@@ -0,0 +1,172 @@
+{
+ "zoom": {
+ "level": "Niveau de zoom",
+ "selectRegion": "Sélectionnez une région de zoom à ajuster",
+ "deleteZoom": "Supprimer le zoom",
+ "focusMode": {
+ "title": "Mode focus",
+ "manual": "Manuel",
+ "auto": "Auto",
+ "autoDescription": "La caméra suit la position du curseur enregistré"
+ }
+ },
+ "speed": {
+ "playbackSpeed": "Vitesse de lecture",
+ "selectRegion": "Sélectionnez une région de vitesse à ajuster",
+ "deleteRegion": "Supprimer la région de vitesse"
+ },
+ "trim": {
+ "deleteRegion": "Supprimer la région de coupe"
+ },
+ "layout": {
+ "title": "Mise en page",
+ "preset": "Préréglage",
+ "selectPreset": "Choisir un préréglage",
+ "pictureInPicture": "Incrustation d'image",
+ "verticalStack": "Empilement vertical",
+ "webcamShape": "Forme de la caméra"
+ },
+ "effects": {
+ "title": "Effets vidéo",
+ "blurBg": "Flou arrière-plan",
+ "motionBlur": "Flou de mouvement",
+ "off": "désactivé",
+ "shadow": "Ombre",
+ "roundness": "Arrondi",
+ "padding": "Marge"
+ },
+ "background": {
+ "title": "Arrière-plan",
+ "image": "Image",
+ "color": "Couleur",
+ "gradient": "Dégradé",
+ "uploadCustom": "Téléverser une image",
+ "gradientLabel": "Dégradé {{index}}"
+ },
+ "crop": {
+ "title": "Recadrage",
+ "cropVideo": "Recadrer la vidéo",
+ "dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
+ "ratio": "Ratio",
+ "free": "Libre",
+ "done": "Terminer",
+ "lockAspectRatio": "Verrouiller le ratio",
+ "unlockAspectRatio": "Déverrouiller le ratio"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "gif": "GIF",
+ "mp4Video": "Vidéo MP4",
+ "mp4Description": "Fichier vidéo haute qualité",
+ "gifAnimation": "Animation GIF",
+ "gifDescription": "Image animée pour le partage"
+ },
+ "exportQuality": {
+ "title": "Qualité d'export",
+ "low": "Faible",
+ "medium": "Moyenne",
+ "high": "Haute"
+ },
+ "gifSettings": {
+ "frameRate": "Fréquence d'images GIF",
+ "size": "Taille du GIF",
+ "loop": "GIF en boucle"
+ },
+ "project": {
+ "save": "Enregistrer le projet",
+ "load": "Charger un projet"
+ },
+ "export": {
+ "videoButton": "Exporter la vidéo",
+ "gifButton": "Exporter le GIF",
+ "chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
+ },
+ "links": {
+ "reportBug": "Signaler un bug",
+ "starOnGithub": "Étoile sur GitHub"
+ },
+ "imageUpload": {
+ "invalidFileType": "Type de fichier invalide",
+ "jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
+ "uploadSuccess": "Image personnalisée téléversée avec succès !",
+ "failedToUpload": "Échec du téléversement de l'image",
+ "errorReading": "Une erreur s'est produite lors de la lecture du fichier."
+ },
+ "annotation": {
+ "title": "Paramètres d'annotation",
+ "active": "Actif",
+ "typeText": "Texte",
+ "typeImage": "Image",
+ "typeArrow": "Flèche",
+ "typeBlur": "Flou",
+ "textContent": "Contenu du texte",
+ "textPlaceholder": "Saisissez votre texte...",
+ "fontStyle": "Style de police",
+ "selectStyle": "Choisir un style",
+ "size": "Taille",
+ "customFonts": "Polices personnalisées",
+ "textColor": "Couleur du texte",
+ "background": "Arrière-plan",
+ "none": "Aucun",
+ "color": "Couleur",
+ "clearBackground": "Supprimer l'arrière-plan",
+ "uploadImage": "Téléverser une image",
+ "supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
+ "arrowDirection": "Direction de la flèche",
+ "strokeWidth": "Épaisseur du trait : {{width}}px",
+ "arrowColor": "Couleur de la flèche",
+ "blurType": "Type de flou",
+ "blurTypeBlur": "Flou",
+ "blurTypeMosaic": "Flou mosaique",
+ "blurColor": "Couleur du flou",
+ "blurColorWhite": "Blanc",
+ "blurColorBlack": "Noir",
+ "blurShape": "Forme du flou",
+ "blurIntensity": "Intensité du flou",
+ "mosaicBlockSize": "Taille des blocs de mosaique",
+ "blurShapeRectangle": "Rectangle",
+ "blurShapeOval": "Ovale",
+ "blurShapeFreehand": "Main levée",
+ "deleteAnnotation": "Supprimer l'annotation",
+ "shortcutsAndTips": "Raccourcis & Astuces",
+ "tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+ "tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
+ "tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
+ "invalidImageType": "Type de fichier invalide",
+ "imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
+ "imageUploadSuccess": "Image téléversée avec succès !",
+ "failedImageUpload": "Échec du téléversement de l'image"
+ },
+ "fontStyles": {
+ "classic": "Classique",
+ "editor": "Éditeur",
+ "strong": "Gras",
+ "typewriter": "Machine à écrire",
+ "deco": "Déco",
+ "simple": "Simple",
+ "modern": "Moderne",
+ "clean": "Épuré"
+ },
+ "customFont": {
+ "dialogTitle": "Ajouter une police Google",
+ "urlLabel": "URL d'import Google Fonts",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
+ "nameLabel": "Nom d'affichage",
+ "namePlaceholder": "Ma police personnalisée",
+ "nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
+ "addButton": "Ajouter la police",
+ "addingButton": "Ajout en cours...",
+ "errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
+ "errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
+ "errorEmptyName": "Veuillez saisir un nom de police",
+ "errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
+ "successMessage": "Police « {{fontName}} » ajoutée avec succès",
+ "failedToAdd": "Échec de l'ajout de la police",
+ "errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
+ "errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
+ },
+ "language": {
+ "title": "Langue"
+ }
+}
diff --git a/src/i18n/locales/fr/shortcuts.json b/src/i18n/locales/fr/shortcuts.json
new file mode 100644
index 0000000..a0ce83f
--- /dev/null
+++ b/src/i18n/locales/fr/shortcuts.json
@@ -0,0 +1,37 @@
+{
+ "title": "Raccourcis clavier",
+ "customize": "Personnaliser",
+ "configurable": "Configurable",
+ "fixed": "Fixe",
+ "pressKey": "Appuyez sur une touche…",
+ "clickToChange": "Cliquez pour modifier",
+ "pressEscToCancel": "Appuyez sur Échap pour annuler",
+ "helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.",
+ "resetToDefaults": "Réinitialiser les valeurs par défaut",
+ "alreadyUsedBy": "Déjà utilisé par {{action}}",
+ "swap": "Échanger",
+ "reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
+ "savedToast": "Raccourcis clavier enregistrés",
+ "resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
+ "actions": {
+ "addZoom": "Ajouter un zoom",
+ "addTrim": "Ajouter une coupe",
+ "addSpeed": "Ajouter une vitesse",
+ "addAnnotation": "Ajouter une annotation",
+ "addBlur": "Ajouter un flou",
+ "addKeyframe": "Ajouter une image-clé",
+ "deleteSelected": "Supprimer la sélection",
+ "playPause": "Lecture / Pause"
+ },
+ "fixedActions": {
+ "undo": "Annuler",
+ "redo": "Rétablir",
+ "cycleAnnotationsForward": "Parcourir les annotations en avant",
+ "cycleAnnotationsBackward": "Parcourir les annotations en arrière",
+ "deleteSelectedAlt": "Supprimer la sélection (alt)",
+ "panTimeline": "Panoramique de la timeline",
+ "zoomTimeline": "Zoom de la timeline",
+ "frameBack": "Image précédente",
+ "frameForward": "Image suivante"
+ }
+}
diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json
new file mode 100644
index 0000000..5985ea6
--- /dev/null
+++ b/src/i18n/locales/fr/timeline.json
@@ -0,0 +1,53 @@
+{
+ "buttons": {
+ "addZoom": "Ajouter un zoom (Z)",
+ "suggestZooms": "Suggérer des zooms depuis le curseur",
+ "addTrim": "Ajouter une coupe (T)",
+ "addAnnotation": "Ajouter une annotation (A)",
+ "addSpeed": "Ajouter une vitesse (S)",
+ "addBlur": "Ajouter un flou (B)"
+ },
+ "hints": {
+ "pressZoom": "Appuyez sur Z pour ajouter un zoom",
+ "pressTrim": "Appuyez sur T pour ajouter une coupe",
+ "pressAnnotation": "Appuyez sur A pour ajouter une annotation",
+ "pressSpeed": "Appuyez sur S pour ajouter une vitesse",
+ "pressBlur": "Appuyez sur B pour ajouter une zone de flou"
+ },
+ "labels": {
+ "pan": "Panoramique",
+ "zoom": "Zoom",
+ "zoomItem": "Zoom {{index}}",
+ "trimItem": "Coupe {{index}}",
+ "speedItem": "Vitesse {{index}}",
+ "annotationItem": "Annotation",
+ "imageItem": "Image",
+ "emptyText": "Texte vide",
+ "blurItem": "Flou {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Aucune vidéo chargée",
+ "dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
+ },
+ "errors": {
+ "cannotPlaceZoom": "Impossible de placer le zoom ici",
+ "zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace 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.",
+ "noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
+ "noUsableTelemetryDescription": "L\u0027enregistrement 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.",
+ "cannotPlaceSpeed": "Impossible de placer la vitesse ici",
+ "speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace 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"
+ }
+}
diff --git a/src/i18n/locales/ko-KR/common.json b/src/i18n/locales/ko-KR/common.json
new file mode 100644
index 0000000..b83cb44
--- /dev/null
+++ b/src/i18n/locales/ko-KR/common.json
@@ -0,0 +1,29 @@
+{
+ "actions": {
+ "cancel": "취소",
+ "save": "저장",
+ "delete": "삭제",
+ "close": "닫기",
+ "share": "공유",
+ "done": "완료",
+ "open": "열기",
+ "upload": "업로드",
+ "export": "내보내기",
+ "file": "파일",
+ "edit": "편집",
+ "view": "보기",
+ "window": "창",
+ "quit": "종료",
+ "stopRecording": "녹화 중지"
+ },
+ "playback": {
+ "play": "재생",
+ "pause": "일시정지",
+ "fullscreen": "전체화면",
+ "exitFullscreen": "전체화면 종료"
+ },
+ "locale": {
+ "name": "한국어",
+ "short": "KO"
+ }
+}
diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json
new file mode 100644
index 0000000..3093cdf
--- /dev/null
+++ b/src/i18n/locales/ko-KR/dialogs.json
@@ -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": "모든 파일"
+ }
+}
diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json
new file mode 100644
index 0000000..4db7d1f
--- /dev/null
+++ b/src/i18n/locales/ko-KR/editor.json
@@ -0,0 +1,41 @@
+{
+ "newRecording": {
+ "title": "녹화로 돌아가기",
+ "description": "현재 세션이 저장되었습니다.",
+ "cancel": "취소",
+ "confirm": "확인"
+ },
+ "errors": {
+ "noVideoLoaded": "불러온 비디오가 없습니다",
+ "videoNotReady": "비디오가 준비되지 않았습니다",
+ "unableToDetermineSourcePath": "소스 비디오 경로를 확인할 수 없습니다",
+ "failedToSaveGif": "GIF 저장에 실패했습니다",
+ "gifExportFailed": "GIF 내보내기에 실패했습니다",
+ "failedToSaveVideo": "비디오 저장에 실패했습니다",
+ "exportFailed": "내보내기에 실패했습니다",
+ "exportFailedWithError": "내보내기 실패: {{error}}",
+ "failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
+ "failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
+ "failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}"
+ },
+ "export": {
+ "canceled": "내보내기가 취소되었습니다",
+ "exportedSuccessfully": "{{format}} 내보내기가 완료되었습니다"
+ },
+ "project": {
+ "saveCanceled": "프로젝트 저장이 취소되었습니다",
+ "failedToSave": "프로젝트 저장에 실패했습니다",
+ "savedTo": "프로젝트가 {{path}}에 저장되었습니다",
+ "failedToLoad": "프로젝트 불러오기에 실패했습니다",
+ "invalidFormat": "유효하지 않은 프로젝트 파일 형식입니다",
+ "loadedFrom": "{{path}}에서 프로젝트를 불러왔습니다"
+ },
+ "recording": {
+ "failedCameraAccess": "카메라 접근 권한 요청에 실패했습니다.",
+ "cameraBlocked": "카메라 접근이 차단되어 있습니다. 시스템 설정에서 권한을 허용해 주세요.",
+ "systemAudioUnavailable": "시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화합니다.",
+ "microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.",
+ "cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
+ "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요."
+ }
+}
diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json
new file mode 100644
index 0000000..1cc695e
--- /dev/null
+++ b/src/i18n/locales/ko-KR/launch.json
@@ -0,0 +1,37 @@
+{
+ "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": "언어"
+}
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
new file mode 100644
index 0000000..cd9f734
--- /dev/null
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -0,0 +1,162 @@
+{
+ "zoom": {
+ "level": "줌 레벨",
+ "selectRegion": "조정할 줌 구간을 선택하세요",
+ "deleteZoom": "줌 삭제",
+ "focusMode": {
+ "title": "포커스 모드",
+ "manual": "수동",
+ "auto": "자동",
+ "autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
+ }
+ },
+ "speed": {
+ "playbackSpeed": "재생 속도",
+ "selectRegion": "조정할 속도 구간을 선택하세요",
+ "deleteRegion": "속도 구간 삭제",
+ "customPlaybackSpeed": "재생 속도 직접 입력",
+ "maxSpeedError": "속도는 16×를 초과할 수 없습니다"
+ },
+ "trim": {
+ "deleteRegion": "트림 구간 삭제"
+ },
+ "layout": {
+ "title": "레이아웃",
+ "preset": "프리셋",
+ "selectPreset": "프리셋 선택",
+ "pictureInPicture": "화면 속 화면",
+ "verticalStack": "세로 배치",
+ "webcamShape": "카메라 모양",
+ "webcamSize": "웹캠 크기"
+ },
+ "effects": {
+ "title": "비디오 효과",
+ "blurBg": "배경 흐림",
+ "motionBlur": "모션 블러",
+ "off": "끄기",
+ "shadow": "그림자",
+ "roundness": "모서리 둥글기",
+ "padding": "여백"
+ },
+ "background": {
+ "title": "배경",
+ "image": "이미지",
+ "color": "색상",
+ "gradient": "그라디언트",
+ "uploadCustom": "직접 업로드",
+ "gradientLabel": "그라디언트 {{index}}"
+ },
+ "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에 Star 남기기"
+ },
+ "imageUpload": {
+ "invalidFileType": "지원하지 않는 파일 형식입니다",
+ "jpgOnly": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.",
+ "uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
+ "failedToUpload": "이미지 업로드에 실패했습니다",
+ "errorReading": "파일을 읽는 중 오류가 발생했습니다."
+ },
+ "annotation": {
+ "title": "주석 설정",
+ "active": "활성",
+ "typeText": "텍스트",
+ "typeImage": "이미지",
+ "typeArrow": "화살표",
+ "textContent": "텍스트 내용",
+ "textPlaceholder": "텍스트를 입력하세요...",
+ "fontStyle": "폰트 스타일",
+ "selectStyle": "스타일 선택",
+ "size": "크기",
+ "customFonts": "커스텀 폰트",
+ "textColor": "텍스트 색상",
+ "background": "배경",
+ "none": "없음",
+ "color": "색상",
+ "clearBackground": "배경 지우기",
+ "uploadImage": "이미지 업로드",
+ "supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
+ "arrowDirection": "화살표 방향",
+ "strokeWidth": "선 두께: {{width}}px",
+ "arrowColor": "화살표 색상",
+ "deleteAnnotation": "주석 삭제",
+ "shortcutsAndTips": "단축키 및 팁",
+ "tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
+ "tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
+ "tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
+ "invalidImageType": "지원하지 않는 파일 형식입니다",
+ "imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
+ "imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
+ "failedImageUpload": "이미지 업로드에 실패했습니다"
+ },
+ "fontStyles": {
+ "classic": "클래식",
+ "editor": "에디터",
+ "strong": "강조",
+ "typewriter": "타자기",
+ "deco": "데코",
+ "simple": "심플",
+ "modern": "모던",
+ "clean": "클린"
+ },
+ "customFont": {
+ "dialogTitle": "Google 폰트 추가",
+ "urlLabel": "Google Fonts 가져오기 URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
+ "nameLabel": "표시 이름",
+ "namePlaceholder": "내 커스텀 폰트",
+ "nameHelp": "폰트 선택기에서 표시될 이름입니다",
+ "addButton": "폰트 추가",
+ "addingButton": "추가 중...",
+ "errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
+ "errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
+ "errorEmptyName": "폰트 이름을 입력해 주세요",
+ "errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
+ "successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
+ "failedToAdd": "폰트 추가에 실패했습니다",
+ "errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
+ "errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
+ },
+ "language": {
+ "title": "언어"
+ }
+}
diff --git a/src/i18n/locales/ko-KR/shortcuts.json b/src/i18n/locales/ko-KR/shortcuts.json
new file mode 100644
index 0000000..1760d8f
--- /dev/null
+++ b/src/i18n/locales/ko-KR/shortcuts.json
@@ -0,0 +1,36 @@
+{
+ "title": "키보드 단축키",
+ "customize": "사용자 지정",
+ "configurable": "변경 가능",
+ "fixed": "고정",
+ "pressKey": "키를 누르세요...",
+ "clickToChange": "클릭해서 변경",
+ "pressEscToCancel": "Esc를 눌러 취소",
+ "helpText": "단축키를 클릭한 후 새 키 조합을 누르세요. 취소하려면 Esc를 누르세요.",
+ "resetToDefaults": "기본값으로 초기화",
+ "alreadyUsedBy": "이미 {{action}}에서 사용 중입니다",
+ "swap": "교체",
+ "reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
+ "savedToast": "키보드 단축키가 저장되었습니다",
+ "resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
+ "actions": {
+ "addZoom": "줌 추가",
+ "addTrim": "트림 추가",
+ "addSpeed": "속도 추가",
+ "addAnnotation": "주석 추가",
+ "addKeyframe": "키프레임 추가",
+ "deleteSelected": "선택 항목 삭제",
+ "playPause": "재생 / 일시정지"
+ },
+ "fixedActions": {
+ "undo": "실행 취소",
+ "redo": "다시 실행",
+ "cycleAnnotationsForward": "주석 앞으로 순환",
+ "cycleAnnotationsBackward": "주석 뒤로 순환",
+ "deleteSelectedAlt": "선택 항목 삭제 (대체)",
+ "panTimeline": "타임라인 이동",
+ "zoomTimeline": "타임라인 확대/축소",
+ "frameBack": "이전 프레임",
+ "frameForward": "다음 프레임"
+ }
+}
diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json
new file mode 100644
index 0000000..167c26f
--- /dev/null
+++ b/src/i18n/locales/ko-KR/timeline.json
@@ -0,0 +1,50 @@
+{
+ "buttons": {
+ "addZoom": "줌 추가 (Z)",
+ "suggestZooms": "커서 기반 줌 제안",
+ "addTrim": "트림 추가 (T)",
+ "addAnnotation": "주석 추가 (A)",
+ "addSpeed": "속도 추가 (S)"
+ },
+ "hints": {
+ "pressZoom": "Z를 눌러 줌 추가",
+ "pressTrim": "T를 눌러 트림 추가",
+ "pressAnnotation": "A를 눌러 주석 추가",
+ "pressSpeed": "S를 눌러 속도 추가"
+ },
+ "labels": {
+ "pan": "이동",
+ "zoom": "줌",
+ "zoomItem": "줌 {{index}}",
+ "trimItem": "트림 {{index}}",
+ "speedItem": "속도 {{index}}",
+ "annotationItem": "주석",
+ "imageItem": "이미지",
+ "emptyText": "빈 텍스트"
+ },
+ "emptyState": {
+ "noVideo": "불러온 비디오 없음",
+ "dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
+ },
+ "errors": {
+ "cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
+ "zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
+ "zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
+ "noCursorTelemetry": "커서 데이터가 없습니다",
+ "noCursorTelemetryDescription": "커서 기반 제안을 생성하려면 먼저 화면을 녹화해 주세요.",
+ "noUsableTelemetry": "사용 가능한 커서 데이터가 없습니다",
+ "noUsableTelemetryDescription": "녹화에 충분한 커서 이동 데이터가 포함되어 있지 않습니다.",
+ "noDwellMoments": "명확한 커서 정지 구간을 찾을 수 없습니다",
+ "noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
+ "noAutoZoomSlots": "자동 줌 슬롯이 없습니다",
+ "noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
+ "cannotPlaceTrim": "이 위치에 트림을 추가할 수 없습니다",
+ "trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
+ "cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
+ "speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다."
+ },
+ "success": {
+ "addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
+ "addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
+ }
+}
diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json
new file mode 100644
index 0000000..3ec132c
--- /dev/null
+++ b/src/i18n/locales/tr/common.json
@@ -0,0 +1,29 @@
+{
+ "actions": {
+ "cancel": "İptal",
+ "save": "Kaydet",
+ "delete": "Sil",
+ "close": "Kapat",
+ "share": "Paylaş",
+ "done": "Tamam",
+ "open": "Aç",
+ "upload": "Yükle",
+ "export": "Dışa Aktar",
+ "file": "Dosya",
+ "edit": "Düzenle",
+ "view": "Görünüm",
+ "window": "Pencere",
+ "quit": "Çıkış",
+ "stopRecording": "Kaydı Durdur"
+ },
+ "playback": {
+ "play": "Oynat",
+ "pause": "Duraklat",
+ "fullscreen": "Tam Ekran",
+ "exitFullscreen": "Tam Ekrandan Çık"
+ },
+ "locale": {
+ "name": "Türkçe",
+ "short": "TR"
+ }
+}
diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json
new file mode 100644
index 0000000..9fab50d
--- /dev/null
+++ b/src/i18n/locales/tr/dialogs.json
@@ -0,0 +1,70 @@
+{
+ "export": {
+ "complete": "Dışa Aktarım Tamamlandı",
+ "yourFormatReady": "{{format}} dosyanız hazır",
+ "showInFolder": "Klasörde Göster",
+ "finalizingVideo": "Video dışa aktarımı sonlandırılıyor...",
+ "compilingGifProgress": "GIF derleniyor... %{{progress}}",
+ "compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir",
+ "takeMoment": "Bu biraz zaman alabilir...",
+ "failed": "Dışa Aktarım Başarısız",
+ "tryAgain": "Lütfen tekrar deneyin",
+ "finalizingVideoTitle": "Video Sonlandırılıyor",
+ "compilingGif": "GIF Derleniyor",
+ "exportingFormat": "{{format}} Dışa Aktarılıyor",
+ "compiling": "Derleniyor",
+ "renderingFrames": "Kareler İşleniyor",
+ "processing": "İşleniyor...",
+ "finalizing": "Sonlandırılıyor...",
+ "compilingStatus": "Derleniyor...",
+ "status": "Durum",
+ "format": "Biçim",
+ "frames": "Kareler",
+ "cancelExport": "Dışa Aktarımı İptal Et",
+ "savedSuccessfully": "{{format}} başarıyla kaydedildi!"
+ },
+ "tutorial": {
+ "triggerLabel": "Kırpma nasıl çalışır",
+ "title": "Kırpma Nasıl Çalışır",
+ "description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
+ "explanationBefore": "Kırpma aracı, istediğiniz bölümleri",
+ "remove": "kaldırmak",
+ "explanationMiddle": " için kullanılır; kırmızı kırpma bölgesiyle",
+ "covered": "kaplanan",
+ "explanationAfter": "her şey dışa aktarımda kesilecektir.",
+ "visualExample": "Görsel Örnek",
+ "removed": "KALDIRILDI",
+ "kept": "Korundu",
+ "part1": "Bölüm 1",
+ "part2": "Bölüm 2",
+ "part3": "Bölüm 3",
+ "finalVideo": "Son Video",
+ "step1Title": "1. Kırpma Ekle",
+ "step1DescriptionBefore": "Kaldırılacak bölümü işaretlemek için ",
+ "step1DescriptionAfter": " tuşuna basın veya makas simgesine tıklayın.",
+ "step2Title": "2. Ayarla",
+ "step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
+ },
+ "unsavedChanges": {
+ "title": "Kaydedilmemiş Değişiklikler",
+ "message": "Kaydedilmemiş değişiklikleriniz var.",
+ "detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
+ "saveAndClose": "Kaydet ve Kapat",
+ "discardAndClose": "Kaydetmeden Kapat",
+ "loadProject": "Proje Yükle…",
+ "saveProject": "Proje Kaydet…",
+ "saveProjectAs": "Farklı Kaydet…"
+ },
+ "fileDialogs": {
+ "saveGif": "Dışa Aktarılan GIF'i Kaydet",
+ "saveVideo": "Dışa Aktarılan Videoyu Kaydet",
+ "selectVideo": "Video Dosyası Seç",
+ "saveProject": "OpenScreen Projesini Kaydet",
+ "openProject": "OpenScreen Projesini Aç",
+ "gifImage": "GIF Görüntüsü",
+ "mp4Video": "MP4 Video",
+ "videoFiles": "Video Dosyaları",
+ "openscreenProject": "OpenScreen Projesi",
+ "allFiles": "Tüm Dosyalar"
+ }
+}
diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json
new file mode 100644
index 0000000..dfa4cb1
--- /dev/null
+++ b/src/i18n/locales/tr/editor.json
@@ -0,0 +1,35 @@
+{
+ "errors": {
+ "noVideoLoaded": "Video yüklenmedi",
+ "videoNotReady": "Video hazır değil",
+ "unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
+ "failedToSaveGif": "GIF kaydedilemedi",
+ "gifExportFailed": "GIF dışa aktarımı başarısız oldu",
+ "failedToSaveVideo": "Video kaydedilemedi",
+ "exportFailed": "Dışa aktarım başarısız oldu",
+ "exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
+ "failedToSaveExport": "Dışa aktarım kaydedilemedi",
+ "failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
+ "failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
+ },
+ "export": {
+ "canceled": "Dışa aktarım iptal edildi",
+ "exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı"
+ },
+ "project": {
+ "saveCanceled": "Proje kaydetme iptal edildi",
+ "failedToSave": "Proje kaydedilemedi",
+ "savedTo": "Proje şuraya kaydedildi: {{path}}",
+ "failedToLoad": "Proje yüklenemedi",
+ "invalidFormat": "Geçersiz proje dosyası biçimi",
+ "loadedFrom": "Proje şuradan yüklendi: {{path}}"
+ },
+ "recording": {
+ "failedCameraAccess": "Kamera erişimi istenemedi.",
+ "cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.",
+ "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."
+ }
+}
diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json
new file mode 100644
index 0000000..f48d99d
--- /dev/null
+++ b/src/i18n/locales/tr/launch.json
@@ -0,0 +1,48 @@
+{
+ "tooltips": {
+ "hideHUD": "Kontrol panelini gizle",
+ "closeApp": "Uygulamayı kapat",
+ "restartRecording": "Kaydı yeniden başlat",
+ "cancelRecording": "Kaydı iptal et",
+ "pauseRecording": "Kaydı duraklat",
+ "resumeRecording": "Kayda devam et",
+ "openVideoFile": "Video dosyası aç",
+ "openProject": "Proje aç"
+ },
+ "audio": {
+ "enableSystemAudio": "Sistem sesini etkinleştir",
+ "disableSystemAudio": "Sistem sesini devre dışı bırak",
+ "enableMicrophone": "Mikrofonu etkinleştir",
+ "disableMicrophone": "Mikrofonu devre dışı bırak",
+ "defaultMicrophone": "Varsayılan Mikrofon",
+ "enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)",
+ "disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak",
+ "noiseReduction": "Gürültü azaltma",
+ "clickToCycle": "Seviye değiştirmek için tıklayın",
+ "nrLevel": {
+ "light": "Hafif",
+ "moderate": "Orta",
+ "aggressive": "Güçlü"
+ },
+ "noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?",
+ "enableNoiseReductionShort": "Etkinleştir"
+ },
+ "webcam": {
+ "enableWebcam": "Kamerayı etkinleştir",
+ "disableWebcam": "Kamerayı devre dışı bırak",
+ "defaultCamera": "Varsayılan Kamera",
+ "searching": "Aranıyor...",
+ "noneFound": "Kamera bulunamadı",
+ "unavailable": "Kamera kullanılamıyor"
+ },
+ "sourceSelector": {
+ "loading": "Kaynaklar yükleniyor...",
+ "screens": "Ekranlar ({{count}})",
+ "windows": "Pencereler ({{count}})",
+ "defaultSourceName": "Ekran"
+ },
+ "recording": {
+ "selectSource": "Lütfen kayıt için bir kaynak seçin"
+ },
+ "language": "Dil"
+}
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
new file mode 100644
index 0000000..936f75c
--- /dev/null
+++ b/src/i18n/locales/tr/settings.json
@@ -0,0 +1,176 @@
+{
+ "zoom": {
+ "level": "Yakınlaştırma Seviyesi",
+ "selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
+ "deleteZoom": "Yakınlaştırmayı Sil",
+ "focusMode": {
+ "title": "Odak Modu",
+ "manual": "Manuel",
+ "auto": "Otomatik",
+ "autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
+ }
+ },
+ "speed": {
+ "playbackSpeed": "Oynatma Hızı",
+ "selectRegion": "Ayarlamak için bir hız bölgesi seçin",
+ "deleteRegion": "Hız Bölgesini Sil"
+ },
+ "trim": {
+ "deleteRegion": "Kırpma Bölgesini Sil"
+ },
+ "layout": {
+ "title": "Düzen",
+ "preset": "Ön Ayar",
+ "selectPreset": "Ön ayar seçin",
+ "pictureInPicture": "Resim İçinde Resim",
+ "verticalStack": "Dikey Yığın",
+ "webcamShape": "Kamera Şekli"
+ },
+ "effects": {
+ "title": "Video Efektleri",
+ "blurBg": "Arka Planı Bulanıklaştır",
+ "motionBlur": "Hareket Bulanıklığı",
+ "off": "kapalı",
+ "shadow": "Gölge",
+ "roundness": "Yuvarlaklık",
+ "padding": "Dolgu"
+ },
+ "background": {
+ "title": "Arka Plan",
+ "image": "Görüntü",
+ "color": "Renk",
+ "gradient": "Gradyan",
+ "uploadCustom": "Özel Yükle",
+ "gradientLabel": "Gradyan {{index}}"
+ },
+ "crop": {
+ "title": "Kırpma",
+ "cropVideo": "Videoyu Kırp",
+ "dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
+ "ratio": "Oran",
+ "free": "Serbest",
+ "done": "Tamam",
+ "lockAspectRatio": "En boy oranını kilitle",
+ "unlockAspectRatio": "En boy oranının kilidini aç"
+ },
+ "exportFormat": {
+ "mp4": "MP4",
+ "gif": "GIF",
+ "mp4Video": "MP4 Video",
+ "mp4Description": "Yüksek kaliteli video dosyası",
+ "gifAnimation": "GIF Animasyon",
+ "gifDescription": "Paylaşım için hareketli görüntü"
+ },
+ "exportQuality": {
+ "title": "Dışa Aktarım Kalitesi",
+ "low": "Düşük",
+ "medium": "Orta",
+ "high": "Yüksek"
+ },
+ "gifSettings": {
+ "frameRate": "GIF Kare Hızı",
+ "size": "GIF Boyutu",
+ "loop": "GIF Döngüsü"
+ },
+ "project": {
+ "save": "Projeyi Kaydet",
+ "load": "Proje Yükle"
+ },
+ "export": {
+ "videoButton": "Videoyu Dışa Aktar",
+ "gifButton": "GIF Olarak Dışa Aktar",
+ "chooseSaveLocation": "Kayıt Konumu Seç"
+ },
+ "links": {
+ "reportBug": "Hata Bildir",
+ "starOnGithub": "GitHub'da Yıldızla"
+ },
+ "imageUpload": {
+ "invalidFileType": "Geçersiz dosya türü",
+ "jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.",
+ "uploadSuccess": "Özel görüntü başarıyla yüklendi!",
+ "failedToUpload": "Görüntü yüklenemedi",
+ "errorReading": "Dosya okunurken bir hata oluştu."
+ },
+ "annotation": {
+ "title": "Açıklama Ayarları",
+ "active": "Aktif",
+ "typeText": "Metin",
+ "typeImage": "Görüntü",
+ "typeArrow": "Ok",
+ "typeBlur": "Bulanık",
+ "textContent": "Metin İçeriği",
+ "textPlaceholder": "Metninizi girin...",
+ "fontStyle": "Yazı Tipi Stili",
+ "selectStyle": "Stil seçin",
+ "size": "Boyut",
+ "customFonts": "Özel Yazı Tipleri",
+ "textColor": "Metin Rengi",
+ "background": "Arka Plan",
+ "none": "Yok",
+ "color": "Renk",
+ "clearBackground": "Arka Planı Temizle",
+ "uploadImage": "Görüntü Yükle",
+ "supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
+ "arrowDirection": "Ok Yönü",
+ "strokeWidth": "Çizgi Kalınlığı: {{width}}px",
+ "arrowColor": "Ok Rengi",
+ "blurShape": "Bulanık Şekli",
+ "blurIntensity": "Bulanıklık Yoğunluğu",
+ "blurShapeRectangle": "Dikdörtgen",
+ "blurShapeOval": "Oval",
+ "blurShapeFreehand": "Serbest",
+ "deleteAnnotation": "Açıklamayı Sil",
+ "shortcutsAndTips": "Kısayollar ve İpuçları",
+ "tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+ "tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
+ "tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
+ "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"
+ },
+ "fontStyles": {
+ "classic": "Klasik",
+ "editor": "Editör",
+ "strong": "Kalın",
+ "typewriter": "Daktilo",
+ "deco": "Dekoratif",
+ "simple": "Sade",
+ "modern": "Modern",
+ "clean": "Temiz"
+ },
+ "customFont": {
+ "dialogTitle": "Google Yazı Tipi Ekle",
+ "urlLabel": "Google Fonts İçe Aktarım URL'si",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
+ "nameLabel": "Görünen Ad",
+ "namePlaceholder": "Özel Yazı Tipim",
+ "nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
+ "addButton": "Yazı Tipi Ekle",
+ "addingButton": "Ekleniyor...",
+ "errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
+ "errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
+ "errorEmptyName": "Lütfen bir yazı tipi adı girin",
+ "errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
+ "successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
+ "failedToAdd": "Yazı tipi eklenemedi",
+ "errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
+ "errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin."
+ },
+ "language": {
+ "title": "Dil"
+ },
+ "audio": {
+ "title": "Ses",
+ "noiseReduction": "Gürültü Azaltma",
+ "level": "Seviye",
+ "nrLevel": {
+ "light": "Hafif",
+ "moderate": "Orta",
+ "aggressive": "Güçlü"
+ },
+ "nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir."
+ }
+}
diff --git a/src/i18n/locales/tr/shortcuts.json b/src/i18n/locales/tr/shortcuts.json
new file mode 100644
index 0000000..69b28ed
--- /dev/null
+++ b/src/i18n/locales/tr/shortcuts.json
@@ -0,0 +1,37 @@
+{
+ "title": "Klavye Kısayolları",
+ "customize": "Özelleştir",
+ "configurable": "Yapılandırılabilir",
+ "fixed": "Sabit",
+ "pressKey": "Bir tuşa basın…",
+ "clickToChange": "Değiştirmek için tıklayın",
+ "pressEscToCancel": "İptal etmek için Esc tuşuna basın",
+ "helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.",
+ "resetToDefaults": "Varsayılanlara sıfırla",
+ "alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor",
+ "swap": "Değiştir",
+ "reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
+ "savedToast": "Klavye kısayolları kaydedildi",
+ "resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
+ "actions": {
+ "addZoom": "Yakınlaştırma Ekle",
+ "addTrim": "Kırpma Ekle",
+ "addSpeed": "Hız Ekle",
+ "addAnnotation": "Açıklama Ekle",
+ "addBlur": "Bulanik Ekle",
+ "addKeyframe": "Anahtar Kare Ekle",
+ "deleteSelected": "Seçileni Sil",
+ "playPause": "Oynat / Duraklat"
+ },
+ "fixedActions": {
+ "undo": "Geri Al",
+ "redo": "Yinele",
+ "cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç",
+ "cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç",
+ "deleteSelectedAlt": "Seçileni Sil (alternatif)",
+ "panTimeline": "Zaman Çizelgesini Kaydır",
+ "zoomTimeline": "Zaman Çizelgesini Yakınlaştır",
+ "frameBack": "Önceki Kare",
+ "frameForward": "Sonraki Kare"
+ }
+}
diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json
new file mode 100644
index 0000000..294640b
--- /dev/null
+++ b/src/i18n/locales/tr/timeline.json
@@ -0,0 +1,53 @@
+{
+ "buttons": {
+ "addZoom": "Yakınlaştırma Ekle (Z)",
+ "suggestZooms": "İmleçten Yakınlaştırma Öner",
+ "addTrim": "Kırpma Ekle (T)",
+ "addAnnotation": "Açıklama Ekle (A)",
+ "addSpeed": "Hız Ekle (S)",
+ "addBlur": "Bulanık ekle (B)"
+ },
+ "hints": {
+ "pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
+ "pressTrim": "Kırpma eklemek için T tuşuna basın",
+ "pressAnnotation": "Açıklama eklemek için A tuşuna basın",
+ "pressSpeed": "Hız eklemek için S tuşuna basın",
+ "pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
+ },
+ "labels": {
+ "pan": "Kaydır",
+ "zoom": "Yakınlaştır",
+ "zoomItem": "Yakınlaştırma {{index}}",
+ "trimItem": "Kırpma {{index}}",
+ "speedItem": "Hız {{index}}",
+ "annotationItem": "Açıklama",
+ "imageItem": "Görüntü",
+ "emptyText": "Boş metin",
+ "blurItem": "Bulanık {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "Video Yüklenmedi",
+ "dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
+ },
+ "errors": {
+ "cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
+ "zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
+ "zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
+ "noCursorTelemetry": "İmleç telemetrisi mevcut değil",
+ "noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
+ "noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
+ "noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
+ "noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
+ "noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
+ "noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
+ "noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
+ "cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
+ "trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
+ "cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
+ "speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok."
+ },
+ "success": {
+ "addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
+ "addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
+ }
+}
diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json
index 9a3cc1c..d8bff69 100644
--- a/src/i18n/locales/zh-CN/common.json
+++ b/src/i18n/locales/zh-CN/common.json
@@ -23,7 +23,7 @@
"exitFullscreen": "退出全屏"
},
"locale": {
- "name": "中文",
- "short": "中文"
+ "name": "简体中文",
+ "short": "简中"
}
}
diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json
index 3f181bc..0385b36 100644
--- a/src/i18n/locales/zh-CN/dialogs.json
+++ b/src/i18n/locales/zh-CN/dialogs.json
@@ -27,10 +27,11 @@
"triggerLabel": "剪辑功能说明",
"title": "剪辑功能说明",
"description": "了解如何剪掉视频中不需要的部分。",
- "explanation": "剪辑工具通过定义您要",
- "explanationRemove": "移除",
- "explanationCovered": "覆盖",
- "explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
+ "explanationBefore": "剪辑工具通过定义您要",
+ "remove": "移除",
+ "explanationMiddle": "——任何被",
+ "covered": "覆盖",
+ "explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
@@ -39,7 +40,8 @@
"part3": "第 3 部分",
"finalVideo": "最终视频",
"step1Title": "1. 添加剪辑",
- "step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
+ "step1DescriptionBefore": "按",
+ "step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。",
"step2Title": "2. 调整",
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
},
diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json
index 5d27bef..44abab9 100644
--- a/src/i18n/locales/zh-CN/editor.json
+++ b/src/i18n/locales/zh-CN/editor.json
@@ -1,4 +1,10 @@
{
+ "newRecording": {
+ "title": "返回录屏",
+ "description": "当前会话已保存。",
+ "cancel": "取消",
+ "confirm": "确认"
+ },
"errors": {
"noVideoLoaded": "未加载视频",
"videoNotReady": "视频未就绪",
diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json
index 84fdcef..a5c2a9d 100644
--- a/src/i18n/locales/zh-CN/launch.json
+++ b/src/i18n/locales/zh-CN/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "隐藏控制面板",
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
+ "cancelRecording": "取消录制",
+ "pauseRecording": "暂停录制",
+ "resumeRecording": "继续录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
},
@@ -30,5 +33,11 @@
"recording": {
"selectSource": "请选择要录制的源"
},
- "language": "语言"
+ "language": "语言",
+ "systemLanguagePrompt": {
+ "title": "使用系统语言吗?",
+ "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?",
+ "switch": "切换到{{language}}",
+ "keepDefault": "保持当前语言"
+ }
}
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index ab0d41b..10a8ecd 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -8,12 +8,21 @@
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
+ },
+ "speed": {
+ "title": "缩放速度",
+ "instant": "即时",
+ "fast": "快速",
+ "smooth": "平滑",
+ "lazy": "缓慢"
}
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
- "deleteRegion": "删除速度区域"
+ "deleteRegion": "删除速度区域",
+ "customPlaybackSpeed": "自定义播放速度",
+ "maxSpeedError": "速度不能超过 16×"
},
"trim": {
"deleteRegion": "删除剪辑区域"
@@ -24,7 +33,9 @@
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
- "webcamShape": "摄像头形状"
+ "dualFrame": "双画框",
+ "webcamShape": "摄像头形状",
+ "webcamSize": "摄像头大小"
},
"effects": {
"title": "视频效果",
@@ -98,6 +109,7 @@
"typeText": "文本",
"typeImage": "图片",
"typeArrow": "箭头",
+ "typeBlur": "模糊",
"textContent": "文本内容",
"textPlaceholder": "输入您的文本...",
"fontStyle": "字体样式",
@@ -114,6 +126,11 @@
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
+ "blurShape": "模糊形状",
+ "blurIntensity": "模糊强度",
+ "blurShapeRectangle": "矩形",
+ "blurShapeOval": "椭圆",
+ "blurShapeFreehand": "自由手绘",
"deleteAnnotation": "删除标注",
"shortcutsAndTips": "快捷键与提示",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json
index 5099b27..3328239 100644
--- a/src/i18n/locales/zh-CN/shortcuts.json
+++ b/src/i18n/locales/zh-CN/shortcuts.json
@@ -18,6 +18,7 @@
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
+ "addBlur": "添加模糊",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除所选",
"playPause": "播放 / 暂停"
@@ -29,6 +30,8 @@
"cycleAnnotationsBackward": "向后切换标注",
"deleteSelectedAlt": "删除所选(替代)",
"panTimeline": "平移时间轴",
- "zoomTimeline": "缩放时间轴"
+ "zoomTimeline": "缩放时间轴",
+ "frameBack": "上一帧",
+ "frameForward": "下一帧"
}
}
diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json
index d712751..7841dcb 100644
--- a/src/i18n/locales/zh-CN/timeline.json
+++ b/src/i18n/locales/zh-CN/timeline.json
@@ -4,13 +4,15 @@
"suggestZooms": "根据光标建议缩放",
"addTrim": "添加剪辑 (T)",
"addAnnotation": "添加标注 (A)",
- "addSpeed": "添加速度 (S)"
+ "addSpeed": "添加速度 (S)",
+ "addBlur": "添加模糊 (B)"
},
"hints": {
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
- "pressSpeed": "按 S 添加速度"
+ "pressSpeed": "按 S 添加速度",
+ "pressBlur": "按 B 添加模糊区域"
},
"labels": {
"pan": "平移",
@@ -20,7 +22,8 @@
"speedItem": "速度 {{index}}",
"annotationItem": "标注",
"imageItem": "图片",
- "emptyText": "空文本"
+ "emptyText": "空文本",
+ "blurItem": "模糊 {{index}}"
},
"emptyState": {
"noVideo": "未加载视频",
diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json
new file mode 100644
index 0000000..971d9ab
--- /dev/null
+++ b/src/i18n/locales/zh-TW/common.json
@@ -0,0 +1,29 @@
+{
+ "actions": {
+ "cancel": "取消",
+ "save": "儲存",
+ "delete": "刪除",
+ "close": "關閉",
+ "share": "分享",
+ "done": "完成",
+ "open": "開啟",
+ "upload": "上傳",
+ "export": "匯出",
+ "file": "檔案",
+ "edit": "編輯",
+ "view": "檢視",
+ "window": "視窗",
+ "quit": "退出",
+ "stopRecording": "停止錄製"
+ },
+ "playback": {
+ "play": "播放",
+ "pause": "暫停",
+ "fullscreen": "全螢幕",
+ "exitFullscreen": "退出全螢幕"
+ },
+ "locale": {
+ "name": "繁體中文",
+ "short": "繁中"
+ }
+}
diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json
new file mode 100644
index 0000000..b582aba
--- /dev/null
+++ b/src/i18n/locales/zh-TW/dialogs.json
@@ -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": "所有檔案"
+ }
+}
diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json
new file mode 100644
index 0000000..73a3f4e
--- /dev/null
+++ b/src/i18n/locales/zh-TW/editor.json
@@ -0,0 +1,41 @@
+{
+ "newRecording": {
+ "title": "返回錄影",
+ "description": "目前工作階段已儲存。",
+ "cancel": "取消",
+ "confirm": "確認"
+ },
+ "errors": {
+ "noVideoLoaded": "未載入影片",
+ "videoNotReady": "影片未就緒",
+ "unableToDetermineSourcePath": "無法確定來源影片路徑",
+ "failedToSaveGif": "儲存 GIF 失敗",
+ "gifExportFailed": "GIF 匯出失敗",
+ "failedToSaveVideo": "儲存影片失敗",
+ "exportFailed": "匯出失敗",
+ "exportFailedWithError": "匯出失敗:{{error}}",
+ "failedToSaveExport": "儲存匯出檔案失敗",
+ "failedToSaveExportedVideo": "儲存匯出的影片失敗",
+ "failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
+ },
+ "export": {
+ "canceled": "匯出已取消",
+ "exportedSuccessfully": "{{format}} 匯出成功"
+ },
+ "project": {
+ "saveCanceled": "專案儲存已取消",
+ "failedToSave": "儲存專案失敗",
+ "savedTo": "專案已儲存至 {{path}}",
+ "failedToLoad": "載入專案失敗",
+ "invalidFormat": "無效的專案檔案格式",
+ "loadedFrom": "專案已從 {{path}} 載入"
+ },
+ "recording": {
+ "failedCameraAccess": "請求攝影機權限失敗。",
+ "cameraBlocked": "攝影機權限已被封鎖。請在系統設定中啟用以使用攝影機。",
+ "systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。",
+ "microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。",
+ "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
+ "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。"
+ }
+}
diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json
new file mode 100644
index 0000000..e8b723f
--- /dev/null
+++ b/src/i18n/locales/zh-TW/launch.json
@@ -0,0 +1,37 @@
+{
+ "tooltips": {
+ "hideHUD": "隱藏控制面板",
+ "closeApp": "關閉應用程式",
+ "restartRecording": "重新開始錄製",
+ "cancelRecording": "取消錄製",
+ "pauseRecording": "暫停錄製",
+ "resumeRecording": "繼續錄製",
+ "openVideoFile": "開啟影片檔案",
+ "openProject": "開啟專案"
+ },
+ "audio": {
+ "enableSystemAudio": "啟用系統音訊",
+ "disableSystemAudio": "停用系統音訊",
+ "enableMicrophone": "啟用麥克風",
+ "disableMicrophone": "停用麥克風",
+ "defaultMicrophone": "預設麥克風"
+ },
+ "webcam": {
+ "enableWebcam": "啟用攝影機",
+ "disableWebcam": "停用攝影機",
+ "defaultCamera": "預設攝影機",
+ "searching": "正在搜尋...",
+ "noneFound": "未找到攝影機",
+ "unavailable": "攝影機不可用"
+ },
+ "sourceSelector": {
+ "loading": "正在載入來源...",
+ "screens": "螢幕 ({{count}})",
+ "windows": "視窗 ({{count}})",
+ "defaultSourceName": "螢幕"
+ },
+ "recording": {
+ "selectSource": "請選擇要錄製的來源"
+ },
+ "language": "語言"
+}
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
new file mode 100644
index 0000000..6344a99
--- /dev/null
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -0,0 +1,176 @@
+{
+ "zoom": {
+ "level": "縮放級別",
+ "selectRegion": "選擇要調整的縮放區域",
+ "deleteZoom": "刪除縮放",
+ "focusMode": {
+ "title": "對焦模式",
+ "manual": "手動",
+ "auto": "自動",
+ "autoDescription": "攝影機跟隨錄製時的游標位置"
+ },
+ "speed": {
+ "title": "縮放速度",
+ "instant": "即時",
+ "fast": "快速",
+ "smooth": "平滑",
+ "lazy": "緩慢"
+ }
+ },
+ "speed": {
+ "playbackSpeed": "播放速度",
+ "selectRegion": "選擇要調整的速度區域",
+ "deleteRegion": "刪除速度區域",
+ "customPlaybackSpeed": "自訂播放速度",
+ "maxSpeedError": "速度不能超過 16×"
+ },
+ "trim": {
+ "deleteRegion": "刪除剪輯區域"
+ },
+ "layout": {
+ "title": "版面配置",
+ "preset": "預設",
+ "selectPreset": "選擇預設",
+ "pictureInPicture": "子母畫面",
+ "verticalStack": "垂直堆疊",
+ "dualFrame": "雙畫框",
+ "webcamShape": "攝影機形狀",
+ "webcamSize": "攝影機大小"
+ },
+ "effects": {
+ "title": "影片效果",
+ "blurBg": "模糊背景",
+ "motionBlur": "動態模糊",
+ "off": "關",
+ "shadow": "陰影",
+ "roundness": "圓角",
+ "padding": "內邊距"
+ },
+ "background": {
+ "title": "背景",
+ "image": "圖片",
+ "color": "顏色",
+ "gradient": "漸層",
+ "uploadCustom": "上傳自訂",
+ "gradientLabel": "漸層 {{index}}"
+ },
+ "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": "顏色",
+ "clearBackground": "清除背景",
+ "uploadImage": "上傳圖片",
+ "supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
+ "arrowDirection": "箭頭方向",
+ "strokeWidth": "描邊寬度:{{width}}px",
+ "arrowColor": "箭頭顏色",
+ "blurShape": "模糊形狀",
+ "blurIntensity": "模糊強度",
+ "blurShapeRectangle": "矩形",
+ "blurShapeOval": "橢圓",
+ "blurShapeFreehand": "自由手繪",
+ "deleteAnnotation": "刪除標註",
+ "shortcutsAndTips": "快捷鍵與提示",
+ "tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
+ "tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
+ "tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
+ "invalidImageType": "無效的檔案類型",
+ "imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
+ "imageUploadSuccess": "圖片上傳成功!",
+ "failedImageUpload": "上傳圖片失敗"
+ },
+ "fontStyles": {
+ "classic": "經典",
+ "editor": "編輯器",
+ "strong": "粗體",
+ "typewriter": "打字機",
+ "deco": "裝飾",
+ "simple": "簡約",
+ "modern": "現代",
+ "clean": "簡潔"
+ },
+ "customFont": {
+ "dialogTitle": "新增 Google 字體",
+ "urlLabel": "Google Fonts 匯入 URL",
+ "urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
+ "urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
+ "nameLabel": "顯示名稱",
+ "namePlaceholder": "我的自訂字體",
+ "nameHelp": "這是字體在字體選擇器中顯示的名稱",
+ "addButton": "新增字體",
+ "addingButton": "新增中...",
+ "errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
+ "errorInvalidUrl": "請輸入有效的 Google Fonts URL",
+ "errorEmptyName": "請輸入字體名稱",
+ "errorExtractFailed": "無法從 URL 中提取字體系列",
+ "successMessage": "字體 \"{{fontName}}\" 新增成功",
+ "failedToAdd": "新增字體失敗",
+ "errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
+ "errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。"
+ },
+ "language": {
+ "title": "語言"
+ }
+}
diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json
new file mode 100644
index 0000000..54c0cfc
--- /dev/null
+++ b/src/i18n/locales/zh-TW/shortcuts.json
@@ -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": "下一影格"
+ }
+}
diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json
new file mode 100644
index 0000000..52457d6
--- /dev/null
+++ b/src/i18n/locales/zh-TW/timeline.json
@@ -0,0 +1,53 @@
+{
+ "buttons": {
+ "addZoom": "新增縮放 (Z)",
+ "suggestZooms": "根據游標建議縮放",
+ "addTrim": "新增剪輯 (T)",
+ "addAnnotation": "新增標註 (A)",
+ "addSpeed": "新增速度 (S)",
+ "addBlur": "新增模糊 (B)"
+ },
+ "hints": {
+ "pressZoom": "按 Z 新增縮放",
+ "pressTrim": "按 T 新增剪輯",
+ "pressAnnotation": "按 A 新增標註",
+ "pressSpeed": "按 S 新增速度",
+ "pressBlur": "按 B 新增模糊區域"
+ },
+ "labels": {
+ "pan": "平移",
+ "zoom": "縮放",
+ "zoomItem": "縮放 {{index}}",
+ "trimItem": "剪輯 {{index}}",
+ "speedItem": "速度 {{index}}",
+ "annotationItem": "標註",
+ "imageItem": "圖片",
+ "emptyText": "空文字",
+ "blurItem": "模糊 {{index}}"
+ },
+ "emptyState": {
+ "noVideo": "未載入影片",
+ "dragAndDrop": "拖放影片以開始編輯"
+ },
+ "errors": {
+ "cannotPlaceZoom": "無法在此處放置縮放",
+ "zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
+ "zoomSuggestionUnavailable": "縮放建議處理器不可用",
+ "noCursorTelemetry": "無可用的游標遙測資料",
+ "noCursorTelemetryDescription": "請先錄製一段螢幕錄影以產生基於游標的建議。",
+ "noUsableTelemetry": "無可用的游標遙測資料",
+ "noUsableTelemetryDescription": "錄製內容沒有包含足夠的游標移動資料。",
+ "noDwellMoments": "未找到明確的游標停留時刻",
+ "noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
+ "noAutoZoomSlots": "無可用的自動縮放位置",
+ "noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
+ "cannotPlaceTrim": "無法在此處放置剪輯",
+ "trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
+ "cannotPlaceSpeed": "無法在此處放置速度",
+ "speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。"
+ },
+ "success": {
+ "addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
+ "addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
+ }
+}
diff --git a/src/lib/__tests__/frameStepNavigation.test.ts b/src/lib/__tests__/frameStepNavigation.test.ts
new file mode 100644
index 0000000..ab0393b
--- /dev/null
+++ b/src/lib/__tests__/frameStepNavigation.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
+
+describe("computeFrameStepTime", () => {
+ const duration = 10;
+
+ it("moves forward by one frame from the middle", () => {
+ const result = computeFrameStepTime(5, duration, "forward");
+ expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
+ });
+
+ it("moves backward by one frame from the middle", () => {
+ const result = computeFrameStepTime(5, duration, "backward");
+ expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
+ });
+
+ it("clamps to 0 when stepping backward at the beginning", () => {
+ const result = computeFrameStepTime(0, duration, "backward");
+ expect(result).toBe(0);
+ });
+
+ it("clamps to 0 when stepping backward near the beginning", () => {
+ const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
+ expect(result).toBe(0);
+ });
+
+ it("clamps to duration when stepping forward at the end", () => {
+ const result = computeFrameStepTime(duration, duration, "forward");
+ expect(result).toBe(duration);
+ });
+
+ it("clamps to duration when stepping forward near the end", () => {
+ const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
+ expect(result).toBe(duration);
+ });
+
+ it("handles duration of 0 gracefully", () => {
+ expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
+ expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
+ });
+});
diff --git a/src/lib/blurEffects.test.ts b/src/lib/blurEffects.test.ts
new file mode 100644
index 0000000..4797e69
--- /dev/null
+++ b/src/lib/blurEffects.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import { applyMosaicToImageData, getBlurOverlayColor, normalizeBlurColor } from "./blurEffects";
+
+function createTestImageData(width: number, height: number) {
+ const data = new Uint8ClampedArray(width * height * 4);
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const offset = (y * width + x) * 4;
+ data[offset] = x * 20 + y;
+ data[offset + 1] = y * 20 + x;
+ data[offset + 2] = (x + y) * 10;
+ data[offset + 3] = 255;
+ }
+ }
+
+ return {
+ data,
+ width,
+ height,
+ } as ImageData;
+}
+
+describe("applyMosaicToImageData", () => {
+ it("collapses each block to a single representative color", () => {
+ const imageData = createTestImageData(4, 4);
+ const original = new Uint8ClampedArray(imageData.data);
+
+ applyMosaicToImageData(imageData, 2);
+
+ const topLeft = Array.from(imageData.data.slice(0, 4));
+ const topRightOffset = (1 * 4 + 1) * 4;
+ const topRight = Array.from(imageData.data.slice(topRightOffset, topRightOffset + 4));
+ expect(topLeft).toEqual(topRight);
+
+ expect(Array.from(original.slice(0, 4))).not.toEqual(topLeft);
+ });
+
+ it("reduces unique pixel colors, making the transform information-lossy", () => {
+ const imageData = createTestImageData(8, 8);
+ const before = new Set();
+ const after = new Set();
+
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ before.add(
+ `${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
+ );
+ }
+
+ applyMosaicToImageData(imageData, 4);
+
+ for (let i = 0; i < imageData.data.length; i += 4) {
+ after.add(
+ `${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
+ );
+ }
+
+ expect(after.size).toBeLessThan(before.size);
+ expect(after.size).toBe(4);
+ });
+});
+
+describe("blur color helpers", () => {
+ it("normalizes invalid blur colors to white", () => {
+ expect(normalizeBlurColor("black")).toBe("black");
+ expect(normalizeBlurColor("invalid")).toBe("white");
+ });
+
+ it("returns a dark overlay when black blur color is selected", () => {
+ expect(
+ getBlurOverlayColor({
+ type: "blur",
+ shape: "rectangle",
+ color: "black",
+ intensity: 12,
+ blockSize: 12,
+ }),
+ ).toBe("rgba(0, 0, 0, 0.18)");
+ });
+});
diff --git a/src/lib/blurEffects.ts b/src/lib/blurEffects.ts
new file mode 100644
index 0000000..6933924
--- /dev/null
+++ b/src/lib/blurEffects.ts
@@ -0,0 +1,113 @@
+import {
+ type BlurColor,
+ type BlurData,
+ type BlurType,
+ DEFAULT_BLUR_BLOCK_SIZE,
+ DEFAULT_BLUR_INTENSITY,
+ MAX_BLUR_BLOCK_SIZE,
+ MAX_BLUR_INTENSITY,
+ MIN_BLUR_BLOCK_SIZE,
+ MIN_BLUR_INTENSITY,
+} from "@/components/video-editor/types";
+
+function clamp(value: number, min: number, max: number) {
+ if (!Number.isFinite(value)) return min;
+ return Math.min(max, Math.max(min, value));
+}
+
+export function normalizeBlurType(value: unknown): BlurType {
+ return value === "mosaic" ? "mosaic" : "blur";
+}
+
+export function normalizeBlurColor(value: unknown): BlurColor {
+ return value === "black" ? "black" : "white";
+}
+
+export function getNormalizedBlurIntensity(blurData?: BlurData | null): number {
+ return clamp(
+ blurData?.intensity ?? DEFAULT_BLUR_INTENSITY,
+ MIN_BLUR_INTENSITY,
+ MAX_BLUR_INTENSITY,
+ );
+}
+
+export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFactor = 1): number {
+ const rawBlockSize = clamp(
+ blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE,
+ MIN_BLUR_BLOCK_SIZE,
+ MAX_BLUR_BLOCK_SIZE,
+ );
+ return Math.max(1, Math.round(rawBlockSize * Math.max(scaleFactor, 0.01)));
+}
+
+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)";
+}
+
+export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
+ return normalizeBlurColor(blurData?.color) === "black"
+ ? "rgba(255,255,255,0.05)"
+ : "rgba(255,255,255,0.04)";
+}
+
+export function applyMosaicToImageData(imageData: ImageData, blockSize: number): ImageData {
+ const width = imageData.width;
+ const height = imageData.height;
+ const data = imageData.data;
+ const normalizedBlockSize = Math.max(1, Math.floor(blockSize));
+
+ if (width <= 0 || height <= 0 || normalizedBlockSize <= 1) {
+ return imageData;
+ }
+
+ for (let blockY = 0; blockY < height; blockY += normalizedBlockSize) {
+ for (let blockX = 0; blockX < width; blockX += normalizedBlockSize) {
+ const blockWidth = Math.min(normalizedBlockSize, width - blockX);
+ const blockHeight = Math.min(normalizedBlockSize, height - blockY);
+ const pixelCount = blockWidth * blockHeight;
+
+ if (pixelCount <= 0) {
+ continue;
+ }
+
+ let redTotal = 0;
+ let greenTotal = 0;
+ let blueTotal = 0;
+ let alphaTotal = 0;
+
+ for (let y = blockY; y < blockY + blockHeight; y++) {
+ for (let x = blockX; x < blockX + blockWidth; x++) {
+ const offset = (y * width + x) * 4;
+ redTotal += data[offset];
+ greenTotal += data[offset + 1];
+ blueTotal += data[offset + 2];
+ alphaTotal += data[offset + 3];
+ }
+ }
+
+ const averageRed = Math.round(redTotal / pixelCount);
+ const averageGreen = Math.round(greenTotal / pixelCount);
+ const averageBlue = Math.round(blueTotal / pixelCount);
+ const averageAlpha = Math.round(alphaTotal / pixelCount);
+
+ for (let y = blockY; y < blockY + blockHeight; y++) {
+ for (let x = blockX; x < blockX + blockWidth; x++) {
+ const offset = (y * width + x) * 4;
+ data[offset] = averageRed;
+ data[offset + 1] = averageGreen;
+ data[offset + 2] = averageBlue;
+ data[offset + 3] = averageAlpha;
+ }
+ }
+ }
+ }
+
+ return imageData;
+}
diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts
index 93ce2c5..596eb75 100644
--- a/src/lib/compositeLayout.test.ts
+++ b/src/lib/compositeLayout.test.ts
@@ -24,16 +24,111 @@ describe("computeCompositeLayout", () => {
webcamSize: { width: 1920, height: 1080 },
});
+ const refDim = Math.sqrt(1280 * 720);
+ const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
expect(layout).not.toBeNull();
expect(layout!.webcamRect).not.toBeNull();
- expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
- expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
+ expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
+ expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
+ Math.round(refDim * defaultFraction) + 1,
+ );
expect(
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
).toBeLessThanOrEqual(1920);
});
- it("uses cover-style full-width stacking in vertical stack mode", () => {
+ it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
+ const webcamSize = { width: 1280, height: 720 };
+ const landscape = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ screenSize: { width: 1920, height: 1080 },
+ webcamSize,
+ webcamSizePreset: 50,
+ });
+ const portrait = computeCompositeLayout({
+ canvasSize: { width: 1080, height: 1920 },
+ screenSize: { width: 1080, height: 1920 },
+ webcamSize,
+ webcamSizePreset: 50,
+ });
+
+ expect(landscape).not.toBeNull();
+ expect(portrait).not.toBeNull();
+ // Same total pixel count — webcam area should be comparable
+ const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
+ const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
+ expect(landscapeArea).toBe(portraitArea);
+ });
+
+ it("scales the webcam proportionally as webcamSizePreset increases", () => {
+ const canvasSize = { width: 1920, height: 1080 };
+ const screenSize = { width: 1920, height: 1080 };
+ const webcamSize = { width: 1280, height: 720 };
+
+ const small = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 10,
+ });
+ const medium = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 25,
+ });
+ const large = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 50,
+ });
+
+ expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
+ expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
+ expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
+ expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
+ });
+
+ it("clamps webcamSizePreset to the valid range (10–50)", () => {
+ const canvasSize = { width: 1920, height: 1080 };
+ const screenSize = { width: 1920, height: 1080 };
+ const webcamSize = { width: 1280, height: 720 };
+
+ const atMin = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 10,
+ });
+ const belowMin = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 1,
+ });
+ const atMax = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 50,
+ });
+ const aboveMax = computeCompositeLayout({
+ canvasSize,
+ screenSize,
+ webcamSize,
+ webcamSizePreset: 100,
+ });
+
+ // Values below 10 should clamp to 10
+ expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
+ expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
+ // Values above 50 should clamp to 50
+ expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
+ expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
+ });
+
+ it("centers the combined screen and webcam stack in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -43,23 +138,19 @@ describe("computeCompositeLayout", () => {
});
expect(layout).not.toBeNull();
- expect(layout?.screenRect).toEqual({
- x: 0,
- y: 0,
- width: 1920,
- height: 0,
- });
- expect(layout?.webcamRect).toEqual({
- x: 0,
- y: 0,
- width: 1920,
- height: 1080,
- borderRadius: 0,
- });
- expect(layout?.screenCover).toBe(true);
+ // Webcam is full-width at the bottom
+ expect(layout!.webcamRect).not.toBeNull();
+ expect(layout!.webcamRect!.x).toBe(0);
+ expect(layout!.webcamRect!.width).toBe(1920);
+ expect(layout!.webcamRect!.borderRadius).toBe(0);
+ // Screen fills remaining space at the top (cover mode)
+ expect(layout!.screenRect.x).toBe(0);
+ expect(layout!.screenRect.y).toBe(0);
+ expect(layout!.screenRect.width).toBe(1920);
+ expect(layout!.screenCover).toBe(true);
});
- it("fills the canvas with the screen when vertical stack has no webcam", () => {
+ it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -78,6 +169,29 @@ describe("computeCompositeLayout", () => {
expect(layout?.screenCover).toBe(true);
});
+ it("uses a 2:1 split layout in dual frame mode", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ maxContentSize: { width: 1536, height: 864 },
+ screenSize: { width: 1920, height: 1080 },
+ webcamSize: { width: 1280, height: 720 },
+ layoutPreset: "dual-frame",
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout?.webcamRect).not.toBeNull();
+ expect(layout?.screenRect.y).toBe(108);
+ expect(layout?.screenRect.height).toBe(864);
+ expect(layout?.screenBorderRadius).toBe(layout?.webcamRect?.borderRadius);
+ expect(layout?.webcamRect?.y).toBe(108);
+ expect(layout?.webcamRect?.height).toBe(864);
+ expect(layout?.webcamRect?.x).toBeGreaterThan(layout?.screenRect.x ?? 0);
+ expect(
+ Math.abs((layout?.screenRect.width ?? 0) - 2 * (layout?.webcamRect?.width ?? 0)),
+ ).toBeLessThanOrEqual(1);
+ expect(layout?.screenCover).toBe(true);
+ });
+
it("forces circular and square masks to use square dimensions", () => {
const circularLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts
index a3c84e8..e6db733 100644
--- a/src/lib/compositeLayout.ts
+++ b/src/lib/compositeLayout.ts
@@ -15,7 +15,9 @@ export interface Size {
height: number;
}
-export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
+export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
+/** Webcam size as a percentage of the canvas reference dimension (10–50). */
+export type WebcamSizePreset = number;
export interface WebcamLayoutShadow {
color: string;
@@ -32,7 +34,6 @@ interface BorderRadiusRule {
interface OverlayTransform {
type: "overlay";
- maxStageFraction: number;
marginFraction: number;
minMargin: number;
minSize: number;
@@ -43,9 +44,17 @@ interface StackTransform {
gap: number;
}
+interface SplitTransform {
+ type: "split";
+ gapFraction: number;
+ minGap: number;
+ screenUnits: number;
+ webcamUnits: number;
+}
+
export interface WebcamLayoutPresetDefinition {
label: string;
- transform: OverlayTransform | StackTransform;
+ transform: OverlayTransform | StackTransform | SplitTransform;
borderRadius: BorderRadiusRule;
shadow: WebcamLayoutShadow | null;
}
@@ -53,11 +62,18 @@ export interface WebcamLayoutPresetDefinition {
export interface WebcamCompositeLayout {
screenRect: RenderRect;
webcamRect: StyledRenderRect | null;
+ screenBorderRadius?: number;
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
screenCover?: boolean;
}
-const MAX_STAGE_FRACTION = 0.18;
+/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */
+function webcamSizeToFraction(percent: number): number {
+ const safe = Number.isFinite(percent) ? percent : 25;
+ const clamped = Math.max(10, Math.min(50, safe));
+ return clamped / 100;
+}
+
const MARGIN_FRACTION = 0.02;
const MAX_BORDER_RADIUS = 24;
const WEBCAM_LAYOUT_PRESET_MAP: Record = {
@@ -65,7 +81,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record = {
@@ -96,6 +106,101 @@ function renderArrow(
ctx.restore();
}
+function drawBlurPath(
+ ctx: CanvasRenderingContext2D,
+ annotation: AnnotationRegion,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+) {
+ const shape = annotation.blurData?.shape || "rectangle";
+ if (shape === "rectangle") {
+ ctx.beginPath();
+ ctx.rect(x, y, width, height);
+ return;
+ }
+
+ if (shape === "oval") {
+ ctx.beginPath();
+ ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
+ return;
+ }
+
+ const points = annotation.blurData?.freehandPoints;
+ if (shape === "freehand" && points && points.length >= 3) {
+ ctx.beginPath();
+ ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
+ for (let i = 1; i < points.length; i++) {
+ ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
+ }
+ ctx.closePath();
+ return;
+ }
+
+ ctx.beginPath();
+ ctx.rect(x, y, width, height);
+}
+
+function renderBlur(
+ ctx: CanvasRenderingContext2D,
+ annotation: AnnotationRegion,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ scaleFactor: number,
+) {
+ const canvas = ctx.canvas;
+ const blurType = normalizeBlurType(annotation.blurData?.type);
+
+ const blurRadius = Math.max(
+ 1,
+ Math.round(getNormalizedBlurIntensity(annotation.blurData) * scaleFactor),
+ );
+ const samplePadding =
+ blurType === "mosaic"
+ ? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor)))
+ : Math.max(2, Math.ceil(blurRadius * 2));
+ const sx = Math.max(0, Math.floor(x) - samplePadding);
+ const sy = Math.max(0, Math.floor(y) - samplePadding);
+ const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
+ const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
+ const sw = Math.max(0, ex - sx);
+ const sh = Math.max(0, ey - sy);
+ if (sw <= 0 || sh <= 0) return;
+
+ if (!blurScratchCanvas || !blurScratchCtx) {
+ blurScratchCanvas = document.createElement("canvas");
+ blurScratchCtx = blurScratchCanvas.getContext("2d");
+ }
+ if (!blurScratchCanvas || !blurScratchCtx) return;
+
+ blurScratchCanvas.width = sw;
+ blurScratchCanvas.height = sh;
+ blurScratchCtx.clearRect(0, 0, sw, sh);
+ blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
+
+ if (blurType === "mosaic") {
+ const imageData = blurScratchCtx.getImageData(0, 0, sw, sh);
+ applyMosaicToImageData(
+ imageData,
+ getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor),
+ );
+ blurScratchCtx.putImageData(imageData, 0, 0);
+ }
+
+ ctx.save();
+ drawBlurPath(ctx, annotation, x, y, width, height);
+ ctx.clip();
+ ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`;
+ ctx.drawImage(blurScratchCanvas, sx, sy);
+ ctx.filter = "none";
+ ctx.fillStyle = getBlurOverlayColor(annotation.blurData);
+ ctx.fillRect(sx, sy, sw, sh);
+ ctx.restore();
+}
+
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
@@ -268,7 +373,7 @@ export async function renderAnnotations(
): Promise {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
- (ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
+ (ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs,
);
// Sort by z-index (lower first, so higher z-index draws on top)
@@ -304,6 +409,10 @@ export async function renderAnnotations(
);
}
break;
+
+ case "blur":
+ renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
+ break;
}
}
}
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 80d2f6d..80424b0 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -13,6 +13,7 @@ import type {
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
+ WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
@@ -70,6 +71,7 @@ interface FrameRenderConfig {
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
@@ -112,6 +114,8 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
+ private rasterCanvas: HTMLCanvasElement | null = null;
+ private rasterCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
@@ -191,6 +195,14 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
+ this.rasterCanvas = document.createElement("canvas");
+ this.rasterCanvas.width = this.config.width;
+ this.rasterCanvas.height = this.config.height;
+ this.rasterCtx = this.rasterCanvas.getContext("2d");
+ if (!this.rasterCtx) {
+ throw new Error("Failed to get 2D context for raster canvas");
+ }
+
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -453,6 +465,7 @@ export class FrameRenderer {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
+ webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
@@ -494,7 +507,12 @@ export class FrameRenderer {
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
- const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
+ const scaledBorderRadius =
+ compositeLayout.screenBorderRadius != null
+ ? compositeLayout.screenBorderRadius
+ : compositeLayout.screenCover
+ ? 0
+ : borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
@@ -522,16 +540,10 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
- const bmEx = this.layoutCache.maskRect;
- const ssEx = this.layoutCache.stageSize;
- const viewportRatio =
- bmEx.width > 0 && bmEx.height > 0
- ? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
- : undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
- { connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
+ { connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -675,10 +687,46 @@ export class FrameRenderer {
);
}
+ // On Linux/Wayland the implicit GPU→2D texture-sharing path
+ // used by drawImage(webglCanvas) can fail silently (EGL/Ozone),
+ // producing green/empty frames. Explicit gl.readPixels always
+ // copies from GPU to CPU memory, bypassing that path.
+ private readbackVideoCanvas(): HTMLCanvasElement {
+ const glCanvas = this.app!.canvas as HTMLCanvasElement;
+ const gl =
+ (glCanvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
+ (glCanvas.getContext("webgl") as WebGLRenderingContext | null);
+
+ if (!gl || !this.rasterCanvas || !this.rasterCtx) {
+ return glCanvas;
+ }
+
+ const w = glCanvas.width;
+ const h = glCanvas.height;
+ const buf = new Uint8Array(w * h * 4);
+ gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
+
+ // readPixels returns rows bottom-to-top; flip vertically
+ const rowSize = w * 4;
+ const temp = new Uint8Array(rowSize);
+ for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
+ const tOff = top * rowSize;
+ const bOff = bot * rowSize;
+ temp.set(buf.subarray(tOff, tOff + rowSize));
+ buf.copyWithin(tOff, bOff, bOff + rowSize);
+ buf.set(temp, bOff);
+ }
+
+ const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
+ this.rasterCtx.putImageData(imageData, 0, 0);
+
+ return this.rasterCanvas;
+ }
+
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
- const videoCanvas = this.app.canvas as HTMLCanvasElement;
+ const videoCanvas = this.readbackVideoCanvas();
const ctx = this.compositeCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
@@ -735,6 +783,22 @@ export class FrameRenderer {
if (webcamFrame && webcamRect) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
+ const sourceWidth =
+ ("displayWidth" in webcamFrame && webcamFrame.displayWidth > 0
+ ? webcamFrame.displayWidth
+ : webcamFrame.codedWidth) || webcamRect.width;
+ const sourceHeight =
+ ("displayHeight" in webcamFrame && webcamFrame.displayHeight > 0
+ ? webcamFrame.displayHeight
+ : webcamFrame.codedHeight) || webcamRect.height;
+ const sourceAspect = sourceWidth / sourceHeight;
+ const targetAspect = webcamRect.width / webcamRect.height;
+ const sourceCropWidth =
+ sourceAspect > targetAspect ? Math.round(sourceHeight * targetAspect) : sourceWidth;
+ const sourceCropHeight =
+ sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect);
+ const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2));
+ const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2));
ctx.save();
drawCanvasClipPath(
ctx,
@@ -756,6 +820,10 @@ export class FrameRenderer {
ctx.clip();
ctx.drawImage(
webcamFrame as unknown as CanvasImageSource,
+ sourceCropX,
+ sourceCropY,
+ sourceCropWidth,
+ sourceCropHeight,
webcamRect.x,
webcamRect.y,
webcamRect.width,
@@ -795,5 +863,7 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
+ this.rasterCanvas = null;
+ this.rasterCtx = null;
}
}
diff --git a/src/lib/exporter/gifExporter.browser.test.ts b/src/lib/exporter/gifExporter.browser.test.ts
new file mode 100644
index 0000000..db9b144
--- /dev/null
+++ b/src/lib/exporter/gifExporter.browser.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
+import { GifExporter } from "./gifExporter";
+import type { ExportProgress } from "./types";
+
+describe("GifExporter (real browser)", () => {
+ it("exports a valid GIF blob from a real video", async () => {
+ const progressEvents: ExportProgress[] = [];
+
+ const exporter = new GifExporter({
+ videoUrl: sampleVideoUrl,
+ width: 320,
+ height: 180,
+ frameRate: 15,
+ loop: true,
+ sizePreset: "medium",
+ wallpaper: "#1a1a2e",
+ zoomRegions: [],
+ showShadow: false,
+ shadowIntensity: 0,
+ showBlur: false,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ onProgress: (p) => progressEvents.push(p),
+ });
+
+ const result = await exporter.export();
+
+ expect(result.success, result.error).toBe(true);
+ expect(result.blob).toBeInstanceOf(Blob);
+
+ const buf = await result.blob!.arrayBuffer();
+ const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
+ expect(header).toMatch(/^GIF8[79]a/);
+
+ expect(result.blob!.size).toBeGreaterThan(1024);
+
+ expect(progressEvents.length).toBeGreaterThan(0);
+
+ const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
+ expect(finalizing.length).toBeGreaterThan(0);
+ expect(finalizing.at(-1)!.percentage).toBe(100);
+ });
+});
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index d059264..58ed693 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -5,6 +5,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
+ WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -42,6 +43,7 @@ interface GifExporterConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -144,6 +146,7 @@ export class GifExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
+ webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
@@ -171,11 +174,11 @@ export class GifExporter {
});
// Calculate effective duration and frame count (excluding trim regions)
- const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
+ const { effectiveDuration, totalFrames } = this.streamingDecoder.getExportMetrics(
+ this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
);
- const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts
index ccb510b..651a557 100644
--- a/src/lib/exporter/streamingDecoder.ts
+++ b/src/lib/exporter/streamingDecoder.ts
@@ -2,6 +2,52 @@ import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
+const EPSILON_SEC = 0.001;
+/**
+ * Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord.
+ * web-demuxer may return a bare "av01" when the WASM-side parser fails to read
+ * the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box).
+ * This function parses the record if present, otherwise returns a safe default.
+ *
+ * @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section
+ */
+function buildAV1CodecString(description?: BufferSource): string {
+ const fallback = "av01.0.01M.08";
+
+ if (!description) return fallback;
+
+ const bytes =
+ description instanceof ArrayBuffer
+ ? new Uint8Array(description)
+ : new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
+
+ // AV1CodecConfigurationRecord layout (4+ bytes):
+ // Byte 0: marker (1) | version (7)
+ // Byte 1: seq_profile (3) | seq_level_idx_0 (5)
+ // Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ...
+ // The spec says version should be 1, but Chrome/Electron's MediaRecorder
+ // may write version 127 (0xFF first byte). We accept any version as long
+ // as the marker bit is set and the record is long enough.
+ if (bytes.length < 4) return fallback;
+ if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1
+
+ // Byte 1: seq_profile (3) | seq_level_idx_0 (5)
+ const profile = (bytes[1] >> 5) & 0x07;
+ const level = bytes[1] & 0x1f;
+
+ // Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | monochrome (1) | ...
+ const tier = (bytes[2] >> 7) & 0x01;
+ const highBitdepth = (bytes[2] >> 6) & 0x01;
+ const twelveBit = (bytes[2] >> 5) & 0x01;
+ let bitdepth = 8;
+ if (highBitdepth) bitdepth = twelveBit ? 12 : 10;
+
+ const tierChar = tier ? "H" : "M";
+ const levelStr = level.toString().padStart(2, "0");
+ const bitdepthStr = bitdepth.toString().padStart(2, "0");
+
+ return `av01.${profile}.${levelStr}${tierChar}.${bitdepthStr}`;
+}
export interface DecodedVideoInfo {
width: number;
@@ -183,17 +229,28 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
- const codec = this.metadata.codec.toLowerCase();
+
+ // web-demuxer may return a bare "av01" for AV1 in WebM containers when the
+ // extradata isn't in the expected ISOBMFF format. WebCodecs requires the
+ // full parametrized form (e.g. "av01.0.05M.08").
+ if (/^av01$/i.test(decoderConfig.codec)) {
+ decoderConfig.codec = buildAV1CodecString(
+ decoderConfig.description as BufferSource | undefined,
+ );
+ }
+
+ const codec = decoderConfig.codec.toLowerCase();
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
speedRegions,
);
const segmentOutputFrameCounts = segments.map((segment) =>
- Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate),
+ Math.ceil(
+ ((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate,
+ ),
);
const frameDurationUs = 1_000_000 / targetFrameRate;
- const epsilonSec = 0.001;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
@@ -304,7 +361,7 @@ export class StreamingVideoDecoder {
const sourceTimeSec =
segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed;
- if (sourceTimeSec >= segment.endSec - epsilonSec) return false;
+ if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false;
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
@@ -323,7 +380,7 @@ export class StreamingVideoDecoder {
// Finalize completed segments before handling this frame.
while (
segmentIdx < segments.length &&
- frameTimeSec >= segments[segmentIdx].endSec - epsilonSec
+ frameTimeSec >= segments[segmentIdx].endSec - EPSILON_SEC
) {
const segment = segments[segmentIdx];
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
@@ -335,7 +392,7 @@ export class StreamingVideoDecoder {
if (
heldFrame &&
segmentIdx < segments.length &&
- heldFrameSec < segments[segmentIdx].startSec - epsilonSec
+ heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC
) {
heldFrame.close();
heldFrame = null;
@@ -350,7 +407,7 @@ export class StreamingVideoDecoder {
const currentSegment = segments[segmentIdx];
// Before current segment (trimmed region or pre-roll).
- if (frameTimeSec < currentSegment.startSec - epsilonSec) {
+ if (frameTimeSec < currentSegment.startSec - EPSILON_SEC) {
frame.close();
continue;
}
@@ -371,7 +428,7 @@ export class StreamingVideoDecoder {
const sourceTimeSec =
currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed;
- if (sourceTimeSec >= currentSegment.endSec - epsilonSec) {
+ if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) {
break;
}
if (sourceTimeSec > handoffBoundarySec) {
@@ -393,7 +450,7 @@ export class StreamingVideoDecoder {
if (heldFrame && segmentIdx < segments.length) {
while (!this.cancelled && segmentIdx < segments.length) {
const segment = segments[segmentIdx];
- if (heldFrameSec < segment.startSec - epsilonSec) {
+ if (heldFrameSec < segment.startSec - EPSILON_SEC) {
break;
}
@@ -405,7 +462,7 @@ export class StreamingVideoDecoder {
segmentFrameIndex = 0;
if (
segmentIdx < segments.length &&
- heldFrameSec < segments[segmentIdx].startSec - epsilonSec
+ heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC
) {
break;
}
@@ -480,11 +537,24 @@ export class StreamingVideoDecoder {
return segments;
}
- getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
+ getExportMetrics(
+ targetFrameRate: number,
+ trimRegions?: TrimRegion[],
+ speedRegions?: SpeedRegion[],
+ ): { effectiveDuration: number; totalFrames: number } {
if (!this.metadata) throw new Error("Must call loadMetadata() first");
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
- const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
- return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
+ const segments = this.splitBySpeed(trimSegments, speedRegions);
+ return {
+ effectiveDuration: segments.reduce(
+ (sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed,
+ 0,
+ ),
+ totalFrames: segments.reduce((sum, seg) => {
+ const segDur = seg.endSec - seg.startSec - EPSILON_SEC;
+ return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate));
+ }, 0),
+ };
}
private splitBySpeed(
diff --git a/src/lib/exporter/videoExporter.browser.test.ts b/src/lib/exporter/videoExporter.browser.test.ts
new file mode 100644
index 0000000..ec2b0f6
--- /dev/null
+++ b/src/lib/exporter/videoExporter.browser.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
+import type { ExportProgress } from "./types";
+import { VideoExporter } from "./videoExporter";
+
+describe("VideoExporter (real browser)", () => {
+ it("exports a valid MP4 blob from a real video", async () => {
+ const progressEvents: ExportProgress[] = [];
+
+ 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 },
+ onProgress: (p) => progressEvents.push(p),
+ });
+
+ const result = await exporter.export();
+
+ expect(result.success, result.error).toBe(true);
+ expect(result.blob).toBeInstanceOf(Blob);
+
+ const buf = await result.blob!.arrayBuffer();
+ const bytes = new Uint8Array(buf);
+ const ftyp = new TextDecoder().decode(bytes.slice(4, 8));
+ expect(ftyp).toBe("ftyp");
+
+ expect(result.blob!.size).toBeGreaterThan(1024);
+
+ expect(progressEvents.length).toBeGreaterThan(0);
+
+ const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
+ expect(finalizing.length).toBeGreaterThan(0);
+ expect(finalizing.at(-1)!.percentage).toBe(100);
+ });
+});
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index 761186f..dcfcc3e 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -4,6 +4,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
+ WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -33,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -137,6 +139,7 @@ export class VideoExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
+ webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
@@ -154,11 +157,11 @@ export class VideoExporter {
this.muxer = muxer;
await muxer.initialize();
- const effectiveDuration = streamingDecoder.getEffectiveDuration(
+ const { effectiveDuration, totalFrames } = streamingDecoder.getExportMetrics(
+ this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
);
- const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5;
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts
new file mode 100644
index 0000000..dc42d78
--- /dev/null
+++ b/src/lib/frameStep.ts
@@ -0,0 +1,15 @@
+/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
+export const FRAME_DURATION_SEC = 1 / 60;
+
+/**
+ * Compute the new playhead time after stepping one frame forward or backward.
+ * The result is clamped to the range [0, duration].
+ */
+export function computeFrameStepTime(
+ currentTime: number,
+ duration: number,
+ direction: "forward" | "backward",
+): number {
+ const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
+ return Math.min(duration, Math.max(0, currentTime + delta));
+}
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index 69af499..96592a4 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
"addTrim",
"addSpeed",
"addAnnotation",
+ "addBlur",
"addKeyframe",
"deleteSelected",
"playPause",
@@ -21,14 +22,16 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record;
export interface FixedShortcut {
+ i18nKey: string;
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
- { label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
+ { i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
+ i18nKey: "redo",
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
@@ -36,19 +39,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ key: "y", ctrl: true },
],
},
- { label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
+ i18nKey: "cycleAnnotationsForward",
+ label: "Cycle Annotations Forward",
+ display: "Tab",
+ bindings: [{ key: "tab" }],
+ },
+ {
+ i18nKey: "cycleAnnotationsBackward",
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
+ i18nKey: "deleteSelectedAlt",
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
- { label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
- { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
+ {
+ i18nKey: "panTimeline",
+ label: "Pan Timeline",
+ display: "Shift + Ctrl + Scroll",
+ bindings: [],
+ },
+ { i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
+ { i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
+ {
+ i18nKey: "frameForward",
+ label: "Frame Forward",
+ display: "→",
+ bindings: [{ key: "arrowright" }],
+ },
];
export type ShortcutConflict =
@@ -87,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addTrim: { key: "t" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
+ addBlur: { key: "b" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
@@ -97,6 +120,7 @@ export const SHORTCUT_LABELS: Record = {
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
+ addBlur: "Add Blur",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
@@ -104,9 +128,10 @@ export const SHORTCUT_LABELS: Record = {
export function matchesShortcut(
e: KeyboardEvent,
- binding: ShortcutBinding,
+ binding: ShortcutBinding | undefined,
isMacPlatform: boolean,
): boolean {
+ if (!binding) return false;
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
new file mode 100644
index 0000000..e060788
--- /dev/null
+++ b/src/lib/userPreferences.ts
@@ -0,0 +1,94 @@
+import type { ExportFormat, ExportQuality } from "@/lib/exporter";
+import type { AspectRatio } from "@/utils/aspectRatioUtils";
+
+const PREFS_KEY = "openscreen_user_preferences";
+
+const VALID_ASPECT_RATIOS: readonly string[] = [
+ "16:9",
+ "9:16",
+ "1:1",
+ "4:3",
+ "4:5",
+ "16:10",
+ "10:16",
+ "native",
+];
+
+export interface UserPreferences {
+ /** Default padding % */
+ padding: number;
+ /** Default aspect ratio */
+ aspectRatio: AspectRatio;
+ /** Default export quality */
+ exportQuality: ExportQuality;
+ /** Default export format */
+ exportFormat: ExportFormat;
+}
+
+const DEFAULT_PREFS: UserPreferences = {
+ padding: 50,
+ aspectRatio: "16:9",
+ exportQuality: "good",
+ exportFormat: "mp4",
+};
+
+function safeJsonParse(text: string | null): Record | null {
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Load persisted user preferences from localStorage.
+ * Returns defaults for any missing or invalid fields.
+ */
+export function loadUserPreferences(): UserPreferences {
+ let raw: Record | null = null;
+ try {
+ raw = safeJsonParse(localStorage.getItem(PREFS_KEY));
+ } catch {
+ return { ...DEFAULT_PREFS };
+ }
+ if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS };
+
+ return {
+ padding:
+ typeof raw.padding === "number" &&
+ Number.isFinite(raw.padding) &&
+ raw.padding >= 0 &&
+ raw.padding <= 100
+ ? raw.padding
+ : DEFAULT_PREFS.padding,
+ aspectRatio:
+ typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio)
+ ? (raw.aspectRatio as AspectRatio)
+ : DEFAULT_PREFS.aspectRatio,
+ exportQuality:
+ raw.exportQuality === "medium" ||
+ raw.exportQuality === "good" ||
+ raw.exportQuality === "source"
+ ? (raw.exportQuality as ExportQuality)
+ : DEFAULT_PREFS.exportQuality,
+ exportFormat:
+ raw.exportFormat === "gif" || raw.exportFormat === "mp4"
+ ? (raw.exportFormat as ExportFormat)
+ : DEFAULT_PREFS.exportFormat,
+ };
+}
+
+/**
+ * Persist user preferences to localStorage.
+ * Only the explicitly provided fields are updated.
+ */
+export function saveUserPreferences(partial: Partial): void {
+ const current = loadUserPreferences();
+ const merged = { ...current, ...partial };
+ try {
+ localStorage.setItem(PREFS_KEY, JSON.stringify(merged));
+ } catch {
+ // localStorage may be unavailable (e.g. private browsing quota exceeded)
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 4e668f3..d76ee15 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -19,6 +19,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise;
switchToEditor: () => Promise;
+ switchToHud: () => Promise;
+ startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise;
selectSource: (source: ProcessedDesktopSource) => Promise;
getSelectedSource: () => Promise;
diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts
index a851546..d1fa3f7 100644
--- a/tests/e2e/gif-export.spec.ts
+++ b/tests/e2e/gif-export.spec.ts
@@ -11,12 +11,15 @@ const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
test("exports a GIF from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
+ let testVideoInRecordings = "";
const app = await electron.launch({
args: [
MAIN_JS,
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
"--no-sandbox",
+ // Force software WebGL in headless CI to avoid GPU framebuffer errors.
+ "--enable-unsafe-swiftshader",
],
env: {
...process.env,
@@ -58,14 +61,25 @@ test("exports a GIF from a loaded video", async () => {
);
});
- await hudWindow.evaluate((videoPath: string) => {
- window.electronAPI.setCurrentVideoPath(videoPath);
- try {
+ // Copy the test fixture into the app's recordings directory so it passes
+ // the path security check in set-current-video-path.
+ const userDataDir = await app.evaluate(({ app: electronApp }) => {
+ return electronApp.getPath("userData");
+ });
+ const recordingsDir = path.join(userDataDir, "recordings");
+ testVideoInRecordings = path.join(recordingsDir, "test-sample.webm");
+ fs.mkdirSync(recordingsDir, { recursive: true });
+ fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
+
+ try {
+ await hudWindow.evaluate((videoPath: string) => {
+ window.electronAPI.setCurrentVideoPath(videoPath);
window.electronAPI.switchToEditor();
- } catch {
- // Expected: HUD window closes during this call, killing the context.
- }
- }, TEST_VIDEO);
+ }, testVideoInRecordings);
+ } catch {
+ // Expected: switchToEditor() closes the HUD window, terminating
+ // the Playwright page context before evaluate() can resolve.
+ }
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
@@ -116,5 +130,8 @@ test("exports a GIF from a loaded video", async () => {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
+ if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
+ fs.unlinkSync(testVideoInRecordings);
+ }
}
});
diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts
new file mode 100644
index 0000000..ba5cc42
--- /dev/null
+++ b/vitest.browser.config.ts
@@ -0,0 +1,28 @@
+import path from "node:path";
+import { playwright } from "@vitest/browser-playwright";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.browser.test.{ts,tsx}"],
+ browser: {
+ enabled: true,
+ provider: playwright({
+ launch: {
+ // Software WebGL so Pixi.js works in headless CI without a GPU.
+ args: ["--enable-unsafe-swiftshader", "--use-gl=swiftshader"],
+ },
+ }),
+ headless: true,
+ instances: [{ browser: "chromium" }],
+ },
+ testTimeout: 120_000,
+ hookTimeout: 30_000,
+ },
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ },
+ },
+ assetsInclude: ["**/*.webm"],
+});