Compare commits

..
161 changed files with 7979 additions and 14862 deletions
-10
View File
@@ -1,10 +0,0 @@
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=
-1
View File
@@ -1 +0,0 @@
use flake
-1
View File
@@ -1 +0,0 @@
* @siddharthvaddem
+20 -173
View File
@@ -3,16 +3,6 @@ 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:
@@ -20,15 +10,18 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Windows app
run: npm run build:win
env:
@@ -43,180 +36,38 @@ 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@v4
uses: actions/checkout@v3
# ─── Setup Node.js ────────────────────────────────────────
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: 22
cache: npm
node-version: '22'
# ─── Setup Python (needed by some native deps) ────────────
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: '3.11'
# ─── Install Dependencies ─────────────────────────────────
- name: Install dependencies
run: npm ci
# ─── 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
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build macOS app
run: npm run build:mac
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 }}
# ─── 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
- name: Upload macOS build
uses: actions/upload-artifact@v4
with:
name: openscreen-mac-${{ matrix.arch }}
path: ${{ steps.dmg.outputs.dmg_path }}
name: macos-installer
path: release/**/*.dmg
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:
@@ -231,10 +82,8 @@ jobs:
- name: Install dependencies
run: npm ci
# bsdtar (from libarchive-tools) is required by fpm to build pacman
# packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
- name: Install pacman build dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Install app dependencies
run: npx electron-builder install-app-deps
- name: Build Linux app
run: npm run build:linux
@@ -248,6 +97,4 @@ jobs:
path: |
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
release/**/*.pacman
retention-days: 30
+23 -13
View File
@@ -31,19 +31,6 @@ 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
@@ -55,3 +42,26 @@ 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
-519
View File
@@ -1,519 +0,0 @@
name: PR to Discord Forum
on:
pull_request_target:
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' && github.actor != 'github-actions[bot]'
concurrency:
group: discord-pr-sync-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: false
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 = /<!--\s*discord-thread-id:(\d+)\s*-->/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<!-- discord-thread-id:${threadId} -->`.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,
})
});
const contentType = (response.headers.get("content-type") || "").toLowerCase();
const text = await response.text();
if (!response.ok) {
throw new Error(`Discord API error ${response.status}: ${text}`);
}
if (!text) return {};
if (contentType.includes("application/json")) return JSON.parse(text);
// Some proxy/CDN edge responses may return HTML with 2xx; avoid crashing on JSON parse.
core.warning(`Discord webhook returned non-JSON response (content-type: ${contentType || "unknown"}).`);
return {};
}
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_target" || 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;
}
if (!webhookUrl) {
const strictEvents = new Set(["pull_request_target", "workflow_dispatch"]);
const msg =
`Discord sync skipped: webhook secret unavailable for event '${context.eventName}'. ` +
"Set either DISCORD_WEBHOOK_URL or DISCORD_PR_FORUM_WEBHOOK in repository secrets.";
if (strictEvents.has(context.eventName)) {
core.setFailed(msg);
} else {
core.warning(msg);
}
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_target" &&
["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_target" && ["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_target") {
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}`);
}
+1 -14
View File
@@ -12,11 +12,9 @@ dist
dist-electron
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
.zed/
!.vscode/extensions.json
.idea
.DS_Store
@@ -27,19 +25,8 @@ dist-ssr
*.sw?
release/**
*.kiro/
.claude/
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
# shell files
/shell.sh
# Nix
result
result-*
.direnv/
playwright-report/
+10 -27
View File
@@ -5,15 +5,12 @@
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
<br />
<br />
<a href="https://trendshift.io/repositories/17427" target="_blank"><img src="https://trendshift.io/api/badge/repositories/17427" alt="siddharthvaddem%2Fopenscreen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<br />
<br />
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
</a>
&nbsp;
<a href="https://discord.gg/yAQQhRaEeg">
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/yAQQhRaEeg?style=flat" alt="Join Discord" />
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
</a>
</p>
@@ -28,20 +25,21 @@ 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 !)
<p align="center">
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
</p>
## Core Features
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record microphone and system audio.
- 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.
- Crop video recordings to hide parts.
- Choose between wallpapers, solid colors, gradients or a custom background.
- Motion blur for smoother pan and zoom effects.
- Add annotations (text, arrows, images).
- Trim sections of the clip.
- Customize the speed of different segments.
- Customize speed at different segments.
- Export in different aspect ratios and resolutions.
## Installation
@@ -80,9 +78,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 works).
- **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).
- **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 work).
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
## Built with
- Electron
@@ -96,25 +94,10 @@ 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.
## Star History
<a href="https://www.star-history.com/?repos=siddharthvaddem%2Fopenscreen&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=siddharthvaddem/openscreen&type=date&legend=top-left" />
</picture>
</a>
## License
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
"formatter": {
+29 -40
View File
@@ -3,11 +3,6 @@
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.siddharthvaddem.openscreen",
"asar": true,
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
"asarUnpack": [
"node_modules/uiohook-napi/**/*",
"**/*.node"
],
"productName": "Openscreen",
"npmRebuild": true,
"buildDependenciesFromSource": true,
@@ -25,20 +20,16 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
// Asset layout contract: "wallpapers/" under resourcesPath must align with
// assetBaseDir in electron/preload.ts (packaged branch).
"extraResources": [
{
"from": "public/wallpapers",
"to": "wallpapers"
}
],
"mac": {
"notarize": false,
"hardenedRuntime": true,
"entitlements": "macos.entitlements",
"entitlementsInherit": "macos.entitlements",
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
"target": [
{
"target": "dmg",
@@ -47,32 +38,30 @@
],
"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": [
"AppImage",
"deb",
"pacman"
"AppImage"
],
"icon": "icons/icons/png",
"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
}
}
+2 -15
View File
@@ -26,8 +26,6 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
@@ -37,12 +35,7 @@ interface Window {
status: string;
error?: string;
}>;
requestAccessibilityAccess: () => Promise<{
success: boolean;
granted: boolean;
error?: string;
}>;
assetBaseUrl: string;
getAssetBasePath: () => Promise<string | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
@@ -68,12 +61,10 @@ interface Window {
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean, recordingId?: number) => Promise<void>;
discardCursorTelemetry: (recordingId: number) => Promise<void>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
clicks: number[];
message?: string;
error?: string;
}>;
@@ -142,10 +133,6 @@ interface Window {
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
showCountdownOverlay: (value: number, runId: number) => Promise<void>;
setCountdownOverlayValue: (value: number, runId: number) => Promise<void>;
hideCountdownOverlay: (runId: number) => Promise<void>;
onCountdownOverlayValue: (callback: (value: number | null) => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
+2 -30
View File
@@ -1,55 +1,27 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
import commonAr from "../src/i18n/locales/ar/common.json";
import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
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 commonJa from "../src/i18n/locales/ja-JP/common.json";
import dialogsJa from "../src/i18n/locales/ja-JP/dialogs.json";
import commonKo from "../src/i18n/locales/ko-KR/common.json";
import dialogsKo from "../src/i18n/locales/ko-KR/dialogs.json";
import commonTr from "../src/i18n/locales/tr/common.json";
import dialogsTr from "../src/i18n/locales/tr/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar";
type Locale = "en" | "zh-CN" | "es";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
"zh-TW": { common: commonZhTw, dialogs: dialogsZhTw },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
"ja-JP": { common: commonJa, dialogs: dialogsJa },
"ko-KR": { common: commonKo, dialogs: dialogsKo },
tr: { common: commonTr, dialogs: dialogsTr },
ar: { common: commonAr, dialogs: dialogsAr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (
locale === "en" ||
locale === "zh-CN" ||
locale === "zh-TW" ||
locale === "es" ||
locale === "fr" ||
locale === "ja-JP" ||
locale === "ko-KR" ||
locale === "tr" ||
locale === "ar"
) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
currentLocale = locale;
}
}
+128 -664
View File
File diff suppressed because it is too large Load Diff
+34 -164
View File
@@ -14,12 +14,7 @@ import {
} from "electron";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import {
createCountdownOverlayWindow,
createEditorWindow,
createHudOverlayWindow,
createSourceSelectorWindow,
} from "./windows";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -30,18 +25,6 @@ if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
// Enable Wayland support for proper screen capture and window management
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
if (process.platform === "linux") {
const isWayland =
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
if (isWayland) {
app.commandLine.appendSwitch("ozone-platform", "wayland");
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
}
}
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
@@ -77,15 +60,12 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
// Window references
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let countdownOverlayWindow: 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", trayIconSize);
const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize);
const defaultTrayIcon = getTrayIcon("openscreen.png");
const recordingTrayIcon = getTrayIcon("rec-button.png");
function createWindow() {
mainWindow = createHudOverlayWindow();
@@ -136,30 +116,15 @@ function setupApplicationMenu() {
template.push({
label: app.name,
submenu: [
{
role: "about",
label: mainT("common", "actions.about") || "About OpenScreen",
},
{ role: "about" },
{ type: "separator" },
{
role: "services",
label: mainT("common", "actions.services") || "Services",
},
{ role: "services" },
{ type: "separator" },
{
role: "hide",
label: mainT("common", "actions.hide") || "Hide OpenScreen",
},
{
role: "hideOthers",
label: mainT("common", "actions.hideOthers") || "Hide Others",
},
{
role: "unhide",
label: mainT("common", "actions.unhide") || "Show All",
},
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
{ role: "quit" },
],
});
}
@@ -183,89 +148,40 @@ function setupApplicationMenu() {
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac
? []
: [
{ type: "separator" as const },
{
role: "quit" as const,
label: mainT("common", "actions.quit") || "Quit",
},
]),
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
],
},
{
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
{
role: "selectAll",
label: mainT("common", "actions.selectAll") || "Select All",
},
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: mainT("common", "actions.view") || "View",
submenu: [
{
role: "reload",
label: mainT("common", "actions.reload") || "Reload",
},
{
role: "forceReload",
label: mainT("common", "actions.forceReload") || "Force Reload",
},
{
role: "toggleDevTools",
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
},
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{
role: "resetZoom",
label: mainT("common", "actions.actualSize") || "Actual Size",
},
{
role: "zoomIn",
label: mainT("common", "actions.zoomIn") || "Zoom In",
},
{
role: "zoomOut",
label: mainT("common", "actions.zoomOut") || "Zoom Out",
},
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{
role: "togglefullscreen",
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
},
{ role: "togglefullscreen" },
],
},
{
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{ role: "zoom" },
{ type: "separator" },
{ role: "front" },
]
: [
{
role: "minimize",
label: mainT("common", "actions.minimize") || "Minimize",
},
{
role: "close",
label: mainT("common", "actions.close") || "Close",
},
],
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
},
);
@@ -283,12 +199,12 @@ function createTray() {
});
}
function getTrayIcon(filename: string, size: number) {
function getTrayIcon(filename: string) {
return nativeImage
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
.resize({
width: size,
height: size,
width: 24,
height: 24,
quality: "best",
});
}
@@ -296,11 +212,7 @@ function getTrayIcon(filename: string, size: number) {
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording
? mainT("common", "actions.recordingStatus", {
source: selectedSourceName,
}) || `Recording: ${selectedSourceName}`
: "OpenScreen";
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording
? [
{
@@ -408,51 +320,22 @@ function createSourceSelectorWindowWrapper() {
return sourceSelectorWindow;
}
function createCountdownOverlayWindowWrapper() {
if (countdownOverlayWindow && !countdownOverlayWindow.isDestroyed()) {
return countdownOverlayWindow;
}
countdownOverlayWindow = createCountdownOverlayWindow();
countdownOverlayWindow.on("closed", () => {
countdownOverlayWindow = null;
});
return countdownOverlayWindow;
}
// Closing every window quits the app entirely (tray icon goes too).
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
// so closing the last window is an explicit "I'm done" signal.
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
app.quit();
// Keep app running (macOS behavior)
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
const hasVisibleWindow = BrowserWindow.getAllWindows().some((window) => {
if (window.isDestroyed() || !window.isVisible()) {
return false;
}
const url = window.webContents.getURL();
const isCountdownOverlayWindow = url.includes("windowType=countdown-overlay");
return !isCountdownOverlayWindow;
});
if (!hasVisibleWindow) {
showMainWindow();
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Force the app into "regular" activation policy so the Dock icon appears.
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
// window we open, and AppKit otherwise classifies us as an accessory app.
if (process.platform === "darwin") {
app.dock?.show();
}
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
@@ -488,23 +371,11 @@ 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,
createCountdownOverlayWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
() => countdownOverlayWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
@@ -513,7 +384,6 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
switchToHudWrapper,
);
createWindow();
});
+6 -36
View File
@@ -1,33 +1,23 @@
import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
// Asset base URL is passed from the main process via webPreferences.additionalArguments
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
// can't compute it here.
const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url=";
const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX));
const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : "";
contextBridge.exposeInMainWorld("electronAPI", {
assetBaseUrl,
hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
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");
},
@@ -40,9 +30,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
requestAccessibilityAccess: () => {
return ipcRenderer.invoke("request-accessibility-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
@@ -54,15 +41,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording: boolean, recordingId?: number) => {
return ipcRenderer.invoke("set-recording-state", recording, recordingId);
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke("set-recording-state", recording);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
discardCursorTelemetry: (recordingId: number) => {
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
@@ -140,20 +124,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
showCountdownOverlay: (value: number, runId: number) => {
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
},
setCountdownOverlayValue: (value: number, runId: number) => {
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
},
hideCountdownOverlay: (runId: number) => {
return ipcRenderer.invoke("countdown-overlay-hide", runId);
},
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
const listener = (_event: unknown, value: number | null) => callback(value);
ipcRenderer.on("countdown-overlay-value", listener);
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
},
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
try {
+1 -89
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import { BrowserWindow, ipcMain, screen } from "electron";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -9,13 +9,6 @@ const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST = path.join(APP_ROOT, "dist");
const HEADLESS = process.env["HEADLESS"] === "true";
// Asset base URL for renderer (wallpapers, etc.). Packaged: extraResources copies
// public/wallpapers -> resources/wallpapers. Unpackaged: <appRoot>/public/.
const ASSET_BASE_DIR = process.defaultApp
? path.join(__dirname, "..", "public")
: process.resourcesPath;
const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`;
let hudOverlayWindow: BrowserWindow | null = null;
ipcMain.on("hud-overlay-hide", () => {
@@ -24,11 +17,6 @@ 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;
@@ -57,19 +45,12 @@ export function createHudOverlayWindow(): BrowserWindow {
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
// 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());
});
@@ -93,10 +74,6 @@ 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";
@@ -118,7 +95,6 @@ export function createEditorWindow(): BrowserWindow {
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
@@ -144,10 +120,6 @@ 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;
@@ -165,18 +137,11 @@ export function createSourceSelectorWindow(): BrowserWindow {
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
},
});
// 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 {
@@ -187,56 +152,3 @@ export function createSourceSelectorWindow(): BrowserWindow {
return win;
}
/**
* Creates a centered transparent countdown overlay window that sits above the
* HUD while recording pre-roll is running.
*/
export function createCountdownOverlayWindow(): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
const overlayWidth = 420;
const overlayHeight = 260;
const win = new BrowserWindow({
width: overlayWidth,
height: overlayHeight,
minWidth: overlayWidth,
maxWidth: overlayWidth,
minHeight: overlayHeight,
maxHeight: overlayHeight,
x: Math.round(workArea.x + (workArea.width - overlayWidth) / 2),
y: Math.round(workArea.y + (workArea.height - overlayHeight) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
transparent: true,
backgroundColor: "#00000000",
hasShadow: false,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
additionalArguments: [ASSET_BASE_URL_ARG],
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
win.setIgnoreMouseEvents(true);
if (process.platform === "darwin") {
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
}
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown-overlay");
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "countdown-overlay" },
});
}
return win;
}
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"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
}
-122
View File
@@ -1,122 +0,0 @@
{
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;
};
}
Binary file not shown.
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Required for Electron's V8 JIT compilation -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Required for Electron's native module loading -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Required for loading Electron's bundled frameworks/dylibs -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Audio input (microphone / system audio capture) -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Camera (webcam capture) -->
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
-36
View File
@@ -1,36 +0,0 @@
# 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 ];
};
}
-42
View File
@@ -1,42 +0,0 @@
# 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;
};
}
-124
View File
@@ -1,124 +0,0 @@
{
lib,
buildNpmPackage,
nodejs_22,
electron,
makeWrapper,
makeDesktopItem,
copyDesktopItems,
}:
buildNpmPackage {
nodejs = nodejs_22;
pname = "openscreen";
version = "1.4.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 <appPath>/public/. Place wallpapers at that root to match the
# packaged layout (electron-builder extraResources -> resources/wallpapers).
mkdir -p "$out/lib/openscreen/public"
cp -r public/wallpapers "$out/lib/openscreen/public/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;
};
}
+6373 -2038
View File
File diff suppressed because it is too large Load Diff
+42 -53
View File
@@ -1,17 +1,13 @@
{
"name": "openscreen",
"private": true,
"version": "1.4.0",
"version": "1.3.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
"node": "22.22.1",
"npm": "10.9.4"
},
"author": {
"name": "Sid",
"email": "svaddem@asu.edu"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -21,17 +17,13 @@
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"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",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"postinstall": "npm run rebuild:native"
"prepare": "husky"
},
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
@@ -42,70 +34,67 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-block": "^2.10.1",
"@uiw/react-color-colorful": "^2.9.2",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dnd-timeline": "^2.4.0",
"emoji-picker-react": "^4.18.0",
"dnd-timeline": "^2.2.0",
"emoji-picker-react": "^4.16.1",
"fix-webm-duration": "^1.0.6",
"gif.js": "^0.2.0",
"gsap": "^3.15.0",
"gsap": "^3.13.0",
"lucide-react": "^0.545.0",
"mediabunny": "^1.40.1",
"motion": "^12.38.0",
"mp4box": "^2.3.0",
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.6.0",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
"react-rnd": "^10.5.3",
"react-rnd": "^10.5.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"uiohook-napi": "^1.5.5",
"uuid": "^13.0.0",
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.12",
"@electron/rebuild": "^4.0.4",
"@playwright/test": "^1.59.1",
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.19.17",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/browser": "^4.1.4",
"@vitest/browser-playwright": "^4.1.4",
"autoprefixer": "^10.5.0",
"electron": "^41.2.1",
"electron-builder": "^26.8.1",
"esbuild": "^0.27.0",
"fast-check": "^4.7.0",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
"electron-icon-builder": "^2.0.1",
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.2",
"lint-staged": "^16.4.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"terser": "^5.46.1",
"typescript": "^5.9.3",
"vite": "^7.3.2",
"vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6",
"vitest": "^4.1.4"
"jsdom": "^29.0.1",
"lint-staged": "^16.3.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"terser": "^5.44.1",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
},
"main": "dist-electron/main.js",
"lint-staged": {
-216
View File
@@ -1,216 +0,0 @@
#!/bin/bash
#
# OpenScreen macOS Build Script
# Produces: release/<version>/OpenScreen-Mac-<arch>-<version>.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 ""
+5 -10
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares all locale folders (except en) against the en baseline for every namespace.
* Compares zh-CN and es against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es"];
function getKeys(obj, prefix = "") {
const keys = [];
@@ -32,19 +34,12 @@ const namespaces = fs
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
const compareLocales = fs
.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((locale) => locale !== BASE_LOCALE)
.sort((a, b) => a.localeCompare(b));
for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);
for (const locale of compareLocales) {
for (const locale of COMPARE_LOCALES) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(localePath)) {
@@ -82,6 +77,6 @@ if (hasErrors) {
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
-21
View File
@@ -1,21 +0,0 @@
import { spawnSync } from "node:child_process";
import process from "node:process";
// uiohook-napi click capture is macOS-only at runtime (gated in
// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
// without X11 dev headers don't fail npm install. The library's prebuilt
// .node binaries are still bundled and loadable; we just don't need a fresh
// build against Electron's ABI on platforms where we don't load it.
if (process.platform !== "darwin") {
console.log(
`[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
);
process.exit(0);
}
const result = spawnSync(
process.execPath,
["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
{ stdio: "inherit" },
);
process.exit(result.status ?? 0);
+5 -28
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
@@ -10,38 +9,18 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
export default function App() {
const [windowType, setWindowType] = useState(
() => new URLSearchParams(window.location.search).get("windowType") || "",
);
const [windowType, setWindowType] = useState("");
useEffect(() => {
const type = new URLSearchParams(window.location.search).get("windowType") || "";
if (type !== windowType) {
setWindowType(type);
}
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
const params = new URLSearchParams(window.location.search);
const type = params.get("windowType") || "";
setWindowType(type);
if (type === "hud-overlay" || type === "source-selector") {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
document.getElementById("root")?.style.setProperty("background", "transparent");
}
// HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow
// so the renderer can't introduce scrollbars (see issue #305).
if (type === "hud-overlay") {
document.documentElement.style.height = "100%";
document.documentElement.style.overflow = "hidden";
document.body.style.height = "100%";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
const root = document.getElementById("root");
root?.style.setProperty("height", "100%");
root?.style.setProperty("min-height", "0");
root?.style.setProperty("overflow", "hidden");
}
}, [windowType]);
useEffect(() => {
// Load custom fonts on app initialization
loadAllCustomFonts().catch((error) => {
console.error("Failed to load custom fonts:", error);
@@ -54,8 +33,6 @@ export default function App() {
return <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "countdown-overlay":
return <CountdownOverlay />;
case "editor":
return (
<ShortcutsProvider>
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
export function CountdownOverlay() {
const [value, setValue] = useState<number | null>(null);
useEffect(() => {
const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => {
setValue(nextValue);
});
return () => unsubscribe();
}, []);
if (value === null) {
return null;
}
return (
<div className="w-screen h-screen bg-transparent flex items-center justify-center pointer-events-none select-none">
<div className="flex items-center justify-center w-40 h-40 rounded-full bg-black/50">
<div
className="text-white/90 text-[80px] font-bold leading-none tabular-nums"
style={{ textShadow: "0 4px 24px rgba(0, 0, 0, 0.65)" }}
>
{value}
</div>
</div>
</div>
);
}
@@ -6,78 +6,3 @@
.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;
}
+101 -258
View File
@@ -1,6 +1,5 @@
import { Check, ChevronDown, Languages } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
@@ -19,7 +18,9 @@ import {
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import { isMac as getIsMac } from "@/utils/platformUtils";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -27,7 +28,6 @@ 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";
@@ -67,26 +67,17 @@ 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 availableLocales = getAvailableLocales();
const {
locale,
setLocale,
systemLocaleSuggestion,
acceptSystemLocaleSuggestion,
dismissSystemLocaleSuggestion,
resolveSystemLocaleSuggestion,
} = useI18n();
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
const { locale, setLocale } = useI18n();
const [isMac, setIsMac] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac);
}, []);
const {
recording,
@@ -118,18 +109,6 @@ 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<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
right: number;
top: number;
maxHeight: number;
}>({
right: 12,
top: 12,
maxHeight: 240,
});
const {
devices: micDevices,
@@ -183,71 +162,6 @@ 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);
@@ -314,48 +228,25 @@ export function LaunchWindow() {
};
return (
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
// 100vw can exceed the inner layout width when scrollbars affect the
// viewport (notably on Windows), causing a horizontal scrollbar once the
// recording toolbar widened (issue #305).
<div
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
>
{systemLocaleSuggestion && (
<div
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
{/* Language switcher — top-left, beside traffic lights */}
<div
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
<div className="text-[13px] font-semibold text-white">
{t("systemLanguagePrompt.title")}
</div>
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
{t("systemLanguagePrompt.description", {
language: suggestedLanguageName,
})}
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={dismissSystemLocaleSuggestion}
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
>
{t("systemLanguagePrompt.keepDefault")}
</Button>
<Button
type="button"
size="sm"
onClick={acceptSystemLocaleSuggestion}
className="h-7 text-xs bg-white text-[#10121b] hover:bg-white/90"
>
{t("systemLanguagePrompt.switch", {
language: suggestedLanguageName,
})}
</Button>
</div>
</div>
)}
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
@@ -532,7 +423,6 @@ export function LaunchWindow() {
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
disabled={recording}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
@@ -543,151 +433,104 @@ export function LaunchWindow() {
{/* Record/Stop group */}
<button
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "bg-red-500/12 hover:bg-red-500/16"
: "animate-record-pulse bg-red-500/10"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
<div className={`flex items-center justify-center ${recording ? "gap-1.5" : ""}`}>
{recording
? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
: getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
{recording && (
{recording ? (
<>
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
<span
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
>
{formatTimePadded(elapsedSeconds)}
</span>
)}
</div>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{recording && (
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={togglePaused}
>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
<Tooltip content={t("tooltips.restartRecording")}>
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
<Tooltip content={t("tooltips.cancelRecording")}>
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
{getIcon("cancel", "text-white/60")}
</button>
</Tooltip>
</div>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
)}
{!recording && (
<>
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
</>
{/* Restart recording */}
{recording && (
<Tooltip content={t("tooltips.restartRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Right sidebar controls */}
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
{/* Cancel recording */}
{recording && (
<Tooltip content={t("tooltips.cancelRecording")}>
<button
ref={languageTriggerRef}
type="button"
aria-label={t("language")}
aria-expanded={isLanguageMenuOpen}
aria-haspopup="menu"
onClick={() => setIsLanguageMenuOpen((open) => !open)}
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={cancelRecording}
>
<div className="flex w-full items-center justify-center">
<Languages size={13} className="text-white/75" />
</div>
{getIcon("cancel", "text-white/60")}
</button>
</div>
</Tooltip>
)}
{isLanguageMenuOpen
? createPortal(
<div
ref={languageMenuPanelRef}
role="menu"
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
style={
{
WebkitAppRegion: "no-drag",
pointerEvents: "auto",
right: `${languageMenuStyle.right}px`,
top: `${languageMenuStyle.top}px`,
maxHeight: `${languageMenuStyle.maxHeight}px`,
} as React.CSSProperties
}
onPointerDown={(event) => event.stopPropagation()}
>
{availableLocales.map((loc) => (
<button
key={loc}
type="button"
role="menuitemradio"
aria-checked={loc === locale}
onClick={() => {
setLocale(loc);
resolveSystemLocaleSuggestion();
setIsLanguageMenuOpen(false);
}}
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
>
<span className="truncate">{getLocaleName(loc)}</span>
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
</button>
))}
</div>,
document.body,
)
: null}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className="flex items-center gap-0.5">
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</div>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</div>
</div>
</div>
+16 -19
View File
@@ -2,21 +2,15 @@
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 30px;
corner-shape: squircle;
/*
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
The result is easily visible when you place a white window just behind the SourceSelector
*/
/* box-shadow:
0 0px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
border: 1.5px solid rgba(60, 60, 80, 0.3);
border-radius: 14px;
box-shadow:
0 4px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
border: 1px solid rgba(60, 60, 80, 0.18);
}
.sourceCard {
corner-shape: squircle;
border-radius: 20px;
border-radius: 12px;
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
border: 1px solid rgba(60, 60, 80, 0.22);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
@@ -34,7 +28,7 @@
}
.selected {
border: 1.5px solid #34b27b;
border: 2px solid #34b27b;
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
box-shadow:
0 0 12px rgba(52, 178, 123, 0.15),
@@ -76,27 +70,30 @@
}
/* scrollbar */
.sourceGridScroll {
scrollbar-width: thin;
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
}
.sourceGridScroll::-webkit-scrollbar {
width: 3px;
width: 8px;
}
.sourceGridScroll::-webkit-scrollbar-track {
background: rgba(30, 30, 38, 0.3);
background: rgba(30, 30, 38, 0.5);
border-radius: 4px;
margin: 4px 0;
}
.sourceGridScroll::-webkit-scrollbar-thumb {
background: rgba(52, 178, 123, 0.5);
border-radius: 10px;
background: rgba(80, 80, 100, 0.6);
border-radius: 4px;
}
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.6);
cursor: grab;
}
.sourceGridScroll::-webkit-scrollbar-thumb:active {
background: rgba(52, 178, 123, 0.8);
cursor: grabbing;
}
+11 -11
View File
@@ -65,7 +65,7 @@ export function SourceSelector() {
style={{ minHeight: "100vh" }}
>
<div className="text-center">
<div className="animate-spin duration-500 rounded-[50%] h-6 w-6 border-2 border-b-transparent border-[#34B27B] mx-auto mb-2" />
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
</div>
</div>
@@ -84,10 +84,10 @@ export function SourceSelector() {
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
className="w-full aspect-video object-cover rounded-lg"
/>
{isSelected && (
<div className="absolute -top-1 -right-1">
<div className="absolute -top-1.5 -right-1.5">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
</div>
@@ -111,16 +111,16 @@ export function SourceSelector() {
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
@@ -128,14 +128,14 @@ export function SourceSelector() {
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
@@ -143,18 +143,18 @@ export function SourceSelector() {
</div>
</Tabs>
</div>
<div className="p-3 justify-center flex gap-2">
<div className="p-3 flex justify-center gap-2">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
{tc("actions.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
{tc("actions.share")}
</Button>
+1 -1
View File
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+1 -1
View File
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
);
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
-161
View File
@@ -1,161 +0,0 @@
import { HsvaColor, hexToHsva } from "@uiw/color-convert";
import Block from "@uiw/react-color-block";
import Colorful from "@uiw/react-color-colorful";
import { useEffect, useState } from "react";
import { Button } from "./button";
import { Input } from "./input";
type BaseProps = {
selectedColor: string;
colorPalette: string[];
onUpdateColor: (color: string) => void;
};
type ColorPickerProps =
| (BaseProps & {
clearBackgroundOption?: false;
translations: Record<"colorWheel" | "colorPalette", string>;
})
| (BaseProps & {
clearBackgroundOption: true;
translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>;
});
export default function ColorPicker(props: ColorPickerProps) {
const { selectedColor, colorPalette, translations, onUpdateColor } = props;
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
const [hexInput, setHexInput] = useState(selectedColor);
const [transparentColorHSVA, setTransparentColorHSVA] = useState<HsvaColor>({
h: 0,
s: 0,
v: 0,
a: 0,
});
useEffect(() => {
setHexInput(selectedColor);
}, [selectedColor]);
const getTextColor = (color: string) => {
if (color === "transparent") return "#ffffff";
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
if (luminance > 186) return "#000000";
return "#ffffff";
};
// Normalize the hex input.
// Adds a # at the beginning of the input if it's not there.
const normalizeHexDraft = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === "") return "";
if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`;
return trimmed;
};
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const normalized = normalizeHexDraft(e.target.value);
setHexInput(normalized);
// Check if the normalized hex is a valid hex color.
// It should follow the format #RRGGBB or #RGB.
const isValidHexColor =
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
if (isValidHexColor) {
onUpdateColor(normalized);
}
};
const toTransparent = (color: string) => {
if (color === "transparent") return;
const hsva = hexToHsva(color);
hsva.a = 0;
return hsva;
};
return (
<div className="p-1 flex flex-col gap-4 items-center">
<div className="flex items-center gap-2 w-full">
<Button
variant="outline"
size="sm"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
onClick={() => setColorMode("wheel")}
style={{
backgroundColor: colorMode === "wheel" ? "#34B27B" : "transparent",
}}
>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{translations.colorWheel}
</span>
</Button>
<Button
variant="outline"
size="sm"
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
onClick={() => setColorMode("palette")}
style={{
backgroundColor: colorMode === "palette" ? "#34B27B" : "transparent",
}}
>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{translations.colorPalette}
</span>
</Button>
</div>
{colorMode === "wheel" && (
<>
<div
className={`w-full h-20 flex items-center justify-center border border-white/10 rounded-lg`}
style={{ backgroundColor: selectedColor }}
>
<span style={{ color: getTextColor(selectedColor) }}>{selectedColor}</span>
</div>
<Colorful
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
onChange={(color) => {
onUpdateColor(color.hex);
}}
style={{
borderRadius: "8px",
}}
disableAlpha={true}
/>
<Input
type="text"
value={hexInput}
className="w-full h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
onChange={handleColorInputChange}
/>
</>
)}
{colorMode === "palette" && (
<Block
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
colors={colorPalette}
onChange={(color) => {
onUpdateColor(color.hex);
}}
style={{
width: "100%",
borderRadius: "8px",
}}
/>
)}
{props.clearBackgroundOption === true && (
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
const hsva = toTransparent(selectedColor);
if (hsva) setTransparentColorHSVA(hsva);
onUpdateColor("transparent");
}}
>
{props.translations.clearBackground}
</Button>
)}
</div>
);
}
+5 -5
View File
@@ -90,13 +90,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogFooter,
DialogTitle,
DialogTrigger,
DialogDescription,
};
+11 -19
View File
@@ -54,11 +54,9 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
portalled?: boolean;
}
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
const content = (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
@@ -69,14 +67,8 @@ const DropdownMenuContent = React.forwardRef<
)}
{...props}
/>
);
if (!portalled) {
return content;
}
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
});
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
@@ -177,18 +169,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuRadioGroup,
};
+1 -1
View File
@@ -57,4 +57,4 @@ function PopoverArrow({
);
}
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
+31 -47
View File
@@ -62,50 +62,34 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
showScrollButtons?: boolean;
viewportClassName?: string;
}
>(
(
{
className,
children,
position = "popper",
showScrollButtons = true,
viewportClassName,
...props
},
ref,
) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"p-1",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
className,
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
position={position}
{...props}
>
{showScrollButtons ? <SelectScrollUpButton /> : null}
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"max-h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
viewportClassName,
)}
>
{children}
</SelectPrimitive.Viewport>
{showScrollButtons ? <SelectScrollDownButton /> : null}
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
),
);
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
@@ -157,13 +141,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+1 -1
View File
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };
export { Tabs, TabsList, TabsTrigger, TabsContent };
+1 -1
View File
@@ -67,4 +67,4 @@ function Tooltip({
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
+16 -431
View File
@@ -1,40 +1,8 @@
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
import { useRef } 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,
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;
}
import type { AnnotationRegion } from "./types";
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -43,13 +11,9 @@ 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({
@@ -59,130 +23,16 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
onBlurDataChange,
onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
previewSourceCanvas,
previewFrameVersion,
}: AnnotationOverlayProps) {
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 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 isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
const 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";
@@ -193,95 +43,6 @@ export function AnnotationOverlay({
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
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":
@@ -352,149 +113,6 @@ export function AnnotationOverlay({
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
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 (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
backgroundColor: blurOverlayColor,
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<canvas
ref={mosaicCanvasRef}
className="absolute inset-0 w-full h-full"
style={{
...shapeMaskStyle,
imageRendering: "pixelated",
}}
/>
)}
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundColor: blurOverlayColor,
}}
/>
)}
{blurType === "mosaic" && (
<div
className="absolute inset-0 pointer-events-none"
style={{
...shapeMaskStyle,
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
backgroundSize: `${blockSize}px ${blockSize}px`,
mixBlendMode: "screen",
opacity: 0.35,
}}
/>
)}
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</div>
);
}
default:
return null;
}
@@ -507,19 +125,7 @@ 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 });
@@ -529,21 +135,7 @@ 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;
@@ -557,23 +149,18 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
"cursor-move",
isSelected &&
annotation.type !== "blur" &&
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
"cursor-move transition-all",
isSelected && "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 && 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",
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",
}}
enableResizing={isSelected && !isSelectedFreehandBlur}
disableDragging={!isSelected || isSelectedFreehandBlur}
enableResizing={isSelected}
disableDragging={!isSelected}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -619,13 +206,11 @@ export function AnnotationOverlay({
>
<div
className={cn(
"w-full h-full",
annotation.type !== "blur" && "rounded-lg",
"w-full h-full rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
isSelected && "shadow-lg",
)}
>
{renderContent()}
@@ -5,7 +5,6 @@ import {
AlignRight,
Bold,
ChevronDown,
Copy,
Image as ImageIcon,
Info,
Italic,
@@ -31,15 +30,9 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import ColorPicker from "../ui/color-picker";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import {
type AnnotationRegion,
type AnnotationType,
type ArrowDirection,
type FigureData,
} from "./types";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -47,7 +40,6 @@ interface AnnotationSettingsPanelProps {
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onDuplicate?: () => void;
onDelete: () => void;
}
@@ -70,12 +62,12 @@ export function AnnotationSettingsPanel({
onTypeChange,
onStyleChange,
onFigureDataChange,
onDuplicate,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("settings");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontStyleLabels: Record<string, string> = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
@@ -388,19 +380,15 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={annotation.style.color}
colorPalette={colorPalette}
translations={{
colorWheel: t("annotation.colorWheel"),
colorPalette: t("annotation.colorPalette"),
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.style.color}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ color: color.hex });
}}
onUpdateColor={(color) => {
onStyleChange({ color: color });
style={{
borderRadius: "8px",
}}
/>
</PopoverContent>
@@ -431,23 +419,31 @@ export function AnnotationSettingsPanel({
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={annotation.style.backgroundColor}
colorPalette={colorPalette}
translations={{
colorWheel: t("annotation.colorWheel"),
colorPalette: t("annotation.colorPalette"),
clearBackground: t("annotation.clearBackground"),
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={
annotation.style.backgroundColor === "transparent"
? "#000000"
: annotation.style.backgroundColor
}
colors={colorPalette}
onChange={(color) => {
onStyleChange({ backgroundColor: color.hex });
}}
clearBackgroundOption={true}
onUpdateColor={(color) => {
onStyleChange({ backgroundColor: color });
style={{
borderRadius: "8px",
}}
/>
<Button
variant="ghost"
size="sm"
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
onClick={() => {
onStyleChange({ backgroundColor: "transparent" });
}}
>
{t("annotation.clearBackground")}
</Button>
</PopoverContent>
</Popover>
</div>
@@ -601,28 +597,15 @@ export function AnnotationSettingsPanel({
</TabsContent>
</Tabs>
<div className="mt-4 grid grid-cols-2 gap-2">
<Button
onClick={() => onDuplicate?.()}
variant="outline"
size="sm"
disabled={!onDuplicate}
className="w-full gap-2 bg-white/5 text-slate-200 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all"
>
<Copy className="w-4 h-4" />
Duplicate
</Button>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
@@ -1,247 +0,0 @@
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 (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
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" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurType")}
</label>
<Select
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
onValueChange={(value) => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
type: value === "mosaic" ? "mosaic" : "blur",
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4">
<label className="text-xs font-medium text-slate-300 mb-2 block">
{t("annotation.blurColor")}
</label>
<div className="grid grid-cols-2 gap-2">
{blurColorOptions.map((option) => {
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
const isActive = activeColor === option.value;
return (
<button
key={option.value}
onClick={() => {
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",
)}
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{
backgroundColor: getBlurOverlayColor({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
color: option.value,
}),
}}
/>
<span className="text-xs text-slate-200">
{t(`annotation.${option.labelKey}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{blurRegion.blurData?.type === "mosaic"
? t("annotation.mosaicBlockSize")
: t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
)}
px
</span>
</div>
<Slider
value={[
blurRegion.blurData?.type === "mosaic"
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
]}
onValueChange={(values) => {
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"
/>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
+70 -441
View File
@@ -1,6 +1,6 @@
import Block from "@uiw/react-color-block";
import {
Bug,
ChevronDown,
Crop,
Download,
Film,
@@ -14,7 +14,7 @@ import {
Upload,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -23,7 +23,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -35,102 +34,34 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import ColorPicker from "../ui/color-picker";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
Rotation3DPreset,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
ROTATION_3D_PRESET_ORDER,
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<HTMLInputElement>) => {
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 (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => 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"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}
import { SPEED_OPTIONS } from "./types";
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `wallpapers/wallpaper${i + 1}.jpg`,
);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -159,12 +90,6 @@ const GRADIENTS = [
];
interface SettingsPanelProps {
cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
onCursorHighlightChange?: (
next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
) => void;
// macOS only — gates the "Only on clicks" toggle (needs uiohook).
cursorHighlightSupportsClicks?: boolean;
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
@@ -174,8 +99,6 @@ interface SettingsPanelProps {
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedZoomRotationPreset?: Rotation3DPreset | null;
onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
@@ -209,11 +132,7 @@ 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[];
@@ -221,13 +140,7 @@ interface SettingsPanelProps {
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
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;
@@ -237,9 +150,6 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -254,9 +164,6 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
];
export function SettingsPanel({
cursorHighlight,
onCursorHighlightChange,
cursorHighlightSupportsClicks = false,
selected,
onWallpaperChange,
selectedZoomDepth,
@@ -266,8 +173,6 @@ export function SettingsPanel({
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedZoomRotationPreset,
onZoomRotationPresetChange,
selectedTrimId,
onTrimDelete,
shadowIntensity = 0,
@@ -308,13 +213,7 @@ export function SettingsPanel({
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDuplicate,
onAnnotationDelete,
selectedBlurId,
blurRegions = [],
onBlurDataChange,
onBlurDataCommit,
onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -324,17 +223,26 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
// on click — never the machine-specific file:// URL.
const wallpaperPreviewUrls = useMemo(() => WALLPAPER_PATHS.map(resolveImageWallpaperUrl), []);
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
if (mounted) setWallpaperPaths(resolved);
} catch (_err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
}
})();
return () => {
mounted = false;
};
}, []);
const colorPalette = [
"#FF0000",
"#FFD700",
@@ -360,7 +268,6 @@ export function SettingsPanel({
const cropSnapshotRef = useRef<CropRegion | null>(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
@@ -517,7 +424,7 @@ export function SettingsPanel({
setCustomImages((prev) => prev.filter((img) => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
onWallpaperChange(WALLPAPER_PATHS[0]);
onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
}
};
@@ -539,9 +446,6 @@ 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 (
@@ -562,25 +466,11 @@ export function SettingsPanel({
? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
: undefined
}
onDuplicate={
onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined
}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<BlurSettingsPanel
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
@@ -657,36 +547,6 @@ export function SettingsPanel({
)}
</div>
)}
{zoomEnabled && (
<div className="mt-4">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.threeD.title")}
</span>
<div className="grid grid-cols-3 gap-1.5">
{ROTATION_3D_PRESET_ORDER.map((preset) => {
const isActive = selectedZoomRotationPreset === preset;
return (
<Button
key={preset}
type="button"
onClick={() => onZoomRotationPresetChange?.(isActive ? null : preset)}
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",
)}
>
<span className="text-xs font-semibold capitalize">
{t(`zoom.threeD.preset.${preset}`)}
</span>
</Button>
);
})}
</div>
</div>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
@@ -724,7 +584,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-5 gap-1.5">
<div className="grid grid-cols-7 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -749,29 +609,6 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
@@ -819,17 +656,15 @@ export function SettingsPanel({
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
if (preset.value === "picture-in-picture") return true;
if (preset.value === "vertical-stack") return isPortraitCanvas;
return !isPortraitCanvas;
}).map((preset) => (
{WEBCAM_LAYOUT_PRESETS.filter(
(preset) =>
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: preset.value === "vertical-stack"
? t("layout.verticalStack")
: t("layout.dualFrame")}
: t("layout.verticalStack")}
</SelectItem>
))}
</SelectContent>
@@ -916,27 +751,6 @@ export function SettingsPanel({
</div>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.webcamSize")}
</div>
<div className="text-[10px] font-medium text-slate-400">
{webcamSizePreset}%
</div>
</div>
<Slider
value={[webcamSizePreset]}
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
onValueCommit={() => onWebcamSizePresetCommit?.()}
min={10}
max={50}
step={1}
className="w-full"
/>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@@ -1042,205 +856,6 @@ export function SettingsPanel({
</div>
</div>
{cursorHighlight && onCursorHighlightChange && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2 space-y-2">
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">
{t("effects.cursorHighlight.title")}
</div>
<button
type="button"
onClick={() =>
onCursorHighlightChange({
...cursorHighlight,
enabled: !cursorHighlight.enabled,
})
}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.enabled
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.enabled ? t("effects.on") : t("effects.off")}
</button>
</div>
<div
className={`grid grid-cols-2 gap-1 ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
{(["dot", "ring"] as const).map((style) => (
<button
key={style}
type="button"
onClick={() => onCursorHighlightChange({ ...cursorHighlight, style })}
className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
cursorHighlight.style === style
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
}`}
>
{t(`effects.cursorHighlight.${style}`)}
</button>
))}
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.size")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorHighlight.sizePx}px
</span>
</div>
<Slider
value={[cursorHighlight.sizePx]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
sizePx: values[0],
})
}
min={10}
max={36}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
{cursorHighlightSupportsClicks && (
<div
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.onlyOnClicks")}
</div>
<button
type="button"
onClick={async () => {
const turningOn = !cursorHighlight.onlyOnClicks;
if (turningOn) {
try {
const result =
await window.electronAPI?.requestAccessibilityAccess?.();
if (!result?.granted) {
toast.message(
t("effects.cursorHighlight.accessibilityPermissionTitle"),
{
description: t(
"effects.cursorHighlight.accessibilityPermissionDescription",
),
},
);
return;
}
} catch (err) {
console.warn("Accessibility request failed:", err);
}
}
onCursorHighlightChange({
...cursorHighlight,
onlyOnClicks: turningOn,
});
}}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.onlyOnClicks
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.onlyOnClicks ? t("effects.on") : t("effects.off")}
</button>
</div>
)}
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="text-[10px] text-slate-400 mb-1">
{t("effects.cursorHighlight.color")}
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-8 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: cursorHighlight.color }}
/>
<span className="text-[10px] text-slate-300 truncate flex-1 text-left font-mono">
{cursorHighlight.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={cursorHighlight.color}
colorPalette={colorPalette}
translations={{
colorWheel: t("background.colorWheel"),
colorPalette: t("background.colorPalette"),
}}
onUpdateColor={(color) =>
onCursorHighlightChange({
...cursorHighlight,
color,
})
}
/>
</PopoverContent>
</Popover>
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.offsetX")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetXNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetXNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.offsetY")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetYNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetYNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
</div>
)}
<Button
onClick={handleCropToggle}
variant="outline"
@@ -1285,7 +900,7 @@ export function SettingsPanel({
</TabsTrigger>
</TabsList>
<div className="overflow-y-auto custom-scrollbar">
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
<TabsContent value="image" className="mt-0 space-y-2">
<input
type="file"
@@ -1333,12 +948,26 @@ export function SettingsPanel({
);
})}
{WALLPAPER_PATHS.map((canonicalPath, i) => {
const previewUrl = wallpaperPreviewUrls[i] ?? canonicalPath;
const isSelected = selected === canonicalPath;
{(wallpaperPaths.length > 0
? wallpaperPaths
: WALLPAPER_RELATIVE.map((p) => `/${p}`)
).map((path) => {
const isSelected = (() => {
if (!selected) return false;
if (selected === path) return true;
try {
const clean = (s: string) =>
s.replace(/^file:\/\//, "").replace(/^\//, "");
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {
// Best-effort comparison; fallback to strict match.
}
return false;
})();
return (
<div
key={canonicalPath}
key={path}
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
isSelected
@@ -1346,11 +975,11 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{
backgroundImage: `url(${previewUrl})`,
backgroundImage: `url(${path})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
onClick={() => onWallpaperChange(canonicalPath)}
onClick={() => onWallpaperChange(path)}
role="button"
/>
);
@@ -1359,18 +988,20 @@ export function SettingsPanel({
</TabsContent>
<TabsContent value="color" className="mt-0">
<ColorPicker
selectedColor={selectedColor}
colorPalette={colorPalette}
translations={{
colorWheel: t("background.colorWheel"),
colorPalette: t("background.colorPalette"),
}}
onUpdateColor={(color) => {
setSelectedColor(color);
onWallpaperChange(color);
}}
/>
<div className="p-1">
<Block
color={selectedColor}
colors={colorPalette}
onChange={(color) => {
setSelectedColor(color.hex);
onWallpaperChange(color.hex);
}}
style={{
width: "100%",
borderRadius: "8px",
}}
/>
</div>
</TabsContent>
<TabsContent value="gradient" className="mt-0">
@@ -1385,9 +1016,7 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{ background: g }}
aria-label={t("background.gradientLabel", {
index: idx + 1,
})}
aria-label={t("background.gradientLabel", { index: idx + 1 })}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
@@ -126,99 +126,95 @@ export function ShortcutsConfigDialog() {
if (!open) handleClose();
}}
>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px] max-h-[85vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
{t("title")}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠{" "}
{t("alreadyUsedBy", {
action: t(`actions.${conflict.conflictWith.action}`),
})}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
</div>
)}
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
);
})}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠{" "}
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
{tc("actions.cancel")}
</button>
</div>
</div>
)}
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
);
})}
</div>
<DialogFooter className="shrink-0 flex gap-2 sm:justify-between mt-2">
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
+44 -412
View File
@@ -1,21 +1,13 @@
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages, Save, Video } from "lucide-react";
import { FolderOpen, Languages, Save } 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 } from "@/i18n/config";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import {
calculateOutputDimensions,
type ExportFormat,
@@ -31,8 +23,6 @@ import {
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
@@ -55,19 +45,16 @@ 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,
type FigureData,
type PlaybackSpeed,
type Rotation3DPreset,
type SpeedRegion,
type TrimRegion,
type ZoomDepth,
@@ -102,9 +89,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
cursorHighlight,
} = editorState;
// ── Non-undoable state
@@ -123,17 +108,14 @@ export default function VideoEditor() {
const durationRef = useRef(duration);
durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
@@ -156,30 +138,14 @@ export default function VideoEditor() {
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
// renderers while keeping the persisted value intact for round-tripping.
const effectiveCursorHighlight = useMemo(
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
[cursorHighlight, isMac],
);
const { locale, setLocale, t: rawT } = useI18n();
const t = useScopedT("editor");
const ts = useScopedT("settings");
const availableLocales = getAvailableLocales();
const { locale, setLocale } = useI18n();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const annotationOnlyRegions = useMemo(
() => annotationRegions.filter((region) => region.type !== "blur"),
[annotationRegions],
);
const blurRegions = useMemo(
() => annotationRegions.filter((region) => region.type === "blur"),
[annotationRegions],
);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
@@ -239,7 +205,6 @@ export default function VideoEditor() {
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -252,7 +217,6 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -370,10 +334,7 @@ export default function VideoEditor() {
setLastSavedSnapshot(
createProjectSnapshot(
webcamSourcePath
? {
screenVideoPath: sourcePath,
webcamVideoPath: webcamSourcePath,
}
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
INITIAL_EDITOR_STATE,
),
@@ -405,28 +366,6 @@ 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) {
@@ -439,7 +378,7 @@ export default function VideoEditor() {
return false;
}
const editorState = {
const projectData = createProjectData(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
@@ -454,25 +393,20 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
cursorHighlight,
};
const projectData = createProjectData(currentProjectMedia, editorState);
});
const fileNameBase =
currentProjectMedia.screenVideoPath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
// Match the normalization path used by `currentProjectSnapshot` so the
// post-save baseline compares equal and `hasUnsavedChanges` clears.
const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
const projectSnapshot = JSON.stringify(projectData);
const result = await window.electronAPI.saveProjectFile(
projectData,
fileNameBase,
@@ -522,8 +456,6 @@ export default function VideoEditor() {
gifSizePreset,
videoPath,
t,
webcamSizePreset,
cursorHighlight,
],
);
@@ -546,16 +478,6 @@ 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();
@@ -564,18 +486,18 @@ export default function VideoEditor() {
}
if (!result.success) {
toast.error(result.message || t("project.failedToLoad"));
toast.error(result.message || "Failed to load project");
return;
}
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error(t("project.invalidFormat"));
toast.error("Invalid project file format");
return;
}
toast.success(t("project.loadedFrom", { path: result.path ?? "" }));
}, [applyLoadedProject, t]);
toast.success(`Project loaded from ${result.path}`);
}, [applyLoadedProject]);
useEffect(() => {
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
@@ -598,7 +520,6 @@ export default function VideoEditor() {
if (!sourcePath) {
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
}
return;
}
@@ -607,13 +528,11 @@ export default function VideoEditor() {
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
}
} catch (telemetryError) {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
}
}
}
@@ -660,11 +579,7 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
if (id) {
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
if (id) setSelectedTrimId(null);
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
@@ -672,7 +587,6 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -681,17 +595,6 @@ 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);
}
}, []);
@@ -709,7 +612,6 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -728,7 +630,6 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -745,7 +646,6 @@ export default function VideoEditor() {
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -755,11 +655,7 @@ 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,
),
}));
@@ -772,11 +668,7 @@ 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,
),
}));
@@ -802,11 +694,7 @@ 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,
),
}));
@@ -828,9 +716,7 @@ 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);
}
@@ -838,28 +724,9 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomRotationPresetChange = useCallback(
(preset: Rotation3DPreset | null) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) => {
if (region.id !== selectedZoomId) return region;
if (preset === null) {
const { rotationPreset: _p, ...rest } = region;
return rest;
}
return { ...region, rotationPreset: preset };
}),
}));
},
[selectedZoomId, pushState],
);
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
}));
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
@@ -873,7 +740,6 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -886,14 +752,11 @@ 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],
);
@@ -954,41 +817,10 @@ 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],
);
@@ -998,11 +830,7 @@ 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,
),
}));
@@ -1010,33 +838,6 @@ export default function VideoEditor() {
[pushState],
);
const handleAnnotationDuplicate = useCallback(
(id: string) => {
const duplicateId = `annotation-${nextAnnotationIdRef.current++}`;
const duplicateZIndex = nextAnnotationZIndexRef.current++;
pushState((prev) => {
const source = prev.annotationRegions.find((region) => region.id === id);
if (!source) return {};
const duplicate: AnnotationRegion = {
...source,
id: duplicateId,
zIndex: duplicateZIndex,
position: { x: source.position.x + 4, y: source.position.y + 4 },
size: { ...source.size },
style: { ...source.style },
figureData: source.figureData ? { ...source.figureData } : undefined,
};
return { annotationRegions: [...prev.annotationRegions, duplicate] };
});
setSelectedAnnotationId(duplicateId);
setSelectedZoomId(null);
setSelectedTrimId(null);
},
[pushState],
);
const handleAnnotationDelete = useCallback(
(id: string) => {
pushState((prev) => ({
@@ -1045,11 +846,8 @@ export default function VideoEditor() {
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
if (selectedBlurId === id) {
setSelectedBlurId(null);
}
},
[selectedAnnotationId, selectedBlurId, pushState],
[selectedAnnotationId, pushState],
);
const handleAnnotationContentChange = useCallback(
@@ -1084,26 +882,12 @@ 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, selectedAnnotationId, selectedBlurId],
[pushState],
);
const handleAnnotationStyleChange = useCallback(
@@ -1128,51 +912,6 @@ 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) => ({
@@ -1286,14 +1025,11 @@ export default function VideoEditor() {
useEffect(() => {
if (
selectedAnnotationId &&
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
!annotationRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
setSelectedBlurId(null);
}
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
}, [selectedAnnotationId, annotationRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
@@ -1319,22 +1055,17 @@ export default function VideoEditor() {
const handleExportSaved = useCallback(
(formatLabel: "GIF" | "Video", filePath: string) => {
setExportedFilePath(filePath);
toast.success(
t("export.exportedSuccessfully", {
format: formatLabel,
}),
{
description: filePath,
action: {
label: rawT("common.actions.showInFolder"),
onClick: () => {
void handleShowExportedFile(filePath);
},
toast.success(`${formatLabel} exported successfully`, {
description: filePath,
action: {
label: "Show in Folder",
onClick: () => {
void handleShowExportedFile(filePath);
},
},
);
});
},
[handleShowExportedFile, t, rawT],
[handleShowExportedFile],
);
const handleSaveUnsavedExport = useCallback(async () => {
@@ -1420,13 +1151,10 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1440,12 +1168,6 @@ export default function VideoEditor() {
const timestamp = Date.now();
const fileName = `export-${timestamp}.gif`;
if (result.warnings) {
for (const warning of result.warnings) {
toast.warning(warning);
}
}
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
@@ -1562,13 +1284,10 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1582,12 +1301,6 @@ export default function VideoEditor() {
const timestamp = Date.now();
const fileName = `export-${timestamp}.mp4`;
if (result.warnings) {
for (const warning of result.warnings) {
toast.warning(warning);
}
}
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
@@ -1611,15 +1324,9 @@ export default function VideoEditor() {
}
} catch (error) {
console.error("Export error:", error);
if (error instanceof BackgroundLoadError) {
const message = t("errors.exportBackgroundLoadFailed", { url: error.displayUrl });
setExportError(message);
toast.error(message);
} else {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(t("errors.exportFailedWithError", { error: errorMessage }));
}
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
setIsExporting(false);
exporterRef.current = null;
@@ -1647,14 +1354,10 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
cursorClickTimestamps,
effectiveCursorHighlight,
t,
],
);
@@ -1733,7 +1436,7 @@ export default function VideoEditor() {
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="text-foreground">{t("loadingVideo")}</div>
<div className="text-foreground">Loading video...</div>
</div>
);
}
@@ -1747,7 +1450,7 @@ export default function VideoEditor() {
onClick={handleLoadProject}
className="px-3 py-1.5 rounded-md bg-[#34B27B] text-white text-sm hover:bg-[#34B27B]/90"
>
{ts("project.load")}
Load Project File
</button>
</div>
</div>
@@ -1756,34 +1459,6 @@ export default function VideoEditor() {
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
<DialogContent
className="sm:max-w-[425px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<DialogHeader>
<DialogTitle>{t("newRecording.title")}</DialogTitle>
<DialogDescription>{t("newRecording.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={() => 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")}
</button>
<button
type="button"
onClick={handleNewRecordingConfirm}
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
>
{t("newRecording.confirm")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
@@ -1802,21 +1477,13 @@ export default function VideoEditor() {
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{availableLocales.map((loc) => (
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#09090b] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => setShowNewRecordingDialog(true)}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
>
<Video size={14} />
{t("newRecording.title")}
</button>
<button
type="button"
onClick={handleLoadProject}
@@ -1873,7 +1540,6 @@ export default function VideoEditor() {
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1898,21 +1564,12 @@ export default function VideoEditor() {
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationOnlyRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
blurRegions={blurRegions}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onBlurPositionChange={handleAnnotationPositionChange}
onBlurSizeChange={handleAnnotationSizeChange}
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
cursorHighlight={effectiveCursorHighlight}
cursorClickTimestamps={cursorClickTimestamps}
/>
</div>
</div>
@@ -1964,25 +1621,18 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationOnlyRegions}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
blurRegions={blurRegions}
onBlurAdded={handleBlurAdded}
onBlurSpanChange={handleAnnotationSpanChange}
onBlurDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
? "picture-in-picture"
: webcamLayoutPreset,
})
@@ -1996,9 +1646,6 @@ export default function VideoEditor() {
{/* Right section: settings panel */}
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
<SettingsPanel
cursorHighlight={cursorHighlight}
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
cursorHighlightSupportsClicks={isMac}
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
@@ -2014,12 +1661,6 @@ export default function VideoEditor() {
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedZoomRotationPreset={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
: null
}
onZoomRotationPresetChange={handleZoomRotationPresetChange}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
@@ -2044,14 +1685,11 @@ export default function VideoEditor() {
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
})
}
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}
@@ -2078,18 +1716,12 @@ export default function VideoEditor() {
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationOnlyRegions}
annotationRegions={annotationRegions}
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
+248 -464
View File
@@ -18,14 +18,13 @@ import {
useRef,
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import {
getWebcamLayoutCssBoxShadow,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
@@ -35,12 +34,6 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
computeRotation3DContainScale,
DEFAULT_ROTATION_3D,
isRotation3DIdentity,
lerpRotation3D,
rotation3DPerspective,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -56,17 +49,7 @@ import {
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import {
adaptiveSmoothFactor,
interpolateCursorAt,
smoothCursorFocus,
} from "./videoPlayback/cursorFollowUtils";
import {
type CursorHighlightConfig,
clickEmphasisAlpha,
DEFAULT_CURSOR_HIGHLIGHT,
drawCursorHighlightGraphics,
} from "./videoPlayback/cursorHighlight";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -86,7 +69,6 @@ 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;
@@ -117,16 +99,7 @@ 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[];
cursorHighlight?: CursorHighlightConfig;
cursorClickTimestamps?: number[];
}
export interface VideoPlaybackRef {
@@ -146,7 +119,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -177,16 +149,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
blurRegions = [],
selectedBlurId,
onSelectBlur,
onBlurPositionChange,
onBlurSizeChange,
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
cursorClickTimestamps = [],
},
ref,
) => {
@@ -200,21 +163,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const composite3DRef = useRef<HTMLDivElement | null>(null);
const outerWrapperRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
const cursorClickTimestampsRef = useRef<number[]>([]);
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -239,14 +194,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const maskGraphicsRef = useRef<Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const isScrubbingRef = useRef(false);
const scrubEndTimerRef = useRef<number | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{
width: number;
height: number;
} | null>(null);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
@@ -334,7 +283,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -366,7 +314,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
@@ -375,11 +322,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
@@ -396,10 +338,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!vid) return;
try {
allowPlaybackRef.current = true;
await vid.play().catch((err) => {
console.log("PLAY ERROR:", err);
throw err;
});
await vid.play();
} catch (error) {
allowPlaybackRef.current = false;
throw error;
@@ -542,17 +481,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
cursorHighlightRef.current = cursorHighlight;
if (cursorHighlightGraphicsRef.current) {
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
}
}, [cursorHighlight]);
useEffect(() => {
cursorClickTimestampsRef.current = cursorClickTimestamps;
}, [cursorClickTimestamps]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -583,24 +511,84 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
if (!pixiReady || !videoReady) return;
const el = overlayRef.current;
if (!el) return;
// Seed immediately so overlays never start at 800×600
setOverlaySize({ width: el.clientWidth, height: el.clientHeight });
const app = appRef.current;
const cameraContainer = cameraContainerRef.current;
const video = videoRef.current;
const observer = new ResizeObserver((entries) => {
if (!entries[0]) return;
const { width, height } = entries[0].contentRect;
setOverlaySize((prev) => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
if (!app || !cameraContainer || !video) return;
const tickerWasStarted = app.ticker?.started || false;
if (tickerWasStarted && app.ticker) {
app.ticker.stop();
}
const wasPlaying = !video.paused;
if (wasPlaying) {
video.pause();
}
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
// Reset motion blur state for clean transitions
motionBlurStateRef.current = createMotionBlurState();
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
}
requestAnimationFrame(() => {
const container = cameraContainerRef.current;
const videoStage = videoContainerRef.current;
const sprite = videoSpriteRef.current;
const currentApp = appRef.current;
if (!container || !videoStage || !sprite || !currentApp) {
return;
}
container.scale.set(1);
container.position.set(0, 0);
videoStage.scale.set(1);
videoStage.position.set(0, 0);
sprite.scale.set(1);
sprite.position.set(0, 0);
layoutVideoContent();
applyZoomTransform({
cameraContainer: container,
blurFilter: blurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurAmount: motionBlurAmountRef.current,
});
requestAnimationFrame(() => {
const finalApp = appRef.current;
if (wasPlaying && video) {
video.play().catch(() => {
// Ignore autoplay restoration failures.
});
}
if (tickerWasStarted && finalApp?.ticker) {
finalApp.ticker.start();
}
});
});
observer.observe(el);
return () => observer.disconnect();
}, [pixiReady, videoReady]);
}, [pixiReady, videoReady, layoutVideoContent]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -621,32 +609,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, [pixiReady, videoReady, layoutVideoContent]);
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
// navigating, not previewing) and restore native DPR on play/idle so the
// preview stays faithful. Mutating renderer.resolution per-frame would
// thrash texture uploads; we only do it on scrub-state transitions.
useEffect(() => {
if (!pixiReady) return;
const app = appRef.current;
const container = containerRef.current;
if (!app || !container) return;
const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
if (app.renderer.resolution === targetResolution) return;
app.renderer.resolution = targetResolution;
app.renderer.resize(container.clientWidth, container.clientHeight);
layoutVideoContentRef.current?.();
}, [isScrubbing, pixiReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
updateOverlayForRegion(selectedZoom);
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const overlayEl = overlayElement;
const overlayEl = overlayRef.current;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -655,34 +624,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
}, [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]);
}, [selectedZoom, isPlaying]);
useEffect(() => {
const container = containerRef.current;
@@ -706,11 +648,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
}
@@ -734,11 +672,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
@@ -794,12 +728,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
const cursorHighlightGraphics = new Graphics();
cursorHighlightGraphics.visible = false;
videoContainer.addChild(cursorHighlightGraphics);
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
@@ -815,6 +743,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
videoContainer.filters = [blurFilter, motionBlurFilter];
blurFilterRef.current = blurFilter;
motionBlurFilterRef.current = motionBlurFilter;
@@ -832,9 +761,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
trimRegionsRef,
speedRegionsRef,
isScrubbingRef,
scrubEndTimerRef,
onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
});
video.addEventListener("play", handlePlay);
@@ -862,15 +788,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
if (cursorHighlightGraphicsRef.current) {
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
cursorHighlightGraphicsRef.current.destroy();
cursorHighlightGraphicsRef.current = null;
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
videoContainer.filters = null;
videoContainer.filters = [];
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
@@ -927,17 +848,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
state.appliedScale = appliedTransform.scale;
};
let lastMotionBlurActive: boolean | null = null;
let lastTransformIsIdentity = true;
let lastPerspectiveValue = 0;
const ticker = () => {
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
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,
},
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -1087,95 +1008,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionIntensity,
motionVector,
);
const cursorGraphics = cursorHighlightGraphicsRef.current;
const cursorConfig = cursorHighlightRef.current;
const lockedDims = lockedVideoDimensionsRef.current;
if (cursorGraphics) {
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
const emphasisAlpha = clickEmphasisAlpha(
currentTimeRef.current,
cursorClickTimestampsRef.current,
cursorConfig,
);
const cursorPoint =
emphasisAlpha > 0
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
: null;
if (cursorPoint) {
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
cursorGraphics.position.set(
baseOffset.x + cx * lockedDims.width * baseScale,
baseOffset.y + cy * lockedDims.height * baseScale,
);
cursorGraphics.alpha = emphasisAlpha;
cursorGraphics.visible = true;
} else {
cursorGraphics.visible = false;
}
} else {
cursorGraphics.visible = false;
}
}
const isMotionBlurActive =
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
if (isMotionBlurActive) {
if (blurFilterRef.current && motionBlurFilterRef.current) {
videoContainerRef.current.filters = [
blurFilterRef.current,
motionBlurFilterRef.current,
];
lastMotionBlurActive = true;
}
} else {
videoContainerRef.current.filters = null;
lastMotionBlurActive = false;
}
}
const composite3D = composite3DRef.current;
const outerWrapper = outerWrapperRef.current;
if (composite3D && outerWrapper) {
const effectiveRotation =
region && targetProgress > 0 && !shouldShowUnzoomedView
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
: DEFAULT_ROTATION_3D;
const isIdentity = isRotation3DIdentity(effectiveRotation);
if (isIdentity) {
if (!lastTransformIsIdentity) {
composite3D.style.transform = "";
composite3D.style.willChange = "auto";
lastTransformIsIdentity = true;
}
if (lastPerspectiveValue !== 0) {
outerWrapper.style.perspective = "";
lastPerspectiveValue = 0;
}
} else {
const wrapperW = outerWrapper.clientWidth || 1;
const wrapperH = outerWrapper.clientHeight || 1;
const persp = rotation3DPerspective(wrapperW, wrapperH);
const containScale = computeRotation3DContainScale(
effectiveRotation,
wrapperW,
wrapperH,
persp,
);
composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
composite3D.style.willChange = "transform";
lastTransformIsIdentity = false;
if (persp !== lastPerspectiveValue) {
outerWrapper.style.perspective = `${persp}px`;
lastPerspectiveValue = persp;
}
}
}
};
app.ticker.add(ticker);
@@ -1213,17 +1045,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
};
const resolvedWallpaper = useMemo<string | null>(() => {
const source = wallpaper || DEFAULT_WALLPAPER;
const classified = classifyWallpaper(source);
if (classified.kind !== "image") return classified.value;
try {
return resolveImageWallpaperUrl(classified.path);
} catch (err) {
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
return null;
}
}, [wallpaper]);
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
@@ -1291,16 +1113,64 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideo.currentTime = 0;
}, [webcamVideoPath]);
useEffect(() => {
let mounted = true;
(async () => {
try {
if (!wallpaper) {
const def = await getAssetPath("wallpapers/wallpaper1.jpg");
if (mounted) setResolvedWallpaper(def);
return;
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's a data URL (custom uploaded image), use as-is
if (wallpaper.startsWith("data:")) {
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
// If it's an absolute web/http or file path, use as-is
if (
wallpaper.startsWith("http") ||
wallpaper.startsWith("file://") ||
wallpaper.startsWith("/")
) {
// If it's an absolute server path (starts with '/'), resolve via getAssetPath as well
if (wallpaper.startsWith("/")) {
const rel = wallpaper.replace(/^\//, "");
const p = await getAssetPath(rel);
if (mounted) setResolvedWallpaper(p);
return;
}
if (mounted) setResolvedWallpaper(wallpaper);
return;
}
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
if (mounted) setResolvedWallpaper(p);
} catch (_err) {
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
}
})();
return () => {
mounted = false;
};
}, [wallpaper]);
useEffect(() => {
return () => {
if (videoReadyRafRef.current) {
cancelAnimationFrame(videoReadyRafRef.current);
videoReadyRafRef.current = null;
}
if (scrubEndTimerRef.current !== null) {
window.clearTimeout(scrubEndTimerRef.current);
scrubEndTimerRef.current = null;
}
};
}, []);
@@ -1317,7 +1187,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={outerWrapperRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -1342,204 +1211,119 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}}
/>
<div
ref={composite3DRef}
ref={containerRef}
className="absolute inset-0"
style={{
transformStyle: "preserve-3d",
transformOrigin: "center center",
filter:
showShadow && shadowIntensity > 0
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: "none",
}}
>
<div
ref={containerRef}
className="absolute inset-0"
style={{
filter:
showShadow && shadowIntensity > 0
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: "none",
}}
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "auto", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
onPointerLeave={handleOverlayPointerLeave}
>
/>
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
ref={focusIndicatorRef}
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (
typeof annotation.startMs !== "number" ||
typeof annotation.endMs !== "number"
)
return false;
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
onPointerLeave={handleOverlayPointerLeave}
>
<div
ref={focusIndicatorRef}
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
if (annotation.id === selectedAnnotationId) return true;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
});
const timeMs = Math.round(currentTime * 1000);
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (
typeof blurRegion.startMs !== "number" ||
typeof blurRegion.endMs !== "number"
)
return false;
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
if (blurRegion.id === selectedBlurId) return true;
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
});
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.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);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
const previewSnapshotCanvas =
filteredBlurRegions.length > 0
? (() => {
const app = appRef.current;
if (!app?.renderer?.extract) return null;
try {
return app.renderer.extract.canvas(app.stage);
} catch {
return null;
}
})()
: null;
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
previewSourceCanvas={previewSnapshotCanvas}
previewFrameVersion={Math.round(currentTime * 1000)}
/>
));
})()}
</div>
)}
</div>
return sorted.map((annotation) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
/>
));
})()}
</div>
)}
<video
ref={videoRef}
src={videoPath}
@@ -44,7 +44,6 @@ describe("projectPersistence media compatibility", () => {
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -67,99 +66,6 @@ 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", () => {
@@ -197,62 +103,3 @@ it("detects unsaved changes from differing snapshots", () => {
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
describe("wallpaper legacy normalization", () => {
it("rewrites pre-fix packaged paths (resources/assets/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/assets/wallpapers/wallpaper5.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper5.jpg");
});
it("rewrites new packaged layout (resources/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper3.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper3.jpg");
});
it("rewrites unpackaged dev layout (public/wallpapers/…)", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///home/user/project/public/wallpapers/wallpaper1.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
it("rewrites Windows-style file URLs with drive letter", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///C:/Users/me/openscreen/resources/wallpapers/wallpaper2.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves canonical relative paths untouched", () => {
const normalized = normalizeProjectEditor({ wallpaper: "/wallpapers/wallpaper2.jpg" });
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper2.jpg");
});
it("leaves data URIs untouched", () => {
const dataUri = "data:image/png;base64,AAA";
expect(normalizeProjectEditor({ wallpaper: dataUri }).wallpaper).toBe(dataUri);
});
it("leaves colors and gradients untouched", () => {
expect(normalizeProjectEditor({ wallpaper: "#1a1a2e" }).wallpaper).toBe("#1a1a2e");
expect(
normalizeProjectEditor({ wallpaper: "linear-gradient(90deg, red, blue)" }).wallpaper,
).toBe("linear-gradient(90deg, red, blue)");
});
it("does NOT rewrite user files outside the known install layout", () => {
const userPath = "file:///home/user/Pictures/wallpapers/wallpaper1.jpg";
expect(normalizeProjectEditor({ wallpaper: userPath }).wallpaper).toBe(userPath);
});
it("falls back to default for bundled paths outside WALLPAPER_PATHS", () => {
const normalized = normalizeProjectEditor({
wallpaper: "file:///opt/Openscreen/resources/wallpapers/wallpaper99.jpg",
});
expect(normalized.wallpaper).toBe("/wallpapers/wallpaper1.jpg");
});
});
+33 -189
View File
@@ -1,60 +1,34 @@
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 { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { ASPECT_RATIOS, type AspectRatio } 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 VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
const WALLPAPER_COUNT = 18;
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
// bundled wallpapers. Rewrite only paths that match a known install layout
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
// a legitimate user file that happens to live in a folder named "wallpapers"
// elsewhere is never silently replaced.
const LEGACY_FILE_WALLPAPER_RE =
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
function normalizeWallpaperValue(value: string): string {
const match = LEGACY_FILE_WALLPAPER_RE.exec(value);
if (!match) return value;
const canonical = `/wallpapers/${match[1]}`;
return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER;
}
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const PROJECT_VERSION = 2;
@@ -73,14 +47,12 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -94,26 +66,6 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function computeNormalizedWebcamLayoutPreset(
webcamLayoutPreset: Partial<ProjectEditorState>["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));
}
@@ -221,26 +173,6 @@ export function resolveProjectMedia(
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(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
@@ -251,12 +183,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const validPreset =
region.rotationPreset === "iso" ||
region.rotationPreset === "left" ||
region.rotationPreset === "right"
? region.rotationPreset
: undefined;
return {
id: region.id,
startMs,
@@ -267,7 +193,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
...(validPreset ? { rotationPreset: validPreset } : {}),
};
})
: [];
@@ -298,10 +223,14 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
isFiniteNumber(region.speed) &&
region.speed >= MIN_PLAYBACK_SPEED &&
region.speed <= MAX_PLAYBACK_SPEED
? clampPlaybackSpeed(region.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
: DEFAULT_PLAYBACK_SPEED;
return {
@@ -323,22 +252,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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 === "blur"
? region.type
: "text",
type: region.type === "image" || region.type === "figure" ? 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,
@@ -385,42 +304,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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,
};
})
: [];
@@ -444,10 +327,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper:
typeof editor.wallpaper === "string"
? normalizeWallpaperValue(editor.wallpaper)
: DEFAULT_WALLPAPER,
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
@@ -469,8 +349,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
aspectRatio: normalizedAspectRatio,
webcamLayoutPreset: normalizedWebcamLayoutPreset,
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,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
@@ -478,11 +363,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: normalizedWebcamPosition,
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,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
@@ -502,52 +392,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
};
}
function normalizeCursorHighlight(
value: unknown,
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
if (!value || typeof value !== "object") return fallback;
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
return {
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
sizePx:
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
color:
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
? v.color
: fallback.color,
opacity:
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
? v.opacity
: fallback.opacity,
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
clickEmphasisDurationMs:
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
? v.clickEmphasisDurationMs
: fallback.clickEmphasisDurationMs,
offsetXNorm:
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
? Math.max(-1, Math.min(1, v.offsetXNorm))
: fallback.offsetXNorm,
offsetYNorm:
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
? Math.max(-1, Math.min(1, v.offsetYNorm))
: fallback.offsetYNorm,
};
}
+4 -14
View File
@@ -1,8 +1,7 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { Gauge, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import glassStyles from "./ItemGlass.module.css";
@@ -15,8 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
variant?: "zoom" | "trim" | "annotation" | "speed";
}
// Map zoom depth to multiplier labels
@@ -47,11 +45,9 @@ export default function Item({
onSelect,
zoomDepth = 1,
speedValue,
isAutoFocus = false,
variant = "zoom",
children,
}: ItemProps) {
const t = useScopedT("timeline");
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -136,25 +132,19 @@ export default function Item({
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isAutoFocus && (
<MousePointer2
className="w-3 h-3 shrink-0 opacity-90"
aria-label="Cursor-follow"
/>
)}
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{t("labels.trim")}
Trim
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : t("labels.speed")}
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
</span>
</>
) : (
@@ -44,7 +44,6 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const BLUR_ROW_ID = "row-blur";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -74,12 +73,6 @@ 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;
@@ -103,8 +96,7 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
isAutoFocus?: boolean;
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
variant: "zoom" | "trim" | "annotation" | "speed";
}
const SCALE_CANDIDATES = [
@@ -533,12 +525,10 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
keyframes = [],
}: {
@@ -550,12 +540,10 @@ 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;
keyframes?: { id: string; time: number }[];
}) {
@@ -580,7 +568,6 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -599,7 +586,6 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -651,7 +637,6 @@ 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 (
@@ -683,7 +668,6 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
isAutoFocus={item.isAutoFocus}
variant="zoom"
>
{item.label}
@@ -727,22 +711,6 @@ function Timeline({
))}
</Row>
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
{blurItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedBlurId}
onSelect={() => onSelectBlur?.(item.id)}
variant={item.variant}
>
{item.label}
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
@@ -787,12 +755,6 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
blurRegions = [],
onBlurAdded,
onBlurSpanChange,
onBlurDelete,
selectedBlurId,
onSelectBlur,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
@@ -877,12 +839,6 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedBlur = useCallback(() => {
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
onBlurDelete(selectedBlurId);
onSelectBlur(null);
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -952,10 +908,9 @@ 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 || isBlurItem) {
if (isAnnotationItem) {
return false;
}
@@ -982,7 +937,7 @@ export default function TimelineEditor({
return false;
},
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
[zoomRegions, trimRegions, annotationRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -1210,21 +1165,6 @@ 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) {
@@ -1243,9 +1183,6 @@ 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();
}
@@ -1286,8 +1223,6 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedBlurId) {
deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1300,19 +1235,16 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
annotationRegions,
currentTime,
@@ -1339,7 +1271,6 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
isAutoFocus: region.focusMode === "auto",
variant: "zoom",
}));
@@ -1373,14 +1304,6 @@ 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,
@@ -1390,8 +1313,8 @@ export default function TimelineEditor({
variant: "speed",
}));
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1412,8 +1335,6 @@ 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);
}
},
[
@@ -1421,12 +1342,10 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
onBlurSpanChange,
],
);
@@ -1484,25 +1403,6 @@ export default function TimelineEditor({
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="8" cy="12" r="3" />
<circle cx="16" cy="12" r="3" />
<path d="M6 6h12M6 18h12" />
</svg>
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
@@ -1589,12 +1489,10 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectBlur={onSelectBlur}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
+2 -190
View File
@@ -3,10 +3,6 @@ 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";
@@ -26,37 +22,6 @@ export interface ZoomFocus {
cy: number; // normalized vertical center (0-1)
}
export interface Rotation3D {
rotationX: number;
rotationY: number;
rotationZ: number;
}
export const DEFAULT_ROTATION_3D: Rotation3D = {
rotationX: 0,
rotationY: 0,
rotationZ: 0,
};
export type Rotation3DPreset = "iso" | "left" | "right";
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
};
export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
/** Perspective distance in CSS px is computed at render-time as this factor times
* min(viewport width, viewport height). Same factor used in preview and export so
* the visual look is identical regardless of canvas resolution. */
export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
export function rotation3DPerspective(width: number, height: number): number {
return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
}
export interface ZoomRegion {
id: string;
startMs: number;
@@ -64,104 +29,6 @@ export interface ZoomRegion {
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
rotationPreset?: Rotation3DPreset;
}
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
return ROTATION_3D_PRESETS[region.rotationPreset];
}
export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
}
export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
return {
rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
};
}
/**
* Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
* of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
* inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
*
* Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case
* |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
* recording sit *inside* the zoom window instead of bleeding past it.
*/
export function computeRotation3DContainScale(
rot: Rotation3D,
width: number,
height: number,
perspective: number,
): number {
const a = (rot.rotationX * Math.PI) / 180;
const b = (rot.rotationY * Math.PI) / 180;
const g = (rot.rotationZ * Math.PI) / 180;
const ca = Math.cos(a);
const sa = Math.sin(a);
const cb = Math.cos(b);
const sb = Math.sin(b);
const cg = Math.cos(g);
const sg = Math.sin(g);
const halfW = width / 2;
const halfH = height / 2;
const corners: Array<[number, number]> = [
[-halfW, -halfH],
[halfW, -halfH],
[halfW, halfH],
[-halfW, halfH],
];
let maxAbsX = 0;
let maxAbsY = 0;
for (const [x0, y0] of corners) {
// CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
let px = x0;
let py = y0;
let pz = 0;
// rotateZ
const zx = px * cg - py * sg;
const zy = px * sg + py * cg;
px = zx;
py = zy;
// rotateY
const yx = px * cb + pz * sb;
const yz = -px * sb + pz * cb;
px = yx;
pz = yz;
// rotateX
const xy = py * ca - pz * sa;
const xz = py * sa + pz * ca;
py = xy;
pz = xz;
// Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz
// is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic.
if (perspective > 0) {
const denom = perspective - pz;
if (denom <= 0) return 1; // pathological — skip scaling rather than crash
const f = perspective / denom;
px *= f;
py *= f;
}
if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
}
if (maxAbsX === 0 || maxAbsY === 0) return 1;
const sx = halfW / maxAbsX;
const sy = halfH / maxAbsY;
return Math.min(sx, sy, 1);
}
export interface CursorTelemetryPoint {
@@ -176,7 +43,7 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = "text" | "image" | "figure" | "blur";
export type AnnotationType = "text" | "image" | "figure";
export type ArrowDirection =
| "up"
@@ -194,27 +61,6 @@ 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;
@@ -249,7 +95,6 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -279,27 +124,6 @@ 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;
@@ -314,16 +138,7 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
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 type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export interface SpeedRegion {
id: string;
@@ -340,9 +155,6 @@ 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;
@@ -1,125 +0,0 @@
import type { Graphics } from "pixi.js";
export type CursorHighlightStyle = "dot" | "ring";
export interface CursorHighlightConfig {
enabled: boolean;
style: CursorHighlightStyle;
sizePx: number;
color: string;
opacity: number;
// Show only on clicks (macOS — depends on click telemetry from uiohook).
onlyOnClicks: boolean;
clickEmphasisDurationMs: number;
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
// but window recordings frame a subset of the display so the highlight
// lands offset. Users dial these in once to align with the actual cursor.
offsetXNorm: number;
offsetYNorm: number;
}
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
// click-only mode; in click-only mode fades 1→0 across each click's window.
export function clickEmphasisAlpha(
timeMs: number,
clickTimestampsMs: number[] | undefined,
config: CursorHighlightConfig,
): number {
if (!config.onlyOnClicks) return 1;
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
const window = Math.max(1, config.clickEmphasisDurationMs);
for (let i = 0; i < clickTimestampsMs.length; i++) {
const dt = timeMs - clickTimestampsMs[i];
if (dt >= 0 && dt <= window) {
return 1 - dt / window;
}
}
return 0;
}
function parseHexColor(hex: string): number {
const cleaned = hex.replace("#", "");
if (cleaned.length === 3) {
const r = cleaned[0];
const g = cleaned[1];
const b = cleaned[2];
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
}
return Number.parseInt(cleaned.slice(0, 6), 16);
}
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
g.clear();
if (!config.enabled) return;
const color = parseHexColor(config.color);
const radius = Math.max(1, config.sizePx / 2);
const alpha = Math.max(0, Math.min(1, config.opacity));
switch (config.style) {
case "dot": {
g.circle(0, 0, radius);
g.fill({ color, alpha });
break;
}
case "ring": {
g.circle(0, 0, radius);
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
break;
}
}
}
export function drawCursorHighlightCanvas(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
config: CursorHighlightConfig,
pixelScale = 1,
): void {
if (!config.enabled) return;
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
const alpha = Math.max(0, Math.min(1, config.opacity));
const color = config.color;
ctx.save();
ctx.globalAlpha = alpha;
switch (config.style) {
case "dot": {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
break;
}
case "ring": {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, radius * 0.18);
ctx.stroke();
break;
}
}
ctx.restore();
}
@@ -5,7 +5,6 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
@@ -21,7 +20,6 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
@@ -49,7 +47,6 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
@@ -98,7 +95,6 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -140,7 +136,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenRect.y,
screenRect.width,
screenRect.height,
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
compositeLayout.screenCover ? 0 : borderRadius,
);
maskGraphics.fill({ color: 0xffffff });
@@ -1,11 +1,6 @@
import type React from "react";
import type { SpeedRegion, TrimRegion } from "../types";
// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
// fires `seeking`/`seeked` dozens of times per second, and toggling effects
// each time would flicker.
const SCRUB_END_DEBOUNCE_MS = 150;
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
@@ -17,9 +12,6 @@ interface VideoEventHandlersParams {
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
isScrubbingRef?: React.MutableRefObject<boolean>;
scrubEndTimerRef?: React.MutableRefObject<number | null>;
onScrubChange?: (scrubbing: boolean) => void;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -34,18 +26,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
isScrubbingRef,
scrubEndTimerRef,
onScrubChange,
} = params;
const clearScrubEndTimer = () => {
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
window.clearTimeout(scrubEndTimerRef.current);
scrubEndTimerRef.current = null;
}
};
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
@@ -131,15 +113,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeked = () => {
isSeekingRef.current = false;
if (isScrubbingRef && scrubEndTimerRef) {
clearScrubEndTimer();
scrubEndTimerRef.current = window.setTimeout(() => {
isScrubbingRef.current = false;
scrubEndTimerRef.current = null;
onScrubChange?.(false);
}, SCRUB_END_DEBOUNCE_MS);
}
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
@@ -164,14 +137,6 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeking = () => {
isSeekingRef.current = true;
if (isScrubbingRef) {
clearScrubEndTimer();
if (!isScrubbingRef.current) {
isScrubbingRef.current = true;
onScrubChange?.(true);
}
}
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
@@ -1,5 +1,5 @@
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
import { DEFAULT_ROTATION_3D, getRotation3D, lerpRotation3D, ZOOM_DEPTH_SCALES } from "../types";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
@@ -164,7 +164,6 @@ function getActiveRegion(
},
strength: activeRegions[0].strength,
blendedScale: null,
rotation3D: getRotation3D(activeRegion),
};
}
@@ -190,7 +189,6 @@ function getConnectedRegionHold(
},
strength: 1,
blendedScale: null,
rotation3D: getRotation3D(pair.nextRegion),
};
}
}
@@ -235,11 +233,6 @@ function getConnectedRegionTransition(
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
const transitionRotation = lerpRotation3D(
getRotation3D(currentRegion),
getRotation3D(nextRegion),
transitionProgress,
);
return {
region: {
@@ -248,7 +241,6 @@ function getConnectedRegionTransition(
},
strength: 1,
blendedScale: transitionScale,
rotation3D: transitionRotation,
transition: {
progress: transitionProgress,
startFocus: currentFocus,
@@ -262,92 +254,34 @@ function getConnectedRegionTransition(
return null;
}
type DominantRegionResult = {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
rotation3D: Rotation3D;
transition: ConnectedPanTransition | null;
};
// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
// unchanged inputs (especially while paused). Reusing the previous result when
// inputs match avoids the per-frame O(N) region scan + allocations.
let dominantRegionCache: {
regions: ZoomRegion[];
timeMsKey: number;
telemetry: CursorTelemetryPoint[] | undefined;
connectZooms: boolean;
viewportRatio: ViewportRatio | undefined;
result: DominantRegionResult;
} | null = null;
export function findDominantRegion(
regions: ZoomRegion[],
timeMs: number,
options: DominantRegionOptions = {},
): DominantRegionResult {
const connectZooms = !!options.connectZooms;
): {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
const timeMsKey = Math.round(timeMs);
if (
dominantRegionCache &&
dominantRegionCache.regions === regions &&
dominantRegionCache.timeMsKey === timeMsKey &&
dominantRegionCache.telemetry === telemetry &&
dominantRegionCache.connectZooms === connectZooms &&
dominantRegionCache.viewportRatio === vr
) {
return dominantRegionCache.result;
}
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
let result: DominantRegionResult;
if (connectZooms) {
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
result = connectedTransition;
} else {
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
result = { ...connectedHold, transition: null };
} else {
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
: {
region: null,
strength: 0,
blendedScale: null,
rotation3D: DEFAULT_ROTATION_3D,
transition: null,
};
}
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
} else {
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
result = activeRegion
? { ...activeRegion, transition: null }
: {
region: null,
strength: 0,
blendedScale: null,
rotation3D: DEFAULT_ROTATION_3D,
transition: null,
};
}
dominantRegionCache = {
regions,
timeMsKey,
telemetry,
connectZooms,
viewportRatio: vr,
result,
};
return result;
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
}
@@ -90,10 +90,8 @@ export function computeZoomTransform({
}
const progress = Math.min(1, Math.max(0, zoomProgress));
// 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 focusStagePxX = baseMask.x + focusX * baseMask.width;
const focusStagePxY = baseMask.y + focusY * baseMask.height;
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
const scale = 1 + (zoomScale - 1) * progress;
@@ -130,8 +128,8 @@ export function computeFocusFromTransform({
const focusStagePxY = (stageCenterY - y) / zoomScale;
return {
cx: focusStagePxX / stageSize.width,
cy: focusStagePxY / stageSize.height,
cx: (focusStagePxX - baseMask.x) / baseMask.width,
cy: (focusStagePxY - baseMask.y) / baseMask.height,
};
}
+10 -112
View File
@@ -5,11 +5,16 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
import { getAvailableLocales, translate } from "@/i18n/loader";
import {
DEFAULT_LOCALE,
type I18nNamespace,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import { translate } from "@/i18n/loader";
type TranslateVars = Record<string, string | number>;
@@ -17,14 +22,8 @@ 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<I18nContextValue | null>(null);
export function useI18n(): I18nContextValue {
@@ -42,37 +41,7 @@ export function useScopedT(namespace: I18nNamespace) {
}
function isSupportedLocale(value: string): value is Locale {
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;
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function getInitialLocale(): Locale {
@@ -87,16 +56,6 @@ function getInitialLocale(): Locale {
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState<Locale | null>(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);
@@ -114,48 +73,6 @@ 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(".");
@@ -167,26 +84,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
[locale],
);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t,
systemLocaleSuggestion,
acceptSystemLocaleSuggestion,
dismissSystemLocaleSuggestion,
resolveSystemLocaleSuggestion,
}),
[
locale,
setLocale,
t,
systemLocaleSuggestion,
acceptSystemLocaleSuggestion,
dismissSystemLocaleSuggestion,
resolveSystemLocaleSuggestion,
],
);
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
+1 -12
View File
@@ -7,7 +7,6 @@ import type {
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import {
@@ -15,13 +14,7 @@ import {
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import {
type CursorHighlightConfig,
DEFAULT_CURSOR_HIGHLIGHT,
} from "@/components/video-editor/videoPlayback/cursorHighlight";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -41,9 +34,7 @@ export interface EditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
cursorHighlight: CursorHighlightConfig;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -52,7 +43,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
speedRegions: [],
annotationRegions: [],
cropRegion: DEFAULT_CROP_REGION,
wallpaper: DEFAULT_WALLPAPER,
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
@@ -61,9 +52,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,
cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+38 -276
View File
@@ -110,10 +110,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const countdownRunId = useRef(0);
const [countdownActive, setCountdownActive] = useState(false);
const webcamReady = useRef(false);
const webcamAcquireId = useRef(0);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
@@ -122,15 +118,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, []);
const selectMimeType = () => {
// H.264 first: hardware-accelerated on all modern devices, gives sharp
// real-time output. AV1/VP9 are great for distribution but too
// CPU-intensive for live 60 fps capture — they produce blurry frames
// when the software encoder can't keep up.
const preferred = [
"video/webm;codecs=h264",
"video/webm;codecs=vp8",
"video/webm;codecs=vp9",
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
];
@@ -166,6 +158,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (webcamStream.current) {
webcamStream.current.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during recorder teardown.
@@ -198,85 +194,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
[t],
);
useEffect(() => {
if (!webcamEnabled) return;
let cancelled = false;
let acquiredStream: MediaStream | null = null;
const thisAcquireId = ++webcamAcquireId.current;
webcamReady.current = false;
const acquire = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}
: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
stream.getTracks().forEach((track) => {
track.onended = null;
track.stop();
});
return;
}
acquiredStream = stream;
stream.getVideoTracks().forEach((track) => {
track.onended = () => {
webcamStream.current = null;
if (!restarting.current) {
setWebcamEnabledState(false);
toast.error(t("recording.cameraDisconnected"));
}
};
});
webcamStream.current = stream;
webcamReady.current = true;
} catch (cameraError) {
if (!cancelled) {
console.warn("Failed to get webcam access:", cameraError);
setWebcamEnabledState(false);
const isDeviceError =
cameraError instanceof DOMException &&
[
"NotFoundError",
"DevicesNotFoundError",
"OverconstrainedError",
"NotReadableError",
].includes(cameraError.name);
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
webcamReady.current = true;
}
}
};
void acquire();
return () => {
cancelled = true;
webcamReady.current = false;
if (acquiredStream) {
acquiredStream.getTracks().forEach((track) => {
track.onended = null;
track.stop();
});
webcamStream.current = null;
}
};
}, [webcamEnabled, webcamDeviceId, t]);
const finalizeRecording = useCallback(
(
activeScreenRecorder: RecorderHandle,
@@ -308,7 +225,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
if (discardRecordingId.current === activeRecordingId) {
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
return;
}
if (screenBlob.size === 0) {
@@ -418,10 +334,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
return () => {
const activeRunId = countdownRunId.current;
if (cleanup) cleanup();
countdownRunId.current += 1;
void safeHideCountdownOverlay(activeRunId);
allowAutoFinalize.current = false;
restarting.current = false;
discardRecordingId.current = null;
@@ -452,117 +365,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
}, [teardownMedia]);
const safeShowCountdownOverlay = async (value: number, runId: number) => {
try {
await window.electronAPI.showCountdownOverlay(value, runId);
return true;
} catch (error) {
console.warn("Failed to show countdown overlay:", error);
return false;
}
};
const cancelCountdown = () => {
const activeRunId = countdownRunId.current;
countdownRunId.current += 1;
setCountdownActive(false);
void safeHideCountdownOverlay(activeRunId);
};
const safeSetCountdownOverlayValue = async (value: number, runId: number) => {
try {
await window.electronAPI.setCountdownOverlayValue(value, runId);
} catch (error) {
console.warn("Failed to update countdown overlay value:", error);
}
};
const safeHideCountdownOverlay = async (runId: number) => {
try {
await window.electronAPI.hideCountdownOverlay(runId);
} catch (error) {
console.warn("Failed to hide countdown overlay:", error);
}
};
const isCountdownRunActive = (runId?: number) =>
runId === undefined || countdownRunId.current === runId;
const startRecordCountdown = async () => {
if (countdownActive || recording) {
return;
}
const runId = countdownRunId.current + 1;
countdownRunId.current = runId;
setCountdownActive(true);
let selectedSource: ProcessedDesktopSource | null = null;
try {
selectedSource = await window.electronAPI.getSelectedSource();
} catch (error) {
console.warn("Failed to read selected source before countdown:", error);
}
if (!isCountdownRunActive(runId)) {
return;
}
if (!selectedSource) {
if (countdownRunId.current === runId) {
setCountdownActive(false);
}
alert(t("recording.selectSource"));
return;
}
let overlayHiddenBeforeStart = false;
try {
const values = [3, 2, 1];
const overlayShown = await safeShowCountdownOverlay(values[0], runId);
if (countdownRunId.current !== runId) {
return;
}
for (const value of values) {
if (countdownRunId.current !== runId) {
return;
}
if (overlayShown && value !== values[0]) {
await safeSetCountdownOverlayValue(value, runId);
if (countdownRunId.current !== runId) {
return;
}
}
await new Promise((resolve) => window.setTimeout(resolve, 1000));
}
if (countdownRunId.current !== runId) {
return;
}
setCountdownActive(false);
await safeHideCountdownOverlay(runId);
overlayHiddenBeforeStart = true;
if (countdownRunId.current !== runId) {
return;
}
await startRecording(runId);
} finally {
if (!overlayHiddenBeforeStart && countdownRunId.current === runId) {
setCountdownActive(false);
await safeHideCountdownOverlay(runId);
}
}
};
const startRecording = async (countdownRunToken?: number) => {
const startRecording = async () => {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
@@ -570,11 +373,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
let screenMediaStream: MediaStream;
const videoConstraints = {
@@ -615,11 +413,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
screenStream.current = screenMediaStream;
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
@@ -644,37 +437,34 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
if (webcamEnabled) {
if (!webcamReady.current) {
await new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (webcamReady.current) {
clearInterval(interval);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(interval);
resolve();
}, 5000);
try {
webcamStream.current = await navigator.mediaDevices.getUserMedia({
audio: false,
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}
: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
}
if (!webcamStream.current) {
webcamAcquireId.current++;
} catch (cameraError) {
console.warn("Failed to get webcam access:", cameraError);
if (webcamStream.current) {
webcamStream.current.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
setWebcamEnabledState(false);
toast.error(t("recording.cameraDenied"));
}
}
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
stream.current = new MediaStream();
const videoTrack = screenMediaStream.getVideoTracks()[0];
if (!videoTrack) {
@@ -715,11 +505,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
}
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
let {
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
@@ -739,11 +524,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
const hasAudio = stream.current.getAudioTracks().length > 0;
if (!isCountdownRunActive(countdownRunToken)) {
teardownMedia();
return;
}
screenRecorder.current = createRecorderHandle(stream.current, {
mimeType,
videoBitsPerSecond,
@@ -773,7 +553,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true, recordingId.current);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
const activeWebcamRecorder = webcamRecorder.current;
@@ -855,17 +635,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
const toggleRecording = () => {
if (recording) {
stopRecording.current();
return;
}
if (countdownActive) {
cancelCountdown();
return;
}
void startRecordCountdown();
recording ? stopRecording.current() : startRecording();
};
const restartRecording = async () => {
@@ -879,6 +649,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = true;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
const stopPromises = [
new Promise<void>((resolve) => {
@@ -929,22 +700,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const cancelRecording = () => {
const activeScreenRecorder = screenRecorder.current;
if (
activeScreenRecorder?.recorder.state === "recording" ||
activeScreenRecorder?.recorder.state === "paused"
) {
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
stopRecording.current();
return;
}
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
if (countdownActive) {
cancelCountdown();
return;
}
stopRecording.current();
};
return {
@@ -1,59 +0,0 @@
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<Locale, { tutorial: Record<string, unknown> }>;
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);
}
}
}
});
});
+2 -12
View File
@@ -1,15 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = [
"en",
"zh-CN",
"zh-TW",
"es",
"fr",
"tr",
"ko-KR",
"ja-JP",
"ar",
] as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
@@ -20,7 +10,7 @@ export const I18N_NAMESPACES = [
"timeline",
] as const;
export type Locale = string;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
export const LOCALE_STORAGE_KEY = "openscreen-locale";
+6 -71
View File
@@ -1,10 +1,6 @@
import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
type MessageMap = Record<string, unknown>;
type LocaleValidationError = {
locale: string;
missingNamespaces: I18nNamespace[];
};
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
string,
@@ -22,62 +18,6 @@ for (const [path, mod] of Object.entries(modules)) {
messages[locale][namespace] = mod.default;
}
const REQUIRED_NAMESPACES = new Set<string>(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<string, MessageMap> | 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;
@@ -94,18 +34,15 @@ function interpolate(str: string, vars?: Record<string, string | number>): strin
}
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
return messages[resolvedLocale]?.[namespace] ?? {};
return messages[locale]?.[namespace] ?? {};
}
export function getLocaleName(locale: Locale): string {
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
}
export function getLocaleShort(locale: Locale): string {
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
}
export function translate(
@@ -115,10 +52,8 @@ export function translate(
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(
messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
key,
) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
getMessageValue(messages[locale]?.[namespace], key) ??
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
-50
View File
@@ -1,50 +0,0 @@
{
"actions": {
"cancel": "الغاء",
"save": "حفظ",
"delete": "حذف",
"close": "اغلاق",
"share": "مشاركة",
"done": "تم",
"open": "فتح",
"upload": "رفع",
"export": "تصدير",
"showInFolder": "عرض في المجلد",
"file": "ملف",
"edit": "تعديل",
"view": "عرض",
"window": "نافذة",
"quit": "خروج",
"stopRecording": "إيقاف التسجيل",
"undo": "تراجع",
"redo": "إعادة",
"cut": "قص",
"copy": "نسخ",
"paste": "لصق",
"selectAll": "تحديد الكل",
"minimize": "تصغير",
"reload": "إعادة تحميل",
"forceReload": "إعادة تحميل إجبارية",
"toggleDevTools": "أدوات المطور",
"actualSize": "الحجم الفعلي",
"zoomIn": "تكبير",
"zoomOut": "تصغير",
"toggleFullScreen": "ملء الشاشة",
"recordingStatus": "جاري التسجيل: {{source}}",
"about": "حول OpenScreen",
"services": "خدمات",
"hide": "إخفاء OpenScreen",
"hideOthers": "إخفاء الآخرين",
"unhide": "إظهار الكل"
},
"playback": {
"play": "تشغيل",
"pause": "ايقاف مؤقت",
"fullscreen": "ملء الشاشة",
"exitFullscreen": "خروج من ملء الشاشة"
},
"locale": {
"name": "عربي",
"short": "AR"
}
}
-70
View File
@@ -1,70 +0,0 @@
{
"export": {
"complete": "اكتمل التصدير",
"yourFormatReady": "{{format}} الخاص بك جاهز",
"showInFolder": "عرض في المجلد",
"finalizingVideo": "جاري إنهاء تصدير الفيديو...",
"compilingGifProgress": "جاري تجميع GIF... {{progress}}%",
"compilingGifWait": "جاري تجميع GIF... قد يستغرق هذا بعض الوقت",
"takeMoment": "قد يستغرق هذا لحظة...",
"failed": "فشل التصدير",
"tryAgain": "يرجى المحاولة مرة أخرى",
"finalizingVideoTitle": "إنهاء الفيديو",
"compilingGif": "تجميع GIF",
"exportingFormat": "تصدير {{format}}",
"compiling": "تجميع",
"renderingFrames": "تصيير الإطارات",
"processing": "جاري المعالجة...",
"finalizing": "جاري الإنهاء...",
"compilingStatus": "جاري التجميع...",
"status": "الحالة",
"format": "الصيغة",
"frames": "الإطارات",
"cancelExport": "إلغاء التصدير",
"savedSuccessfully": "تم حفظ {{format}} بنجاح!"
},
"tutorial": {
"triggerLabel": "كيف يعمل القص",
"title": "كيف يعمل القص",
"description": "فهم كيفية قص الأجزاء غير المرغوب فيها من الفيديو الخاص بك.",
"explanationBefore": "تعمل أداة القص من خلال تحديد المقاطع التي تريد",
"remove": "إزالتها",
"explanationMiddle": " — أي شيء",
"covered": "مغطى",
"explanationAfter": "بمقطع قص أحمر سيتم قصه عند التصدير.",
"visualExample": "مثال مرئي",
"removed": "مُزال",
"kept": "مُحتفظ به",
"part1": "الجزء 1",
"part2": "الجزء 2",
"part3": "الجزء 3",
"finalVideo": "الفيديو النهائي",
"step1Title": "1. إضافة قص",
"step1DescriptionBefore": "اضغط على ",
"step1DescriptionAfter": " أو انقر على أيقونة المقص لتحديد قسم لإزالته.",
"step2Title": "2. تعديل",
"step2Description": "اسحب حواف المنطقة الحمراء لتغطي بالضبط ما تريد قصه."
},
"unsavedChanges": {
"title": "تغييرات غير محفوظة",
"message": "لديك تغييرات غير محفوظة.",
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
"saveAndClose": "حفظ وإغلاق",
"discardAndClose": "تجاهل وإغلاق",
"loadProject": "تحميل مشروع...",
"saveProject": "حفظ المشروع...",
"saveProjectAs": "حفظ المشروع باسم..."
},
"fileDialogs": {
"saveGif": "حفظ GIF المصدر",
"saveVideo": "حفظ الفيديو المصدر",
"selectVideo": "حدد ملف فيديو",
"saveProject": "حفظ مشروع OpenScreen",
"openProject": "فتح مشروع OpenScreen",
"gifImage": "صورة GIF",
"mp4Video": "فيديو MP4",
"videoFiles": "ملفات فيديو",
"openscreenProject": "مشروع OpenScreen",
"allFiles": "جميع الملفات"
}
}
-45
View File
@@ -1,45 +0,0 @@
{
"newRecording": {
"title": "العودة إلى المسجل",
"description": "تم حفظ جلستك الحالية.",
"cancel": "إلغاء",
"confirm": "تأكيد"
},
"loadingVideo": "جاري تحميل الفيديو...",
"errors": {
"noVideoLoaded": "لم يتم تحميل أي فيديو",
"videoNotReady": "الفيديو غير جاهز",
"unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
"failedToSaveGif": "فشل حفظ GIF",
"gifExportFailed": "فشل تصدير GIF",
"failedToSaveVideo": "فشل حفظ الفيديو",
"exportFailed": "فشل التصدير",
"exportFailedWithError": "فشل التصدير: {{error}}",
"exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
"failedToSaveExport": "فشل حفظ التصدير",
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
},
"export": {
"canceled": "تم إلغاء التصدير",
"exportedSuccessfully": "تم تصدير {{format}} بنجاح"
},
"project": {
"saveCanceled": "تم إلغاء حفظ المشروع",
"failedToSave": "فشل حفظ المشروع",
"savedTo": "تم حفظ المشروع في {{path}}",
"failedToLoad": "فشل تحميل المشروع",
"invalidFormat": "تنسيق ملف المشروع غير صالح",
"loadedFrom": "تم تحميل المشروع من {{path}}"
},
"recording": {
"failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.",
"cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.",
"systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.",
"microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.",
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
"cameraDisconnected": "تم فصل كاميرا الويب.",
"cameraNotFound": "لم يتم العثور على كاميرا.",
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
}
}
-43
View File
@@ -1,43 +0,0 @@
{
"tooltips": {
"hideHUD": "إخفاء واجهة العرض",
"closeApp": "إغلاق التطبيق",
"restartRecording": "إعادة تشغيل التسجيل",
"cancelRecording": "إلغاء التسجيل",
"pauseRecording": "إيقاف التسجيل مؤقتاً",
"resumeRecording": "استئناف التسجيل",
"openVideoFile": "فتح ملف فيديو",
"openProject": "فتح مشروع"
},
"audio": {
"enableSystemAudio": "تفعيل صوت النظام",
"disableSystemAudio": "تعطيل صوت النظام",
"enableMicrophone": "تفعيل الميكروفون",
"disableMicrophone": "تعطيل الميكروفون",
"defaultMicrophone": "الميكروفون الافتراضي"
},
"webcam": {
"enableWebcam": "تفعيل كاميرا الويب",
"disableWebcam": "تعطيل كاميرا الويب",
"defaultCamera": "الكاميرا الافتراضية",
"searching": "جاري البحث...",
"noneFound": "لم يتم العثور على كاميرا",
"unavailable": "الكاميرا غير متوفرة"
},
"sourceSelector": {
"loading": "جاري تحميل المصادر...",
"screens": "الشاشات ({{count}})",
"windows": "النوافذ ({{count}})",
"defaultSourceName": "الشاشة"
},
"recording": {
"selectSource": "يرجى تحديد مصدر للتسجيل"
},
"language": "اللغة",
"systemLanguagePrompt": {
"title": "هل تريد استخدام لغة نظامك؟",
"description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟",
"switch": "التبديل إلى {{language}}",
"keepDefault": "الاحتفاظ باللغة الحالية"
}
}
-194
View File
@@ -1,194 +0,0 @@
{
"zoom": {
"level": "مستوى التكبير",
"selectRegion": "حدد منطقة التكبير للتعديل",
"deleteZoom": "حذف التكبير",
"focusMode": {
"title": "وضع التركيز",
"manual": "يدوي",
"auto": "تلقائي",
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
}
},
"speed": {
"playbackSpeed": "سرعة التشغيل",
"selectRegion": "حدد منطقة السرعة للتعديل",
"deleteRegion": "حذف منطقة السرعة",
"customPlaybackSpeed": "سرعة تشغيل مخصصة",
"maxSpeedError": "لا يمكن للسرعة أن تتجاوز 16×"
},
"trim": {
"deleteRegion": "حذف منطقة القص"
},
"layout": {
"title": "التخطيط",
"preset": "الإعداد المسبق",
"selectPreset": "حدد إعدادًا مسبقًا",
"pictureInPicture": "صورة داخل صورة",
"verticalStack": "تكدس عمودي",
"dualFrame": "إطار مزدوج",
"webcamShape": "شكل الكاميرا",
"webcamSize": "حجم كاميرا الويب"
},
"effects": {
"title": "تأثيرات الفيديو",
"blurBg": "تمويه الخلفية",
"motionBlur": "ضبابية الحركة",
"off": "إيقاف",
"on": "تشغيل",
"shadow": "ظل",
"roundness": "الاستدارة",
"padding": "المسافة البادئة",
"cursorHighlight": {
"title": "تمييز المؤشر",
"style": "النمط",
"dot": "نقطة",
"ring": "حلقة",
"size": "الحجم",
"onlyOnClicks": "عند النقر فقط",
"color": "اللون",
"offsetX": "إزاحة X (لتسجيلات النوافذ)",
"offsetY": "إزاحة Y",
"accessibilityPermissionTitle": "مطلوب إذن الوصول",
"accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق."
}
},
"background": {
"title": "الخلفية",
"image": "صورة",
"color": "لون",
"gradient": "تدرج لوني",
"uploadCustom": "رفع صورة مخصصة",
"gradientLabel": "تدرج لوني {{index}}",
"colorWheel": "عجلة الألوان",
"colorPalette": "لوحة الألوان"
},
"crop": {
"title": "اقتصاص",
"cropVideo": "اقتصاص الفيديو",
"dragInstruction": "اسحب من كل جانب لضبط منطقة الاقتصاص",
"ratio": "النسبة",
"free": "حر",
"done": "تم",
"lockAspectRatio": "قفل نسبة العرض إلى الارتفاع",
"unlockAspectRatio": "إلغاء قفل نسبة العرض إلى الارتفاع"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "فيديو MP4",
"mp4Description": "ملف فيديو عالي الجودة",
"gifAnimation": "صورة GIF متحركة",
"gifDescription": "صورة متحركة للمشاركة"
},
"exportQuality": {
"title": "جودة التصدير",
"low": "منخفضة",
"medium": "متوسطة",
"high": "عالية"
},
"gifSettings": {
"frameRate": "معدل إطارات GIF",
"size": "حجم GIF",
"loop": "تكرار GIF"
},
"project": {
"save": "حفظ المشروع",
"load": "تحميل المشروع"
},
"export": {
"videoButton": "تصدير الفيديو",
"gifButton": "تصدير GIF",
"chooseSaveLocation": "اختيار موقع الحفظ"
},
"links": {
"reportBug": "الإبلاغ عن خطأ",
"starOnGithub": "إعطاء نجمة على GitHub"
},
"imageUpload": {
"invalidFileType": "نوع ملف غير صالح",
"jpgOnly": "يرجى رفع ملف صورة JPG أو JPEG.",
"uploadSuccess": "تم رفع الصورة المخصصة بنجاح!",
"failedToUpload": "فشل رفع الصورة",
"errorReading": "حدث خطأ أثناء قراءة الملف."
},
"annotation": {
"title": "إعدادات الشروح",
"active": "نشط",
"typeText": "نص",
"typeImage": "صورة",
"typeArrow": "سهم",
"typeBlur": "تمويه",
"textContent": "محتوى النص",
"textPlaceholder": "أدخل النص هنا...",
"fontStyle": "نمط الخط",
"selectStyle": "حدد النمط",
"size": "الحجم",
"customFonts": "خطوط مخصصة",
"textColor": "لون النص",
"background": "الخلفية",
"none": "بدون",
"color": "لون",
"colorWheel": "عجلة الألوان",
"colorPalette": "لوحة الألوان",
"clearBackground": "مسح الخلفية",
"uploadImage": "رفع صورة",
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
"arrowDirection": "اتجاه السهم",
"strokeWidth": "عرض الخط: {{width}}px",
"arrowColor": "لون السهم",
"blurType": "نوع التمويه",
"blurTypeBlur": "تمويه",
"blurTypeMosaic": "فسيفساء",
"blurColor": "لون التمويه",
"blurColorWhite": "أبيض",
"blurColorBlack": "أسود",
"blurShape": "شكل التمويه",
"blurIntensity": "كثافة التمويه",
"mosaicBlockSize": "حجم كتلة الفسيفساء",
"blurShapeRectangle": "مستطيل",
"blurShapeOval": "بيضاوي",
"blurShapeFreehand": "رسم حر",
"deleteAnnotation": "حذف الشرح",
"shortcutsAndTips": "اختصارات ونصائح",
"tipMovePlayhead": "انقل رأس التشغيل إلى قسم الشروح المتداخلة وحدد عنصرًا.",
"tipTabCycle": "استخدم Tab للتنقل بين العناصر المتداخلة.",
"tipShiftTabCycle": "استخدم Shift+Tab للتنقل للخلف.",
"invalidImageType": "نوع ملف غير صالح",
"imageFormatsOnly": "يرجى رفع ملف صورة JPG أو PNG أو GIF أو WebP.",
"imageUploadSuccess": "تم رفع الصورة بنجاح!",
"failedImageUpload": "فشل في رفع الصورة"
},
"fontStyles": {
"classic": "كلاسيكي",
"editor": "محرر",
"strong": "قوي",
"typewriter": "آلة كاتبة",
"deco": "ديكو",
"simple": "بسيط",
"modern": "حديث",
"clean": "نظيف"
},
"customFont": {
"dialogTitle": "إضافة خط Google",
"urlLabel": "رابط استيراد خطوط Google",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
"nameLabel": "اسم العرض",
"namePlaceholder": "خطي المخصص",
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
"addButton": "إضافة خط",
"addingButton": "جاري الإضافة...",
"errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
"errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
"errorEmptyName": "يرجى إدخال اسم الخط",
"errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
"successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
"failedToAdd": "فشل في إضافة الخط",
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
},
"language": {
"title": "اللغة"
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"title": "اختصارات لوحة المفاتيح",
"customize": "تخصيص",
"configurable": "قابل للتكوين",
"fixed": "ثابت",
"pressKey": "اضغط على مفتاح...",
"clickToChange": "انقر للتغيير",
"pressEscToCancel": "اضغط على Esc للإلغاء",
"helpText": "انقر على اختصار ثم اضغط على مجموعة المفاتيح الجديدة. اضغط على Esc للإلغاء.",
"resetToDefaults": "إعادة تعيين إلى الافتراضيات",
"alreadyUsedBy": "مستخدم بالفعل بواسطة {{action}}",
"swap": "تبديل",
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
"actions": {
"addZoom": "إضافة تكبير",
"addTrim": "إضافة قص",
"addSpeed": "إضافة سرعة",
"addAnnotation": "إضافة شرح",
"addBlur": "إضافة تمويه",
"addKeyframe": "إضافة إطار رئيسي",
"deleteSelected": "حذف المحدد",
"playPause": "تشغيل / إيقاف مؤقت"
},
"fixedActions": {
"undo": "تراجع",
"redo": "إعادة",
"cycleAnnotationsForward": "التنقل بين الشروح للأمام",
"cycleAnnotationsBackward": "التنقل بين الشروح للخلف",
"deleteSelectedAlt": "حذف المحدد (alt)",
"panTimeline": "تحريك المخطط الزمني",
"zoomTimeline": "تكبير المخطط الزمني",
"frameBack": "إطار للخلف",
"frameForward": "إطار للأمام"
}
}
-55
View File
@@ -1,55 +0,0 @@
{
"buttons": {
"addZoom": "إضافة تكبير (Z)",
"suggestZooms": "اقتراح تكبير من المؤشر",
"addTrim": "إضافة قص (T)",
"addAnnotation": "إضافة شرح (A)",
"addBlur": "إضافة تمويه (B)",
"addSpeed": "إضافة سرعة (S)"
},
"hints": {
"pressZoom": "اضغط Z لإضافة تكبير",
"pressTrim": "اضغط T لإضافة قص",
"pressAnnotation": "اضغط A لإضافة شرح",
"pressBlur": "اضغط B لإضافة منطقة تمويه",
"pressSpeed": "اضغط S لإضافة سرعة"
},
"labels": {
"pan": "تحريك",
"zoom": "تكبير",
"trim": "قص",
"speed": "سرعة",
"zoomItem": "تكبير {{index}}",
"trimItem": "قص {{index}}",
"speedItem": "سرعة {{index}}",
"annotationItem": "شرح",
"blurItem": "تمويه {{index}}",
"imageItem": "صورة",
"emptyText": "نص فارغ"
},
"emptyState": {
"noVideo": "لم يتم تحميل أي فيديو",
"dragAndDrop": "اسحب وأفلت مقطع فيديو لبدء التعديل"
},
"errors": {
"cannotPlaceZoom": "لا يمكن وضع التكبير هنا",
"zoomExistsAtLocation": "يوجد تكبير بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"zoomSuggestionUnavailable": "معالج اقتراح التكبير غير متوفر",
"noCursorTelemetry": "لا تتوفر بيانات قياس المؤشر",
"noCursorTelemetryDescription": "قم بتسجيل الشاشة أولاً لإنشاء اقتراحات بناءً على المؤشر.",
"noUsableTelemetry": "لا توجد بيانات قياس مؤشر قابلة للاستخدام",
"noUsableTelemetryDescription": "التسجيل لا يتضمن بيانات حركة مؤشر كافية.",
"noDwellMoments": "لم يتم العثور على لحظات توقف واضحة للمؤشر",
"noDwellMomentsDescription": "جرب تسجيلاً مع توقفات مؤشر أبطأ عند الإجراءات المهمة.",
"noAutoZoomSlots": "لا تتوفر خانات تكبير تلقائي",
"noAutoZoomSlotsDescription": "نقاط التوقف المكتشفة تتداخل مع مناطق التكبير الحالية.",
"cannotPlaceTrim": "لا يمكن وضع القص هنا",
"trimExistsAtLocation": "يوجد قص بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة.",
"cannotPlaceSpeed": "لا يمكن وضع السرعة هنا",
"speedExistsAtLocation": "توجد منطقة سرعة بالفعل في هذا الموقع أو لا توجد مساحة كافية متاحة."
},
"success": {
"addedZoomSuggestions": "تمت إضافة {{count}} اقتراح تكبير بناءً على المؤشر",
"addedZoomSuggestionsPlural": "تمت إضافة {{count}} اقتراحات تكبير بناءً على المؤشر"
}
}
+1 -22
View File
@@ -9,33 +9,12 @@
"open": "Open",
"upload": "Upload",
"export": "Export",
"showInFolder": "Show in Folder",
"file": "File",
"edit": "Edit",
"view": "View",
"window": "Window",
"quit": "Quit",
"stopRecording": "Stop Recording",
"undo": "Undo",
"redo": "Redo",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All",
"minimize": "Minimize",
"reload": "Reload",
"forceReload": "Force Reload",
"toggleDevTools": "Toggle Developer Tools",
"actualSize": "Actual Size",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"toggleFullScreen": "Toggle Full Screen",
"recordingStatus": "Recording: {{source}}",
"about": "About OpenScreen",
"services": "Services",
"hide": "Hide OpenScreen",
"hideOthers": "Hide Others",
"unhide": "Show All"
"stopRecording": "Stop Recording"
},
"playback": {
"play": "Play",
+5 -8
View File
@@ -27,11 +27,10 @@
"triggerLabel": "How trimming works",
"title": "How Trimming Works",
"description": "Understanding how to cut out unwanted parts of your video.",
"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.",
"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.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
@@ -40,9 +39,7 @@
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
"step1DescriptionBefore": "Press ",
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
"step1Description": "Press T 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."
},
-10
View File
@@ -1,11 +1,4 @@
{
"newRecording": {
"title": "Return to Recorder",
"description": "Your current session has been saved.",
"cancel": "Cancel",
"confirm": "Confirm"
},
"loadingVideo": "Loading video...",
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
@@ -15,7 +8,6 @@
"failedToSaveVideo": "Failed to save video",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
"exportBackgroundLoadFailed": "Export failed: could not load background image ({{url}})",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
@@ -38,8 +30,6 @@
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
"cameraDisconnected": "Webcam disconnected.",
"cameraNotFound": "Camera not found.",
"permissionDenied": "Recording permission denied. Please allow screen recording."
}
}
+1 -7
View File
@@ -33,11 +33,5 @@
"recording": {
"selectSource": "Please select a source to record"
},
"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"
}
"language": "Language"
}
+4 -47
View File
@@ -8,22 +8,12 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
},
"threeD": {
"title": "3D Rotation",
"preset": {
"iso": "Iso",
"left": "Left",
"right": "Right"
}
}
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region",
"customPlaybackSpeed": "Custom Playback Speed",
"maxSpeedError": "Speed can't go higher than 16×"
"deleteRegion": "Delete Speed Region"
},
"trim": {
"deleteRegion": "Delete Trim Region"
@@ -34,32 +24,16 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"dualFrame": "Dual Frame",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
"webcamShape": "Camera Shape"
},
"effects": {
"title": "Video Effects",
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
"on": "on",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding",
"cursorHighlight": {
"title": "Cursor highlight",
"style": "Style",
"dot": "Dot",
"ring": "Ring",
"size": "Size",
"onlyOnClicks": "Only on clicks",
"color": "Color",
"offsetX": "Offset X (window recordings)",
"offsetY": "Offset Y",
"accessibilityPermissionTitle": "Accessibility permission needed",
"accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
}
"padding": "Padding"
},
"background": {
"title": "Background",
@@ -67,9 +41,7 @@
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"gradientLabel": "Gradient {{index}}",
"colorWheel": "Color Wheel",
"colorPalette": "Color Palette"
"gradientLabel": "Gradient {{index}}"
},
"crop": {
"title": "Crop",
@@ -126,7 +98,6 @@
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
"typeBlur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
@@ -137,26 +108,12 @@
"background": "Background",
"none": "None",
"color": "Color",
"colorWheel": "Color Wheel",
"colorPalette": "Color Palette",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
"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.",
-1
View File
@@ -18,7 +18,6 @@
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addBlur": "Add Blur",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
-5
View File
@@ -4,26 +4,21 @@
"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": {
"pan": "Pan",
"zoom": "Zoom",
"trim": "Trim",
"speed": "Speed",
"zoomItem": "Zoom {{index}}",
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
"blurItem": "Blur {{index}}",
"imageItem": "Image",
"emptyText": "Empty text"
},
-1
View File
@@ -9,7 +9,6 @@
"open": "Abrir",
"upload": "Subir",
"export": "Exportar",
"showInFolder": "Mostrar en carpeta",
"file": "Archivo",
"edit": "Editar",
"view": "Vista",
+5 -7
View File
@@ -27,11 +27,10 @@
"triggerLabel": "Cómo funciona el recorte",
"title": "Cómo funciona el recorte",
"description": "Aprende a eliminar las partes no deseadas de tu video.",
"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.",
"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.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
@@ -40,8 +39,7 @@
"part3": "Parte 3",
"finalVideo": "Video final",
"step1Title": "1. Agregar recorte",
"step1DescriptionBefore": "Presiona ",
"step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step1Description": "Presiona T 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."
},
-10
View File
@@ -8,7 +8,6 @@
"failedToSaveVideo": "Error al guardar el video",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
"exportBackgroundLoadFailed": "La exportación falló: no se pudo cargar la imagen de fondo ({{url}})",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
@@ -31,15 +30,6 @@
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
"cameraDisconnected": "Cámara web desconectada.",
"cameraNotFound": "Cámara no encontrada.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
},
"loadingVideo": "Cargando video...",
"newRecording": {
"title": "Volver a la grabadora",
"description": "Tu sesión actual ha sido guardada.",
"cancel": "Cancelar",
"confirm": "Confirmar"
}
}
+1 -7
View File
@@ -33,11 +33,5 @@
"recording": {
"selectSource": "Por favor selecciona una fuente para grabar"
},
"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"
}
"language": "Idioma"
}
+3 -32
View File
@@ -8,22 +8,12 @@
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
},
"threeD": {
"title": "Rotación 3D",
"preset": {
"iso": "Iso",
"left": "Izquierda",
"right": "Derecha"
}
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad",
"customPlaybackSpeed": "Velocidad personalizada",
"maxSpeedError": "La velocidad no puede superar 16×"
"deleteRegion": "Eliminar región de velocidad"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
@@ -34,9 +24,7 @@
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"dualFrame": "Marco dual",
"webcamShape": "Forma de cámara",
"webcamSize": "Tamaño de cámara"
"webcamShape": "Forma de cámara"
},
"effects": {
"title": "Efectos de video",
@@ -53,9 +41,7 @@
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"gradientLabel": "Degradado {{index}}",
"colorWheel": "Rueda de colores",
"colorPalette": "Paleta de colores"
"gradientLabel": "Degradado {{index}}"
},
"crop": {
"title": "Recortar",
@@ -112,7 +98,6 @@
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
"typeBlur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
@@ -123,26 +108,12 @@
"background": "Fondo",
"none": "Ninguno",
"color": "Color",
"colorWheel": "Rueda de colores",
"colorPalette": "Paleta de colores",
"clearBackground": "Quitar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
"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.",
-1
View File
@@ -18,7 +18,6 @@
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
"addBlur": "Agregar desenfoque",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
+3 -8
View File
@@ -4,28 +4,23 @@
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
"addSpeed": "Agregar velocidad (S)",
"addBlur": "Agregar desenfoque (B)"
"addSpeed": "Agregar velocidad (S)"
},
"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",
"pressBlur": "Presiona B para agregar una región de desenfoque"
"pressSpeed": "Presiona S para agregar velocidad"
},
"labels": {
"pan": "Desplazar",
"zoom": "Zoom",
"trim": "Recortar",
"speed": "Velocidad",
"zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
"emptyText": "Texto vacío",
"blurItem": "Desenfoque {{index}}"
"emptyText": "Texto vacío"
},
"emptyState": {
"noVideo": "No hay video cargado",
-30
View File
@@ -1,30 +0,0 @@
{
"actions": {
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"close": "Fermer",
"share": "Partager",
"done": "Terminer",
"open": "Ouvrir",
"upload": "Téléverser",
"export": "Exporter",
"showInFolder": "Afficher dans le dossier",
"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"
}
}
-70
View File
@@ -1,70 +0,0 @@
{
"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 ce qui est",
"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"
}
}
-45
View File
@@ -1,45 +0,0 @@
{
"newRecording": {
"title": "Retour à l'enregistreur",
"description": "Votre session actuelle a été enregistrée.",
"cancel": "Annuler",
"confirm": "Confirmer"
},
"errors": {
"noVideoLoaded": "Aucune vidéo chargée",
"videoNotReady": "Vidéo non prête",
"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}}",
"exportBackgroundLoadFailed": "L'export a échoué : impossible de charger l'image d'arrière-plan ({{url}})",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
},
"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.",
"cameraDisconnected": "Webcam déconnectée.",
"cameraNotFound": "Caméra introuvable.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
},
"loadingVideo": "Chargement de la vidéo..."
}
-43
View File
@@ -1,43 +0,0 @@
{
"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",
"systemLanguagePrompt": {
"title": "Utiliser la langue de votre système ?",
"description": "Nous avons détecté {{language}} comme langue système. Voulez-vous passer OpenScreen en {{language}} ?",
"switch": "Passer en {{language}}",
"keepDefault": "Conserver la langue actuelle"
}
}
-195
View File
@@ -1,195 +0,0 @@
{
"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": {
"title": "Vitesse du zoom",
"instant": "Instantané",
"fast": "Rapide",
"smooth": "Fluide",
"lazy": "Lent"
},
"threeD": {
"title": "Rotation 3D",
"preset": {
"iso": "Iso",
"left": "Gauche",
"right": "Droite"
}
}
},
"speed": {
"playbackSpeed": "Vitesse de lecture",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"deleteRegion": "Supprimer la région de vitesse",
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
"maxSpeedError": "La vitesse ne peut pas dépasser 16×"
},
"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",
"dualFrame": "Double cadre",
"webcamShape": "Forme de la caméra",
"webcamSize": "Taille 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}}",
"colorWheel": "Roue chromatique",
"colorPalette": "Palette de couleurs"
},
"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",
"colorWheel": "Roue chromatique",
"colorPalette": "Palette de couleurs",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
"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"
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"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"
}
}
-55
View File
@@ -1,55 +0,0 @@
{
"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",
"trim": "Couper",
"speed": "Vitesse",
"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"
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"actions": {
"cancel": "キャンセル",
"save": "保存",
"delete": "削除",
"close": "閉じる",
"share": "共有",
"done": "完了",
"open": "開く",
"upload": "読み込む",
"export": "エクスポート",
"showInFolder": "フォルダに表示",
"file": "ファイル",
"edit": "編集",
"view": "表示",
"window": "ウィンドウ",
"quit": "終了",
"stopRecording": "録画を停止"
},
"playback": {
"play": "再生",
"pause": "一時停止",
"fullscreen": "全画面表示",
"exitFullscreen": "全画面表示を終了"
},
"locale": {
"name": "日本語",
"short": "JA"
}
}
-71
View File
@@ -1,71 +0,0 @@
{
"export": {
"complete": "エクスポート完了",
"yourFormatReady": "{{format}}の準備ができました",
"showInFolder": "フォルダで表示",
"finalizingVideo": "動画のエクスポートを仕上げています...",
"compilingGifProgress": "GIFを生成中... {{progress}}%",
"compilingGifWait": "GIFを生成中... しばらくお待ちください",
"takeMoment": "少々お待ちください...",
"failed": "エクスポートに失敗しました",
"tryAgain": "もう一度お試しください",
"finalizingVideoTitle": "動画の仕上げ",
"compilingGif": "GIFを生成中",
"exportingFormat": "{{format}}をエクスポート中",
"compiling": "生成中",
"renderingFrames": "フレームをレンダリング中",
"processing": "処理中...",
"finalizing": "最終処理中...",
"compilingStatus": "生成中...",
"status": "ステータス",
"format": "フォーマット",
"frames": "フレーム",
"cancelExport": "エクスポートをキャンセル",
"savedSuccessfully": "{{format}}を正常に保存しました!"
},
"tutorial": {
"triggerLabel": "トリミングの仕組み",
"title": "トリミングの仕組み",
"description": "動画の不要な部分を削除する方法について解説します。",
"explanationBefore": "トリムツールは、動画から",
"remove": "「削除したい部分」",
"explanationMiddle": "を指定することで機能します。エクスポート時には、赤い枠で",
"covered": "囲まれた部分",
"explanationAfter": "がすべてカットされます。",
"visualExample": "視覚的な例",
"removed": "削除",
"kept": "保持",
"part1": "パート 1",
"part2": "パート 2",
"part3": "パート 3",
"finalVideo": "完成動画",
"step1Title": "1. 削除範囲を追加",
"step1DescriptionBefore": "キーボードの",
"step1DescriptionAfter": "、またはハサミのアイコンをクリックして、削除したい範囲を指定します。",
"step2Title": "2. 範囲を調整",
"step2Description": "赤い領域の両端をドラッグして、削除したい範囲を正確に調整します。"
},
"unsavedChanges": {
"title": "未保存の変更",
"message": "未保存の変更があります。",
"detail": "閉じる前にプロジェクトを保存しますか?",
"saveAndClose": "保存して閉じる",
"discardAndClose": "破棄して閉じる",
"loadProject": "プロジェクトを読み込む…",
"saveProject": "プロジェクトを保存…",
"saveProjectAs": "プロジェクトを名前を付けて保存…"
},
"fileDialogs": {
"saveGif": "エクスポートしたGIFを保存",
"saveVideo": "エクスポートした動画を保存",
"selectVideo": "動画ファイルを選択",
"saveProject": "OpenScreen プロジェクトを保存",
"openProject": "OpenScreen プロジェクトを開く",
"gifImage": "GIF 画像",
"mp4Video": "MP4 動画",
"videoFiles": "動画ファイル",
"openscreenProject": "OpenScreen プロジェクト",
"allFiles": "すべてのファイル"
}
}
-45
View File
@@ -1,45 +0,0 @@
{
"newRecording": {
"title": "レコーダーに戻る",
"description": "現在の作業内容が保存されました。",
"cancel": "キャンセル",
"confirm": "確認"
},
"loadingVideo": "動画を読み込み中...",
"errors": {
"noVideoLoaded": "動画が読み込まれていません",
"videoNotReady": "動画の準備ができていません",
"unableToDetermineSourcePath": "元動画のパスを特定できません",
"failedToSaveGif": "GIFの保存に失敗しました",
"gifExportFailed": "GIFのエクスポートに失敗しました",
"failedToSaveVideo": "動画の保存に失敗しました",
"exportFailed": "エクスポートに失敗しました",
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
"failedToSaveExport": "エクスポートの保存に失敗しました",
"failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました",
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})"
},
"export": {
"canceled": "エクスポートがキャンセルされました",
"exportedSuccessfully": "{{format}}を正常にエクスポートしました"
},
"project": {
"saveCanceled": "プロジェクトの保存がキャンセルされました",
"failedToSave": "プロジェクトの保存に失敗しました",
"savedTo": "プロジェクトを保存しました: {{path}}",
"failedToLoad": "プロジェクトの読み込みに失敗しました",
"invalidFormat": "無効なプロジェクトファイル形式です",
"loadedFrom": "プロジェクトを読み込みました: {{path}}"
},
"recording": {
"failedCameraAccess": "カメラのアクセス要求に失敗しました。",
"cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。",
"systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。",
"microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。",
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
"cameraDisconnected": "ウェブカメラが切断されました。",
"cameraNotFound": "カメラが見つかりません。"
}
}
-43
View File
@@ -1,43 +0,0 @@
{
"tooltips": {
"hideHUD": "HUDを隠す",
"closeApp": "アプリを閉じる",
"restartRecording": "録画を再開",
"cancelRecording": "録画をキャンセル",
"pauseRecording": "録画を一時停止",
"resumeRecording": "録画を再開",
"openVideoFile": "動画ファイルを開く",
"openProject": "プロジェクトを開く"
},
"audio": {
"enableSystemAudio": "システム音声を有効にする",
"disableSystemAudio": "システム音声を無効にする",
"enableMicrophone": "マイクを有効にする",
"disableMicrophone": "マイクを無効にする",
"defaultMicrophone": "デフォルトのマイク"
},
"webcam": {
"enableWebcam": "ウェブカメラを有効にする",
"disableWebcam": "ウェブカメラを無効にする",
"defaultCamera": "デフォルトのカメラ",
"searching": "検索中...",
"noneFound": "カメラが見つかりません",
"unavailable": "カメラが利用できません"
},
"sourceSelector": {
"loading": "ソースを読み込み中...",
"screens": "画面 ({{count}})",
"windows": "ウィンドウ ({{count}})",
"defaultSourceName": "画面"
},
"recording": {
"selectSource": "録画するソースを選択してください"
},
"language": "言語",
"systemLanguagePrompt": {
"title": "システム言語を使用しますか?",
"description": "システム言語として{{language}}が検出されました。OpenScreenを{{language}}に切り替えますか?",
"switch": "{{language}}に切り替え",
"keepDefault": "現在の言語を保持"
}
}
-188
View File
@@ -1,188 +0,0 @@
{
"zoom": {
"level": "ズーム倍率",
"selectRegion": "ズーム範囲を選択して調整",
"deleteZoom": "ズームを削除",
"focusMode": {
"title": "フォーカスモード",
"manual": "手動",
"auto": "自動",
"autoDescription": "表示範囲が録画中のカーソル位置に追従します"
},
"threeD": {
"title": "3D回転",
"preset": {
"iso": "Iso",
"left": "左",
"right": "右"
}
}
},
"speed": {
"playbackSpeed": "再生速度",
"selectRegion": "再生速度の範囲を選択して調整",
"deleteRegion": "再生速度の範囲を削除",
"customPlaybackSpeed": "カスタム再生速度",
"maxSpeedError": "速度は16×を超えることはできません"
},
"trim": {
"deleteRegion": "トリム範囲を削除"
},
"layout": {
"title": "レイアウト",
"preset": "プリセット",
"selectPreset": "プリセットを選択",
"pictureInPicture": "ピクチャーインピクチャ",
"verticalStack": "縦並び",
"dualFrame": "デュアルフレーム",
"webcamShape": "カメラの形状",
"webcamSize": "カメラのサイズ"
},
"effects": {
"title": "動画効果",
"blurBg": "背景をぼかす",
"motionBlur": "モーションブラー",
"off": "オフ",
"shadow": "影",
"roundness": "丸み",
"padding": "余白"
},
"background": {
"title": "背景",
"image": "画像",
"color": "色",
"gradient": "グラデーション",
"uploadCustom": "カスタム画像を読み込む",
"gradientLabel": "グラデーション {{index}}",
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット"
},
"crop": {
"title": "クロップ",
"cropVideo": "動画をクロップ",
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
"ratio": "比率",
"free": "自由",
"done": "完了",
"lockAspectRatio": "アスペクト比を固定",
"unlockAspectRatio": "アスペクト比の固定を解除"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 動画",
"mp4Description": "高品質の動画ファイル",
"gifAnimation": "GIF アニメーション",
"gifDescription": "共有用のアニメーション画像"
},
"exportQuality": {
"title": "エクスポート品質",
"low": "低画質",
"medium": "中画質",
"high": "高画質"
},
"gifSettings": {
"frameRate": "GIF フレームレート",
"size": "GIF サイズ",
"loop": "GIF をループする"
},
"project": {
"save": "プロジェクトを保存",
"load": "プロジェクトを読み込む"
},
"export": {
"videoButton": "動画をエクスポート",
"gifButton": "GIF をエクスポート",
"chooseSaveLocation": "保存場所を選択"
},
"links": {
"reportBug": "バグを報告",
"starOnGithub": "GitHub でスターを付ける"
},
"imageUpload": {
"invalidFileType": "無効なファイル形式",
"jpgOnly": "JPG または JPEG 画像ファイルを選択してください。",
"uploadSuccess": "カスタム画像を読み込みました。",
"failedToUpload": "画像の読み込みに失敗しました",
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
},
"annotation": {
"title": "注釈設定",
"active": "アクティブ",
"typeText": "テキスト",
"typeImage": "画像",
"typeArrow": "矢印",
"typeBlur": "ぼかし",
"textContent": "テキスト内容",
"textPlaceholder": "テキストを入力してください...",
"fontStyle": "フォントスタイル",
"selectStyle": "スタイルを選択",
"size": "サイズ",
"customFonts": "カスタムフォント",
"textColor": "文字色",
"background": "背景",
"none": "なし",
"color": "色",
"colorWheel": "カラーホイール",
"colorPalette": "カラーパレット",
"clearBackground": "背景をクリア",
"uploadImage": "画像を読み込む",
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
"arrowDirection": "矢印の方向",
"strokeWidth": "線の太さ: {{width}}px",
"arrowColor": "矢印の色",
"blurType": "ぼかしの種類",
"blurTypeBlur": "ぼかし",
"blurTypeMosaic": "モザイクぼかし",
"blurColor": "ぼかしの色",
"blurColorWhite": "白",
"blurColorBlack": "黒",
"blurShape": "ぼかしの形状",
"blurIntensity": "ぼかしの強さ",
"mosaicBlockSize": "モザイクブロックのサイズ",
"blurShapeRectangle": "長方形",
"blurShapeOval": "楕円",
"blurShapeFreehand": "自由形状",
"deleteAnnotation": "注釈を削除",
"shortcutsAndTips": "ショートカットとヒント",
"tipMovePlayhead": "重なっている注釈セクションに再生ヘッドを移動し、項目を選択します。",
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
"tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
"invalidImageType": "無効なファイル形式",
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
"imageUploadSuccess": "画像を読み込みました。",
"failedImageUpload": "画像の読み込みに失敗しました"
},
"fontStyles": {
"classic": "クラシック",
"editor": "エディター",
"strong": "ストロング",
"typewriter": "タイプライター",
"deco": "デコ",
"simple": "シンプル",
"modern": "モダン",
"clean": "クリーン"
},
"customFont": {
"dialogTitle": "Googleフォントを追加",
"urlLabel": "GoogleフォントのインポートURL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Googleフォントから取得: フォントを選択 → 「フォントを取得」をクリック → @import URLをコピー",
"nameLabel": "表示名",
"namePlaceholder": "マイカスタムフォント",
"nameHelp": "フォントセレクターに表示される名前です",
"addButton": "フォントを追加",
"addingButton": "追加中...",
"errorEmptyUrl": "GoogleフォントのインポートURLを入力してください",
"errorInvalidUrl": "有効なGoogleフォントURLを入力してください",
"errorEmptyName": "フォント名を入力してください",
"errorExtractFailed": "URLからフォントファミリーを抽出できませんでした",
"successMessage": "フォント \"{{fontName}}\" が正常に追加されました",
"failedToAdd": "フォントの追加に失敗しました",
"errorTimeout": "フォントの読み込みに時間がかかりすぎました。URLを確認して再試行してください。",
"errorLoadFailed": "フォントを読み込めませんでした。GoogleフォントのURLが正しいことを確認してください。"
},
"language": {
"title": "言語"
}
}
-37
View File
@@ -1,37 +0,0 @@
{
"title": "キーボードショートカット",
"customize": "カスタマイズ",
"configurable": "設定可能",
"fixed": "固定",
"pressKey": "キーを押してください…",
"clickToChange": "クリックして変更",
"pressEscToCancel": "Escキーを押してキャンセル",
"helpText": "ショートカットをクリックして新しいキーの組み合わせを押します。Escキーを押してキャンセルします。",
"resetToDefaults": "デフォルトにリセット",
"alreadyUsedBy": "すでに {{action}} に使用されています",
"swap": "入れ替え",
"reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。",
"savedToast": "キーボードショートカットが保存されました",
"resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用",
"actions": {
"addZoom": "ズームを追加",
"addTrim": "トリムを追加",
"addSpeed": "速度を追加",
"addAnnotation": "注釈を追加",
"addBlur": "ぼかしを追加",
"addKeyframe": "キーフレームを追加",
"deleteSelected": "選択を削除",
"playPause": "再生 / 一時停止"
},
"fixedActions": {
"undo": "元に戻す",
"redo": "やり直す",
"cycleAnnotationsForward": "注釈を順に切り替え",
"cycleAnnotationsBackward": "注釈を逆順に切り替え",
"deleteSelectedAlt": "選択を削除 (alt)",
"panTimeline": "タイムラインをパン",
"zoomTimeline": "タイムラインをズーム",
"frameBack": "フレームを戻す",
"frameForward": "フレームを進める"
}
}

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